Compare commits

..
Author SHA1 Message Date
Gergő Magyar c978c9b3d4 Merge branch 'main' into optimize/go-scope-capture 2026-05-30 12:25:30 +01:00
Gergo MagyarandClaude Opus 4.8 b9008024ab fix(test): remove TOCTOU file-system race in golden test + format
CodeQL flagged a high-severity 'potential file system race condition': the golden
test did fs.existsSync(GOLDEN_FILE) then later writeFileSync/readFileSync on it.
Replace the existsSync-then-use with a single race-free read (ENOENT => missing),
reusing the read content for the compare path. Behaviour is unchanged (the pure
resolveGoldenAction helper still decides regenerate/compare/fail). Also applies
prettier formatting to the file (fixes the quality/format check).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 11:20:51 +00:00
Gergo MagyarandClaude Opus 4.8 cfe4e49c41 test(go): strengthen func_literal smoke case to a positive receiver assertion (#1848 U3)
The old case used a closure-only source and only asserted ABSENCE of
@type-binding.self, so it would pass even if the method_declaration receiver
branch regressed (Codex F3). The fixture now has both a method and a closure, and
positively asserts exactly one @type-binding.self from the method (name=u,
type=User — the type also confirms *User pointer-stripping) and none from the closure.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 11:02:44 +00:00
Gergo MagyarandClaude Opus 4.8 1e2aaeabf9 test(go): make the golden digest order-sensitive (#1848 U2)
Drops the cross-match .sort() in digestCaptures so the digest reflects emission
order — a true byte-identical guard that catches a reordering refactor (Codex F1),
not just a set-equality check. Safe because emitGoScopeCaptures output is
deterministic. Within-match key order stays normalized (a CaptureMatch is a Record).
Replaces the order-independence test with an order-sensitivity assertion and
regenerates expected-captures.json under the new scheme (all 90 digests).
Trade-off: a tree-sitter-go grammar bump that reorders matches now requires a
deliberate UPDATE_GOLDEN=1 regen — intentional (a tree-shape change deserves a look).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 11:02:43 +00:00
Gergo MagyarandClaude Opus 4.8 fa8033859f test(go): fail on a missing golden in CI via a pure resolveGoldenAction helper (#1848 U1)
Extracts the golden test's missing-file gate into a pure
resolveGoldenAction({update,exists,isCI}) -> regenerate|compare|fail helper, so
a missing golden no longer self-heals + passes in CI (Codex F2). The rule is
unit-tested directly across all combos with no filesystem mutation (can't corrupt
the committed golden). CI detection uses a truthy check (!!process.env.CI) so it
fires on any runner. Locally a missing golden still regenerates as first-run convenience.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 11:02:43 +00:00
github-actions[bot] 2cb39bc09b chore(autofix): apply prettier + eslint fixes via /autofix command 2026-05-30 10:09:48 +00:00
Gergő Magyar 05e67c683f Merge branch 'main' into optimize/go-scope-capture 2026-05-30 11:03:24 +01:00
Gergo MagyarandClaude Opus 4.8 e12b526e07 test(go): tighten O(n^2) tripwire budget 10s -> 5s (#1848 U3)
The fixed path is ~250ms; a quadratic regression at 400 structs is ~25s. 5s keeps
~20x headroom over the fixed path while tripping a ~20x regression (vs the prior
~40x). Correctness is guarded separately by the U1 golden test, so this stays a
pure perf tripwire.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 09:40:15 +00:00
Gergo MagyarandClaude Opus 4.8 5f0841c8bc test(go): cover func_literal, var-form bindings, single import, generics (#1848 U2)
Adds smoke cases for the Go shapes the #1915 captured-node refactor reasons
about but no lang-resolution fixture exercised: func_literal under @scope.function
(no receiver synthesized), var-form @type-binding.assertion and .call-return (not
dropped by isRawMultiAssignTypeBinding), a single unparenthesized import through
resolveImportNode, and a generic function declaration.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 09:40:14 +00:00
Gergo MagyarandClaude Opus 4.8 b090f2b4e1 test(go): golden capture-parity guard for emitGoScopeCaptures (#1848 U1)
Pins emitGoScopeCaptures output across all 89 go-* fixtures + a synthetic DAO
shape as a committed golden (test/fixtures/go-captures-golden/expected-captures.json),
so future drift in the Go scope-capture path fails CI instead of only the coarse
perf tripwire. Match-grouped, order-independent sha256 canonicalization; regenerate
intentionally with UPDATE_GOLDEN=1. Mirrors test/integration/pipeline-graph-golden.test.ts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 09:40:14 +00:00
Gergő Magyar f86150707c Merge branch 'main' into optimize/go-scope-capture 2026-05-30 09:57:24 +01:00
Gergő Magyar a62a7e56cb Merge branch 'main' into optimize/go-scope-capture 2026-05-30 09:31:52 +01:00
Gergő Magyar 150a95bae4 Merge branch 'main' into optimize/go-scope-capture 2026-05-30 08:50:56 +01:00
Gergo MagyarandClaude Opus 4.8 ed33489e38 test(go-scope-capture): address code-review findings
Self-review (ce-code-review) polish on the #1848 fix + benchmark:

- benchmark: tighten the scaling guard from timeRatio/fileRatio < 3 to < 1.5.
  At the 2.5x/2x scale steps, a quadratic regression yields ratio == fileRatio
  (2.5, 2.0), which < 3 waved through — the guard could not detect the O(n^2)
  it exists for. Measured O(n) ratios are 0.45/0.59, so < 1.5 has headroom.
- benchmark: add a non-gated O(n^2) regression tripwire that calls
  emitGoScopeCaptures on a 400-struct source directly (no worker, no
  GITNEXUS_BENCH gate) so the regression is actually guarded in CI.
- benchmark: clearTimeout the Promise.race timer in finally (no lingering
  rejection); set the worker-suite env vars inside the try so finally always
  restores them.
- captures.ts: clarify the isRawMultiAssignTypeBinding comment to name both
  var-form cases (assertion + call-return). Comment-only.

Left as-is: resolveImportNode's defensive range-equality branch — deleting it
as dead code would remove the self-documentation of the grammar invariant the
threaded-node logic depends on (reviewer tension; a wash).

Verified: tsc clean; 165/165 Go resolver + scope tests; new tripwire passes
(237ms); scaling suite passes at <1.5; #1848 worker suite still green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 07:33:54 +00:00
Gergo MagyarandClaude Opus 4.8 eaf0a3052a optimize(go-scope-capture): thread captured nodes to kill O(n^2) findNodeAtRange re-walks
emitGoScopeCaptures re-derived each match's AST node via findNodeAtRange from
the tree root on every query match, giving O(matches x rootChildren) ~ O(n^2)
behaviour (the #1848 root cause: a 250-struct generated DAO took ~10.8s, 800
structs ~100s+ — long enough to trip the worker sub-batch idle timeout and get
quarantined). Thread the query-captured SyntaxNode (c.node) through a parallel
tag->node map and use it directly (or via a bounded local parent walk for the
import_declaration ancestor case) instead of re-walking from root.

Output is byte-identical (capture fingerprint over the DAO file + all 89 go-*
fixtures unchanged; capture_groups=13501). 250 entities: 10835ms -> 114ms (95x).
800 entities: ~100s -> 384ms. Go resolver + scope-resolution suites: 165/165 pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 07:09:25 +00:00
Gergo MagyarandClaude Opus 4.8 5d1695f66a test(go): add #1848 Go pipeline + worker-pool benchmark
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 07:09:24 +00:00
171 changed files with 954 additions and 14767 deletions
-67
View File
@@ -12,13 +12,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 25
steps:
# persist-credentials: false — this job runs tests and uploads a
# test-reports artifact (if: always()). The default-persisted token in
# .git/config must not be capturable through that upload (zizmor
# credential-persistence / artipacked audit). The job never pushes.
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- uses: ./.github/actions/setup-gitnexus
with:
build: 'true'
@@ -78,11 +72,7 @@ jobs:
runs-on: ${{ matrix.os }}
timeout-minutes: 20
steps:
# persist-credentials: false — runs tests only, never pushes (zizmor
# credential-persistence / artipacked audit).
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- uses: ./.github/actions/setup-gitnexus
with:
build: 'true'
@@ -191,60 +181,3 @@ jobs:
else
"$PREFIX/bin/gitnexus" --version
fi
# ── Dedicated benchmark gate ─────────────────────────────────────
# The cross-language `*-pipeline-benchmark.test.ts` suites are gated behind
# GITNEXUS_BENCH (they generate synthetic codebases at scale), so the main
# coverage job above SKIPS them — their O(n^2) scaling guards never ran in CI.
# Run them here with GITNEXUS_BENCH=1, alongside the Python scope-capture and
# import-resolution fingerprint + scaling guards (PR #1918 P2a).
#
# `--no-file-parallelism` is REQUIRED: these suites measure wall-clock and peak
# heap, so parallel forks both skew the timings and OOM the worker pool — they
# must run one file at a time.
#
# go-pipeline-benchmark.test.ts is deliberately NOT included: its
# worker-pool (#1848) suite spins a real worker pool that exits unexpectedly
# under vitest's fork pool (reproduced in validation), which would make this
# gate flaky. Go is already guarded by its non-gated O(n^2) tripwire (runs in
# the main coverage job) plus its golden capture-parity test.
benchmarks:
name: benchmarks (GITNEXUS_BENCH)
runs-on: ubuntu-latest
timeout-minutes: 25
steps:
# persist-credentials: false — this job only runs npm + vitest benchmarks
# and never pushes; the default-persisted token in .git/config would be at
# risk of leaking through an artifact upload (zizmor credential-persistence
# / artipacked audit). Mirrors the packaged-install-smoke job below.
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- uses: ./.github/actions/setup-gitnexus
with:
build: 'true'
- name: Python scope-capture + import-resolution fingerprint / scaling guards
run: |
node --import tsx bench/python-scope/measure.mjs --check
node --import tsx bench/python-scope/import-target-fingerprint.mjs --check
working-directory: gitnexus
- name: Cross-language scope-capture fingerprint + scaling guards
# Build-free: asserts emit<Lang>ScopeCaptures output is unchanged
# (fingerprint) and stays linear (scaling < 1.5) for go/csharp/rust/php/
# ruby/cobol. Catches an O(n^2) re-regression without the worker pool.
run: node --import tsx bench/scope-capture/measure.mjs --check
working-directory: gitnexus
- name: Cross-language pipeline benchmarks (GITNEXUS_BENCH, serial)
env:
GITNEXUS_BENCH: '1'
run: >-
npx vitest run --no-file-parallelism
test/integration/cobol-pipeline-benchmark.test.ts
test/integration/csharp-pipeline-benchmark.test.ts
test/integration/rust-pipeline-benchmark.test.ts
test/integration/php-pipeline-benchmark.test.ts
test/integration/ruby-pipeline-benchmark.test.ts
working-directory: gitnexus
@@ -1 +0,0 @@
f2b4376f30dab76f3befc9cbd3d7cc2bf1afbd7329a5e953439083e005de4a7c
@@ -1 +0,0 @@
d51ea9edd1902fc20dd888f3fc51907c8119a4692b92c32a427801ac7f41d451
@@ -1,202 +0,0 @@
/**
* Resolver-output correctness fingerprint for `resolvePythonImportTarget`
* (ce-optimize: python-scope-capture, hypothesis H2).
*
* H2 replaces the per-import O(files) suffix scan + candidate scan in
* import-target.ts with a memoized index. The index MUST reproduce the exact
* resolution result (including the deterministic tie-break and the
* false-positive gating) for every input. This harness pins that: it runs
* resolvePythonImportTarget over an exhaustive branch matrix PLUS a large
* deterministic fuzz (varied repo layouts that force collisions / multi-match
* tie-breaks), and prints an order-independent sha256 over every
* `fromFile | targetRaw | result` triple.
*
* Build-free via tsx (static .ts import). Run:
* node --import tsx bench/python-scope/import-target-fingerprint.mjs
*
* The fingerprint before and after the H2 change MUST be identical.
*/
import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { resolvePythonImportTarget } from '../../src/core/ingestion/languages/python/import-target.ts';
function mkImport(targetRaw) {
return { kind: 'absolute', targetRaw, isRelative: false, names: [] };
}
function resolve(fromFile, files, targetRaw) {
const ctx = { fromFile, allFilePaths: new Set(files) };
return resolvePythonImportTarget(mkImport(targetRaw), ctx);
}
const lines = [];
let nonNull = 0;
function record(fromFile, files, targetRaw) {
const r = resolve(fromFile, files, targetRaw);
if (r !== null) nonNull++;
lines.push(`${fromFile}\t${targetRaw}\t${r === null ? 'NULL' : r}`);
}
// ---- 1. Exhaustive branch matrix ----------------------------------------
// direct root hit
record('app/main.py', ['services/sync.py', 'services/__init__.py'], 'services.sync');
// direct package (__init__) hit
record('app/main.py', ['services/__init__.py'], 'services');
// ancestor walk hit
record('backend/routers/cron.py', ['backend/services/sync.py'], 'services.sync');
// ancestor pkg hit
record('backend/routers/cron.py', ['backend/services/__init__.py'], 'services');
// suffix fallback single match (nested vendor layout)
record('app/main.py', ['pkg/__init__.py', 'vendor/pkg/thing.py'], 'pkg.thing');
// suffix fallback to __init__ (package)
record('app/main.py', ['pkg/__init__.py', 'x/pkg/subpkg/__init__.py'], 'pkg.subpkg');
// suffix multi-match tie-break: fewest segments wins
record('app/main.py', ['pkg/__init__.py', 'a/pkg/models.py', 'b/c/pkg/models.py'], 'pkg.models');
// suffix multi-match tie-break at SAME depth: lexicographic
record('app/main.py', ['pkg/__init__.py', 'z/pkg/models.py', 'a/pkg/models.py'], 'pkg.models');
// suffix file vs pkg same name, mixed — both candidate forms present
record(
'app/main.py',
['pkg/__init__.py', 'q/pkg/models.py', 'r/pkg/models/__init__.py'],
'pkg.models',
);
// hasRepoCandidate FALSE — external dotted import w/ colliding local basename (django.apps guard)
record('app/main.py', ['accounts/apps.py'], 'django.apps');
// hasRepoCandidate TRUE via top-level package, but no concrete file -> null
record('app/main.py', ['pkg/__init__.py'], 'pkg.ghost');
// hasRepoCandidate via nested ancestor namespace package
record('backend/routers/cron.py', ['backend/services/sync.py'], 'services.helpers.util');
// collision: accounts.models must NOT match billing/models.py
record('app/main.py', ['accounts/__init__.py', 'billing/models.py'], 'accounts.models');
// relative imports
record('app/main.py', ['app/sibling.py'], '.sibling');
record('app/pkg/mod.py', ['app/sibling.py'], '..sibling');
record('app/main.py', ['app/sibling.py'], '...way.too.far');
// single-segment bare import (no '/'): skips candidate gate
record('app/main.py', ['mod.py'], 'mod');
record('app/main.py', ['lib/mod.py'], 'mod');
record('app/pkg/main.py', ['app/pkg/local.py'], 'local');
// empty / dynamic
record('app/main.py', ['a.py'], '');
// windows-style backslash paths in the set
record('app\\main.py', ['svc\\sync.py', 'svc\\__init__.py'], 'svc.sync');
// ---- 2. Deterministic fuzz ----------------------------------------------
// LCG (no Math.random — deterministic + reproducible).
let seed = 0x9e3779b9;
function rnd() {
seed = (seed * 1664525 + 1013904223) >>> 0;
return seed / 0x100000000;
}
function pick(arr) {
return arr[Math.floor(rnd() * arr.length)];
}
const DIRS = ['', 'a', 'b', 'a/b', 'b/c', 'x/y/z', 'vendor', 'src', 'src/app', 'pkg'];
const SEGS = [
'pkg',
'services',
'models',
'sync',
'util',
'core',
'apps',
'sub',
'thing',
'helpers',
];
function randPath() {
const dir = pick(DIRS);
const base = pick(SEGS);
const isPkg = rnd() < 0.3;
const file = isPkg ? `${base}/__init__.py` : `${base}.py`;
return dir ? `${dir}/${file}` : file;
}
function randDotted() {
const n = 1 + Math.floor(rnd() * 3);
const parts = [];
for (let i = 0; i < n; i++) parts.push(pick(SEGS));
const rel = rnd() < 0.15 ? '.'.repeat(1 + Math.floor(rnd() * 2)) : '';
return rel + parts.join('.');
}
for (let repo = 0; repo < 400; repo++) {
const fileCount = 3 + Math.floor(rnd() * 14);
const files = [];
for (let i = 0; i < fileCount; i++) files.push(randPath());
const fromFile = randPath();
for (let imp = 0; imp < 25; imp++) {
record(fromFile, files, randDotted());
}
}
// ---- 3. Absolute-path coverage (PR #1918 review P3a) --------------------
// Production paths are repo-relative, but the index's prefix gating must
// reproduce the old `f.startsWith(prefix)` semantics for absolute paths too.
// The reviewer's exact case + a fuzz over leading-`/` file sets and absolute
// importer paths lock the absolute-path behavior end to end.
// The flagged case: an absolute file under the importer's own root.
record('/repo/app/main.py', ['/repo/svc/x.py'], 'svc.x');
record('/repo/app/main.py', ['/repo/svc/__init__.py', '/repo/svc/x.py'], 'svc.x');
// Absolute file NOT under the importer root — gate must not pass it.
record('/repo/app/main.py', ['/other/svc/x.py'], 'svc.x');
// Absolute vendored layout reachable only by suffix.
record('/repo/app/main.py', ['/repo/pkg/__init__.py', '/repo/vendor/pkg/thing.py'], 'pkg.thing');
// Absolute tie-break.
record(
'/repo/app/main.py',
['/repo/pkg/__init__.py', '/a/pkg/models.py', '/b/c/pkg/models.py'],
'pkg.models',
);
// Mixed absolute/relative file set.
record('/repo/app/main.py', ['/repo/pkg/__init__.py', 'pkg/models.py'], 'pkg.models');
function randAbsPath() {
// Reuse the relative generator under one of a few absolute roots.
const root = pick(['/repo', '/srv/app', '/']);
const rel = randPath();
return root === '/' ? `/${rel}` : `${root}/${rel}`;
}
for (let repo = 0; repo < 200; repo++) {
const fileCount = 3 + Math.floor(rnd() * 12);
const files = [];
for (let i = 0; i < fileCount; i++) files.push(randAbsPath());
const fromFile = randAbsPath();
for (let imp = 0; imp < 20; imp++) {
record(fromFile, files, randDotted());
}
}
const fingerprint = crypto
.createHash('sha256')
.update([...lines].sort().join('\n'))
.digest('hex');
const result = { fingerprint, cases: lines.length, non_null: nonNull };
if (!process.argv.includes('--check')) {
process.stdout.write(JSON.stringify(result) + '\n');
} else {
// CI gate: resolver output unchanged (fingerprint == committed baseline).
// Re-baseline a legitimate resolution change by running without --check and
// committing the new baseline-import-target-fingerprint.txt deliberately.
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const baseline = fs
.readFileSync(path.resolve(__dirname, 'baseline-import-target-fingerprint.txt'), 'utf8')
.trim();
process.stdout.write(JSON.stringify(result) + '\n');
if (result.fingerprint !== baseline) {
process.stderr.write(
`[import-target-fingerprint --check] FAIL: resolver fingerprint drift: got ` +
`${result.fingerprint}, expected ${baseline} (resolvePythonImportTarget output changed — ` +
`re-baseline intentionally if expected)\n`,
);
process.exit(1);
}
process.stderr.write('[import-target-fingerprint --check] PASS (resolver fingerprint)\n');
}
-216
View File
@@ -1,216 +0,0 @@
/**
* Build-free measurement harness for `emitPythonScopeCaptures`
* (ce-optimize: python-scope-capture).
*
* This is Python's counterpart to `bench/scope-capture/measure.mjs` (which
* covers go/csharp/rust/php/ruby/cobol). Python lives here, NOT in that unified
* harness, because this one ALSO covers import resolution
* (`import-target-fingerprint.mjs`). Python's capture-scaling guard therefore
* runs via `python-scope/measure.mjs --check`, not the unified harness — don't
* remove either thinking the other covers Python.
*
* Mirrors the Go scope-capture harness (#1848). Imports the `.ts` hotpath
* directly through tsx (`node --import tsx bench/python-scope/measure.mjs`):
* a static `.ts` import works; a top-level `await import()` breaks tsx's lexer.
*
* Emits ONE JSON object on stdout with:
* - elapsed_ms_250 / elapsed_ms_800: median wall-clock (ms) of
* emitPythonScopeCaptures over a synthetic DAO-style source at that many
* top-level entities (warmed up first). 800/250 ~ 3.2x input; an O(n^2)
* path scales ~quadratically, an O(n) path ~linearly.
* - scaling_ratio: (t800/t250)/(800/250). ~3.2 = quadratic, ~1.0 = linear.
* - capture_groups_250 / capture_groups_800: match counts (a fast-but-empty
* regression can't pass — counts must stay > 0).
* - fingerprint: order-independent sha256 over emitPythonScopeCaptures output
* across the whole lang-resolution/python-* fixture corpus + a fixed
* 20-entity synthetic DAO. This is the CORRECTNESS gate: any change to the
* captures changes the fingerprint. Entity-count-fixed so it is comparable
* across experiments regardless of the timing sizes.
* - capture_groups_fp / fixture_count: corpus sanity.
*/
import fs from 'node:fs';
import path from 'node:path';
import crypto from 'node:crypto';
import { fileURLToPath } from 'node:url';
import { emitPythonScopeCaptures } from '../../src/core/ingestion/languages/python/captures.ts';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const FIXTURE_ROOT = path.resolve(__dirname, '..', '..', 'test', 'fixtures', 'lang-resolution');
// ---- correctness fingerprint (order-independent, mirrors the Go golden) ----
function canonicalizeMatch(match) {
const parts = [];
for (const tag of Object.keys(match)) {
const cap = match[tag];
const r = cap.range;
parts.push(`${tag}|${cap.text}|${r.startLine}:${r.startCol}-${r.endLine}:${r.endCol}`);
}
parts.sort();
return parts.join(';');
}
function digestCaptures(matches) {
const matchStrings = matches.map(canonicalizeMatch).sort();
return crypto.createHash('sha256').update(matchStrings.join('\n')).digest('hex');
}
/** All `.py` files under `lang-resolution/python-*`, sorted by repo-relative key. */
function collectPythonFixtures() {
const out = [];
for (const entry of fs.readdirSync(FIXTURE_ROOT, { withFileTypes: true })) {
if (!entry.isDirectory() || !entry.name.startsWith('python-')) continue;
const stack = [path.join(FIXTURE_ROOT, entry.name)];
while (stack.length) {
const dir = stack.pop();
for (const c of fs.readdirSync(dir, { withFileTypes: true })) {
const p = path.join(dir, c.name);
if (c.isDirectory()) stack.push(p);
else if (c.name.endsWith('.py')) {
out.push({ key: path.relative(FIXTURE_ROOT, p).split(path.sep).join('/'), absPath: p });
}
}
}
}
out.sort((a, b) => a.key.localeCompare(b.key));
return out;
}
/**
* Synthetic DAO-style source: top-level imports + N classes (each with methods,
* exercising @scope.function + @declaration.function + receiver binding) + N
* module functions. Maximizes top-level children (rootChildren) AND function
* matches, which is exactly the O(matches x rootChildren) shape #1848 hit.
*/
function generatePyDao(entityCount) {
const lines = [];
for (let i = 0; i < 12; i++) {
lines.push(`from pkg.mod${i} import alpha${i}, beta${i}, gamma${i} as g${i}`);
lines.push(`import top.level.module${i}`);
}
lines.push('');
for (let i = 0; i < entityCount; i++) {
const n = String(i).padStart(4, '0');
lines.push(
`class Entity${n}:`,
` def __init__(self, id: int, name: str):`,
` self.id = id`,
` self.name = name`,
` def get_id(self) -> int:`,
` return self.id`,
` def set_name(self, name: str) -> None:`,
` self.name = name`,
` @classmethod`,
` def make(cls, id: int):`,
` return cls(id, "x")`,
'',
`def build_entity${n}(id: int, name: str) -> Entity${n}:`,
` return Entity${n}(id, name)`,
'',
);
}
return lines.join('\n');
}
// ---- timing ----
function timeOnce(src, filePath) {
const start = process.hrtime.bigint();
const matches = emitPythonScopeCaptures(src, filePath);
const end = process.hrtime.bigint();
return { ms: Number(end - start) / 1e6, count: matches.length };
}
function median(xs) {
const s = [...xs].sort((a, b) => a - b);
const m = Math.floor(s.length / 2);
return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2;
}
function measureSize(entityCount, reps) {
const src = generatePyDao(entityCount);
// Warm up parser/query JIT (not counted).
timeOnce(src, 'warmup.py');
const samples = [];
let count = 0;
for (let i = 0; i < reps; i++) {
const r = timeOnce(src, `bench-${entityCount}.py`);
samples.push(r.ms);
count = r.count;
}
return { ms: median(samples), count };
}
// ---- run ----
function computeFingerprint() {
let groups = 0;
const perFixtureDigests = [];
for (const { key, absPath } of collectPythonFixtures()) {
const src = fs.readFileSync(absPath, 'utf8');
const matches = emitPythonScopeCaptures(src, absPath);
groups += matches.length;
perFixtureDigests.push(`${key}\t${matches.length}\t${digestCaptures(matches)}`);
}
// Fixed 20-entity synthetic source so the fingerprint is comparable across
// experiments independent of the timing sizes.
const daoMatches = emitPythonScopeCaptures(generatePyDao(20), 'synthetic-dao-20.py');
groups += daoMatches.length;
perFixtureDigests.push(`synthetic:dao-20\t${daoMatches.length}\t${digestCaptures(daoMatches)}`);
const fingerprint = crypto
.createHash('sha256')
.update(perFixtureDigests.sort().join('\n'))
.digest('hex');
return { fingerprint, groups, fixtureCount: perFixtureDigests.length };
}
// Higher rep count keeps the median stable on noisy shared CI runners.
const REPS = 7;
const SCALING_BUDGET = 1.5; // ~3.2 (quadratic) vs ~1.0 (linear); 1.5 has headroom.
const CHECK = process.argv.includes('--check');
const fp = computeFingerprint();
const small = measureSize(250, REPS);
const large = measureSize(800, REPS);
const scalingRatio = small.ms > 0 ? large.ms / small.ms / (800 / 250) : 0;
const result = {
elapsed_ms_250: Number(small.ms.toFixed(2)),
elapsed_ms_800: Number(large.ms.toFixed(2)),
scaling_ratio: Number(scalingRatio.toFixed(3)),
capture_groups_250: small.count,
capture_groups_800: large.count,
fingerprint: fp.fingerprint,
capture_groups_fp: fp.groups,
fixture_count: fp.fixtureCount,
};
if (!CHECK) {
process.stdout.write(JSON.stringify(result) + '\n');
} else {
// CI gate: capture output unchanged (fingerprint == committed baseline) AND
// the path is still linear (scaling ratio under budget). Re-baseline a
// legitimate capture change with `node --import tsx measure.mjs` (no --check)
// and commit the new baseline-fingerprint.txt deliberately.
const baselinePath = path.resolve(__dirname, 'baseline-fingerprint.txt');
const baseline = fs.readFileSync(baselinePath, 'utf8').trim();
const failures = [];
if (result.fingerprint !== baseline) {
failures.push(
`capture fingerprint drift: got ${result.fingerprint}, expected ${baseline} ` +
`(emitPythonScopeCaptures output changed — re-baseline intentionally if expected)`,
);
}
if (result.scaling_ratio >= SCALING_BUDGET) {
failures.push(
`capture scaling ratio ${result.scaling_ratio} >= ${SCALING_BUDGET} ` +
`(possible O(n^2) regression; 250->800ms ${result.elapsed_ms_250}->${result.elapsed_ms_800})`,
);
}
process.stdout.write(JSON.stringify(result) + '\n');
if (failures.length > 0) {
for (const f of failures) process.stderr.write(`[measure --check] FAIL: ${f}\n`);
process.exit(1);
}
process.stderr.write('[measure --check] PASS (capture fingerprint + scaling)\n');
}
@@ -1,31 +0,0 @@
{
"_comment": "Per-language baselines for bench/scope-capture/measure.mjs --check. fingerprint = order-independent sha256 over the lang-resolution/<lang>-* fixture corpus + a 20-entity synthetic source (correctness gate; re-baseline intentionally on a legitimate capture change). scaling_budget = max allowed (t800/t250)/(800/250); ~1.0 is linear, ~3.2 is quadratic. All six languages now thread the tree-sitter captured node instead of re-deriving it with findNodeAtRange(tree.rootNode,...) per match, so all are linear (go #1915, python #1918, ruby/php/rust/csharp this PR).",
"go": {
"fingerprint": "faca3555c61ed6980d2b739bf6b1cac7f4ad4644968a27e4687532d9835cd4c7",
"scaling_budget": 1.5
},
"cobol": {
"fingerprint": "575016f329c0be29eb90db974f750d02a21b4a12515f7029bda312df713b27b0",
"scaling_budget": 1.5
},
"csharp": {
"fingerprint": "bdc7803046011876b2d21ae38e9cb8c97ca1e01769f93ca8affe9317585427bf",
"scaling_budget": 1.5
},
"rust": {
"fingerprint": "025f5b6d4cf1d8cc42033f1f6b592f8d5428e571939c7f61df4d34b4bbe14be3",
"scaling_budget": 1.5
},
"php": {
"fingerprint": "00fe6e83cebd67c5f346fedb4234ebedf192995f9f171a424551cb792a0b91a9",
"scaling_budget": 1.5
},
"ruby": {
"fingerprint": "0f44b0d153b4534866589db93c582928651238b319cb606f2c6396362770cc18",
"scaling_budget": 1.5
},
"swift": {
"fingerprint": "e6870c409c1005944c51dffd6e485005bb206f7afcbc2ef078e0c2f781c2b2ad",
"scaling_budget": 1.5
}
}
-280
View File
@@ -1,280 +0,0 @@
/**
* Unified build-free scope-capture measurement harness for every currently
* benchmarked language (the ones with a `*-pipeline-benchmark.test.ts`):
* go, csharp, rust, php, ruby, cobol, swift — plus python lives in its own
* `bench/python-scope/` harness (richer: it also covers import resolution).
*
* For each language it:
* - times `emit<Lang>ScopeCaptures` on a synthetic DAO-style source at two
* sizes (250 / 800 top-level entities), reporting elapsed_ms + a scaling
* ratio `(t_large/t_small)/(800/250)`: ~1.0 is linear, ~3.2 is quadratic
* (the O(matches × rootChildren) shape #1848 hit in Go);
* - computes an order-independent sha256 fingerprint over the whole
* `lang-resolution/<lang>-*` fixture corpus + a fixed 20-entity synthetic
* source, as the correctness gate.
*
* Build-free: imports the `.ts` hotpaths through tsx
* (`node --import tsx bench/scope-capture/measure.mjs`). Static `.ts` imports
* work; a top-level `await import()` breaks tsx's lexer.
*
* Without args: prints one JSON object per language.
* With `--check`: asserts each language's fingerprint == its committed baseline
* (baselines.json) AND scaling_ratio < that language's recorded budget; exits
* non-zero on any drift/regression.
*/
import fs from 'node:fs';
import path from 'node:path';
import crypto from 'node:crypto';
import { fileURLToPath } from 'node:url';
import { emitGoScopeCaptures } from '../../src/core/ingestion/languages/go/index.ts';
import { emitCsharpScopeCaptures } from '../../src/core/ingestion/languages/csharp/index.ts';
import { emitRustScopeCaptures } from '../../src/core/ingestion/languages/rust/index.ts';
import { emitPhpScopeCaptures } from '../../src/core/ingestion/languages/php/index.ts';
import { emitRubyScopeCaptures } from '../../src/core/ingestion/languages/ruby/index.ts';
import { emitCobolScopeCaptures } from '../../src/core/ingestion/languages/cobol/index.ts';
import { emitSwiftScopeCaptures } from '../../src/core/ingestion/languages/swift/index.ts';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const FIXTURE_ROOT = path.resolve(__dirname, '..', '..', 'test', 'fixtures', 'lang-resolution');
const BASELINE_PATH = path.resolve(__dirname, 'baselines.json');
// ---- correctness fingerprint (order-independent; mirrors python harness) ----
function canonicalizeMatch(match) {
const parts = [];
for (const tag of Object.keys(match)) {
const cap = match[tag];
if (cap === undefined || cap === null || cap.range === undefined) {
parts.push(`${tag}|<no-range>`);
continue;
}
const r = cap.range;
parts.push(`${tag}|${cap.text}|${r.startLine}:${r.startCol}-${r.endLine}:${r.endCol}`);
}
parts.sort();
return parts.join(';');
}
function digestCaptures(matches) {
return crypto
.createHash('sha256')
.update(matches.map(canonicalizeMatch).sort().join('\n'))
.digest('hex');
}
/** All fixture files for a language, sorted by repo-relative key. */
function collectFixtures(prefix, exts) {
const out = [];
for (const entry of fs.readdirSync(FIXTURE_ROOT, { withFileTypes: true })) {
if (!entry.isDirectory() || !entry.name.startsWith(`${prefix}-`)) continue;
const stack = [path.join(FIXTURE_ROOT, entry.name)];
while (stack.length) {
const dir = stack.pop();
for (const c of fs.readdirSync(dir, { withFileTypes: true })) {
const p = path.join(dir, c.name);
if (c.isDirectory()) stack.push(p);
else if (exts.some((e) => c.name.endsWith(e))) {
out.push({ key: path.relative(FIXTURE_ROOT, p).split(path.sep).join('/'), absPath: p });
}
}
}
}
out.sort((a, b) => a.key.localeCompare(b.key));
return out;
}
// ---- per-language config: synthetic DAO generators + fixture globs ----
const LANGS = [
{
name: 'go',
emit: emitGoScopeCaptures,
fixturePrefix: 'go',
exts: ['.go'],
file: 'bench.go',
header: 'package generated\n\n',
unit: (n) =>
`type Entity${n} struct {\n\tid int64\n\tname string\n}\n\n` +
`func (e *Entity${n}) GetID() int64 { return e.id }\n` +
`func (e *Entity${n}) SetName(v string) { e.name = v }\n\n`,
},
{
name: 'csharp',
emit: emitCsharpScopeCaptures,
fixturePrefix: 'csharp',
exts: ['.cs'],
file: 'bench.cs',
header: 'namespace Generated;\n\n',
unit: (n) =>
`public class Entity${n} {\n` +
` public long Id;\n public string Name;\n` +
` public long GetId() { return Id; }\n` +
` public void SetName(string v) { Name = v; }\n}\n\n`,
},
{
name: 'rust',
emit: emitRustScopeCaptures,
fixturePrefix: 'rust',
exts: ['.rs'],
file: 'bench.rs',
header: '',
unit: (n) =>
`struct Entity${n} {\n id: i64,\n name: String,\n}\n\n` +
`impl Entity${n} {\n` +
` fn get_id(&self) -> i64 { self.id }\n` +
` fn set_name(&mut self, v: String) { self.name = v; }\n}\n\n`,
},
{
name: 'php',
emit: emitPhpScopeCaptures,
fixturePrefix: 'php',
exts: ['.php'],
file: 'bench.php',
header: '<?php\n\n',
unit: (n) =>
`class Entity${n} {\n` +
` public $id;\n public $name;\n` +
` function getId() { return $this->id; }\n` +
` function setName($v) { $this->name = $v; }\n}\n\n`,
},
{
name: 'ruby',
emit: emitRubyScopeCaptures,
fixturePrefix: 'ruby',
exts: ['.rb'],
file: 'bench.rb',
header: '',
unit: (n) =>
`class Entity${n}\n` +
` def get_id\n @id\n end\n` +
` def set_name(v)\n @name = v\n end\nend\n\n`,
},
{
name: 'cobol',
emit: emitCobolScopeCaptures,
fixturePrefix: 'cobol',
exts: ['.cbl', '.cpy'],
file: 'bench.cbl',
header:
' IDENTIFICATION DIVISION.\n' +
' PROGRAM-ID. BENCH.\n' +
' PROCEDURE DIVISION.\n',
unit: (n) => ` PARA-${String(n).padStart(5, '0')}.\n DISPLAY "P${n}".\n`,
},
{
name: 'swift',
emit: emitSwiftScopeCaptures,
fixturePrefix: 'swift',
exts: ['.swift'],
file: 'bench.swift',
header: '',
unit: (n) =>
`class Entity${n} {\n` +
` var id: Int64 = 0\n var name: String = ""\n` +
` func getId() -> Int64 { return self.id }\n` +
` func setName(_ v: String) { self.name = v }\n}\n\n`,
},
];
function generate(lang, entityCount) {
let src = lang.header;
for (let i = 0; i < entityCount; i++) src += lang.unit(i);
return src;
}
// ---- timing ----
function median(xs) {
const s = [...xs].sort((a, b) => a - b);
const m = Math.floor(s.length / 2);
return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2;
}
function timeEmit(emit, src, file, reps) {
emit(src, `warmup-${file}`); // warm parser/query JIT (not counted)
const samples = [];
let count = 0;
for (let i = 0; i < reps; i++) {
const start = process.hrtime.bigint();
const out = emit(src, file);
samples.push(Number(process.hrtime.bigint() - start) / 1e6);
count = out.length;
}
return { ms: median(samples), count };
}
const SMALL = 250;
const LARGE = 800;
const REPS = 7;
function measureLang(lang) {
// Correctness fingerprint over the fixture corpus + a fixed 20-entity source.
const perFixture = [];
let groups = 0;
for (const { key, absPath } of collectFixtures(lang.fixturePrefix, lang.exts)) {
const matches = lang.emit(fs.readFileSync(absPath, 'utf8'), absPath);
groups += matches.length;
perFixture.push(`${key}\t${matches.length}\t${digestCaptures(matches)}`);
}
const daoMatches = lang.emit(generate(lang, 20), `synthetic-dao-20${path.extname(lang.file)}`);
groups += daoMatches.length;
perFixture.push(`synthetic:dao-20\t${daoMatches.length}\t${digestCaptures(daoMatches)}`);
const fingerprint = crypto
.createHash('sha256')
.update(perFixture.sort().join('\n'))
.digest('hex');
// Scaling.
const small = timeEmit(lang.emit, generate(lang, SMALL), lang.file, REPS);
const large = timeEmit(lang.emit, generate(lang, LARGE), lang.file, REPS);
const scalingRatio = small.ms > 0 ? large.ms / small.ms / (LARGE / SMALL) : 0;
return {
language: lang.name,
elapsed_ms_small: Number(small.ms.toFixed(2)),
elapsed_ms_large: Number(large.ms.toFixed(2)),
scaling_ratio: Number(scalingRatio.toFixed(3)),
capture_groups_small: small.count,
capture_groups_large: large.count,
fingerprint,
capture_groups_fp: groups,
fixture_count: perFixture.length,
};
}
// ---- run ----
const CHECK = process.argv.includes('--check');
const results = LANGS.map(measureLang);
if (!CHECK) {
for (const r of results) process.stdout.write(JSON.stringify(r) + '\n');
} else {
const baselines = JSON.parse(fs.readFileSync(BASELINE_PATH, 'utf8'));
const failures = [];
for (const r of results) {
const base = baselines[r.language];
if (base === undefined) {
failures.push(`${r.language}: no baseline recorded`);
continue;
}
if (r.fingerprint !== base.fingerprint) {
failures.push(
`${r.language}: capture fingerprint drift (got ${r.fingerprint}, expected ${base.fingerprint})`,
);
}
if (r.scaling_ratio >= base.scaling_budget) {
failures.push(
`${r.language}: scaling ratio ${r.scaling_ratio} >= budget ${base.scaling_budget} ` +
`(${SMALL}->${LARGE} ms ${r.elapsed_ms_small}->${r.elapsed_ms_large})`,
);
}
process.stdout.write(JSON.stringify(r) + '\n');
}
if (failures.length > 0) {
for (const f of failures) process.stderr.write(`[scope-capture --check] FAIL: ${f}\n`);
process.exit(1);
}
process.stderr.write(`[scope-capture --check] PASS (${results.length} languages)\n`);
}
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "gitnexus",
"version": "1.6.6-rc.107",
"version": "1.6.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "gitnexus",
"version": "1.6.6-rc.107",
"version": "1.6.5",
"hasInstallScript": true,
"license": "PolyForm-Noncommercial-1.0.0",
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "gitnexus",
"version": "1.6.6-rc.107",
"version": "1.6.5",
"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",
+16 -63
View File
@@ -40,8 +40,6 @@ import { getLanguageFromFilename, SupportedLanguages } from 'gitnexus-shared';
import { isRegistryPrimary } from './registry-primary-flag.js';
import { isVerboseIngestionEnabled } from './utils/verbose.js';
import {
ALWAYS_ON_SLOW_FILE_WARN_THROTTLE_MS,
alwaysOnSlowFileWarnMs,
deferredCallFileSlowMs,
deferredCallLogEveryN,
getDeferredProfileDroppedCount,
@@ -410,7 +408,7 @@ const findEnclosingFunction = (
while (current) {
if (FUNCTION_NODE_TYPES.has(current.type)) {
const efnResult = provider.methodExtractor?.extractFunctionName?.(current, filePath);
const efnResult = provider.methodExtractor?.extractFunctionName?.(current);
const funcName = efnResult?.funcName ?? genericFuncName(current);
const label = efnResult?.label ?? inferFunctionLabel(current.type);
@@ -925,7 +923,6 @@ export const processCalls = async (
const importedReturnTypes = importedReturnTypesMap?.get(file.path);
const importedRawReturnTypes = importedRawReturnTypesMap?.get(file.path);
const typeEnv = buildTypeEnv(tree, language, {
filePath: file.path,
model: ctx.model,
parentMap,
importedBindings,
@@ -1036,18 +1033,15 @@ export const processCalls = async (
? { declaredType: routedFieldInfo.type }
: {}),
});
// Only emit File -> Property DEFINES for top-level properties (issue #1944).
if (!propEnclosingClassId) {
const relId = generateId('DEFINES', `${fileId}->${nodeId}`);
graph.addRelationship({
id: relId,
sourceId: fileId,
targetId: nodeId,
type: 'DEFINES',
confidence: 1.0,
reason: '',
});
}
const relId = generateId('DEFINES', `${fileId}->${nodeId}`);
graph.addRelationship({
id: relId,
sourceId: fileId,
targetId: nodeId,
type: 'DEFINES',
confidence: 1.0,
reason: '',
});
if (propEnclosingClassId) {
graph.addRelationship({
id: generateId('HAS_PROPERTY', `${propEnclosingClassId}->${nodeId}`),
@@ -1297,8 +1291,7 @@ export const processCalls = async (
while (p) {
if (FUNCTION_NODE_TYPES.has(p.type)) {
const funcName =
provider.methodExtractor?.extractFunctionName?.(p, file.path)?.funcName ??
genericFuncName(p);
provider.methodExtractor?.extractFunctionName?.(p)?.funcName ?? genericFuncName(p);
if (funcName) {
scope = `${funcName}@${p.startIndex}`;
break;
@@ -2937,15 +2930,6 @@ export const processCallsFromExtracted = async (
const logEveryN = profileCalls ? deferredCallLogEveryN() : 0;
let skippedRegistryPrimaryFiles = 0;
// Always-on slow-file watchdog (#1741). Independent of the verbose/profile
// gate above: even a plain `analyze` run surfaces ONE actionable warning
// when a single file's call resolution is pathologically slow — turning the
// silent "stuck at Resolving calls (N/M)" symptom into a named culprit.
// Throttled so a genuinely slow repo can't produce a warn storm.
const alwaysSlowFileMs = alwaysOnSlowFileWarnMs();
let lastSlowFileWarnAt = 0;
let suppressedSlowFileWarnings = 0;
// Fresh dropped-log counter per analyze run — the module-private counter
// in deferred-resolution-profile.ts is process-lived, so without a reset
// here it would accumulate across consecutive analyze invocations in the
@@ -2957,16 +2941,12 @@ export const processCallsFromExtracted = async (
// denominator stays stable as the loop iterates. Otherwise `${totalFiles -
// skippedRegistryPrimaryFiles}` drifts upward — files iterated before later
// registry-primary skips have been seen carry an inflated denominator, and
// the ratio only self-corrects after every file has been classified.
//
// Runs whenever its result will actually be read: on the profile path (the
// live deferred-profile log) OR when the always-on slow-file watchdog is
// active (#1741) — the watchdog's warning prints `${resolvedFiles}/${resolvedTotal}`
// unconditionally, so leaving resolvedTotal at 0 on a plain run produced a
// bogus "Resolved N/0 files" denominator on exactly the unprofiled runs the
// watchdog exists for. When both gates are off, skip the extra Map pass.
// the ratio only self-corrects after every file has been classified. Pre-
// count runs only on the enabled path so the disabled path stays free of
// the extra Map iteration. Defaults to 0 on the disabled path; the live log
// gate is also disabled there, so the value is never read.
let resolvedTotal = 0;
if (profileCalls || alwaysSlowFileMs > 0) {
if (profileCalls) {
for (const filePath of byFile.keys()) {
const lang = getLanguageFromFilename(filePath);
if (!lang || !isRegistryPrimary(lang)) resolvedTotal++;
@@ -2990,9 +2970,6 @@ export const processCallsFromExtracted = async (
resolvedFiles++;
const tFile = startTimer(profileCalls);
// Always-on timer (cheap: one hrtime read) feeding the slow-file watchdog
// below. Distinct from `tFile`, which is null unless profiling is on.
const tFileAlways = alwaysSlowFileMs > 0 ? process.hrtime.bigint() : null;
if (profileCalls && (resolvedFiles === 1 || resolvedFiles % logEveryN === 0)) {
logDeferredProfile(
@@ -3166,30 +3143,6 @@ export const processCallsFromExtracted = async (
);
}
}
// Always-on slow-file watchdog (#1741) — fires regardless of verbose.
if (tFileAlways !== null) {
const elapsedAlways = profileElapsedMs(tFileAlways);
if (elapsedAlways >= alwaysSlowFileMs) {
const now = Date.now();
if (now - lastSlowFileWarnAt >= ALWAYS_ON_SLOW_FILE_WARN_THROTTLE_MS) {
lastSlowFileWarnAt = now;
const suppressedNote =
suppressedSlowFileWarnings > 0
? ` (+${suppressedSlowFileWarnings} more slow files since the last warning)`
: '';
logger.warn(
`⏳ Call resolution for ${filePath} took ${(elapsedAlways / 1000).toFixed(1)}s ` +
`(${calls.length} call sites, ${fileLanguage ?? 'unknown'}). The run is not frozen — ` +
`this file is unusually expensive to resolve. Resolved ${resolvedFiles}/${resolvedTotal} ` +
`files so far.${suppressedNote} Pass -v for per-file deferred-resolution timing.`,
);
suppressedSlowFileWarnings = 0;
} else {
suppressedSlowFileWarnings++;
}
}
}
}
if (profileCalls) {
@@ -73,6 +73,11 @@ const CSHARP_DECL_TYPES = new Set([
'struct_declaration',
'enum_declaration',
'record_declaration',
// tree-sitter-c-sharp absorbs 'record struct' and 'record class' into
// record_declaration — these two node types are listed defensively but
// never emitted by the grammar in practice (verified against ^0.23.1).
'record_struct_declaration',
'record_class_declaration',
'delegate_declaration',
'property_declaration',
'field_declaration',
@@ -45,7 +45,12 @@ export const dartConfig: FieldExtractionConfig = {
// declaration > type_identifier (first named child usually)
for (let i = 0; i < node.namedChildCount; i++) {
const child = node.namedChild(i);
if (child && (child.type === 'type_identifier' || child.type === 'function_type')) {
if (
child &&
(child.type === 'type_identifier' ||
child.type === 'generic_type' ||
child.type === 'function_type')
) {
return extractSimpleTypeName(child) ?? child.text?.trim();
}
}
@@ -53,7 +53,8 @@ export const phpConfig: FieldExtractionConfig = {
child.type === 'named_type' ||
child.type === 'optional_type' ||
child.type === 'primitive_type' ||
child.type === 'intersection_type'
child.type === 'intersection_type' ||
child.type === 'nullable_type'
) {
return extractSimpleTypeName(child) ?? child.text?.trim();
}
@@ -22,7 +22,7 @@ const SWIFT_VIS = new Set<FieldVisibility>([
*/
export const swiftConfig: FieldExtractionConfig = {
language: SupportedLanguages.Swift,
typeDeclarationNodes: ['class_declaration', 'protocol_declaration'],
typeDeclarationNodes: ['class_declaration', 'struct_declaration', 'protocol_declaration'],
fieldNodeTypes: ['property_declaration'],
bodyNodeTypes: ['class_body', 'protocol_body'],
defaultVisibility: 'internal',
@@ -86,6 +86,18 @@ export class TypeScriptFieldExtractor extends BaseFieldExtractor {
}
}
// Check for modifier node (tree-sitter typescript may group these)
const modifiers = node.childForFieldName('modifiers');
if (modifiers) {
for (let i = 0; i < modifiers.childCount; i++) {
const modifier = modifiers.child(i);
const modText = modifier?.text.trim() as FieldVisibility | undefined;
if (modText && TypeScriptFieldExtractor.VISIBILITY_MODIFIERS.has(modText)) {
return modText;
}
}
}
// TypeScript class members are public by default
return 'public';
}
@@ -101,6 +113,16 @@ export class TypeScriptFieldExtractor extends BaseFieldExtractor {
}
}
const modifiers = node.childForFieldName('modifiers');
if (modifiers) {
for (let i = 0; i < modifiers.childCount; i++) {
const modifier = modifiers.child(i);
if (modifier && modifier.text === 'static') {
return true;
}
}
}
return false;
}
@@ -115,6 +137,16 @@ export class TypeScriptFieldExtractor extends BaseFieldExtractor {
}
}
const modifiers = node.childForFieldName('modifiers');
if (modifiers) {
for (let i = 0; i < modifiers.childCount; i++) {
const modifier = modifiers.child(i);
if (modifier && modifier.text === 'readonly') {
return true;
}
}
}
return false;
}
@@ -145,10 +145,6 @@ export function finalizeScopeModel(
// consumes the bundle. Most languages leave it empty.
bindingAugmentations: new Map(),
workspaceFqnBindings: new Map(),
workspaceTypeBindings: new Map(),
namespaceFqnBindings: new Map(),
namespaceTypeBindings: new Map(),
accessibleNamespacesByScope: new Map(),
referenceSites: Object.freeze([...allReferenceSites]),
sccs: finalizeOut.sccs,
stats: finalizeOut.stats,
@@ -1,99 +1,28 @@
/**
* Swift import resolution config.
* Package.swift target map strategy — no standard fallback (unresolved = external framework).
*
* ## Performance (anti-O(imports × files))
*
* The previous implementation rescanned the whole `normalizedFileList`
* on every import to collect the `.swift` files under the requested
* target's directory — O(imports × files) per run, the exact hot path
* fixed for Python in PR #1918. We now build a `target → files` index
* ONCE per run, memoized on the stable `allFileList` array reference
* (the same `ResolveCtx` — and therefore the same array — is passed to
* every strategy invocation, per `import-processor`'s build-once
* context). Lookup per import is then O(1).
*
* Behavior is preserved bit-for-bit: a file is attributed to a target
* iff its **forward-slash (backslash-normalized), case-sensitive** path
* starts with `<targetDir>/`, matching the old
* `normalizedFileList[i].startsWith(targetDir + '/')` comparison
* (`normalizedFileList` is only backslash→forward-slash normalized — NOT
* lowercased — so the match is case-sensitive); the returned paths are
* the original-case `allFileList` entries; and the per-target file ORDER
* follows `allFileList`, so the emitted `{ kind: 'files', files }` set and
* ordering are identical to the old scan.
*/
import { SupportedLanguages } from 'gitnexus-shared';
import type { ImportResolutionConfig, ImportResolverStrategy, ResolveCtx } from '../types.js';
interface SwiftTargetIndex {
/** Target name → original-case `.swift` file paths under that target dir. */
readonly byTarget: ReadonlyMap<string, string[]>;
}
/**
* Memoized on the `allFileList` array identity. `import-processor` builds
* the `ResolveCtx` once per run and threads the same object (and the same
* `allFileList`) through every strategy call, so the WeakMap is keyed on a
* stable reference and the index is built once — not once per import. A
* fresh run produces a fresh array → a fresh index, so cross-run staleness
* is impossible.
*/
const SWIFT_TARGET_INDEX_CACHE = new WeakMap<object, SwiftTargetIndex>();
function getSwiftTargetIndex(
ctx: ResolveCtx,
targets: ReadonlyMap<string, string>,
): SwiftTargetIndex {
const key = ctx.allFileList as object;
const cached = SWIFT_TARGET_INDEX_CACHE.get(key);
if (cached !== undefined) return cached;
// Pre-compute each target's directory prefix once (original case, to
// match the legacy comparison against the forward-slash-normalized,
// case-sensitive file list — see module docstring).
const targetPrefixes: { name: string; prefix: string }[] = [];
const byTarget = new Map<string, string[]>();
for (const [name, dir] of targets) {
targetPrefixes.push({ name, prefix: dir + '/' });
byTarget.set(name, []);
}
// Single pass over the file list. `normalizedFileList` is forward-slash
// (backslash-normalized), case-sensitive, and index-aligned with
// `allFileList`; attribute the original-case path to every target whose
// prefix the normalized path starts with (a file under a nested target
// dir can legitimately belong to multiple configured targets — the
// legacy per-import scan would have returned it for each).
for (let i = 0; i < ctx.allFileList.length; i++) {
const norm = ctx.normalizedFileList[i];
if (!norm.endsWith('.swift')) continue;
for (const { name, prefix } of targetPrefixes) {
if (norm.startsWith(prefix)) {
byTarget.get(name)!.push(ctx.allFileList[i]);
}
}
}
const index: SwiftTargetIndex = { byTarget };
SWIFT_TARGET_INDEX_CACHE.set(key, index);
return index;
}
import type { ImportResolutionConfig, ImportResolverStrategy } from '../types.js';
/** Swift Package.swift target map resolution strategy. */
export const swiftPackageStrategy: ImportResolverStrategy = (rawImportPath, _filePath, ctx) => {
const swiftPackageConfig = ctx.configs.swiftPackageConfig;
if (swiftPackageConfig) {
// Only the targets map is needed; build the index lazily so repos
// without a Package.swift config pay nothing.
if (swiftPackageConfig.targets.has(rawImportPath)) {
const index = getSwiftTargetIndex(ctx, swiftPackageConfig.targets);
const files = index.byTarget.get(rawImportPath);
if (files !== undefined && files.length > 0) {
// Copy so callers can't mutate the cached index bucket.
return { kind: 'files', files: [...files] };
const targetDir = swiftPackageConfig.targets.get(rawImportPath);
if (targetDir) {
const dirPrefix = targetDir + '/';
const files: string[] = [];
for (let i = 0; i < ctx.normalizedFileList.length; i++) {
if (
ctx.normalizedFileList[i].startsWith(dirPrefix) &&
ctx.normalizedFileList[i].endsWith('.swift')
) {
files.push(ctx.allFileList[i]);
}
}
if (files.length > 0) return { kind: 'files', files };
}
}
return null; // External framework (Foundation, UIKit, etc.)
@@ -25,7 +25,7 @@ import { tryResolveWithExtensions } from './utils.js';
export function resolvePythonImportInternal(
currentFile: string,
importPath: string,
allFiles: ReadonlySet<string>,
allFiles: Set<string>,
): string | null {
// Relative import — PEP 328 (https://peps.python.org/pep-0328/)
if (importPath.startsWith('.')) {
@@ -57,10 +57,7 @@ export const EXTENSIONS = [
* Try to match a path (with extensions) against the known file set.
* Returns the matched file path or null.
*/
export function tryResolveWithExtensions(
basePath: string,
allFiles: ReadonlySet<string>,
): string | null {
export function tryResolveWithExtensions(basePath: string, allFiles: Set<string>): string | null {
for (const ext of EXTENSIONS) {
const candidate = basePath + ext;
if (allFiles.has(candidate)) return candidate;
@@ -27,9 +27,9 @@ export function emitCScopeCaptures(
const rawMatches = getCScopeQuery().matches(tree.rootNode);
const out: CaptureMatch[] = [];
// Track ranges where typedef-struct/union/enum was captured as its concrete
// type so we can suppress the duplicate @declaration.typedef match.
const concreteTypedefRanges = new Set<string>();
// Track ranges where typedef-struct/union was captured as @declaration.struct/union
// so we can suppress the duplicate @declaration.typedef match at the same range.
const structTypedefRanges = new Set<string>();
for (const m of rawMatches) {
const grouped: Record<string, Capture> = {};
@@ -53,22 +53,19 @@ export function emitCScopeCaptures(
}
}
// Track typedef struct/union/enum ranges to suppress duplicate typedef declarations
const concreteTypeAnchor =
grouped['@declaration.struct'] ??
grouped['@declaration.union'] ??
grouped['@declaration.enum'];
if (concreteTypeAnchor !== undefined) {
const r = concreteTypeAnchor.range;
concreteTypedefRanges.add(`${r.startLine}:${r.startCol}:${r.endLine}:${r.endCol}`);
// Track typedef-struct ranges to suppress duplicate typedef declarations
const structAnchor = grouped['@declaration.struct'] ?? grouped['@declaration.union'];
if (structAnchor !== undefined) {
const r = structAnchor.range;
structTypedefRanges.add(`${r.startLine}:${r.startCol}:${r.endLine}:${r.endCol}`);
}
// Suppress @declaration.typedef if the same range was already captured as a concrete type.
// Suppress @declaration.typedef if the same range was already captured as struct/union
const typedefAnchor = grouped['@declaration.typedef'];
if (typedefAnchor !== undefined) {
const r = typedefAnchor.range;
const key = `${r.startLine}:${r.startCol}:${r.endLine}:${r.endCol}`;
if (concreteTypedefRanges.has(key)) continue;
if (structTypedefRanges.has(key)) continue;
}
// Enrich function declarations with arity metadata and detect static linkage
@@ -41,12 +41,6 @@ const C_SCOPE_QUERY = `
(enum_specifier
name: (type_identifier) @declaration.name) @declaration.enum
;; Declarations — enum (typedef enum { ... } Name)
(type_definition
type: (enum_specifier
body: (enumerator_list))
declarator: (type_identifier) @declaration.name) @declaration.enum
;; Declarations — function definition
(function_definition
declarator: (function_declarator
@@ -33,6 +33,7 @@ export function computeCppDeclarationArity(node: SyntaxNode): CppArityInfo {
if (
child.type === 'parameter_declaration' ||
child.type === 'optional_parameter_declaration' ||
child.type === 'variadic_parameter' ||
child.type === 'variadic_parameter_declaration'
) {
params.push(child);
@@ -59,7 +60,11 @@ export function computeCppDeclarationArity(node: SyntaxNode): CppArityInfo {
// token in tree-sitter-cpp, detected via `hasEllipsis` above.
// C++ parameter packs: `template<typename... Ts> void foo(Ts... args)` —
// detected as `variadic_parameter_declaration`.
const isVariadic = hasEllipsis || params.some((p) => p.type === 'variadic_parameter_declaration');
const isVariadic =
hasEllipsis ||
params.some(
(p) => p.type === 'variadic_parameter' || p.type === 'variadic_parameter_declaration',
);
const optionalCount = params.filter((p) => p.type === 'optional_parameter_declaration').length;
const requiredCount = params.filter(
(p) =>
@@ -72,7 +77,10 @@ export function computeCppDeclarationArity(node: SyntaxNode): CppArityInfo {
const types: string[] = [];
const typeClasses: ParameterTypeClass[] = [];
for (const p of params) {
if (p.type === 'variadic_parameter_declaration') {
if (p.type === 'variadic_parameter') {
types.push('...');
typeClasses.push(unknownTypeClass('...'));
} else if (p.type === 'variadic_parameter_declaration') {
// Parameter pack: treated as variadic
types.push('...');
typeClasses.push(unknownTypeClass('...'));
@@ -15,7 +15,7 @@ import {
computeCppCallArity,
} from './arity-metadata.js';
import { markCppAnonymousNamespaceRange, markFileLocal } from './file-local-linkage.js';
import { markCppDependentBase, markCppDependentPackBase } from './two-phase-lookup.js';
import { markCppDependentBase } from './two-phase-lookup.js';
import { markCppAdlSiteArgs, markCppAdlSiteNoAdl, type CppAdlArgInfo } from './adl.js';
import { markCppInlineNamespaceRange } from './inline-namespaces.js';
import { extractCppTemplateConstraints } from './constraint-extractor.js';
@@ -35,9 +35,9 @@ export function emitCppScopeCaptures(
const rawMatches = getCppScopeQuery().matches(tree.rootNode);
const out: CaptureMatch[] = [];
// Track ranges where typedef-struct/enum was captured as its concrete type
// Track ranges where typedef-struct was captured as @declaration.struct
// so we can suppress the duplicate @declaration.typedef match.
const concreteTypedefRanges = new Set<string>();
const structTypedefRanges = new Set<string>();
for (const m of rawMatches) {
const grouped: Record<string, Capture> = {};
@@ -74,14 +74,11 @@ export function emitCppScopeCaptures(
}
}
// ── Track concrete typedef ranges ───────────────────────────────
const concreteTypeAnchor =
grouped['@declaration.struct'] ??
grouped['@declaration.class'] ??
grouped['@declaration.enum'];
if (concreteTypeAnchor !== undefined) {
const r = concreteTypeAnchor.range;
concreteTypedefRanges.add(`${r.startLine}:${r.startCol}:${r.endLine}:${r.endCol}`);
// ── Track typedef-struct ranges ─────────────────────────────────
const structAnchor = grouped['@declaration.struct'] ?? grouped['@declaration.class'];
if (structAnchor !== undefined) {
const r = structAnchor.range;
structTypedefRanges.add(`${r.startLine}:${r.startCol}:${r.endLine}:${r.endCol}`);
}
// Suppress @declaration.typedef if the same range was already captured
@@ -89,7 +86,7 @@ export function emitCppScopeCaptures(
if (typedefAnchor !== undefined) {
const r = typedefAnchor.range;
const key = `${r.startLine}:${r.startCol}:${r.endLine}:${r.endCol}`;
if (concreteTypedefRanges.has(key)) continue;
if (structTypedefRanges.has(key)) continue;
}
// ── Enrich function/method declarations with arity metadata ─────
@@ -413,7 +410,7 @@ export function emitCppScopeCaptures(
// captures consumed by the registry-primary graph bridge. The lookup name
// is normalized to the bare class name so `Base<T>` / `outer::v1::Base<T>`
// resolve through V1's simple-name `findClassBindingInScope('Base')`.
emitCppInheritanceCaptures(tree.rootNode, out, filePath);
emitCppInheritanceCaptures(tree.rootNode, out);
// ── Detect dependent-base relationships for two-phase template lookup ──
// Walk the tree once, finding every `template_declaration` whose
@@ -462,7 +459,7 @@ function isCppUnsupportedReturnTypeDeclarator(funcDeclarator: SyntaxNode): boole
* here instead of introducing a C++-only name-resolution lane in shared
* ingestion infrastructure.
*/
function emitCppInheritanceCaptures(root: SyntaxNode, out: CaptureMatch[], filePath: string): void {
function emitCppInheritanceCaptures(root: SyntaxNode, out: CaptureMatch[]): void {
const stack: SyntaxNode[] = [root];
while (stack.length > 0) {
const node = stack.pop()!;
@@ -470,15 +467,11 @@ function emitCppInheritanceCaptures(root: SyntaxNode, out: CaptureMatch[], fileP
const baseClause = findChildOfType(node, ['base_class_clause']);
if (baseClause !== null) {
for (const base of iterBaseClasses(baseClause)) {
if (base.isPackExpansion) {
markClassWithPackExpandedBase(filePath, node);
continue;
}
const baseName = extractBaseLookupName(base.node);
const baseName = extractBaseLookupName(base);
if (baseName.length === 0) continue;
out.push({
'@reference.inherits': nodeToCapture('@reference.inherits', base.node),
'@reference.name': syntheticCapture('@reference.name', base.node, baseName),
'@reference.inherits': nodeToCapture('@reference.inherits', base),
'@reference.name': syntheticCapture('@reference.name', base, baseName),
});
}
}
@@ -519,12 +512,9 @@ function detectCppDependentBases(root: SyntaxNode, filePath: string): void {
const baseClause = findChildOfType(classNode, ['base_class_clause']);
if (baseClause !== null) {
for (const base of iterBaseClasses(baseClause)) {
if (base.isPackExpansion || isBaseDependent(base.node, params)) {
if (base.isPackExpansion) {
markClassWithPackExpandedBase(filePath, classNode);
}
const baseName = extractBaseLookupName(base.node);
const baseQualifier = extractBaseLookupQualifier(base.node);
if (isBaseDependent(base, params)) {
const baseName = extractBaseLookupName(base);
const baseQualifier = extractBaseLookupQualifier(base);
if (baseName !== '') {
markCppDependentBase(filePath, className, baseName, baseQualifier);
}
@@ -573,18 +563,8 @@ function collectTemplateParameterNames(templateDecl: SyntaxNode): Set<string> {
return names;
}
function markClassWithPackExpandedBase(filePath: string, classNode: SyntaxNode): void {
const className = getTypeIdentifierName(classNode);
if (className !== '') markCppDependentPackBase(filePath, className);
}
interface CppBaseClassEntry {
readonly node: SyntaxNode;
readonly isPackExpansion: boolean;
}
/** Yield each base-class entry from a `base_class_clause`. */
function* iterBaseClasses(baseClause: SyntaxNode): IterableIterator<CppBaseClassEntry> {
function* iterBaseClasses(baseClause: SyntaxNode): IterableIterator<SyntaxNode> {
for (let i = 0; i < baseClause.childCount; i++) {
const child = baseClause.child(i);
if (child === null) continue;
@@ -595,22 +575,11 @@ function* iterBaseClasses(baseClause: SyntaxNode): IterableIterator<CppBaseClass
child.type === 'template_type' ||
child.type === 'qualified_identifier'
) {
yield { node: child, isPackExpansion: isFollowedByPackExpansion(baseClause, i) };
yield child;
}
}
}
function isFollowedByPackExpansion(baseClause: SyntaxNode, childIndex: number): boolean {
for (let i = childIndex + 1; i < baseClause.childCount; i++) {
const sibling = baseClause.child(i);
if (sibling === null) continue;
if (sibling.type === '...' || (!sibling.isNamed && sibling.text === '...')) return true;
if (sibling.type === ',' || sibling.type === 'access_specifier') return false;
if (sibling.isNamed) return false;
}
return false;
}
/**
* A base is dependent when:
* - it's a `template_type` and its argument list contains a
@@ -1363,7 +1332,7 @@ function lookupAdlIdentifierType(identNode: SyntaxNode): CppAdlArgInfo | null {
inner = next;
continue;
}
if (inner.type === 'reference_declarator') {
if (inner.type === 'reference_declarator' || inner.type === 'rvalue_reference_declarator') {
// reference_declarator has a single child (the inner declarator).
let next: SyntaxNode | null = null;
for (let j = 0; j < inner.namedChildCount; j++) {
@@ -48,12 +48,6 @@ const CPP_SCOPE_QUERY = `
(template_argument_list) @declaration.template-arguments)
body: (field_declaration_list)) @declaration.struct
;; Declarations — struct (typedef struct { ... } Name)
(type_definition
type: (struct_specifier
body: (field_declaration_list))
declarator: (type_identifier) @declaration.name) @declaration.struct
;; ─── Declarations — class / struct inside template_declaration ───────
(template_declaration
(class_specifier
@@ -83,12 +77,6 @@ const CPP_SCOPE_QUERY = `
(enum_specifier
name: (type_identifier) @declaration.name) @declaration.enum
;; ─── Declarations — enum (typedef enum { ... } Name) ─────────────────
(type_definition
type: (enum_specifier
body: (enumerator_list))
declarator: (type_identifier) @declaration.name) @declaration.enum
;; ─── Declarations — enum constants ───────────────────────────────────
(enumerator
name: (identifier) @declaration.name) @declaration.const
@@ -46,13 +46,6 @@ import { findEnclosingClassDef } from '../../scope-resolution/scope/walkers.js';
*/
const dependentBasesByFile = new Map<string, Map<string, Map<string, Set<string>>>>();
/**
* Class templates with pack-expanded bases (`struct Mix : Bases...`) have
* an unknown set of base classes. Unqualified member lookup inside the class
* cannot safely bind to class-owned methods outside the current class.
*/
const dependentPackBaseClassesByFile = new Map<string, Set<string>>();
/**
* Post-`populateOwners` resolution: per-class-nodeId, the set of
* dependent-base-class nodeIds. Built by `populateCppDependentBases`
@@ -95,19 +88,9 @@ export function markCppDependentBase(
quals.add(qualifier);
}
export function markCppDependentPackBase(filePath: string, className: string): void {
let perFile = dependentPackBaseClassesByFile.get(filePath);
if (perFile === undefined) {
perFile = new Set();
dependentPackBaseClassesByFile.set(filePath, perFile);
}
perFile.add(className);
}
/** Clear two-phase-lookup state. Called from `clearFileLocalNames`. */
export function clearCppDependentBases(): void {
dependentBasesByFile.clear();
dependentPackBaseClassesByFile.clear();
dependentBaseNodeIds.clear();
}
@@ -127,7 +110,7 @@ export function clearCppDependentBases(): void {
* found (conservative: avoids false associations).
*/
export function populateCppDependentBases(parsedFiles: readonly ParsedFile[]): void {
if (dependentBasesByFile.size === 0 && dependentPackBaseClassesByFile.size === 0) return;
if (dependentBasesByFile.size === 0) return;
// Build workspace-wide index: simpleName → {nodeId, nsPrefix}[]
// nsPrefix is the dot-joined namespace path (qualifiedName without the
@@ -181,16 +164,6 @@ export function populateCppDependentBases(parsedFiles: readonly ParsedFile[]): v
localClassByName.set(simple, { nodeId: def.nodeId, nsPrefix });
}
const packBaseClasses = dependentPackBaseClassesByFile.get(filePath);
if (packBaseClasses !== undefined) {
for (const className of packBaseClasses) {
const classEntry = localClassByName.get(className);
if (classEntry !== undefined) {
dependentBaseNodeIds.set(classEntry.nodeId, new Set(['*pack-expansion*']));
}
}
}
// V3: qualifier-based exact targeting. When the base specifier carries
// a syntactic qualifier (e.g., `detail` in `detail::Inner<T>`), compute
// the expected namespace prefix and use exact (===) match. Falls back to
@@ -297,31 +270,10 @@ export function isCppDependentBaseMember(
candidateDef: SymbolDefinition,
scopes: ScopeResolutionIndexes,
): boolean {
if (candidateDef.ownerId === undefined) return false;
const enclosing = findEnclosingClassDef(callerScopeId, scopes);
if (enclosing === undefined) return false;
const bases = dependentBaseNodeIds.get(enclosing.nodeId);
if (bases === undefined) return false;
if (bases.has('*pack-expansion*')) {
if (candidateDef.ownerId !== undefined) return candidateDef.ownerId !== enclosing.nodeId;
if (candidateDef.type !== 'Method' && candidateDef.type !== 'Constructor') return false;
const ownerName = getQualifiedParentName(candidateDef.qualifiedName);
const enclosingName = getQualifiedSimpleName(enclosing.qualifiedName);
return ownerName !== undefined && ownerName !== enclosingName;
}
if (candidateDef.ownerId === undefined) return false;
return bases.has(candidateDef.ownerId);
}
function getQualifiedParentName(qualifiedName: string | undefined): string | undefined {
if (qualifiedName === undefined) return undefined;
const lastDot = qualifiedName.lastIndexOf('.');
if (lastDot < 0) return undefined;
const parent = qualifiedName.slice(0, lastDot);
return getQualifiedSimpleName(parent);
}
function getQualifiedSimpleName(qualifiedName: string | undefined): string | undefined {
if (qualifiedName === undefined) return undefined;
const lastDot = qualifiedName.lastIndexOf('.');
return lastDot >= 0 ? qualifiedName.slice(lastDot + 1) : qualifiedName;
}
@@ -17,7 +17,7 @@
*/
import type { Capture, CaptureMatch } from 'gitnexus-shared';
import { nodeIfType, nodeToCapture, syntheticCapture } from '../../utils/ast-helpers.js';
import { findNodeAtRange, nodeToCapture, syntheticCapture } from '../../utils/ast-helpers.js';
import { splitUsingDirective } from './import-decomposer.js';
import { computeCsharpArityMetadata } from './arity-metadata.js';
import { synthesizeCsharpReceiverBinding } from './receiver-binding.js';
@@ -103,16 +103,9 @@ export function emitCsharpScopeCaptures(
// `@`; we put it back so the central extractor's prefix lookups
// (`@scope.`, `@declaration.`, …) work.
const grouped: Record<string, Capture> = {};
// Parallel tag -> captured SyntaxNode map: the query hands us each matched
// node as c.node, so anchors resolve via a type-guarded lookup (nodeIfType)
// instead of re-deriving them with findNodeAtRange(tree.rootNode, ...) per
// match — the O(matches x rootChildren) root-walk fixed for go #1915 /
// python #1918, mirrored here.
const nodeMap: Record<string, SyntaxNode> = {};
for (const c of m.captures) {
const tag = '@' + c.name;
grouped[tag] = nodeToCapture(tag, c.node);
nodeMap[tag] = c.node;
}
if (Object.keys(grouped).length === 0) continue;
@@ -120,7 +113,8 @@ export function emitCsharpScopeCaptures(
// the kind/source/name/alias markers it consumes. Raw query match
// only carries the @import.statement anchor.
if (grouped['@import.statement'] !== undefined) {
const stmtNode = nodeIfType(nodeMap['@import.statement'], 'using_directive');
const stmtCapture = grouped['@import.statement'];
const stmtNode = findNodeAtRange(tree.rootNode, stmtCapture.range, 'using_directive');
if (stmtNode !== null) {
const decomposed = splitUsingDirective(stmtNode);
if (decomposed !== null) {
@@ -135,7 +129,8 @@ export function emitCsharpScopeCaptures(
}
if (grouped['@reference.read.member'] !== undefined) {
const memberNode = nodeIfType(nodeMap['@reference.read.member'], 'member_access_expression');
const anchor = grouped['@reference.read.member'];
const memberNode = findNodeAtRange(tree.rootNode, anchor.range, 'member_access_expression');
if (memberNode === null || !shouldEmitReadMember(memberNode)) {
continue;
}
@@ -149,7 +144,8 @@ export function emitCsharpScopeCaptures(
// `@scope.function` matches.
if (grouped['@scope.function'] !== undefined) {
out.push(grouped);
const fnNode = nodeIfType(nodeMap['@scope.function'], ...FUNCTION_NODE_TYPES);
const anchor = grouped['@scope.function']!;
const fnNode = findFunctionNode(tree.rootNode, anchor.range);
if (fnNode !== null) {
for (const synth of synthesizeCsharpReceiverBinding(fnNode)) {
out.push(synth);
@@ -164,7 +160,8 @@ export function emitCsharpScopeCaptures(
// the first tag that matches.
const declTag = FUNCTION_DECL_TAGS.find((t) => grouped[t] !== undefined);
if (declTag !== undefined) {
const fnNode = nodeIfType(nodeMap[declTag], ...FUNCTION_NODE_TYPES);
const anchor = grouped[declTag]!;
const fnNode = findFunctionNode(tree.rootNode, anchor.range);
if (fnNode !== null) {
const arity = computeCsharpArityMetadata(fnNode);
if (arity.parameterCount !== undefined) {
@@ -201,11 +198,10 @@ export function emitCsharpScopeCaptures(
['@reference.call.free', '@reference.call.member', '@reference.call.constructor'] as const
).find((t) => grouped[t] !== undefined);
if (callTag !== undefined && grouped['@reference.arity'] === undefined) {
const callNode = nodeIfType(
nodeMap[callTag],
'invocation_expression',
'object_creation_expression',
);
const anchor = grouped[callTag]!;
const callNode =
findNodeAtRange(tree.rootNode, anchor.range, 'invocation_expression') ??
findNodeAtRange(tree.rootNode, anchor.range, 'object_creation_expression');
if (callNode !== null) {
const argList = callNode.childForFieldName('arguments');
const args =
@@ -244,11 +240,10 @@ export function emitCsharpScopeCaptures(
grouped['@declaration.class'] !== undefined ||
grouped['@declaration.record'] !== undefined
) {
const typeNode = nodeIfType(
nodeMap['@declaration.class'] ?? nodeMap['@declaration.record'],
'class_declaration',
'record_declaration',
);
const anchor = grouped['@declaration.class'] ?? grouped['@declaration.record']!;
const typeNode =
findNodeAtRange(tree.rootNode, anchor.range, 'class_declaration') ??
findNodeAtRange(tree.rootNode, anchor.range, 'record_declaration');
if (typeNode !== null) {
const synth = synthesizePrimaryConstructor(typeNode);
if (synth !== null) out.push(synth);
@@ -293,9 +288,7 @@ function terminalTypeNameNode(node: SyntaxNode): SyntaxNode | null {
case 'qualified_name':
return node.lastNamedChild;
case 'generic_name':
// generic_name has no `name` field (verified by real parse, #1920); the
// base identifier is the first named child.
return node.firstNamedChild;
return node.childForFieldName('name') ?? node.firstNamedChild;
default:
return null;
}
@@ -398,3 +391,14 @@ function inferArgType(argNode: SyntaxNode): string {
return '';
}
}
/** Find the first C# function-like node at the given range. The
* declaration anchor range covers the whole method/constructor/etc.
* node, but the tag alone doesn't tell us which node type. */
function findFunctionNode(rootNode: SyntaxNode, range: Capture['range']): SyntaxNode | null {
for (const nodeType of FUNCTION_NODE_TYPES) {
const n = findNodeAtRange(rootNode, range, nodeType);
if (n !== null) return n as SyntaxNode;
}
return null;
}
@@ -18,20 +18,11 @@
* The orchestrator hands us its `treeCache` so files already parsed
* by `extractParsedFile` are re-used instead of re-parsed —
* `ParsedFile`'s underlying tree is the single source of truth.
* Group classes by namespace, then route cross-file sibling classes
* (and method return-type bindings) through SHARED, per-namespace
* channels — `namespaceFqnBindings` / `namespaceTypeBindings`, keyed by
* namespace name — populated ONCE per bucket with `origin: 'namespace'`,
* rather than copied into every sibling's per-scope `bindingAugmentations`
* / `Scope.typeBindings`. That per-file copy was O(files²) for a
* concentrated namespace and OOM'd large solutions (#1871; the global
* twin was fixed in #1905/#1954). The walkers consult these channels
* gated by `accessibleNamespacesByScope` (a file's own namespace +
* `using` targets), so visibility is unchanged — only the storage is
* shared. Finalized bindings remain first in `lookupBindingsAt`, and
* local lexical `Scope.bindings` remains the first-tier shadowing
* channel. (`using static` member exposure still uses the per-scope
* augmentation channel — it is per-import and not part of the blow-up.)
* Group classes by namespace, and append cross-file sibling classes
* into each Namespace scope's `bindingAugmentations` bucket with
* `origin: 'namespace'`. Finalized bindings remain first in
* `lookupBindingsAt`, and local lexical `Scope.bindings` remains the
* first-tier shadowing channel.
*
* The tree-sitter walk is authoritative: it sees `global using static`,
* aliased `using static X = Y.Z;`, attributed namespace declarations,
@@ -45,14 +36,7 @@
*/
import type { SyntaxNode } from 'tree-sitter';
import type {
BindingRef,
ParsedFile,
Scope,
ScopeId,
SymbolDefinition,
TypeRef,
} from 'gitnexus-shared';
import type { BindingRef, ParsedFile, Scope, ScopeId, SymbolDefinition } from 'gitnexus-shared';
import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js';
import { getCsharpParser } from './query.js';
@@ -466,83 +450,41 @@ export function populateCsharpNamespaceSiblings(
// `indexes.bindings`.
const augmentations = indexes.bindingAugmentations as Map<ScopeId, Map<string, BindingRef[]>>;
// Global ('') namespace type-binding propagation (#1871). Types in the C#
// unnamed/global namespace form a single declaration space that is
// type-visible from EVERY file — including files inside named namespaces
// (C# spec: "any identifier in the global namespace is available for use in
// a named namespace"). Route their module-scope return-type bindings through
// the scope-independent `workspaceTypeBindings` channel ONCE, rather than
// copying every global file's bindings into every other file's
// `Scope.typeBindings`. That per-file copy was O(files × distinct-names) in
// both time and memory and OOM'd large no-namespace solutions (tens of
// thousands of files in the global bucket → billions of Map entries). This
// mirrors how Roslyn resolves against a single `Compilation.GlobalNamespace`
// symbol instead of per-file copies, and is the typeBindings analogue of the
// `workspaceFqnBindings` fast-path (#1905). `findReceiverTypeBinding` /
// `followChainPostFinalize` consult this channel as a final fallback.
const workspaceTypeBindings = indexes.workspaceTypeBindings as Map<string, TypeRef>;
const globalBucket = buckets.get('');
if (globalBucket !== undefined) {
for (const scopeInfo of globalBucket.scopes) {
if (scopeInfo.scope.kind !== 'Module') continue;
for (const [boundName, typeRef] of scopeInfo.scope.typeBindings) {
// First-wins, matching the old per-importer `has(boundName)` skip:
// same-name collisions across global files are inherently ambiguous.
if (!workspaceTypeBindings.has(boundName)) {
workspaceTypeBindings.set(boundName, typeRef);
}
}
}
}
// Named-namespace type-binding propagation (#1871). Method return-type
// bindings are routed through the per-namespace `namespaceTypeBindings`
// channel — populated ONCE per named bucket — instead of copied into every
// sibling/importer file's Module `Scope.typeBindings`. That per-file copy was
// O(files²) for a concentrated named namespace (the global twin of which #1954
// already moved to `workspaceTypeBindings`); a solution with all files under
// one `namespace App;` reproduced the same OOM. The chain-walkers
// (`findReceiverTypeBinding`, `followChainPostFinalize`) consult this channel
// gated by `accessibleNamespacesByScope`, so a file sees exactly the
// namespaces it could before (its own + `using`d), just shared not copied.
const namespaceTypeBindings = indexes.namespaceTypeBindings as Map<string, Map<string, TypeRef>>;
for (const [nsName, bucket] of buckets) {
if (nsName === '') continue; // global → workspaceTypeBindings (above)
let nsMap = namespaceTypeBindings.get(nsName);
for (const scopeInfo of bucket.scopes) {
if (scopeInfo.scope.kind !== 'Module') continue;
for (const [boundName, typeRef] of scopeInfo.scope.typeBindings) {
if (nsMap === undefined) {
nsMap = new Map<string, TypeRef>();
namespaceTypeBindings.set(nsName, nsMap);
}
// First-wins, matching the old per-importer `has(boundName)` skip.
if (!nsMap.has(boundName)) nsMap.set(boundName, typeRef);
}
}
}
// Materialize each file's accessible namespaces (its own declared namespaces +
// every `using namespace X;` target, plus dotted-path prefixes), keyed by the
// file's Module scope id. This is the SAME accessibility set the per-file copy
// above used to derive — now stored so the language-neutral walkers can gate
// their `namespaceFqnBindings` / `namespaceTypeBindings` lookups to exactly the
// namespaces a file can see. The global ('') namespace is excluded: it is
// type-visible everywhere and handled by the flat workspace channels.
const accessibleNamespacesByScope = indexes.accessibleNamespacesByScope as Map<ScopeId, string[]>;
// Cross-namespace type-binding propagation: for each file, mirror
// method return-type bindings from same-namespace sibling files and
// from files in namespaces the importer `using`s, into the
// importer's Module scope typeBindings. This enables
// chain-follow from `var u = svc.GetUser()` → `GetUser → User`
// even across files — without it the chain stalls at `GetUser`
// because the return binding lives in the defining file's Module
// scope, which isn't an ancestor of the importer's scope chain.
for (const parsed of parsedFiles) {
const moduleScope = parsed.scopes.find((s) => s.kind === 'Module');
if (moduleScope === undefined) continue;
const moduleTypeBindings = moduleScope.typeBindings as Map<
string,
import('gitnexus-shared').TypeRef
>;
// Accessible namespaces = this file's own namespaces + every
// `using namespace X;` target. Source of truth is the cached AST
// structure captured above.
const accessibleNamespaces = new Set<string>();
const struct = structureByFile.get(parsed.filePath);
if (struct !== undefined) {
for (const n of struct.namespaces) accessibleNamespaces.add(n);
}
if (accessibleNamespaces.size === 0) accessibleNamespaces.add('');
for (const imp of parsed.parsedImports) {
if (imp.kind === 'namespace' && imp.targetRaw !== null) {
accessibleNamespaces.add(imp.targetRaw);
}
}
// For each accessible namespace, also walk up the dotted path —
// `using static X.Y.Z;` targets a type, so the real namespace is
// `X.Y`. Both parse into `accessibleNamespaces` as-is; we probe
// the bucket map with every prefix.
const expandedNamespaces = new Set<string>(accessibleNamespaces);
for (const ns of accessibleNamespaces) {
const segments = ns.split('.');
@@ -550,9 +492,18 @@ export function populateCsharpNamespaceSiblings(
expandedNamespaces.add(segments.slice(0, i).join('.'));
}
}
expandedNamespaces.delete(''); // global handled by the flat workspace channels
if (expandedNamespaces.size > 0) {
accessibleNamespacesByScope.set(moduleScope.id, [...expandedNamespaces]);
for (const nsName of expandedNamespaces) {
const bucket = buckets.get(nsName);
if (bucket === undefined) continue;
for (const scopeInfo of bucket.scopes) {
if (scopeInfo.filePath === parsed.filePath) continue;
if (scopeInfo.scope.kind !== 'Module') continue;
for (const [boundName, typeRef] of scopeInfo.scope.typeBindings) {
if (moduleTypeBindings.has(boundName)) continue;
moduleTypeBindings.set(boundName, typeRef);
}
}
}
}
@@ -619,14 +570,43 @@ export function populateCsharpNamespaceSiblings(
}
}
// Cross-namespace `using X;` class visibility is no longer injected per-file
// here (#1871). It is now subsumed by the per-namespace `namespaceFqnBindings`
// channel populated below: a file's `using` targets are already in its
// `accessibleNamespacesByScope` entry (materialized above), so `lookupBindingsAt`
// consults `namespaceFqnBindings[targetNs]` for it. Routing through the shared
// channel instead of copying each target bucket's classes into every importer's
// augmentation removes an O(importers × bucket-size) fanout on popular
// namespaces — the cross-namespace twin of the same-namespace blow-up.
// Cross-namespace imports: for each file's `using X;` directive,
// if `X` matches a known namespace bucket, inject that bucket's
// classes into the importer's module scope. This is what makes
// `new User()` in `namespace App;` resolve to `User` declared in
// a sibling file with `namespace Models;` when the importer says
// `using Models;`. Legacy uses csproj directory↔namespace mapping;
// the scope-resolver layer uses the declared namespace directly.
for (const parsed of parsedFiles) {
const moduleScope = parsed.scopes.find((s) => s.kind === 'Module');
if (moduleScope === undefined) continue;
// Per-file de-dup sets keyed by simple name, seeded lazily from the
// augmentation bucket — replaces the per-def O(A) `.some` scan below.
const seenByName = new Map<string, Set<string>>();
for (const imp of parsed.parsedImports) {
if (imp.kind !== 'namespace') continue;
const targetNs = imp.targetRaw;
if (targetNs === null || targetNs === '') continue;
const bucket = buckets.get(targetNs);
if (bucket === undefined) continue;
for (const def of bucket.classDefs) {
if (def.filePath === parsed.filePath) continue;
const q = def.qualifiedName ?? '';
const simpleName = q.includes('.') ? q.slice(q.lastIndexOf('.') + 1) : q;
if (simpleName === '') continue;
const bucketArr = getAugmentationBucket(augmentations, moduleScope.id, simpleName);
let seen = seenByName.get(simpleName);
if (seen === undefined) {
seen = new Set<string>();
for (const b of bucketArr) seen.add(b.def.nodeId);
seenByName.set(simpleName, seen);
}
if (seen.has(def.nodeId)) continue;
seen.add(def.nodeId);
bucketArr.push({ def, origin: 'namespace' });
}
}
}
// Workspace-level binding channel for global-namespace types (see the
// global fast-path below). `lookupBindingsAt` consults this as a third
@@ -636,13 +616,6 @@ export function populateCsharpNamespaceSiblings(
// ReadonlyMap→Map cast is localized to this one line and all writes go
// through `getWorkspaceBucket`.
const workspace = indexes.workspaceFqnBindings as Map<string, BindingRef[]>;
// Per-namespace binding channel for NAMED-namespace class visibility — the
// namespace-scoped analogue of `workspace`, populated once per named bucket
// (#1871). Replaces the per-scope `bindingAugmentations` fanout (O(scopes ×
// defs)) for same-namespace siblings AND the per-file cross-namespace-`using`
// injection above. `lookupBindingsAt` consults it gated by
// `accessibleNamespacesByScope`.
const namespaceFqn = indexes.namespaceFqnBindings as Map<string, Map<string, BindingRef[]>>;
for (const [nsName, bucket] of buckets) {
// Group sibling defs by simple name. Append in place — the previous
@@ -690,37 +663,46 @@ export function populateCsharpNamespaceSiblings(
continue;
}
// Named-namespace fast path (#1871) — the namespace-scoped mirror of the
// global path above. One `namespaceFqnBindings[nsName]` entry per simple
// name, O(D) per bucket, instead of the prior O(scopes × defs) per-scope
// augmentation that materialized O(files²) BindingRefs for a concentrated
// named namespace. `lookupBindingsAt` consults this gated by
// `accessibleNamespacesByScope` and ranks finalized `scope.bindings`
// (local declarations) ABOVE it, so local types still shadow — exactly as
// `walkScopeChain` shadows the global workspace entries. Dedup by
// `def.nodeId` keeps partial-class / duplicate declarations from
// double-emitting. (The prior local-shadow and self-reference skips are now
// handled by lookup precedence, identical to the global path.)
let nsMap = namespaceFqn.get(nsName);
for (const [name, defs] of defsByName) {
let bucketArr = nsMap?.get(name);
let seen: Set<string> | null = null;
for (const def of defs) {
if (bucketArr === undefined) {
if (nsMap === undefined) {
nsMap = new Map<string, BindingRef[]>();
namespaceFqn.set(nsName, nsMap);
// Pre-index the first scope per file once (O(S)) instead of an
// O(S) `.find` re-run for every (scope, name) pair, which made the
// injection loop O(S²·D) and was the dominant cost on large buckets.
// Multiple scopes share a filePath (Module + Namespace); the local
// shadow check only needs that file's lexical `Scope.bindings`, which
// is identical regardless of which of those scopes we read.
const firstScopeByFile = new Map<string, Scope>();
for (const s of bucket.scopes) {
if (!firstScopeByFile.has(s.filePath)) firstScopeByFile.set(s.filePath, s.scope);
}
for (const { scopeId, filePath } of bucket.scopes) {
const localScope = firstScopeByFile.get(filePath);
for (const [name, defs] of defsByName) {
// Skip names already present locally — `origin: 'local'` in
// scope.bindings would naturally shadow the cross-file
// namespace entry, but we also keep this index lean.
const local = localScope?.bindings.get(name);
if (local !== undefined && local.some((b) => b.origin === 'local')) continue;
// Bind the augmentation bucket and its seeded de-dup set together
// under one nullable lifecycle, so neither needs a non-null
// assertion (they are always set or unset as a pair). Stays lazy:
// nothing is allocated for a name with no cross-file defs.
let inject: { bucket: BindingRef[]; seen: Set<string> } | null = null;
for (const def of defs) {
if (def.filePath === filePath) continue; // don't self-reference
if (inject === null) {
const bucket = getAugmentationBucket(augmentations, scopeId, name);
// Seed the de-dup set from any entries an earlier pass
// (using-static / cross-namespace imports) already added,
// replacing the per-def O(A) `.some` scan.
const seen = new Set<string>();
for (const b of bucket) seen.add(b.def.nodeId);
inject = { bucket, seen };
}
bucketArr = [];
nsMap.set(name, bucketArr);
if (inject.seen.has(def.nodeId)) continue;
inject.seen.add(def.nodeId);
inject.bucket.push({ def, origin: 'namespace' });
}
if (seen === null) {
seen = new Set<string>();
for (const b of bucketArr) seen.add(b.def.nodeId);
}
if (seen.has(def.nodeId)) continue;
seen.add(def.nodeId);
bucketArr.push({ def, origin: 'namespace' });
}
}
}
@@ -165,15 +165,10 @@ export function emitJavaScopeCaptures(
findNodeAtRange(tree.rootNode, anchor.range, 'object_creation_expression');
if (callNode !== null) {
const argList = callNode.childForFieldName('arguments');
// Exclude interleaved comments — tree-sitter-java emits `block_comment` /
// `line_comment` as named children of argument_list, which would inflate
// arity (and arity feeds call-processor symbol-ID generation). #1920
const args =
argList === null
? []
: argList.namedChildren.filter(
(c) => c !== null && c.type !== 'block_comment' && c.type !== 'line_comment',
);
: argList.namedChildren.filter((c) => c !== null && c.type !== 'comment');
grouped['@reference.arity'] = syntheticCapture(
'@reference.arity',
callNode,
@@ -41,11 +41,6 @@ import { synthesizeTsReceiverBinding } from '../typescript/receiver-binding.js';
import { isArrayMethodCallbackArrow } from '../typescript/array-callback.js';
import { getTreeSitterBufferSize } from '../../constants.js';
import { parseSourceSafe } from '../../../tree-sitter/safe-parse.js';
import {
deriveDefaultExportHocName,
isBlockedDefaultExportHoc,
isDefaultExportHocFunctionNode,
} from '../../ts-js-hoc-utils.js';
/** JS function-like node types that may carry a synthesized `this` binding.
* Kept in sync with the `@scope.function` patterns in `query.ts`. */
@@ -659,20 +654,6 @@ export function emitJsScopeCaptures(
if (arrowNode !== null && isArrayMethodCallbackArrow(arrowNode)) {
continue;
}
if (arrowNode !== null && isBlockedDefaultExportHoc(arrowNode)) {
continue;
}
}
if (fnDeclAnchor !== undefined) {
const fnNode = findFunctionNode(tree.rootNode, fnDeclAnchor.range);
if (fnNode !== null && isDefaultExportHocFunctionNode(fnNode)) {
grouped['@declaration.name'] = syntheticCapture(
'@declaration.name',
fnNode,
deriveDefaultExportHocName(filePath),
);
}
}
// Synthesize arity metadata on function-like declarations.
@@ -48,10 +48,6 @@
import Parser from 'tree-sitter';
import JS from 'tree-sitter-javascript';
import {
ARRAY_METHOD_NOT_ANY_OF_PREDICATE,
DEFAULT_EXPORT_IDENTIFIER_NOT_ANY_OF_PREDICATE,
} from '../../ts-js-hoc-utils.js';
const JS_GRAMMAR = JS as Parameters<Parser['setLanguage']>[0];
@@ -158,13 +154,10 @@ const JAVASCRIPT_SCOPE_QUERY = `
;; Those are filtered out emit-side in captures.ts via
;; isArrayMethodCallbackArrow (member-expression callee whose property
;; is a known Array method), so only the @declaration.const survives.
;; Excludes common array methods (map, filter, reduce, etc.) to avoid
;; false positives like \`const x = arr.map(a => ...)\`.
(lexical_declaration
(variable_declarator
name: (identifier) @declaration.name
value: (call_expression
function: (identifier)
arguments: (arguments
(arrow_function) @declaration.function))))
@@ -172,36 +165,14 @@ const JAVASCRIPT_SCOPE_QUERY = `
(variable_declarator
name: (identifier) @declaration.name
value: (call_expression
function: (identifier)
arguments: (arguments
(function_expression) @declaration.function))))
(lexical_declaration
(variable_declarator
name: (identifier) @declaration.name
value: (call_expression
function: (member_expression
property: (property_identifier) @callee)
arguments: (arguments
(arrow_function) @declaration.function)))
${ARRAY_METHOD_NOT_ANY_OF_PREDICATE})
(lexical_declaration
(variable_declarator
name: (identifier) @declaration.name
value: (call_expression
function: (member_expression
property: (property_identifier) @callee)
arguments: (arguments
(function_expression) @declaration.function)))
${ARRAY_METHOD_NOT_ANY_OF_PREDICATE})
(export_statement
declaration: (lexical_declaration
(variable_declarator
name: (identifier) @declaration.name
value: (call_expression
function: (identifier)
arguments: (arguments
(arrow_function) @declaration.function)))))
@@ -210,37 +181,13 @@ const JAVASCRIPT_SCOPE_QUERY = `
(variable_declarator
name: (identifier) @declaration.name
value: (call_expression
function: (identifier)
arguments: (arguments
(function_expression) @declaration.function)))))
(export_statement
declaration: (lexical_declaration
(variable_declarator
name: (identifier) @declaration.name
value: (call_expression
function: (member_expression
property: (property_identifier) @callee)
arguments: (arguments
(arrow_function) @declaration.function))))
${ARRAY_METHOD_NOT_ANY_OF_PREDICATE})
(export_statement
declaration: (lexical_declaration
(variable_declarator
name: (identifier) @declaration.name
value: (call_expression
function: (member_expression
property: (property_identifier) @callee)
arguments: (arguments
(function_expression) @declaration.function))))
${ARRAY_METHOD_NOT_ANY_OF_PREDICATE})
(variable_declaration
(variable_declarator
name: (identifier) @declaration.name
value: (call_expression
function: (identifier)
arguments: (arguments
(arrow_function) @declaration.function))))
@@ -248,64 +195,9 @@ const JAVASCRIPT_SCOPE_QUERY = `
(variable_declarator
name: (identifier) @declaration.name
value: (call_expression
function: (identifier)
arguments: (arguments
(function_expression) @declaration.function))))
(variable_declaration
(variable_declarator
name: (identifier) @declaration.name
value: (call_expression
function: (member_expression
property: (property_identifier) @callee)
arguments: (arguments
(arrow_function) @declaration.function)))
${ARRAY_METHOD_NOT_ANY_OF_PREDICATE})
(variable_declaration
(variable_declarator
name: (identifier) @declaration.name
value: (call_expression
function: (member_expression
property: (property_identifier) @callee)
arguments: (arguments
(function_expression) @declaration.function)))
${ARRAY_METHOD_NOT_ANY_OF_PREDICATE})
;; HOC-wrapped default exports (JS parity with TS patterns in
;; languages/typescript/query.ts). The emit phase rewrites
;; @declaration.name to a file-derived name so wrapper helpers do not
;; become the graph-visible symbol name.
((export_statement
value: (call_expression
function: (identifier) @hoc
arguments: (arguments
(arrow_function) @declaration.function)))
${DEFAULT_EXPORT_IDENTIFIER_NOT_ANY_OF_PREDICATE})
((export_statement
value: (call_expression
function: (identifier) @hoc
arguments: (arguments
(function_expression) @declaration.function)))
${DEFAULT_EXPORT_IDENTIFIER_NOT_ANY_OF_PREDICATE})
((export_statement
value: (call_expression
function: (member_expression
property: (property_identifier) @callee)
arguments: (arguments
(arrow_function) @declaration.function)))
${ARRAY_METHOD_NOT_ANY_OF_PREDICATE})
((export_statement
value: (call_expression
function: (member_expression
property: (property_identifier) @callee)
arguments: (arguments
(function_expression) @declaration.function)))
${ARRAY_METHOD_NOT_ANY_OF_PREDICATE})
;; Variable / constant declarations (non-function values).
(lexical_declaration
(variable_declarator
@@ -32,7 +32,7 @@
*/
import type { Capture, CaptureMatch } from 'gitnexus-shared';
import { nodeIfType, nodeToCapture, syntheticCapture } from '../../utils/ast-helpers.js';
import { findNodeAtRange, nodeToCapture, syntheticCapture } from '../../utils/ast-helpers.js';
import { splitNamespaceUseDeclaration } from './import-decomposer.js';
import { computePhpArityMetadata } from './arity-metadata.js';
import { synthesizePhpReceiverBinding } from './receiver-binding.js';
@@ -102,16 +102,9 @@ export function emitPhpScopeCaptures(
// Group captures by their tag name. Tree-sitter strips the leading
// `@`; we put it back so the central extractor's prefix lookups work.
const grouped: Record<string, Capture> = {};
// Parallel tag -> captured SyntaxNode map: the query hands us each matched
// node as c.node, so anchors resolve via a type-guarded lookup (nodeIfType)
// instead of re-deriving them with findNodeAtRange(tree.rootNode, ...) per
// match — the O(matches x rootChildren) root-walk fixed for go #1915 /
// python #1918, mirrored here.
const nodeMap: Record<string, SyntaxNode> = {};
for (const c of m.captures) {
const tag = '@' + c.name;
grouped[tag] = nodeToCapture(tag, c.node);
nodeMap[tag] = c.node;
}
if (Object.keys(grouped).length === 0) continue;
@@ -182,7 +175,12 @@ export function emitPhpScopeCaptures(
// Decompose each `namespace_use_declaration` so `interpretPhpImport`
// sees the kind/source/name/alias markers it consumes.
if (grouped['@import.statement'] !== undefined) {
const stmtNode = nodeIfType(nodeMap['@import.statement'], 'namespace_use_declaration');
const stmtCapture = grouped['@import.statement'];
const stmtNode = findNodeAtRange(
tree.rootNode,
stmtCapture.range,
'namespace_use_declaration',
);
if (stmtNode !== null) {
const decomposed = splitNamespaceUseDeclaration(stmtNode);
if (decomposed.length > 0) {
@@ -199,7 +197,8 @@ export function emitPhpScopeCaptures(
// non-static method-like. Mirrors C#'s `this` / `base` synthesis.
if (grouped['@scope.function'] !== undefined) {
out.push(grouped);
const fnNode = nodeIfType(nodeMap['@scope.function'], ...FUNCTION_NODE_TYPES);
const anchor = grouped['@scope.function']!;
const fnNode = findFunctionNode(tree.rootNode, anchor.range);
if (fnNode !== null) {
for (const synth of synthesizePhpReceiverBinding(fnNode)) {
out.push(synth);
@@ -220,7 +219,8 @@ export function emitPhpScopeCaptures(
// registry can narrow overloads.
const declTag = FUNCTION_DECL_TAGS.find((t) => grouped[t] !== undefined);
if (declTag !== undefined) {
const fnNode = nodeIfType(nodeMap[declTag], ...FUNCTION_NODE_TYPES);
const anchor = grouped[declTag]!;
const fnNode = findFunctionNode(tree.rootNode, anchor.range);
if (fnNode !== null) {
const arity = computePhpArityMetadata(fnNode);
if (arity.parameterCount !== undefined) {
@@ -255,14 +255,13 @@ export function emitPhpScopeCaptures(
['@reference.call.free', '@reference.call.member', '@reference.call.constructor'] as const
).find((t) => grouped[t] !== undefined);
if (callTag !== undefined && grouped['@reference.arity'] === undefined) {
const callNode = nodeIfType(
nodeMap[callTag],
'function_call_expression',
'member_call_expression',
'nullsafe_member_call_expression',
'scoped_call_expression',
'object_creation_expression',
);
const anchor = grouped[callTag]!;
const callNode =
findNodeAtRange(tree.rootNode, anchor.range, 'function_call_expression') ??
findNodeAtRange(tree.rootNode, anchor.range, 'member_call_expression') ??
findNodeAtRange(tree.rootNode, anchor.range, 'nullsafe_member_call_expression') ??
findNodeAtRange(tree.rootNode, anchor.range, 'scoped_call_expression') ??
findNodeAtRange(tree.rootNode, anchor.range, 'object_creation_expression');
if (callNode !== null) {
const argList = callNode.childForFieldName('arguments');
const args: SyntaxNode[] = [];
@@ -294,6 +293,15 @@ export function emitPhpScopeCaptures(
return out;
}
/** Find the first PHP function-like node at the given range. */
function findFunctionNode(rootNode: SyntaxNode, range: Capture['range']): SyntaxNode | null {
for (const nodeType of FUNCTION_NODE_TYPES) {
const n = findNodeAtRange(rootNode, range, nodeType);
if (n !== null) return n as SyntaxNode;
}
return null;
}
// ─── PHP receiver normalization ──────────────────────────────────────────────
/**
@@ -116,7 +116,23 @@ function parseUseClause(clause: SyntaxNode, qualifier: PhpImportKind): PhpImport
const source = qualName.text.trim();
if (source === '') return null;
// Strategy: bare sibling `name` node after the qualified_name.
// Strategy 1: explicit alias_clause wrapper (older grammar versions).
const aliasClause = findNamedChild(clause, 'alias_clause');
if (aliasClause !== null) {
// alias_clause: "as" name
const aliasName = findNamedChild(aliasClause, 'name') ?? aliasClause.firstNamedChild;
const alias = aliasName?.text.trim() ?? '';
if (alias === '') return null;
return {
kind: 'alias',
source,
name: alias,
alias,
atNode: clause,
};
}
// Strategy 2: bare sibling `name` node after the qualified_name.
// tree-sitter-php (≥ 0.22) emits `use Foo\Bar as Baz` as:
// namespace_use_clause
// qualified_name "Foo\Bar"
@@ -215,7 +231,22 @@ function parseInnerClause(
const source = prefix !== '' ? `${prefix}\\${innerPath}` : innerPath;
// Strategy: bare sibling `name` node after the qualified_name (tree-sitter-php ≥ 0.22).
// Strategy 1: explicit alias_clause wrapper (older grammar versions).
const aliasClause = findNamedChild(clause, 'alias_clause');
if (aliasClause !== null) {
const aliasName = findNamedChild(aliasClause, 'name') ?? aliasClause.firstNamedChild;
const alias = aliasName?.text.trim() ?? '';
if (alias === '') return null;
return {
kind: 'alias',
source,
name: alias,
alias,
atNode: clause,
};
}
// Strategy 2: bare sibling `name` node after the qualified_name (tree-sitter-php ≥ 0.22).
if (clause.namedChildCount >= 2) {
const lastChild = clause.namedChild(clause.namedChildCount - 1);
if (lastChild !== null && lastChild !== qualName && lastChild.type === 'name') {
@@ -27,11 +27,6 @@ const TYPE_DECL_NODE_TYPES = new Set([
'interface_declaration',
'trait_declaration',
'enum_declaration',
// tree-sitter-php node for `new class {...}` (real node is `anonymous_class`,
// not `anonymous_class_declaration`). Included so the enclosing-type walk
// stops AT the anon class and the guard below skips it (otherwise a method in
// an anon class nested in a named class would mis-bind $this to the outer class).
'anonymous_class',
]);
const FUNCTION_NODE_TYPES = new Set([
@@ -103,7 +98,7 @@ export function synthesizePhpReceiverBinding(fnNode: SyntaxNode): CaptureMatch[]
if (enclosingType === null) return [];
// Anonymous class — skip (no stable name).
if (enclosingType.type === 'anonymous_class') return [];
if (enclosingType.type === 'anonymous_class_declaration') return [];
const enclosingName = typeName(enclosingType);
if (enclosingName === null) return [];
@@ -111,8 +106,10 @@ export function synthesizePhpReceiverBinding(fnNode: SyntaxNode): CaptureMatch[]
// Anchor the synthesized captures to the method body (compound_statement)
// so they land inside the function scope, not at the class scope.
// For interface/abstract methods that have no body, skip.
// tree-sitter-php arrow_function also exposes its expression via the `body` field.
const bodyNode = fnNode.childForFieldName('body');
const bodyNode =
fnNode.childForFieldName('body') ??
// arrow_function: body is the expression after `=>`
fnNode.childForFieldName('return_value');
if (bodyNode === null) return [];
const out: CaptureMatch[] = [];
@@ -17,7 +17,7 @@
*/
import type { Capture, CaptureMatch } from 'gitnexus-shared';
import { nodeToCapture, syntheticCapture, type SyntaxNode } from '../../utils/ast-helpers.js';
import { findNodeAtRange, nodeToCapture, syntheticCapture } from '../../utils/ast-helpers.js';
import { splitImportStatement } from './import-decomposer.js';
import { getPythonParser, getPythonScopeQuery } from './query.js';
import { synthesizeReceiverTypeBinding } from './receiver-binding.js';
@@ -66,30 +66,21 @@ export function emitPythonScopeCaptures(
// `@`; we put it back so the central extractor's prefix lookups
// (`@scope.`, `@declaration.`, …) work.
const grouped: Record<string, Capture> = {};
// Parallel tag -> captured SyntaxNode map. The tree-sitter query already
// hands us each matched node as `c.node`, so anchor nodes can be used
// directly (or via a bounded LOCAL walk) instead of re-deriving them with
// `findNodeAtRange(tree.rootNode, ...)`, which scanned all of root's named
// children on every match -> O(matches x rootChildren). That was the #1848
// hotpath in Go (fixed in eaf0a305); the same shape lived here in Python.
const nodeMap: Record<string, SyntaxNode> = {};
for (const c of m.captures) {
const tag = '@' + c.name;
grouped[tag] = nodeToCapture(tag, c.node);
nodeMap[tag] = c.node;
}
if (Object.keys(grouped).length === 0) continue;
if (grouped['@import.statement'] !== undefined) {
// `@import.statement` is captured directly ON the `import_statement` /
// `import_from_statement` node (query: `(import_statement) @import.statement`
// and `(import_from_statement) @import.statement`), so the captured node IS
// the one the old findNodeAtRange re-derived. `splitImportStatement`
// dispatches on those two types; a captured node of any other type would
// have made the old range+type lookup return null -> the defensive raw
// fallback, which the type guard below reproduces exactly.
const stmtNode = nodeMap['@import.statement']!;
if (stmtNode.type === 'import_from_statement' || stmtNode.type === 'import_statement') {
// Decompose multi-name imports. Both `import_statement` and
// `import_from_statement` share the matched range, so we try the
// `from` form first and fall back to plain.
const stmtCapture = grouped['@import.statement'];
const stmtNode =
findNodeAtRange(tree.rootNode, stmtCapture.range, 'import_from_statement') ??
findNodeAtRange(tree.rootNode, stmtCapture.range, 'import_statement');
if (stmtNode !== null) {
for (const piece of splitImportStatement(stmtNode)) out.push(piece);
} else {
// Defensive fallback: emit the raw match.
@@ -100,11 +91,11 @@ export function emitPythonScopeCaptures(
if (grouped['@scope.function'] !== undefined) {
out.push(grouped);
// `@scope.function` is captured directly on the `function_definition`
// node (query: `(function_definition) @scope.function`), so it IS the
// node the old findNodeAtRange re-derived at that range.
const scopeNode = nodeMap['@scope.function']!;
const fnNode = scopeNode.type === 'function_definition' ? scopeNode : null;
const fnNode = findNodeAtRange(
tree.rootNode,
grouped['@scope.function']!.range,
'function_definition',
);
if (fnNode !== null) {
const synth = synthesizeReceiverTypeBinding(fnNode);
if (synth !== null) out.push(synth);
@@ -119,11 +110,7 @@ export function emitPythonScopeCaptures(
// The anchor range is the function_definition itself — we resolve
// the node and pipe it through the arity helper.
const anchorCap = grouped['@declaration.function']!;
// `@declaration.function` is captured directly on the `function_definition`
// node (query: `(function_definition name: (identifier) @declaration.name)
// @declaration.function`), so use the captured node, not a root re-walk.
const anchorNode = nodeMap['@declaration.function']!;
const fnNode = anchorNode.type === 'function_definition' ? anchorNode : null;
const fnNode = findNodeAtRange(tree.rootNode, anchorCap.range, 'function_definition');
if (fnNode !== null) {
if (pythonFunctionDefinitionLabel(fnNode, 'Function') === 'Method') {
delete grouped['@declaration.function'];
@@ -32,7 +32,7 @@ export function synthesizeDependsReferences(fnNode: SyntaxNode): readonly Captur
continue;
}
const defaultValue = param.childForFieldName('value');
const defaultValue = param.childForFieldName('value') ?? param.childForFieldName('default');
if (defaultValue === null) continue;
const callNode = defaultValue.type === 'call' ? defaultValue : null;
@@ -12,14 +12,14 @@
import type { ParsedImport, WorkspaceIndex } from 'gitnexus-shared';
import { resolvePythonImportInternal } from '../../import-resolvers/python.js';
import { recordPythonFileIndexBuild } from './index-stats.js';
export interface PythonResolveContext {
readonly fromFile: string;
/** `ReadonlySet` so the orchestrator's stable run-level set flows straight
* through to `getPythonFileIndex`'s `WeakMap` key (built once per run, not
* copied per import). The whole resolver chain only reads the set. */
readonly allFilePaths: ReadonlySet<string>;
/** Mutable `Set` because the legacy `resolvePythonImportInternal`
* chain downstream is typed to accept `Set<string>`. Callers that
* only hold a `ReadonlySet` should copy via `new Set(...)` at the
* adapter boundary. */
readonly allFilePaths: Set<string>;
}
export function resolvePythonImportTarget(
@@ -31,17 +31,10 @@ export function resolvePythonImportTarget(
// PythonResolveContext-shaped object; narrow structurally rather
// than via a cast chain so unexpected shapes return null cleanly.
const ctx = workspaceIndex as PythonResolveContext | undefined;
// Duck-type the set rather than `instanceof Set`: `allFilePaths` is typed
// `ReadonlySet<string>` and the chain only ever calls `.has()` + iterates, so
// any set-like is valid. An `instanceof Set` check would reject a legitimate
// non-`Set` `ReadonlySet` implementation and silently return null for every
// import (PR #1918 tri-review P2).
const allFilePaths = (ctx as { allFilePaths?: unknown } | undefined)?.allFilePaths;
if (
ctx === undefined ||
typeof (ctx as { fromFile?: unknown }).fromFile !== 'string' ||
typeof (allFilePaths as { has?: unknown } | undefined)?.has !== 'function' ||
typeof (allFilePaths as Iterable<string> | undefined)?.[Symbol.iterator] !== 'function'
!((ctx as { allFilePaths?: unknown }).allFilePaths instanceof Set)
) {
return null;
}
@@ -110,7 +103,7 @@ export function resolvePythonImportTarget(
*/
function resolveAbsoluteFromFiles(
pathLike: string,
allFilePaths: ReadonlySet<string>,
allFilePaths: Set<string>,
fromFile: string,
): string | null {
const directFile = `${pathLike}.py`;
@@ -152,27 +145,12 @@ function resolveAbsoluteFromFiles(
// multi-directory collision repos.
const suffixFile = `/${directFile}`;
const suffixPkg = `/${directPkg}`;
// Indexed suffix gather. A file matching `…/<pathLike>.py` has basename
// `<lastSeg>.py`; one matching `…/<pathLike>/__init__.py` has basename
// `__init__.py`. Look up only those basename buckets and confirm the full
// suffix, instead of scanning every file (the O(imports x files) hotpath).
// The candidate SET is identical to the old full scan, and the tie-break
// sort below fully determines the result, so output is unchanged. The
// shared buildSuffixIndex is deliberately NOT used: it keeps only one
// path per suffix (longest wins) and so cannot reproduce this exact
// fewest-segments-then-lexicographic tie-break across all candidates.
const index = getPythonFileIndex(allFilePaths);
const lastSeg = pathLike.slice(pathLike.lastIndexOf('/') + 1);
const matches: { raw: string; norm: string }[] = [];
for (const cand of index.byBasename.get(`${lastSeg}.py`) ?? []) {
if (cand.norm.endsWith(suffixFile)) matches.push(cand);
}
// Package form: only `__init__.py` files whose parent dir is named `<lastSeg>`
// can match `…/<lastSeg>/__init__.py` — look them up by parent key (P2b) and
// confirm the full suffix. Same final candidate set as the old `__init__.py`
// scan, just without iterating unrelated packages.
for (const cand of index.byInitParent.get(`${lastSeg}/__init__.py`) ?? []) {
if (cand.norm.endsWith(suffixPkg)) matches.push(cand);
for (const raw of allFilePaths) {
const norm = raw.replace(/\\/g, '/');
if (norm.endsWith(suffixFile) || norm.endsWith(suffixPkg)) {
matches.push({ raw, norm });
}
}
if (matches.length === 0) return null;
if (matches.length === 1) return matches[0].raw;
@@ -210,7 +188,7 @@ function resolveAbsoluteFromFiles(
*/
function hasRepoCandidate(
leadingSegment: string,
allFilePaths: ReadonlySet<string>,
allFilePaths: Set<string>,
fromFile: string,
): boolean {
const prefix = `${leadingSegment}/`;
@@ -227,117 +205,15 @@ function hasRepoCandidate(
ancestorPrefixes.push(`${dirParts.slice(0, i).join('/')}/${leadingSegment}/`);
}
// Indexed equivalents of the old O(files) scan:
// (1) `f === rootFile || f === initFile` -> normalized-path membership.
// (2) `f.startsWith(`${seg}/`) && f.endsWith('.py')` -> some .py file lives
// under directory `${seg}/`, i.e. `${seg}/` is a known .py dir prefix.
// (3) ancestor namespace case -> `${ancestor}/${seg}/` is a known .py dir
// prefix.
const index = getPythonFileIndex(allFilePaths);
if (index.normSet.has(rootFile) || index.normSet.has(initFile)) return true;
if (index.dirPrefixes.has(prefix)) return true;
for (const ap of ancestorPrefixes) {
if (index.dirPrefixes.has(ap)) return true;
for (const raw of allFilePaths) {
const f = raw.replace(/\\/g, '/');
if (f === rootFile || f === initFile) return true;
if (f.startsWith(prefix) && f.endsWith('.py')) return true;
if (f.endsWith('.py')) {
for (const ap of ancestorPrefixes) {
if (f.startsWith(ap)) return true;
}
}
}
return false;
}
/**
* Per-file-set index for Python import resolution, memoized on the
* `allFilePaths` Set object (the same Set is passed for every import in a run,
* so the index is built once and reused). Replaces the per-import O(files)
* scans in `resolveAbsoluteFromFiles` (suffix match) and `hasRepoCandidate`
* (package-existence gate) with O(1)/O(bucket) lookups.
*
* - `normSet`: every file path, normalized to forward slashes (for the exact
* `f === rootFile|initFile` membership checks).
* - `byBasename`: last path component (e.g. `models.py`, `__init__.py`) ->
* all `{ raw, norm }` candidates, so suffix matches can be gathered from the
* relevant bucket and the exact tie-break applied across ALL of them.
* - `byInitParent`: `__init__.py` files keyed by their last TWO components
* (`<parentDir>/__init__.py`). The package suffix lookup (`pkg.sub` ->
* `…/sub/__init__.py`) targets only same-named package dirs via this map
* instead of scanning every `__init__.py` in the repo — the common
* multi-segment import path no longer scales with package count
* (PR #1918 review P2b). `__init__.py` files stay in `byBasename` too, for
* the rarer explicit `pkg.__init__` import that resolves via the module
* (`…<lastSeg>.py`) lookup.
* - `dirPrefixes`: every directory prefix of a `.py` file, trailing-slashed
* (`a/b/c.py` -> `a/`, `a/b/`), for "is there a .py file under `<dir>/`".
*/
interface PythonFileIndex {
readonly normSet: Set<string>;
readonly byBasename: Map<string, { raw: string; norm: string }[]>;
readonly byInitParent: Map<string, { raw: string; norm: string }[]>;
readonly dirPrefixes: Set<string>;
}
const PYTHON_FILE_INDEX_CACHE = new WeakMap<ReadonlySet<string>, PythonFileIndex>();
function getPythonFileIndex(allFilePaths: ReadonlySet<string>): PythonFileIndex {
const cached = PYTHON_FILE_INDEX_CACHE.get(allFilePaths);
if (cached !== undefined) return cached;
// Cache miss: materialize a fresh index. Counted so a test can assert this
// happens once per run, not once per import (PR #1918 review P1 guard).
recordPythonFileIndexBuild();
const normSet = new Set<string>();
const byBasename = new Map<string, { raw: string; norm: string }[]>();
const byInitParent = new Map<string, { raw: string; norm: string }[]>();
const dirPrefixes = new Set<string>();
for (const raw of allFilePaths) {
const norm = raw.replace(/\\/g, '/');
// Python import resolution only ever queries `.py` paths: module `<seg>.py`
// and package `<seg>/__init__.py` membership (normSet), `<lastSeg>.py` /
// `__init__.py` basename buckets (byBasename), and `.py` directory prefixes
// (dirPrefixes). Non-`.py` files can never match any of those, so skip them
// — they were dead weight in every structure on polyglot monorepos
// (PR #1918 review P3b; dirPrefixes was already `.py`-gated).
if (!norm.endsWith('.py')) continue;
normSet.add(norm);
const lastSlash = norm.lastIndexOf('/');
const base = lastSlash >= 0 ? norm.slice(lastSlash + 1) : norm;
let bucket = byBasename.get(base);
if (bucket === undefined) {
bucket = [];
byBasename.set(base, bucket);
}
bucket.push({ raw, norm });
// Package files also get a parent-keyed bucket so a `pkg.sub` lookup hits
// only `…/sub/__init__.py` candidates, not every `__init__.py` (P2b).
if (base === '__init__.py' && lastSlash >= 0) {
const dir = norm.slice(0, lastSlash);
const parentSlash = dir.lastIndexOf('/');
const parentName = parentSlash >= 0 ? dir.slice(parentSlash + 1) : dir;
if (parentName) {
const initKey = `${parentName}/__init__.py`;
let ib = byInitParent.get(initKey);
if (ib === undefined) {
ib = [];
byInitParent.set(initKey, ib);
}
ib.push({ raw, norm });
}
}
// Directory prefixes: every slash-terminated prefix of the path (every
// index just past a '/', up to and including the file's own directory).
// Scanning the FULL normalized path — including any leading '/' for
// absolute paths — makes `dirPrefixes.has(X)` match exactly when the old
// gate's `f.startsWith(X)` (X always ends in '/') matched. The previous
// split+`filter(Boolean)` dropped the leading empty component, so an
// absolute file `/repo/svc/x.py` yielded `repo/svc/` (no leading slash) and
// gate-passed where `"/repo/svc/x.py".startsWith("repo/svc/")` is false
// (PR #1918 review P3a). For relative paths the set is identical.
for (let i = 0; i <= lastSlash; i++) {
if (norm[i] === '/') dirPrefixes.add(norm.slice(0, i + 1));
}
}
const index: PythonFileIndex = { normSet, byBasename, byInitParent, dirPrefixes };
PYTHON_FILE_INDEX_CACHE.set(allFilePaths, index);
return index;
}
@@ -1,29 +0,0 @@
/**
* Build counter for the per-file-set Python import-resolution index
* (`getPythonFileIndex` in `import-target.ts`).
*
* A "build" is a `WeakMap` cache MISS that materializes a fresh
* `PythonFileIndex` (O(files)). Unlike `cache-stats.ts` (which gates its
* counters behind `PROF_SCOPE_RESOLUTION` because they sit on the per-capture
* hot path), this counter is always live: an index build happens at most once
* per resolution run, so the single increment is negligible and an unconditional
* counter avoids env-var load-order fragility in tests.
*
* Used by `test/integration/python-import-index-reuse.test.ts` to assert the
* index is reused across imports (built once per run) rather than rebuilt per
* import — the regression guard for PR #1918 review finding P1.
*/
let INDEX_BUILDS = 0;
export function recordPythonFileIndexBuild(): void {
INDEX_BUILDS++;
}
export function getPythonFileIndexBuildCount(): number {
return INDEX_BUILDS;
}
export function resetPythonFileIndexBuildCount(): void {
INDEX_BUILDS = 0;
}
@@ -31,13 +31,12 @@ const pythonScopeResolver: ScopeResolver = {
importEdgeReason: 'python-scope: import',
resolveImportTarget: (targetRaw, fromFile, allFilePaths) => {
// Pass the orchestrator's stable run-level `ReadonlySet` straight through
// (no per-import copy). The Python resolver chain only reads the set, and
// `getPythonFileIndex` memoizes its index on the set's identity via a
// WeakMap — so the index is built once per run and reused across every
// import. Copying here (the previous `new Set(allFilePaths)`) handed a
// fresh identity to every import, defeating that cache (PR #1918 review P1).
const ws: PythonResolveContext = { fromFile, allFilePaths };
// Copy the orchestrator's `ReadonlySet` into a `Set` because the
// legacy Python resolver chain (`resolvePythonImportInternal` →
// `resolveAbsoluteFromFiles` / `hasRepoCandidate`) is typed to
// receive a mutable `Set<string>`. The copy is O(N) but called
// once per import — trivial compared to the parser work.
const ws: PythonResolveContext = { fromFile, allFilePaths: new Set(allFilePaths) };
// `WorkspaceIndex` is an opaque `unknown` placeholder in the
// shared contract, so `ws` passes structurally without a cast.
return resolvePythonImportTarget(
@@ -1,6 +1,6 @@
import type { Capture, CaptureMatch } from 'gitnexus-shared';
import {
nodeIfType,
findNodeAtRange,
nodeToCapture,
syntheticCapture,
type SyntaxNode,
@@ -49,24 +49,17 @@ export function emitRubyScopeCaptures(
for (const m of rawMatches) {
const grouped: Record<string, Capture> = {};
// Parallel tag -> captured SyntaxNode map. The query already hands us each
// matched node as c.node, so anchors are used directly (via nodeIfType)
// instead of re-deriving them with findNodeAtRange(tree.rootNode, ...) per
// match — the O(matches x rootChildren) root-walk fixed for go #1915 /
// python #1918, mirrored here.
const nodeMap: Record<string, SyntaxNode> = {};
for (const c of m.captures) {
const tag = '@' + c.name;
if (tag.startsWith('@_')) continue;
grouped[tag] = nodeToCapture(tag, c.node);
nodeMap[tag] = c.node;
}
if (Object.keys(grouped).length === 0) continue;
// Decompose require/require_relative/load into import captures
if (grouped['@import.statement'] !== undefined) {
const anchor = grouped['@import.statement']!;
const callNode = nodeIfType(nodeMap['@import.statement'], 'call');
const callNode = findNodeAtRange(tree.rootNode, anchor.range, 'call');
if (callNode !== null) {
const decomposed = decomposeRubyImport(callNode, anchor);
if (decomposed !== null) {
@@ -80,7 +73,8 @@ export function emitRubyScopeCaptures(
// Synthesize self receiver bindings for methods inside class/module
if (grouped['@scope.function'] !== undefined) {
const fnNode = nodeIfType(nodeMap['@scope.function'], ...FUNCTION_NODE_TYPES);
const scopeCap = grouped['@scope.function']!;
const fnNode = findFunctionNode(tree.rootNode, scopeCap.range);
if (fnNode !== null) {
const enclosingNode = findEnclosingClassOrModule(fnNode);
const receiver = synthesizeRubyReceiverBinding(fnNode, enclosingNode);
@@ -92,7 +86,8 @@ export function emitRubyScopeCaptures(
// Reclassify declaration.function as declaration.method + attach arity
if (grouped['@declaration.function'] !== undefined) {
const fnNode = nodeIfType(nodeMap['@declaration.function'], ...FUNCTION_NODE_TYPES);
const anchorCap = grouped['@declaration.function']!;
const fnNode = findFunctionNode(tree.rootNode, anchorCap.range);
if (fnNode !== null) {
const enclosingNode = findEnclosingClassOrModule(fnNode);
if (enclosingNode !== null) {
@@ -140,7 +135,11 @@ export function emitRubyScopeCaptures(
if (grouped['@reference.call.free'] !== undefined && grouped['@reference.name'] !== undefined) {
const callName = grouped['@reference.name']!.text;
if (HERITAGE_CALL_NAMES.has(callName)) {
const callNode = nodeIfType(nodeMap['@reference.call.free'], 'call');
const callNode = findNodeAtRange(
tree.rootNode,
grouped['@reference.call.free']!.range,
'call',
);
if (callNode !== null) {
const enclosing = findEnclosingClassOrModule(callNode);
const ownerName = enclosing?.childForFieldName('name')?.text;
@@ -174,7 +173,11 @@ export function emitRubyScopeCaptures(
// localDefs and gets reconciled into model.fields, enabling write-access
// resolution via receiver-bound-calls (Case 4 → findOwnedMember).
if (ATTR_CALL_NAMES.has(callName)) {
const callNode = nodeIfType(nodeMap['@reference.call.free'], 'call');
const callNode = findNodeAtRange(
tree.rootNode,
grouped['@reference.call.free']!.range,
'call',
);
if (callNode !== null) {
const enclosing = findEnclosingClassOrModule(callNode);
const ownerName = enclosing?.childForFieldName('name')?.text;
@@ -183,7 +186,7 @@ export function emitRubyScopeCaptures(
if (argList !== null) {
for (let ai = 0; ai < argList.namedChildCount; ai++) {
const arg = argList.namedChild(ai);
if (arg !== null && arg.type === 'simple_symbol') {
if (arg !== null && (arg.type === 'simple_symbol' || arg.type === 'symbol')) {
const propName = arg.text.replace(/^:/, '');
out.push({
'@import.statement': grouped['@reference.call.free']!,
@@ -219,7 +222,8 @@ export function emitRubyScopeCaptures(
(t) => grouped[t] !== undefined,
);
if (callTag !== undefined && grouped['@reference.arity'] === undefined) {
const callNode = nodeIfType(nodeMap[callTag], 'call');
const anchor = grouped[callTag]!;
const callNode = findNodeAtRange(tree.rootNode, anchor.range, 'call');
if (callNode !== null) {
const arity = computeRubyCallArity(callNode);
grouped['@reference.arity'] = syntheticCapture('@reference.arity', callNode, String(arity));
@@ -327,7 +331,7 @@ export function emitRubyScopeCaptures(
if (argList !== null) {
for (let ai = 0; ai < argList.namedChildCount; ai++) {
const arg = argList.namedChild(ai);
if (arg !== null && arg.type === 'simple_symbol') {
if (arg !== null && (arg.type === 'simple_symbol' || arg.type === 'symbol')) {
const propName = arg.text.replace(/^:/, '');
out.push({
'@type-binding.return': syntheticCapture('@type-binding.return', attrNode, text),
@@ -369,37 +373,21 @@ export function emitRubyScopeCaptures(
// return-type binding `methodName → ClassName` on the method node.
// This enables cross-file return-type propagation for factory methods
// like `def self.get_user; User.new; end` → `get_user → User`.
// Keys of methods that already got a return binding from the YARD pass above,
// precomputed once. The previous `out.some(...)` per method was
// O(methods x out.length) ~ O(n^2); this makes the dedup O(1) per method.
// Key = `<name>:<return-binding startLine>`, matching the old AND condition.
//
// Snapshot-vs-live note (PR #1918 tri-review P3): the old `out.some` was
// evaluated LIVE, so it also saw constructor-return bindings this very loop
// pushed in earlier iterations. That made the old code suppress the 2nd of
// two same-named methods one source row apart whose bodies both end in
// `Const.new` (the 1st's pushed binding startLine == the 2nd's row via the
// 1-based/0-based offset below). The snapshot is built from the YARD pass
// only, so it no longer cross-suppresses — both bindings are emitted, which
// is the intended behavior (the cross-suppression was unintended). This
// corner is absent from fixtures, so the capture fingerprint is unchanged;
// ruby-captures-golden.test.ts pins it explicitly.
const yardReturnKeys = new Set<string>();
for (const m of out) {
const ret = m['@type-binding.return'];
const name = m['@type-binding.name'];
if (ret !== undefined && name !== undefined) {
yardReturnKeys.add(`${name.text}:${ret.range.startLine}`);
}
}
for (const methodNode of [
...tree.rootNode.descendantsOfType('method'),
...tree.rootNode.descendantsOfType('singleton_method'),
]) {
const methodName = methodNode.childForFieldName('name')?.text;
if (methodName === undefined) continue;
// Skip if a YARD @return already created a return binding for this method.
if (yardReturnKeys.has(`${methodName}:${methodNode.startPosition.row}`)) {
// Skip if a YARD @return already created a return binding for this method
if (
out.some(
(m) =>
m['@type-binding.return'] !== undefined &&
m['@type-binding.name']?.text === methodName &&
m['@type-binding.return']?.range.startLine === methodNode.startPosition.row,
)
) {
continue;
}
const body = methodNode.childForFieldName('body');
@@ -541,6 +529,14 @@ function computeRubyCallArity(callNode: SyntaxNode): number {
return count;
}
function findFunctionNode(rootNode: SyntaxNode, range: Capture['range']): SyntaxNode | null {
for (const nodeType of FUNCTION_NODE_TYPES) {
const n = findNodeAtRange(rootNode, range, nodeType);
if (n !== null) return n;
}
return null;
}
function scopeExtractionError(stage: string, filePath: string, err: unknown): Error {
const reason = err instanceof Error ? err.message : String(err);
return new Error(
@@ -1,6 +1,6 @@
import type { Capture, CaptureMatch } from 'gitnexus-shared';
import {
nodeIfType,
findNodeAtRange,
nodeToCapture,
syntheticCapture,
type SyntaxNode,
@@ -32,23 +32,17 @@ export function emitRustScopeCaptures(
for (const m of rawMatches) {
const grouped: Record<string, Capture> = {};
// Parallel tag -> captured SyntaxNode map: the query hands us each matched
// node as c.node, so anchors resolve via a type-guarded lookup (nodeIfType)
// instead of re-deriving them with findNodeAtRange(tree.rootNode, ...) per
// match — the O(matches x rootChildren) root-walk fixed for go #1915 /
// python #1918, mirrored here.
const nodeMap: Record<string, SyntaxNode> = {};
for (const c of m.captures) {
const tag = '@' + c.name;
if (tag.startsWith('@_')) continue;
grouped[tag] = nodeToCapture(tag, c.node);
nodeMap[tag] = c.node;
}
if (Object.keys(grouped).length === 0) continue;
// Decompose use declarations into individual import captures
if (grouped['@import.statement'] !== undefined) {
const useNode = nodeIfType(nodeMap['@import.statement'], 'use_declaration');
const anchor = grouped['@import.statement']!;
const useNode = findNodeAtRange(tree.rootNode, anchor.range, 'use_declaration');
if (useNode !== null) {
out.push(...splitRustUseDeclaration(useNode));
continue;
@@ -58,7 +52,8 @@ export function emitRustScopeCaptures(
// Synthesize self receiver bindings for methods inside impl blocks
let cachedImplLookup: { fnNode: SyntaxNode; implNode: SyntaxNode | null } | undefined;
if (grouped['@scope.function'] !== undefined) {
const fnNode = nodeIfType(nodeMap['@scope.function'], 'function_item');
const scopeCap = grouped['@scope.function']!;
const fnNode = findNodeAtRange(tree.rootNode, scopeCap.range, 'function_item');
if (fnNode !== null) {
const implNode = findEnclosingImpl(fnNode);
cachedImplLookup = { fnNode, implNode };
@@ -70,7 +65,7 @@ export function emitRustScopeCaptures(
// Attach declaration arity for functions/methods
const declAnchor = grouped['@declaration.function'];
if (declAnchor !== undefined) {
const fnNode = nodeIfType(nodeMap['@declaration.function'], 'function_item');
const fnNode = findNodeAtRange(tree.rootNode, declAnchor.range, 'function_item');
if (fnNode !== null) {
const implNode =
cachedImplLookup?.fnNode === fnNode
@@ -120,7 +115,7 @@ export function emitRustScopeCaptures(
grouped['@type-binding.name'] !== undefined
) {
const tbReturnAnchor = grouped['@type-binding.return']!;
const fnNode = nodeIfType(nodeMap['@type-binding.return'], 'function_item');
const fnNode = findNodeAtRange(tree.rootNode, tbReturnAnchor.range, 'function_item');
if (fnNode !== null) {
const implNode = findEnclosingImpl(fnNode);
if (implNode !== null) {
@@ -146,12 +141,14 @@ export function emitRustScopeCaptures(
}
// Attach call arity for call expressions
const callAnchorNode =
nodeMap['@reference.call.free'] ??
nodeMap['@reference.call.member'] ??
nodeMap['@reference.call.constructor'];
if (callAnchorNode !== undefined) {
const callNode = nodeIfType(callAnchorNode, 'call_expression', 'struct_expression');
const callAnchor =
grouped['@reference.call.free'] ??
grouped['@reference.call.member'] ??
grouped['@reference.call.constructor'];
if (callAnchor !== undefined) {
const callNode =
findNodeAtRange(tree.rootNode, callAnchor.range, 'call_expression') ??
findNodeAtRange(tree.rootNode, callAnchor.range, 'struct_expression');
if (callNode !== null) {
const arity = computeRustCallArity(callNode);
grouped['@reference.arity'] = syntheticCapture('@reference.arity', callNode, String(arity));
@@ -310,9 +310,9 @@ function processStructDestructuring(
for (const fieldNode of patternNode.namedChildren) {
let fieldName: string | undefined;
if (fieldNode.type === 'field_pattern') {
// shorthand `{ a }` and full `{ b: c }` are both field_pattern; the
// `name` field is shorthand_field_identifier or field_identifier.
fieldName = fieldNode.childForFieldName('name')?.text;
} else if (fieldNode.type === 'shorthand_field_pattern') {
fieldName = fieldNode.firstNamedChild?.text;
}
if (fieldName === undefined) continue;
@@ -32,16 +32,6 @@ import { swiftVariableConfig } from '../variable-extractors/configs/swift.js';
import { createCallExtractor } from '../call-extractors/generic.js';
import { swiftCallConfig } from '../call-extractors/configs/swift.js';
import { createHeritageExtractor } from '../heritage-extractors/generic.js';
import {
emitSwiftScopeCaptures,
interpretSwiftImport,
interpretSwiftTypeBinding,
swiftBindingScopeFor,
swiftImportOwningScope,
swiftReceiverBinding,
swiftMergeBindings,
swiftArityCompatibility,
} from './swift/index.js';
/**
* Group Swift files by SPM target for implicit module visibility.
@@ -342,14 +332,4 @@ export const swiftProvider = defineLanguage({
implicitImportWirer: wireSwiftImplicitImports,
orderSameNameTypeCandidates: orderSwiftSameNameTypeCandidates,
builtInNames: BUILT_INS,
// ── Scope-based resolution hooks (RFC #909 Ring 3, issue #937). See
// languages/swift/ for the implementations. ──────────────────────
emitScopeCaptures: emitSwiftScopeCaptures,
interpretImport: interpretSwiftImport,
interpretTypeBinding: interpretSwiftTypeBinding,
bindingScopeFor: swiftBindingScopeFor,
importOwningScope: swiftImportOwningScope,
receiverBinding: swiftReceiverBinding,
mergeBindings: (_scope, bindings) => swiftMergeBindings(bindings),
arityCompatibility: swiftArityCompatibility,
});
@@ -1,49 +0,0 @@
/**
* Extract Swift arity metadata from a function-like tree-sitter node —
* `function_declaration`, `protocol_function_declaration`, or
* `init_declaration`.
*
* Reuses `swiftMethodConfig.extractParameters` so scope-extracted defs
* carry the same arity semantics as the legacy parse-worker path:
* - Variadic params (`xs: Int...`) collapse `parameterCount` to
* `undefined`, which `swiftArityCompatibility` treats as "max
* unknown" — the candidate stays eligible at `argCount >= required`.
* - Defaulted params (`= expr`) contribute to `optionalCount`;
* `requiredParameterCount = total − optionalCount`.
* - `parameterTypes` collects declared type names for narrowing; a
* literal `'variadic'` marker is appended for variadic methods so
* `swiftArityCompatibility` can detect them without re-reading AST.
*/
import type { SyntaxNode } from '../../utils/ast-helpers.js';
import { swiftMethodConfig } from '../../method-extractors/configs/swift.js';
interface SwiftArityMetadata {
readonly parameterCount: number | undefined;
readonly requiredParameterCount: number | undefined;
readonly parameterTypes: readonly string[] | undefined;
}
export function computeSwiftArityMetadata(fnNode: SyntaxNode): SwiftArityMetadata {
const params = swiftMethodConfig.extractParameters?.(fnNode) ?? [];
let hasVariadic = false;
let optionalCount = 0;
const types: string[] = [];
for (const p of params) {
if (p.isVariadic) hasVariadic = true;
else if (p.isOptional) optionalCount++;
if (p.type !== null) types.push(p.type);
}
if (hasVariadic) types.push('variadic');
const total = params.length;
const parameterCount = hasVariadic ? undefined : total;
const requiredParameterCount = hasVariadic ? undefined : total - optionalCount;
return {
parameterCount,
requiredParameterCount,
parameterTypes: types.length > 0 ? types : undefined,
};
}
@@ -1,49 +0,0 @@
/**
* Swift arity check, accommodating variadic and default parameters.
*
* Per the migration decision (count-primary, labels soft): we narrow on
* arity only. Swift's argument labels are a soft signal — a label
* mismatch is NOT treated as incompatible here, mirroring how the other
* migrated languages narrow and aligning with RFC §4 soft-penalty
* semantics. Label-precise dispatch is left to the registry's
* type-binding layer.
*
* The `def` metadata we care about (synthesized by `arity-metadata.ts`):
* - `parameterCount` — total formal parameters; `undefined`
* when the method has a variadic param.
* - `requiredParameterCount` — min required (excludes defaulted params
* and the variadic).
* - `parameterTypes` — declared type strings; contains the
* literal `'variadic'` when variadic.
*
* Verdicts:
* - `'compatible'` — `requiredParameterCount <= argCount <= parameterCount`,
* OR the def is variadic (then any `argCount >= required`).
* - `'incompatible'` — argCount below required, OR above max with no variadic.
* - `'unknown'` — metadata absent / incomplete.
*
* `'incompatible'` is a soft signal in `Registry.lookup` (penalized but
* still considered when no compatible candidate exists), per RFC §4.
*/
import type { Callsite, SymbolDefinition } from 'gitnexus-shared';
export function swiftArityCompatibility(
def: SymbolDefinition,
callsite: Callsite,
): 'compatible' | 'unknown' | 'incompatible' {
const max = def.parameterCount;
const min = def.requiredParameterCount;
if (max === undefined && min === undefined) return 'unknown';
const argCount = callsite.arity;
if (!Number.isFinite(argCount) || argCount < 0) return 'unknown';
const hasVarArgs =
def.parameterTypes !== undefined && def.parameterTypes.some((t) => t === 'variadic');
if (min !== undefined && argCount < min) return 'incompatible';
if (max !== undefined && argCount > max && !hasVarArgs) return 'incompatible';
return 'compatible';
}
@@ -1,30 +0,0 @@
/**
* Dev-mode counters for the cross-phase scope-captures parse cache
* (Swift mirror of `languages/csharp/cache-stats.ts`).
*
* Gated by `PROF_SCOPE_RESOLUTION=1`. Production builds fold every
* increment into dead code via the module-level `PROF` constant, so
* the hot path in `captures.ts` stays branch-free.
*/
const PROF = process.env.PROF_SCOPE_RESOLUTION === '1';
let CACHE_HITS = 0;
let CACHE_MISSES = 0;
export function recordCacheHit(): void {
if (PROF) CACHE_HITS++;
}
export function recordCacheMiss(): void {
if (PROF) CACHE_MISSES++;
}
export function getSwiftCaptureCacheStats(): { hits: number; misses: number } {
return { hits: CACHE_HITS, misses: CACHE_MISSES };
}
export function resetSwiftCaptureCacheStats(): void {
CACHE_HITS = 0;
CACHE_MISSES = 0;
}
@@ -1,484 +0,0 @@
/**
* `emitScopeCaptures` for Swift.
*
* Drives the Swift scope query against tree-sitter-swift and groups raw
* matches into `CaptureMatch[]` for the central extractor. Synthesizes
* several streams on top of the raw query captures:
*
* 1. **Decomposed imports** — each `import_declaration` is re-emitted
* with `@import.kind/source/name` markers (and `@import.testable`
* when present) so `interpretSwiftImport` recovers the ParsedImport
* shape without re-parsing raw text (`import-decomposer.ts`).
* 2. **Optional bindings** — `if let u = getUser()` / `guard let …`
* synthesize a `@type-binding.constructor` (name → callee) by
* walking the anchored statement (`@optional.binding`).
* 3. **Receiver bindings** — `self` (+ `super`) `@type-binding.self`
* anchors on every instance method/init (`receiver-binding.ts`).
* 4. **Signature bindings** — parameter-type and return-type
* `@type-binding.*` synthesized from the function node, because
* Swift's grammar reuses the `name:` field for func-name / param /
* return so a query can't disambiguate (`signature-bindings.ts`).
* 5. **Arity metadata** — `@declaration.parameter-count` etc. on
* function-like declarations and `@reference.arity` on call sites,
* so the registry can narrow by arity (`arity-metadata.ts`).
*
* Extension handling: a `class_declaration` whose `name:` is a
* `(user_type …)` is an `extension Foo { … }`. The query tags it
* `@declaration.extension`; we re-key it to `@declaration.class` with a
* synthesized `@declaration.name` of the extended type so its members
* hoist onto `Foo`'s scope (`populateClassOwnedMembers` completes the
* ownership stamp) — the same mechanism C# uses for `partial class`.
*
* Pure given the input source text. No I/O, no globals consulted.
*/
import type { Capture, CaptureMatch } from 'gitnexus-shared';
import {
nodeIfType,
nodeToCapture,
syntheticCapture,
type SyntaxNode,
} from '../../utils/ast-helpers.js';
import { splitSwiftImport } from './import-decomposer.js';
import { computeSwiftArityMetadata } from './arity-metadata.js';
import { synthesizeSwiftReceiverBinding } from './receiver-binding.js';
import { synthesizeSwiftSignatureBindings } from './signature-bindings.js';
import { getSwiftParser, getSwiftScopeQuery } from './query.js';
import { recordCacheHit, recordCacheMiss } from './cache-stats.js';
import { getTreeSitterBufferSize } from '../../constants.js';
import { parseSourceSafe } from '../../../tree-sitter/safe-parse.js';
/** Declaration anchors that carry function-like arity metadata. */
const FUNCTION_DECL_TAGS = ['@declaration.method', '@declaration.constructor'] as const;
/** tree-sitter-swift node types that carry arity. */
const FUNCTION_NODE_TYPES = [
'function_declaration',
'protocol_function_declaration',
'init_declaration',
] as const;
/** Function-like nodes eligible for receiver-binding synthesis. */
const RECEIVER_NODE_TYPES = [
'function_declaration',
'init_declaration',
'deinit_declaration',
] as const;
export function emitSwiftScopeCaptures(
sourceText: string,
_filePath: string,
cachedTree?: unknown,
): readonly CaptureMatch[] {
// Reuse the parse phase's cached Tree when available; otherwise parse.
let tree = cachedTree as ReturnType<ReturnType<typeof getSwiftParser>['parse']> | undefined;
if (tree === undefined) {
tree = parseSourceSafe(getSwiftParser(), sourceText, undefined, {
bufferSize: getTreeSitterBufferSize(sourceText),
});
recordCacheMiss();
} else {
recordCacheHit();
}
const rawMatches = getSwiftScopeQuery().matches(tree.rootNode);
const out: CaptureMatch[] = [];
// Dedup genuine field reads by span — tree-sitter-swift can match the
// same navigation_expression twice (stacked nodes with identical spans).
const seenReadSpans = new Set<string>();
for (const m of rawMatches) {
// Group captures by tag. Tree-sitter strips the leading `@`; put it
// back so the central extractor's prefix lookups work. Keep a
// parallel tag → node map so anchors resolve via nodeIfType (the
// captured node IS the node at that range — no findNodeAtRange
// root-walk, the O(matches × rootChildren) hot path fixed in #1918).
const grouped: Record<string, Capture> = {};
const nodeMap: Record<string, SyntaxNode> = {};
for (const c of m.captures) {
const tag = '@' + c.name;
grouped[tag] = nodeToCapture(tag, c.node);
nodeMap[tag] = c.node;
}
if (Object.keys(grouped).length === 0) continue;
// ── Imports ──────────────────────────────────────────────────────
if (grouped['@import.statement'] !== undefined) {
const stmtNode = nodeIfType(nodeMap['@import.statement'], 'import_declaration');
if (stmtNode !== null) {
const decomposed = splitSwiftImport(stmtNode);
if (decomposed !== null) {
out.push(decomposed);
continue;
}
}
out.push(grouped); // defensive fallback
continue;
}
// ── Optional bindings: if-let / guard-let. Synthesize a
// @type-binding.constructor (name → callee); chain-follow resolves
// the callee to its return type. ─────────────────────────────────
if (grouped['@optional.binding'] !== undefined) {
const stmtNode = nodeIfType(nodeMap['@optional.binding'], 'if_statement', 'guard_statement');
if (stmtNode !== null) {
for (const synth of synthesizeOptionalBindings(stmtNode)) out.push(synth);
}
continue;
}
// ── Field accesses: a `navigation_expression` (`obj.field`) is one of
// three things. Drop it when it's a call's callee (`u.save` in
// `u.save()` — the @reference.call.member query already covers that).
// Re-tag it as a write when it's the LHS of an assignment
// (`obj.field = x`) so a `write` ACCESSES edge emits (mirrors
// Kotlin/PHP `@reference.write.member`); otherwise keep it as a
// genuine read (`u.address`) and dedup identical spans. ───────────
if (grouped['@reference.read.member'] !== undefined) {
const navNode = nodeIfType(nodeMap['@reference.read.member'], 'navigation_expression');
if (navNode === null) continue;
if (isSwiftMemberCallCallee(navNode)) continue;
if (isSwiftMemberWriteLhs(navNode)) {
// Re-tag the read anchor as a write. The extractor's anchor
// classifier reads the capture's `.name` (not the map key —
// `referenceKindFromAnchor` derives the site kind from
// `@reference.<kind>`), so we build a FRESH capture whose `.name`
// is the write tag rather than aliasing the read capture object
// (which would still classify as a read — a silent no-op). The
// sibling `@reference.name` / `@reference.receiver` captures carry
// over unchanged so the field + receiver still resolve. (Mirrors
// the constructor re-tag below and PHP's write re-tag.)
const reKeyed: Record<string, Capture> = { ...grouped };
delete reKeyed['@reference.read.member'];
reKeyed['@reference.write.member'] = nodeToCapture('@reference.write.member', navNode);
out.push(reKeyed);
continue;
}
const span = `${navNode.startIndex}-${navNode.endIndex}`;
if (seenReadSpans.has(span)) continue;
seenReadSpans.add(span);
out.push(grouped);
continue;
}
// ── Extensions: re-key @declaration.extension → @declaration.class
// with the extended type's bare name so members hoist onto it. ────
if (grouped['@declaration.extension'] !== undefined) {
const extNode = nodeIfType(nodeMap['@declaration.extension'], 'class_declaration');
const reKeyed: Record<string, Capture> = { ...grouped };
delete reKeyed['@declaration.extension'];
reKeyed['@declaration.class'] = grouped['@declaration.extension'];
if (extNode !== null) {
const nameNode = extNode.childForFieldName('name');
// For a nested type `extension Foo.Bar`, the name is
// `(user_type (type_identifier Foo) (type_identifier Bar))`; the
// EXTENDED type is the trailing identifier `Bar` (lastNamedChild),
// not `Foo`. For a single `extension Foo`, first === last, so this
// is unchanged. Members must hoist onto `Bar`, not `Foo`.
const bare =
nameNode?.type === 'user_type'
? (nameNode.lastNamedChild?.text ?? nameNode.text)
: (nameNode?.text ?? grouped['@declaration.name']?.text ?? '');
if (bare !== '') {
reKeyed['@declaration.name'] = syntheticCapture('@declaration.name', extNode, bare);
}
}
out.push(reKeyed);
continue;
}
// ── `let x = Type.init(...)` — explicit-initializer call. The
// constructor type-binding query only matches a bare `Type(...)`
// (simple_identifier callee); the `Type.init(...)` navigation form
// needs a synthesized `x: Type` binding so a later `x.method()`
// resolves. Emitted in ADDITION to the normal @declaration.property
// match, which still flows through to the final push below. ───────
if (grouped['@declaration.property'] !== undefined) {
const propNode = nodeIfType(nodeMap['@declaration.property'], 'property_declaration');
const synth = propNode === null ? null : synthesizeInitCtorBinding(propNode);
if (synth !== null) out.push(synth);
}
// ── init: synthesize @declaration.name = "init" (no name field). ──
if (
grouped['@declaration.constructor'] !== undefined &&
grouped['@declaration.name'] === undefined
) {
const initNode = nodeIfType(nodeMap['@declaration.constructor'], 'init_declaration');
if (initNode !== null) {
grouped['@declaration.name'] = syntheticCapture('@declaration.name', initNode, 'init');
}
}
// ── @scope.function: arity + receiver + signature bindings. ──────
if (grouped['@scope.function'] !== undefined) {
const fnNodeForArity = nodeIfType(
nodeMap['@scope.function'] ??
nodeMap['@declaration.method'] ??
nodeMap['@declaration.constructor'],
...FUNCTION_NODE_TYPES,
);
if (fnNodeForArity !== null) attachArityMetadata(grouped, fnNodeForArity);
out.push(grouped);
const recvNode = nodeIfType(nodeMap['@scope.function'], ...RECEIVER_NODE_TYPES);
if (recvNode !== null) {
for (const synth of synthesizeSwiftReceiverBinding(recvNode)) out.push(synth);
}
const sigNode = nodeIfType(nodeMap['@scope.function'], ...FUNCTION_NODE_TYPES);
if (sigNode !== null) {
for (const synth of synthesizeSwiftSignatureBindings(sigNode)) out.push(synth);
}
continue;
}
// ── Arity metadata on function-like declarations (non-scope). ────
const declTag = FUNCTION_DECL_TAGS.find((t) => grouped[t] !== undefined);
if (declTag !== undefined) {
const fnNode = nodeIfType(nodeMap[declTag], ...FUNCTION_NODE_TYPES);
if (fnNode !== null) attachArityMetadata(grouped, fnNode);
}
// ── Constructor calls: Swift has no `new`, so `Foo()` is a free call
// whose callee is a type. Re-tag an UpperCamelCase free-call callee as
// a constructor reference so the resolver's constructor branch targets
// the type's Constructor/Class (mirrors how other no-`new` languages
// classify `Type(...)`). Types are UpperCamelCase by Swift convention;
// functions are lowerCamelCase — so the first-letter test is a reliable
// syntactic discriminator with no scope lookup. ──────────────────────
if (grouped['@reference.call.free'] !== undefined) {
const calleeName = grouped['@reference.name']?.text ?? '';
const first = calleeName.charAt(0);
if (first !== '' && first === first.toUpperCase() && first !== first.toLowerCase()) {
// Build a fresh capture whose `.name` is the constructor tag — the
// extractor's anchor classifier reads the capture's `.name`, not the
// map key, so reusing the free-call capture object would keep
// classifying it as a free call (silent no-op).
const callNode = nodeMap['@reference.call.free'];
grouped['@reference.call.constructor'] = nodeToCapture(
'@reference.call.constructor',
callNode,
);
nodeMap['@reference.call.constructor'] = callNode;
delete grouped['@reference.call.free'];
}
}
// ── @reference.arity on call sites. ──────────────────────────────
const callTag = (
['@reference.call.free', '@reference.call.member', '@reference.call.constructor'] as const
).find((t) => grouped[t] !== undefined);
if (callTag !== undefined && grouped['@reference.arity'] === undefined) {
const callNode = nodeIfType(nodeMap[callTag], 'call_expression');
if (callNode !== null) {
grouped['@reference.arity'] = syntheticCapture(
'@reference.arity',
callNode,
String(countCallArguments(callNode)),
);
}
}
out.push(grouped);
}
return out;
}
/** Synthesize a `@type-binding.constructor` for EACH clause of an
* if-let / guard-let optional binding:
* `if let u = getUser()` → one binding `u: getUser`
* `if let a = makeA(), let b = makeB()` → two bindings `a: makeA`, `b: makeB`
* (chain-follow resolves each callee → its return type).
*
* The statement has a FLAT child list (verified, tree-sitter-swift 0.7.1):
* each clause is `value_binding_pattern` · `simple_identifier` (the bound
* name) · `=` · value, where value is a `call_expression` directly, or an
* `await_expression` / `try_expression` wrapping one. NOTE: every bound
* name carries the `bound_identifier` field, but `childForFieldName`
* returns only the FIRST — so we walk the children in order instead.
*
* Clauses whose value isn't a call (`if let a = optionalVar`) are skipped
* WITHOUT consuming the following clause's call. Single-clause output is
* byte-identical to the prior single-binding implementation. `if_statement`
* and `guard_statement` share this shape and are handled identically. */
function synthesizeOptionalBindings(stmtNode: SyntaxNode): CaptureMatch[] {
const out: CaptureMatch[] = [];
// State machine over the flat clause list. `pendingName` is the bound
// name of the clause currently awaiting its value; `awaitingName` is set
// right after a `value_binding_pattern` so the next `simple_identifier`
// is taken as the name (not as a value).
let pendingName: SyntaxNode | null = null;
let awaitingName = false;
for (let i = 0; i < stmtNode.childCount; i++) {
const child = stmtNode.child(i);
if (child === null) continue;
// The clause list ends at the body / else / statements.
if (child.type === 'statements' || child.type === '{' || child.text === 'else') break;
if (child.type === 'value_binding_pattern') {
// A new clause begins; any prior clause whose value never arrived was
// a non-call clause — drop it without consuming this one.
pendingName = null;
awaitingName = true;
continue;
}
if (awaitingName) {
if (child.type === 'simple_identifier') {
pendingName = child;
awaitingName = false;
}
continue;
}
if (pendingName === null) continue;
if (child.text === '=' || child.text === ',') continue;
// First non-`=` node after the name is the clause value. Take a binding
// only when it's a call; clear pendingName either way so a non-call
// clause doesn't steal the next clause's call.
const callee = optionalBindingCallee(child);
if (callee !== null) {
out.push({
'@type-binding.constructor': nodeToCapture('@type-binding.constructor', stmtNode),
'@type-binding.name': syntheticCapture('@type-binding.name', pendingName, pendingName.text),
'@type-binding.type': syntheticCapture('@type-binding.type', callee, callee.text),
});
}
pendingName = null;
}
return out;
}
/** Resolve an optional-binding clause VALUE node to its call callee
* (`simple_identifier`), unwrapping a single `await`/`try` layer. Returns
* null when the value isn't a bare-identifier call (e.g. `optionalVar`,
* or `obj.method()` — which must NOT bind to `obj`). */
function optionalBindingCallee(value: SyntaxNode): SyntaxNode | null {
let call: SyntaxNode | null = null;
if (value.type === 'call_expression') {
call = value;
} else if (value.type === 'await_expression' || value.type === 'try_expression') {
for (let j = 0; j < value.namedChildCount; j++) {
const inner = value.namedChild(j);
if (inner !== null && inner.type === 'call_expression') {
call = inner;
break;
}
}
}
if (call === null) return null;
const callee = call.namedChild(0);
return callee !== null && callee.type === 'simple_identifier' ? callee : null;
}
/** Synthesize a `@type-binding.constructor` for `let x = Type.init(...)`.
* The property's `value:` is a call_expression whose callee is a
* navigation_expression `Type.init`; bind `x` to the navigation target
* `Type` (the explicit-initializer form of `let x = Type(...)`). Returns
* null for any other value shape (e.g. `let x = obj.method()`, which must
* NOT bind x to `obj`). */
function synthesizeInitCtorBinding(propNode: SyntaxNode): CaptureMatch | null {
const namePattern = propNode.childForFieldName('name');
const nameNode = namePattern?.childForFieldName('bound_identifier') ?? null;
if (nameNode === null) return null;
const value = propNode.childForFieldName('value');
if (value === null || value.type !== 'call_expression') return null;
const callee = value.namedChild(0);
if (callee === null || callee.type !== 'navigation_expression') return null;
const target = callee.childForFieldName('target');
const suffix = callee.childForFieldName('suffix');
const member = suffix?.childForFieldName('suffix') ?? null;
if (
target === null ||
target.type !== 'simple_identifier' ||
member === null ||
member.text !== 'init'
) {
return null;
}
const m: Record<string, Capture> = {
'@type-binding.constructor': nodeToCapture('@type-binding.constructor', propNode),
'@type-binding.name': syntheticCapture('@type-binding.name', nameNode, nameNode.text),
'@type-binding.type': syntheticCapture('@type-binding.type', target, target.text),
};
return m;
}
/** Is this navigation_expression the callee of a call (`a.b` in `a.b()`)?
* That is a member call, already captured by @reference.call.member, so
* the read.member emission must be dropped. */
function isSwiftMemberCallCallee(navNode: SyntaxNode): boolean {
return navNode.parent?.type === 'call_expression';
}
/** Is this navigation_expression the LHS of an assignment (`a.b = …` — a
* field write)? tree-sitter-swift wraps the assignment target in a
* `directly_assignable_expression`, so the write discriminator is the
* GRANDPARENT `assignment` reached via that wrapper — NOT a direct
* `parent.type === 'assignment'` (which never matches; the old guard was
* dead). The inner `obj.a` of `obj.a.b = x` has parent
* `navigation_expression` (the outer access), so it is correctly NOT a
* write — only the outermost nav under `directly_assignable_expression`
* is the write target. */
function isSwiftMemberWriteLhs(navNode: SyntaxNode): boolean {
const parent = navNode.parent;
if (parent === null || parent.type !== 'directly_assignable_expression') return false;
return parent.parent?.type === 'assignment';
}
/** Attach @declaration.parameter-count / required-parameter-count /
* parameter-types synthesized from a function-like node. */
function attachArityMetadata(grouped: Record<string, Capture>, fnNode: SyntaxNode): void {
const arity = computeSwiftArityMetadata(fnNode);
if (arity.parameterCount !== undefined) {
grouped['@declaration.parameter-count'] = syntheticCapture(
'@declaration.parameter-count',
fnNode,
String(arity.parameterCount),
);
}
if (arity.requiredParameterCount !== undefined) {
grouped['@declaration.required-parameter-count'] = syntheticCapture(
'@declaration.required-parameter-count',
fnNode,
String(arity.requiredParameterCount),
);
}
if (arity.parameterTypes !== undefined) {
grouped['@declaration.parameter-types'] = syntheticCapture(
'@declaration.parameter-types',
fnNode,
JSON.stringify(arity.parameterTypes),
);
}
}
/** Count call arguments: the `value_argument` named children of the
* call's `call_suffix > value_arguments`. */
function countCallArguments(callNode: SyntaxNode): number {
for (let i = 0; i < callNode.namedChildCount; i++) {
const child = callNode.namedChild(i);
if (child === null || child.type !== 'call_suffix') continue;
for (let j = 0; j < child.namedChildCount; j++) {
const va = child.namedChild(j);
if (va === null || va.type !== 'value_arguments') continue;
let n = 0;
for (let k = 0; k < va.namedChildCount; k++) {
const arg = va.namedChild(k);
if (arg !== null && arg.type === 'value_argument') n++;
}
return n;
}
}
return 0;
}
@@ -1,71 +0,0 @@
/**
* Swift same-module implicit IMPORTS-edge emission for the
* `emitImplicitImportEdges` hook.
*
* Swift gives every file in a module (an SPM target) visibility of every
* other file's top-level declarations WITHOUT any `import` statement
* (whole-module visibility). The legacy DAG models this with File→File
* IMPORTS edges via `wireSwiftImplicitImports`; under registry-primary
* that wirer's `addImportEdge` is gated off, and the scope-resolution
* import pipeline (`emitImportEdges`) only materializes edges from
* finalized `ImportEdge`s — of which there are none here, because there
* is no syntactic `import`. This hook emits the missing edges directly.
*
* Module identity: Swift has no in-source `package X` marker. Module
* membership is the SPM target *subtree* (`Sources/<Target>/…`), threaded
* in via the SPM target map (`resolutionConfig` → `coerceSwiftTargets`)
* and grouped by `groupSwiftFilesBySpmTarget` — replicating legacy
* `groupSwiftFilesByTarget`. With no scanned source dir the map is null
* and all files form one `__default__` module (single-Xcode-project
* assumption). Every pair of distinct `.swift` files in the same module
* gets a directed IMPORTS edge in both directions (whole-module
* visibility is symmetric).
*
* Node identity + edge construction mirror the generic `emitImportEdges`
* convention (`graph-bridge/imports-to-edges.ts`): `generateId('File', path)`
* for endpoints and `generateId('IMPORTS', key)` for the relationship id,
* deduped by `(sourceFile -> targetFile)`. Re-invocation idempotency comes
* from `graph.addRelationship` id-dedup (the same `IMPORTS` id is produced
* for a given ordered pair), so no local `seen` set is needed.
*/
import type { ParsedFile } from 'gitnexus-shared';
import type { KnowledgeGraph } from '../../../graph/types.js';
import type { GraphNodeLookup } from '../../scope-resolution/graph-bridge/node-lookup.js';
import { generateId } from '../../../../lib/utils.js';
import { coerceSwiftTargets, groupSwiftFilesBySpmTarget } from './target-grouping.js';
export function emitSwiftImplicitImportEdges(
graph: KnowledgeGraph,
parsedFiles: readonly ParsedFile[],
_nodeLookup: GraphNodeLookup,
resolutionConfig?: unknown,
): void {
// Group files by SPM target subtree (the module). No-source-dir → all
// files in one `__default__` bucket.
const targets = coerceSwiftTargets(resolutionConfig);
const filesByTarget = groupSwiftFilesBySpmTarget(
parsedFiles,
(parsed) => parsed.filePath,
targets,
);
for (const [, group] of filesByTarget) {
if (group.length < 2) continue; // no siblings to import
for (const source of group) {
for (const target of group) {
if (source.filePath === target.filePath) continue; // no self-import
const dedupKey = `${source.filePath}->${target.filePath}`;
graph.addRelationship({
id: generateId('IMPORTS', dedupKey),
sourceId: generateId('File', source.filePath),
targetId: generateId('File', target.filePath),
type: 'IMPORTS',
confidence: 1.0,
reason: 'swift-scope: implicit module visibility',
});
}
}
}
}
@@ -1,95 +0,0 @@
/**
* Decompose a Swift `import_declaration` into a `CaptureMatch` carrying
* the synthesized markers `@import.kind` / `@import.source` /
* `@import.name` / `@import.testable` that `interpretSwiftImport`
* consumes.
*
* Swift imports are whole-module (no named members), so this is 1:1 —
* one `import` produces exactly one import. The split layer exposes the
* module name and the `@testable` flag without pushing raw-text parsing
* into `interpret.ts`.
*
* import Foundation → kind=namespace, source=Foundation
* import Foo.Bar → kind=namespace, source=Foo (SPM target),
* name=Foo.Bar (full path, for reference)
* @testable import MyApp → kind=namespace, source=MyApp, testable=1
*
* Verified against tree-sitter-swift 0.7.1:
* (import_declaration
* (modifiers (attribute (user_type (type_identifier))))? ; @testable / @_exported
* (identifier (simple_identifier)+)) ; one per dotted segment
*/
import type { Capture, CaptureMatch } from 'gitnexus-shared';
import { nodeToCapture, syntheticCapture, type SyntaxNode } from '../../utils/ast-helpers.js';
interface SwiftImportSpec {
/** SPM target name — the first dotted segment (`Foo` in `import Foo.Bar`). */
readonly source: string;
/** Full dotted module path (`Foo.Bar`). */
readonly fullPath: string;
/** True for `@testable import` (test-scope visibility; resolves identically). */
readonly testable: boolean;
readonly atNode: SyntaxNode;
}
export function splitSwiftImport(stmtNode: SyntaxNode): CaptureMatch | null {
if (stmtNode.type !== 'import_declaration') return null;
const spec = parseSwiftImport(stmtNode);
if (spec === null) return null;
return buildImportMatch(stmtNode, spec);
}
function parseSwiftImport(node: SyntaxNode): SwiftImportSpec | null {
let testable = false;
let identifierNode: SyntaxNode | null = null;
for (let i = 0; i < node.namedChildCount; i++) {
const child = node.namedChild(i);
if (child === null) continue;
if (child.type === 'modifiers') {
// Any attribute whose text mentions `testable` flips the flag.
if (/\btestable\b/.test(child.text)) testable = true;
} else if (child.type === 'identifier') {
identifierNode = child;
}
}
if (identifierNode === null) return null;
// The module path is one or more simple_identifier children, one per
// dotted segment. The SPM target is the FIRST segment.
const segments: string[] = [];
for (let i = 0; i < identifierNode.namedChildCount; i++) {
const seg = identifierNode.namedChild(i);
if (seg !== null && seg.type === 'simple_identifier') segments.push(seg.text);
}
if (segments.length === 0) {
// Fall back to the raw identifier text (e.g. a grammar shape we didn't
// anticipate). Split on `.` to recover the target segment.
const raw = identifierNode.text.trim();
if (raw === '') return null;
const parts = raw.split('.');
return { source: parts[0], fullPath: raw, testable, atNode: node };
}
return {
source: segments[0],
fullPath: segments.join('.'),
testable,
atNode: node,
};
}
function buildImportMatch(stmtNode: SyntaxNode, spec: SwiftImportSpec): CaptureMatch {
const m: Record<string, Capture> = {
'@import.statement': nodeToCapture('@import.statement', stmtNode),
'@import.kind': syntheticCapture('@import.kind', spec.atNode, 'namespace'),
'@import.source': syntheticCapture('@import.source', spec.atNode, spec.source),
'@import.name': syntheticCapture('@import.name', spec.atNode, spec.fullPath),
};
if (spec.testable) {
m['@import.testable'] = syntheticCapture('@import.testable', spec.atNode, '1');
}
return m;
}
@@ -1,103 +0,0 @@
/**
* `resolveImportTarget` adapter for the Swift `ScopeResolver`.
*
* Swift's `import ModuleName` brings in a whole SPM target / framework
* module. The scope-resolution contract passes only `allFilePaths` (no
* `SwiftPackageConfig`), so we resolve a module name to the `.swift`
* files under a directory segment named after the module — the SPM
* convention `Sources/<Module>/*.swift` (and the common
* `<Module>/*.swift` layout). This needs no manifest parsing.
*
* Same-module (intra-target) visibility — the bulk of Swift cross-file
* resolution, which needs NO `import` statement — is handled separately
* by `populateSwiftTargetSiblings` (see `target-siblings.ts`). This
* adapter only resolves EXPLICIT `import` statements (cross-module).
*
* Returns all matching files (one ImportEdge per file, like Go's
* package resolver) so every exported symbol in the module materializes
* a binding. Returns `null` for external frameworks (Foundation, UIKit,
* …) that have no in-repo directory.
*
* Performance: the directory→files grouping is memoized on the stable
* `allFilePaths` Set identity (the same Set is threaded to every import
* in a run), so it is built once per run — NOT once per import. Mirrors
* Python's `getPythonFileIndex` WeakMap pattern (PR #1918).
*/
import type { ParsedImport, WorkspaceIndex } from 'gitnexus-shared';
export interface SwiftResolveContext {
readonly fromFile: string;
/** `ReadonlySet` so the orchestrator's stable run-level set flows
* straight through to the memoized index key. */
readonly allFilePaths: ReadonlySet<string>;
}
interface SwiftModuleIndex {
/** Module (directory-segment) name → original-case `.swift` files
* whose path contains a `/<module>/` directory segment. */
readonly byModule: Map<string, string[]>;
}
const SWIFT_MODULE_INDEX_CACHE = new WeakMap<ReadonlySet<string>, SwiftModuleIndex>();
function getSwiftModuleIndex(allFilePaths: ReadonlySet<string>): SwiftModuleIndex {
const cached = SWIFT_MODULE_INDEX_CACHE.get(allFilePaths);
if (cached !== undefined) return cached;
const byModule = new Map<string, string[]>();
for (const raw of allFilePaths) {
const norm = raw.replace(/\\/g, '/');
if (!norm.endsWith('.swift')) continue;
// Each interior directory segment is a candidate module name. A file
// `Sources/Models/User.swift` is attributed to module `Sources` and
// module `Models`; an `import Models` then resolves to it.
const segments = norm.split('/');
// Drop the filename (last segment); the rest are directory segments.
for (let i = 0; i < segments.length - 1; i++) {
const seg = segments[i];
if (seg === '') continue;
let bucket = byModule.get(seg);
if (bucket === undefined) {
bucket = [];
byModule.set(seg, bucket);
}
bucket.push(raw);
}
}
const index: SwiftModuleIndex = { byModule };
SWIFT_MODULE_INDEX_CACHE.set(allFilePaths, index);
return index;
}
export function resolveSwiftImportTarget(
parsedImport: ParsedImport,
workspaceIndex: WorkspaceIndex,
): string | readonly string[] | null {
const ctx = workspaceIndex as SwiftResolveContext | undefined;
// Duck-type the set (PR #1918 P2: don't `instanceof Set`).
const allFilePaths = (ctx as { allFilePaths?: unknown } | undefined)?.allFilePaths;
if (
ctx === undefined ||
typeof (ctx as { fromFile?: unknown }).fromFile !== 'string' ||
typeof (allFilePaths as { has?: unknown } | undefined)?.has !== 'function' ||
typeof (allFilePaths as Iterable<string> | undefined)?.[Symbol.iterator] !== 'function'
) {
return null;
}
// Swift import target is the SPM module name (first dotted segment).
const targetRaw = parsedImport.targetRaw;
if (targetRaw === null || targetRaw === '') return null;
const moduleName = targetRaw.split('.')[0];
if (moduleName === '') return null;
const index = getSwiftModuleIndex(ctx.allFilePaths);
const files = index.byModule.get(moduleName);
if (files === undefined || files.length === 0) return null; // external framework
// Exclude the importer itself (a file under `Foo/` importing `Foo`).
const out = files.filter((f) => f !== ctx.fromFile);
return out.length > 0 ? out : null;
}
@@ -1,42 +0,0 @@
/**
* Swift scope-resolution hooks (RFC #909 Ring 3, issue #937 — the final
* per-language migration).
*
* Public API barrel. Consumers import from this file rather than the
* individual modules.
*
* Module layout (each file is a single concern):
*
* - `query.ts` — tree-sitter query + lazy parser/query singletons
* - `captures.ts` — `emitSwiftScopeCaptures` orchestrator
* - `import-decomposer.ts` — each `import` → ParsedImport-shaped captures
* - `interpret.ts` — capture-match → `ParsedImport` / `ParsedTypeBinding`
* - `simple-hooks.ts` — small/no-op hooks made explicit
* - `receiver-binding.ts` — synthesize `self` / `super` type-bindings
* - `merge-bindings.ts` — Swift import-vs-local precedence
* - `arity.ts` — Swift arity compatibility (count-primary)
* - `arity-metadata.ts` — synthesize arity metadata from declarations
* - `import-target.ts` — `(ParsedImport, WorkspaceIndex) → file path` adapter
* - `target-grouping.ts` — group same-module files by SPM target subtree
* - `target-siblings.ts` — same-SPM-target implicit cross-file visibility
* - `implicit-imports.ts` — same-SPM-target File→File IMPORTS edges
* - `sibling-type-bindings.ts` — mirror sibling return-type typeBindings
* - `scope-resolver.ts` — `ScopeResolver` registered in `SCOPE_RESOLVERS`
* - `cache-stats.ts` — PROF_SCOPE_RESOLUTION cache hit/miss counters
*/
export { emitSwiftScopeCaptures } from './captures.js';
export { getSwiftCaptureCacheStats, resetSwiftCaptureCacheStats } from './cache-stats.js';
export { interpretSwiftImport, interpretSwiftTypeBinding } from './interpret.js';
export { swiftMergeBindings } from './merge-bindings.js';
export { swiftArityCompatibility } from './arity.js';
export { resolveSwiftImportTarget, type SwiftResolveContext } from './import-target.js';
export { groupSwiftFilesBySpmTarget, coerceSwiftTargets } from './target-grouping.js';
export { populateSwiftTargetSiblings } from './target-siblings.js';
export { emitSwiftImplicitImportEdges } from './implicit-imports.js';
export { mirrorSwiftSiblingTypeBindings } from './sibling-type-bindings.js';
export {
swiftBindingScopeFor,
swiftImportOwningScope,
swiftReceiverBinding,
} from './simple-hooks.js';
@@ -1,95 +0,0 @@
/**
* Capture-match → semantic-shape interpreters for Swift.
*
* - `interpretSwiftImport` → `ParsedImport`
* - `interpretSwiftTypeBinding` → `ParsedTypeBinding`
*
* Import matches arrive pre-decomposed by `emitSwiftScopeCaptures` (one
* import per match, with synthesized `@import.kind/source/name` markers
* and an optional `@import.testable` flag). Type-binding matches arrive
* from the raw query captures — each `@type-binding.*` anchor carries
* `@type-binding.name` + `@type-binding.type`.
*/
import type { CaptureMatch, ParsedImport, ParsedTypeBinding, TypeRef } from 'gitnexus-shared';
// ─── interpretImport ──────────────────────────────────────────────────────
export function interpretSwiftImport(captures: CaptureMatch): ParsedImport | null {
const sourceCap = captures['@import.source'];
if (sourceCap === undefined) return null;
// Swift imports are whole-module (wildcard semantics): `import Foundation`
// brings the entire module into scope, no named members. The SPM target
// (first dotted segment) is the resolution target; the full path is kept
// as importedName for reference. `@testable` resolves identically to a
// plain import (same module is visible in test scope).
const source = sourceCap.text;
const fullPath = captures['@import.name']?.text ?? source;
return {
kind: 'namespace',
localName: source,
importedName: fullPath,
targetRaw: source,
};
}
// ─── interpretTypeBinding ─────────────────────────────────────────────────
export function interpretSwiftTypeBinding(captures: CaptureMatch): ParsedTypeBinding | null {
const nameCap = captures['@type-binding.name'];
const typeCap = captures['@type-binding.type'];
if (nameCap === undefined || typeCap === undefined) return null;
// Normalize so receiver-typed resolution treats these identically:
// `User?` / `User!` → User (optional / IUO)
// `[User]` → User (array sugar)
// `Array<User>` / `Optional<User>` → User (single-arg generic)
// `Foundation.URL` → URL (qualifier)
const rawType = stripQualifier(stripGeneric(stripArraySugar(stripOptional(typeCap.text.trim()))));
let source: TypeRef['source'] = 'parameter-annotation';
if (captures['@type-binding.self'] !== undefined) source = 'self';
else if (captures['@type-binding.constructor'] !== undefined) source = 'constructor-inferred';
else if (captures['@type-binding.annotation'] !== undefined) source = 'annotation';
else if (captures['@type-binding.alias'] !== undefined) source = 'assignment-inferred';
else if (captures['@type-binding.return'] !== undefined) source = 'return-annotation';
return { boundName: nameCap.text, rawTypeName: rawType, source };
}
/** `User?` / `User!` → `User`. */
function stripOptional(text: string): string {
if (text.endsWith('?') || text.endsWith('!')) return text.slice(0, -1).trim();
return text;
}
/** `[User]` → `User` (array sugar). `[K: V]` (dictionary) is left alone —
* element semantics aren't unambiguous. */
function stripArraySugar(text: string): string {
if (text.startsWith('[') && text.endsWith(']') && !text.includes(':')) {
return text.slice(1, -1).trim();
}
return text;
}
/**
* Unwrap a single-arg generic collection wrapper — `Array<User>`,
* `Optional<User>`, `Set<User>` — to its element type. Mirrors C#'s
* `stripGeneric`. Multi-arg generics (`Dictionary<K, V>`,
* `Result<T, E>`) are left alone.
*/
function stripGeneric(text: string): string {
const single = text.match(
/^(?:[A-Za-z_][A-Za-z0-9_.]*\.)?(?:Array|Optional|Set|ContiguousArray|ArraySlice)<([^,<>]+)>$/,
);
if (single !== null) return single[1].trim();
return text;
}
/** `Foundation.URL` → `URL`. */
function stripQualifier(text: string): string {
const lastDot = text.lastIndexOf('.');
if (lastDot === -1) return text;
return text.slice(lastDot + 1);
}
@@ -1,52 +0,0 @@
/**
* Swift shadowing precedence for the `mergeBindings` hook.
*
* Tier ranking (lower wins in shadowing):
*
* - 0: `local` — a type member, method, local var, or parameter
* declared in this scope.
* - 1: `import` / `namespace` / `reexport` — names brought in by an
* `import ModuleName` (whole-module, including `@testable`).
* - 2: `wildcard` — reserved; Swift's whole-module import already
* behaves like a namespace tier, so this is rarely populated.
*
* Swift resolves an ambiguity between two imported modules by requiring
* an explicit `Module.Symbol` qualifier; for receiver-typed dispatch we
* treat all imports as one tier. Locals always shadow imports.
*
* Within a surviving tier we de-dup by `DefId`, last-write-wins.
*/
import type { BindingRef } from 'gitnexus-shared';
const TIER_LOCAL = 0;
const TIER_IMPORT = 1;
const TIER_WILDCARD = 2;
const TIER_UNKNOWN = 3;
function tierOf(b: BindingRef): number {
switch (b.origin) {
case 'local':
return TIER_LOCAL;
case 'reexport':
case 'import':
case 'namespace':
return TIER_IMPORT;
case 'wildcard':
return TIER_WILDCARD;
default:
return TIER_UNKNOWN;
}
}
export function swiftMergeBindings(bindings: readonly BindingRef[]): readonly BindingRef[] {
if (bindings.length === 0) return bindings;
let bestTier = Number.POSITIVE_INFINITY;
for (const b of bindings) bestTier = Math.min(bestTier, tierOf(b));
const survivors = bindings.filter((b) => tierOf(b) === bestTier);
const seen = new Map<string, BindingRef>();
for (const b of survivors) seen.set(b.def.nodeId, b);
return [...seen.values()];
}
@@ -1,199 +0,0 @@
/**
* Tree-sitter query for Swift scope captures (RFC §5.1).
*
* Captures the structural skeleton the generic scope-resolution
* pipeline consumes: scopes (module/class/function), declarations
* (class-likes, methods, init, properties), imports, type bindings
* (parameter annotations, property/field annotations, constructor
* inference, receiver self), and references (call sites, member calls).
*
* Swift specifics that shape this query (all verified against
* tree-sitter-swift 0.7.1 live s-expressions):
*
* - `class`, `struct`, AND `extension` all parse to a single node
* type `class_declaration`. They are distinguished by the `name:`
* field node type: class/struct name is a bare `(type_identifier)`,
* while an extension's name field wraps the extended type in a
* `(user_type (type_identifier))`. The capture below grabs all
* three under `@scope.class` + `@declaration.class`; the captures
* orchestrator (`captures.ts`) re-tags extensions via the
* user_type discriminator so extension members hoist onto the
* extended type.
* - `protocol_declaration` is its own node; its bodyless method
* requirements are `protocol_function_declaration` (NOT
* `function_declaration`).
* - `init_declaration` has no `name:` field — identity is the `init`
* keyword. The captures layer synthesizes its `@declaration.name`.
* - Inside a `parameter`, BOTH the label and the type use the field
* name `name:` — disambiguate by child node type (simple_identifier
* = label, user_type = type). Parameter type bindings are therefore
* synthesized in `captures.ts`, not matched here.
* - A labeled call argument wraps its label in a dedicated
* `value_argument_label` node.
* - `self` is its own node `self_expression`; a `self.member()` call
* is `call_expression > navigation_expression(target: self_expression,
* suffix: navigation_suffix > simple_identifier)`.
* - `import_declaration` carries the module path as an `(identifier
* (simple_identifier)+)` — one `simple_identifier` per dotted
* segment. `@testable` and other attributes surface as a leading
* `(modifiers (attribute (user_type (type_identifier))))`.
*
* Exposes lazy `Parser` and `Query` singletons so callers don't pay
* tree-sitter init cost per file.
*/
import Parser from 'tree-sitter';
import Swift from 'tree-sitter-swift';
const SWIFT_SCOPE_QUERY = `
;; ── Scopes ──────────────────────────────────────────────────────────
(source_file) @scope.module
;; class / struct / extension all parse to class_declaration; the
;; captures orchestrator splits extensions out via the user_type
;; name discriminator.
(class_declaration) @scope.class
(protocol_declaration) @scope.class
(function_declaration) @scope.function
(protocol_function_declaration) @scope.function
(init_declaration) @scope.function
(deinit_declaration) @scope.function
;; ── Declarations — types ────────────────────────────────────────────
;; class / struct: name is a bare type_identifier.
(class_declaration
name: (type_identifier) @declaration.name) @declaration.class
;; extension: name is (user_type (type_identifier)). Captured separately
;; so captures.ts can re-tag it as an extension of the wrapped type.
(class_declaration
name: (user_type) @declaration.name) @declaration.extension
(protocol_declaration
name: (type_identifier) @declaration.name) @declaration.interface
;; ── Declarations — methods / init / properties ──────────────────────
(function_declaration
name: (simple_identifier) @declaration.name) @declaration.method
(protocol_function_declaration
name: (simple_identifier) @declaration.name) @declaration.method
;; init has no name field — captures.ts synthesizes @declaration.name = "init".
(init_declaration) @declaration.constructor
(property_declaration
name: (pattern
bound_identifier: (simple_identifier) @declaration.name)) @declaration.property
;; ── Imports ─────────────────────────────────────────────────────────
;; Single anchor per import; the captures layer decomposes the module
;; path (and detects @testable) into @import.kind/source/name markers.
(import_declaration) @import.statement
;; ── Type bindings — property annotations: \`var owner: Owner\` ─────────
(property_declaration
name: (pattern
bound_identifier: (simple_identifier) @type-binding.name)
(type_annotation
(user_type (type_identifier) @type-binding.type))) @type-binding.annotation
;; ── Type bindings — stored / local-var constructor inference:
;; \`let p = Product(...)\` (constructor) and \`let u = getUser()\`
;; (free-call result; chain-follow resolves getUser → its return type).
;; property_declaration is the node for both class-level stored
;; properties AND let/var inside a function body. ───────────────────
(property_declaration
name: (pattern
bound_identifier: (simple_identifier) @type-binding.name)
value: (call_expression
(simple_identifier) @type-binding.type)) @type-binding.constructor
;; \`let u = await fetchUser()\` — unwrap the await wrapper to the call.
(property_declaration
name: (pattern
bound_identifier: (simple_identifier) @type-binding.name)
value: (await_expression
(call_expression
(simple_identifier) @type-binding.type))) @type-binding.constructor
;; \`let r = try parseRepo()\` — unwrap the try wrapper to the call.
(property_declaration
name: (pattern
bound_identifier: (simple_identifier) @type-binding.name)
value: (try_expression
(call_expression
(simple_identifier) @type-binding.type))) @type-binding.constructor
;; ── Optional binding anchors: if-let / guard-let. In tree-sitter-swift
;; 0.7.1 these are if_statement / guard_statement with a flat shape — a
;; \`bound_identifier:\` field for the name plus separate \`condition:\`
;; children (one of which is the bound value expression). A static .scm
;; pattern can't pair the name with the value across those sibling
;; condition fields, so we only ANCHOR the statement here and synthesize
;; the @type-binding.constructor in captures.ts (synthesizeOptionalBinding)
;; by walking the node. (A nonexistent if_let_binding node type would throw
;; TSQueryErrorNodeType and break the WHOLE query — do not reintroduce it.)
(if_statement
bound_identifier: (simple_identifier)) @optional.binding
(guard_statement
bound_identifier: (simple_identifier)) @optional.binding
;; NOTE: parameter-type bindings (\`func f(u: User)\`) and function
;; return-type bindings (\`func getUser() -> User\`) are NOT matched by
;; tree-sitter patterns here. Swift's grammar reuses the SAME \`name:\`
;; field for the function name, each parameter's label, AND the return
;; type, so a two-\`name:\`-field query cross-assigns and produces garbage
;; bindings (\`save: save\`). They are synthesized in code instead —
;; \`captures.ts\` reads the function node via
;; \`swiftMethodConfig.extractParameters\` / \`extractReturnType\`, which
;; handle the grammar correctly. (Mirrors how receiver + arity are
;; synthesized rather than queried.)
;; ── References — field reads: \`u.address\` (member access that is NOT a
;; call callee or assignment LHS). Emit-side filtering in captures.ts
;; drops call targets and write LHS so only genuine reads emit ACCESSES.
(navigation_expression
target: (_) @reference.receiver
suffix: (navigation_suffix
suffix: (simple_identifier) @reference.name)) @reference.read.member
;; ── References — free calls: \`foo(...)\` ─────────────────────────────
(call_expression
(simple_identifier) @reference.name) @reference.call.free
;; ── References — member / method calls: \`obj.method(...)\` ───────────
;; navigation_expression carries the receiver (target:) and the member
;; (suffix > navigation_suffix > simple_identifier). \`self\` is a
;; self_expression; other receivers are simple_identifier / nested
;; navigation_expression — capture the whole target as @reference.receiver.
(call_expression
(navigation_expression
target: (_) @reference.receiver
suffix: (navigation_suffix
suffix: (simple_identifier) @reference.name))) @reference.call.member
;; ── References — constructor calls handled via @reference.call.free
;; (a Swift \`Foo(...)\` is a call_expression with a simple_identifier
;; callee; pickConstructorOrClass in free-call fallback targets the
;; type's Constructor). No separate object_creation node in Swift.
`;
let _parser: Parser | null = null;
let _query: Parser.Query | null = null;
export function getSwiftParser(): Parser {
if (_parser === null) {
_parser = new Parser();
_parser.setLanguage(Swift as Parameters<Parser['setLanguage']>[0]);
}
return _parser;
}
export function getSwiftScopeQuery(): Parser.Query {
if (_query === null) {
_query = new Parser.Query(Swift as Parameters<Parser['setLanguage']>[0], SWIFT_SCOPE_QUERY);
}
return _query;
}
@@ -1,163 +0,0 @@
/**
* Synthesize `@type-binding.self` captures for Swift instance methods —
* one for `self` (always on non-static methods inside a type body) and
* optionally one for `super` (only on class methods when the enclosing
* class declares a superclass).
*
* Mirrors `languages/csharp/receiver-binding.ts`. tree-sitter can't
* express "the implicit receiver of a non-static member of a
* class/struct/extension/protocol" via a static `.scm` pattern because
* the receiver isn't a parameter — it's implicit. Synthesis in code is
* the same approach C#/Python use for `this`/`self`.
*
* Swift AST facts (tree-sitter-swift 0.7.1, verified):
* - class / struct / extension all parse to `class_declaration`. For
* class/struct the `name:` field is `(type_identifier)`; for an
* extension it is `(user_type (type_identifier))` wrapping the
* EXTENDED type — so `self` in an extension binds to the extended
* type, which is exactly what we want for method dispatch.
* - `protocol_declaration` has a `(type_identifier)` name.
* - the method body is the `body:` field (`function_body`); a bodyless
* `protocol_function_declaration` has no function scope to anchor to.
* - a superclass / conformance is an `(inheritance_specifier
* inherits_from: (user_type (type_identifier)))` child of the
* class_declaration.
*/
import type { Capture, CaptureMatch } from 'gitnexus-shared';
import { nodeToCapture, syntheticCapture, type SyntaxNode } from '../../utils/ast-helpers.js';
import { swiftMethodConfig } from '../../method-extractors/configs/swift.js';
const TYPE_DECL_NODE_TYPES = new Set(['class_declaration', 'protocol_declaration']);
const FUNCTION_NODE_TYPES = new Set([
'function_declaration',
'init_declaration',
'deinit_declaration',
]);
/** Walk up to the enclosing type declaration (class/struct/extension/
* protocol). Nested local functions still see `self` from the enclosing
* type, so don't stop at function-like nodes. */
function findEnclosingTypeDeclaration(node: SyntaxNode): SyntaxNode | null {
let cur: SyntaxNode | null = node.parent;
while (cur !== null) {
if (TYPE_DECL_NODE_TYPES.has(cur.type)) return cur;
cur = cur.parent;
}
return null;
}
/** Bare type name of the enclosing type. class/struct → type_identifier
* text; extension → the wrapped user_type's identifier text; protocol →
* type_identifier text. */
function enclosingTypeName(typeNode: SyntaxNode): string | null {
const nameNode = typeNode.childForFieldName('name');
if (nameNode === null) return null;
if (nameNode.type === 'user_type') {
// extension Foo { } → name is (user_type (type_identifier)).
// extension Foo.Bar { } → (user_type (type_identifier Foo)
// (type_identifier Bar)); the extended type — and therefore `self` —
// is the TRAILING identifier `Bar` (lastNamedChild), not `Foo`. For a
// single identifier first === last, so this is unchanged.
const inner = nameNode.lastNamedChild;
return inner?.text ?? nameNode.text;
}
return nameNode.text;
}
/** Is this declaration a `class` (vs `struct`/`extension`)? Only classes
* have a meaningful `super`. Detected by the leading keyword token —
* class/struct/extension share the `class_declaration` node type. */
function isClassKeyword(typeNode: SyntaxNode): boolean {
for (let i = 0; i < typeNode.childCount; i++) {
const child = typeNode.child(i);
if (child !== null && !child.isNamed) {
const t = child.text.trim();
if (t === 'class') return true;
if (t === 'struct' || t === 'extension' || t === 'enum' || t === 'actor') return false;
}
}
return false;
}
/** First inherited type (superclass or first protocol) as raw text, or
* null. For a class the first `inheritance_specifier` is conventionally
* the superclass — `super.x()` only compiles when that is true. */
function firstInheritedType(typeNode: SyntaxNode): string | null {
for (let i = 0; i < typeNode.namedChildCount; i++) {
const child = typeNode.namedChild(i);
if (child === null || child.type !== 'inheritance_specifier') continue;
const inheritsFrom = child.childForFieldName('inherits_from') ?? child.firstNamedChild;
if (inheritsFrom === null) return null;
if (inheritsFrom.type === 'user_type') {
return inheritsFrom.firstNamedChild?.text ?? inheritsFrom.text;
}
return inheritsFrom.text;
}
return null;
}
/** A Swift type method (`static func` OR `class func`) has no `self`
* instance receiver. Delegate to `swiftMethodConfig.isStatic`, which is
* the single source of truth: `static func` emits the modifier under a
* `modifiers > property_modifier` wrapper, but `class func` emits a BARE
* anonymous `class` token directly under `function_declaration` (verified,
* tree-sitter-swift 0.7.1). `swiftMethodConfig.isStatic` covers both via
* `hasKeyword(node, 'static'|'class')` (scans direct children) and
* `hasModifier(...)` — so reusing it avoids re-deriving the same scan and
* fixes the prior `modifiers`-only check that missed `class func`. */
function isStaticMethod(fnNode: SyntaxNode): boolean {
return swiftMethodConfig.isStatic(fnNode);
}
/**
* Build zero, one, or two `@type-binding.self` matches for `fnNode`:
* - `null`/`[]` if the function is free (no enclosing type), static, or
* the enclosing type has no resolvable name, or the function is
* bodyless (no scope to anchor to).
* - one match (`self`) for instance methods of a class/struct/extension/
* protocol.
* - two matches (`self` + `super`) only when the function lives in a
* `class` (keyword) declaration with a declared superclass.
*
* Caller must guarantee `FUNCTION_NODE_TYPES.has(fnNode.type)`.
*/
export function synthesizeSwiftReceiverBinding(fnNode: SyntaxNode): CaptureMatch[] {
if (!FUNCTION_NODE_TYPES.has(fnNode.type)) return [];
if (isStaticMethod(fnNode)) return [];
const enclosingType = findEnclosingTypeDeclaration(fnNode);
if (enclosingType === null) return [];
const enclosingName = enclosingTypeName(enclosingType);
if (enclosingName === null) return [];
// Anchor inside the function scope. `body:` is the function_body; its
// range is guaranteed inside the function scope (unlike the method's
// start position, which maps to the enclosing type scope via
// positionIndex). Bodyless declarations have no function scope.
const anchorNode = fnNode.childForFieldName('body');
if (anchorNode === null) return [];
const out: CaptureMatch[] = [buildReceiverMatch(anchorNode, 'self', enclosingName)];
// `super` only for class methods with a declared superclass.
if (isClassKeyword(enclosingType)) {
const superType = firstInheritedType(enclosingType);
if (superType !== null) {
out.push(buildReceiverMatch(anchorNode, 'super', superType));
}
}
return out;
}
function buildReceiverMatch(anchorNode: SyntaxNode, name: string, typeText: string): CaptureMatch {
const m: Record<string, Capture> = {
'@type-binding.self': nodeToCapture('@type-binding.self', anchorNode),
'@type-binding.name': syntheticCapture('@type-binding.name', anchorNode, name),
'@type-binding.type': syntheticCapture('@type-binding.type', anchorNode, typeText),
};
return m;
}
@@ -1,227 +0,0 @@
/**
* Swift `ScopeResolver` registered in `SCOPE_RESOLVERS` and consumed by
* the generic `runScopeResolution` orchestrator (RFC #909 Ring 3,
* issue #937 — the final per-language migration).
*
* Closest reference: C# (`csharpScopeResolver`) — both are OOP with
* classes, structs, interfaces/protocols, and explicit instance
* receivers. MRO follows Kotlin's shape (single superclass + multiple
* protocol conformance).
*
* ## Swift specifics
*
* - **Extensions** add members to an existing type. `emitSwiftScopeCaptures`
* re-keys an `extension Foo { … }` to a `class_declaration`-style def
* named `Foo`, so its members land on `Foo`'s scope and the shared
* `populateClassOwnedMembers` stamps them with `Foo`'s ownerId — the
* same mechanism C# uses for `partial class`. No separate hoist pass.
* - **Labeled arguments** narrow by ARITY only (count-primary, labels
* soft) — see `arity.ts`. Label-precise dispatch is deferred to the
* type-binding layer.
* - **Same-module visibility**: every file in an SPM target sees its
* siblings' top-level defs without an `import`. Modeled via
* `populateSwiftTargetSiblings`, grouped by the SPM target *subtree*
* (`Sources/<Target>/…`) via `groupSwiftFilesBySpmTarget` fed from the
* `loadResolutionConfig` SPM map, mirroring Go's package siblings. With
* no scanned source dir (no `Sources/`/`Package/Sources/`/`src/`) the
* map is null and all files form one `__default__` module.
* - **`super`** is the superclass receiver (`super.method()`); plain
* `self` is the instance receiver. Both synthesized in
* `receiver-binding.ts`.
*
* ## Known limitations (conscious migration trade-offs; parity gate flags
* anything that matters in the corpus)
*
* 1. **Protocol associated types / generic constraints** (`extension
* Array where Element: Equatable`) are not narrowed — the `Self`
* type of a protocol method resolves to the protocol, not the
* conforming type.
* 2. **Cross-module `import` resolution** is still directory-segment
* based (`import Foo` → files under a `Foo/` dir); explicit imports do
* not yet consult the SPM target map (follow-up, tracked under #1935).
* Same-target visibility (the common case) IS SPM-target-subtree
* accurate — handled by sibling augmentation grouped via
* `groupSwiftFilesBySpmTarget`, not by explicit imports.
* 3. **Operator / subscript overloads** dispatch by name only.
* 4. **`@_exported import` re-exports** are treated as plain imports.
*/
import type { ParsedFile } from 'gitnexus-shared';
import { SupportedLanguages } from 'gitnexus-shared';
import { loadSwiftPackageConfig } from '../../language-config.js';
import { buildMro, defaultLinearize } from '../../scope-resolution/passes/mro.js';
import { populateClassOwnedMembers, isClassLike } from '../../scope-resolution/scope/walkers.js';
import { resolveDefGraphId } from '../../scope-resolution/graph-bridge/ids.js';
import type { GraphNodeLookup } from '../../scope-resolution/graph-bridge/node-lookup.js';
import type { KnowledgeGraph } from '../../../graph/types.js';
import type { ScopeResolver } from '../../scope-resolution/contract/scope-resolver.js';
import { swiftProvider } from '../swift.js';
import {
swiftArityCompatibility,
swiftMergeBindings,
interpretSwiftImport,
resolveSwiftImportTarget,
populateSwiftTargetSiblings,
emitSwiftImplicitImportEdges,
mirrorSwiftSiblingTypeBindings,
type SwiftResolveContext,
} from './index.js';
const ZERO_RANGE = { startLine: 0, startCol: 0, endLine: 0, endCol: 0 } as const;
const swiftScopeResolver: ScopeResolver = {
language: SupportedLanguages.Swift,
languageProvider: swiftProvider,
importEdgeReason: 'swift-scope: import',
// Load the SPM target map (Sources/<Target>/ subtree mapping) once per
// workspace pass. Threaded through the orchestrator as `resolutionConfig`
// and consumed by the three same-module grouping hooks
// (`emitImplicitImportEdges`, `populateNamespaceSiblings`,
// `mirrorNamespaceTypeBindings`) via `coerceSwiftTargets` so they group by
// the SPM target subtree, not the immediate directory. Mirrors
// `goScopeResolver`'s `loadGoModulePath`.
loadResolutionConfig: (repoPath: string) => loadSwiftPackageConfig(repoPath),
resolveImportTarget: (targetRaw, fromFile, allFilePaths) => {
const ws: SwiftResolveContext = { fromFile, allFilePaths };
return resolveSwiftImportTarget(
interpretSwiftImport({
'@import.source': { name: '@import.source', text: targetRaw, range: ZERO_RANGE },
}) ?? { kind: 'namespace', localName: targetRaw, importedName: targetRaw, targetRaw },
ws,
);
},
// Swift shadowing: local declarations hide imports.
mergeBindings: (existing, incoming) => [...swiftMergeBindings([...existing, ...incoming])],
// Adapter: swiftArityCompatibility uses (def, callsite); contract is (callsite, def).
arityCompatibility: (callsite, def) => swiftArityCompatibility(def, callsite),
buildMro: (graph, parsedFiles, nodeLookup) => buildSwiftMro(graph, parsedFiles, nodeLookup),
// Methods/properties/init are owned by their enclosing class/struct/
// extension(→extended type)/protocol. Extension members hoist for free
// because captures.ts re-keys the extension to a Class def named after
// the extended type.
populateOwners: (parsed: ParsedFile) => populateClassOwnedMembers(parsed),
// `super.method()` dispatches through the superclass chain.
isSuperReceiver: (text) => text.trim() === 'super',
// Whole-module same-target visibility without `import`.
populateNamespaceSiblings: populateSwiftTargetSiblings,
// Same-target File→File IMPORTS edges (no syntactic `import`). The
// generic finalized-ImportEdge pipeline has nothing to emit here, so
// these whole-module-visibility edges are emitted directly.
emitImplicitImportEdges: emitSwiftImplicitImportEdges,
// Mirror sibling files' return-type typeBindings into each file's
// module scope so cross-file chains (`let u = siblingFn(); u.m()`)
// resolve. Whole-module visibility has no import edge for
// `propagateImportedReturnTypes` to follow, so this directory-sibling
// mirror feeds it (mirrors Go's namespace-typeBinding mirror).
mirrorNamespaceTypeBindings: mirrorSwiftSiblingTypeBindings,
// Swift is statically typed — type info is reliable; the field-fallback
// heuristic over-connects, so keep it off. Return-type propagation on.
fieldFallbackOnMethodLookup: false,
propagatesReturnTypesAcrossImports: true,
// Swift has no `new` keyword: `UserService()` is a bare call that
// resolves to the type's Constructor/Class. With whole-module sibling
// visibility, the callee is reachable workspace-wide, so allow the
// global free-call fallback (as Python/Go/Ruby/COBOL do for the same
// no-`new` constructor + cross-file free-call shape).
allowGlobalFreeCallFallback: true,
// Swift's call graph models `Type(...)` as a reference to the type
// itself, not its `init` — both the legacy DAG and this test suite link
// `Foo()` to the Class node even when an explicit `init` exists.
constructorCallTargetsClass: true,
};
export { swiftScopeResolver };
/**
* Swift MRO — `defaultLinearize` (EXTENDS-only superclass chain) extended
* with protocol ancestors discovered via `IMPLEMENTS` edges. Protocols
* with default method implementations (via protocol extensions) are
* inherited by conforming types without an explicit `override`; the
* generic EXTENDS-only MRO would miss them because the conformer has no
* EXTENDS link to the protocol.
*
* Mirrors `buildKotlinMro`: append protocols after the superclass chain
* (Swift requires an explicit implementation on ambiguity, so first-seen
* ordering approximates method lookup). Transitive protocol inheritance
* (`protocol A: B`) is closed via BFS.
*/
function buildSwiftMro(
graph: KnowledgeGraph,
parsedFiles: readonly ParsedFile[],
nodeLookup: GraphNodeLookup,
): Map<string, string[]> {
const mro = buildMro(graph, parsedFiles, nodeLookup, defaultLinearize);
const defIdByGraphId = new Map<string, string>();
for (const parsed of parsedFiles) {
for (const def of parsed.localDefs) {
if (!isClassLike(def.type)) continue;
const graphId = resolveDefGraphId(parsed.filePath, def, nodeLookup);
if (graphId !== undefined) defIdByGraphId.set(graphId, def.nodeId);
}
}
const directImpls = new Map<string, string[]>();
for (const rel of graph.iterRelationshipsByType('IMPLEMENTS')) {
const source = defIdByGraphId.get(rel.sourceId);
const target = defIdByGraphId.get(rel.targetId);
if (source === undefined || target === undefined) continue;
let list = directImpls.get(source);
if (list === undefined) {
list = [];
directImpls.set(source, list);
}
if (!list.includes(target)) list.push(target);
}
for (const [classDefId, extendsMro] of mro) {
const ancestorChain = [classDefId, ...extendsMro];
const seeds: string[] = [];
for (const ancestorId of ancestorChain) {
for (const ifaceId of directImpls.get(ancestorId) ?? []) seeds.push(ifaceId);
}
if (seeds.length === 0) continue;
const protocols = closeProtocols(seeds, directImpls);
mro.set(classDefId, [...extendsMro, ...protocols.filter((i) => !extendsMro.includes(i))]);
}
// Types that only conform to protocols (no superclass) still need an MRO.
for (const [classDefId, ifaces] of directImpls) {
if (mro.has(classDefId)) continue;
mro.set(classDefId, closeProtocols([...ifaces], directImpls));
}
return mro;
}
function closeProtocols(
seeds: readonly string[],
directImpls: ReadonlyMap<string, readonly string[]>,
): string[] {
const out: string[] = [];
const seen = new Set<string>();
const queue: string[] = [...seeds];
while (queue.length > 0) {
const cur = queue.shift()!;
if (seen.has(cur)) continue;
seen.add(cur);
out.push(cur);
for (const next of directImpls.get(cur) ?? []) {
if (!seen.has(next)) queue.push(next);
}
}
return out;
}
@@ -1,78 +0,0 @@
/**
* Swift same-module return-type typeBinding mirroring for the
* `mirrorNamespaceTypeBindings` hook.
*
* Swift gives every file in a module (an SPM target) visibility of every
* sibling's top-level declarations without a syntactic `import`. For a
* chained call like
*
* App.swift: let user = getUser(); user.save() // user → getUser → ?
* Models.swift: func getUser() -> User { … } // getUser → User
*
* to resolve `user.save()` cross-file, App.swift's scope chain must be
* able to follow `getUser → User`. The function return-type binding
* (`getUser → User`) lives on Models.swift's module scope, so we mirror
* sibling module-scope typeBindings into the importer's module scope —
* the same trick Go uses (`mirrorGoNamespaceTypeBindings`), but Swift has
* no namespace-import edges, so module membership is the SPM target
* subtree (`Sources/<Target>/…`): threaded in via the SPM target map
* (`resolutionConfig` → `coerceSwiftTargets`) and grouped by
* `groupSwiftFilesBySpmTarget` (replicating legacy `groupSwiftFilesByTarget`;
* no-source-dir → all files form one `__default__` module).
*
* Runs after `populateNamespaceSiblings` and before
* `propagateImportedReturnTypes`, so the SCC-ordered propagation pass
* sees the mirrored bindings and chains `user → getUser → User` to the
* terminal class. Each mirrored binding is chain-followed inside its
* source module first so we mirror the terminal type, not an intermediate
* intra-module reference. `Scope.typeBindings` is mutated via the
* sanctioned non-frozen Map cast (Contract Invariant I6).
*/
import type { ParsedFile, TypeRef } from 'gitnexus-shared';
import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js';
import type { WorkspaceResolutionIndex } from '../../scope-resolution/workspace-index.js';
import { followChainPostFinalize } from '../../scope-resolution/passes/imported-return-types.js';
import { coerceSwiftTargets, groupSwiftFilesBySpmTarget } from './target-grouping.js';
export function mirrorSwiftSiblingTypeBindings(
parsedFiles: readonly ParsedFile[],
indexes: ScopeResolutionIndexes,
workspaceIndex: WorkspaceResolutionIndex,
resolutionConfig?: unknown,
): void {
const moduleScopeByFile = workspaceIndex.moduleScopeByFile;
// Group files by SPM target subtree (the module). No-source-dir → all
// files in one `__default__` bucket.
const targets = coerceSwiftTargets(resolutionConfig);
const filesByTarget = groupSwiftFilesBySpmTarget(
parsedFiles,
(parsed) => parsed.filePath,
targets,
);
for (const [, group] of filesByTarget) {
if (group.length < 2) continue; // no siblings to mirror from
const files = group.map((parsed) => parsed.filePath);
for (const importerFile of files) {
const importerModule = moduleScopeByFile.get(importerFile);
if (importerModule === undefined) continue;
for (const sourceFile of files) {
if (sourceFile === importerFile) continue;
const sourceModule = moduleScopeByFile.get(sourceFile);
if (sourceModule === undefined) continue;
for (const [name, ref] of sourceModule.typeBindings) {
if (name.length === 0) continue;
// A local annotation on the importer must win over a sibling's.
if (importerModule.typeBindings.has(name)) continue;
const terminal = followChainPostFinalize(ref, sourceModule.id, indexes);
(importerModule.typeBindings as Map<string, TypeRef>).set(name, terminal);
}
}
}
}
}
@@ -1,80 +0,0 @@
/**
* Synthesize parameter-type and function return-type `@type-binding.*`
* captures for a Swift function-like node.
*
* Why synthesized rather than queried: Swift's tree-sitter grammar reuses
* the field name `name:` for the function name, each parameter's label,
* each parameter's type, AND the function's return type. A tree-sitter
* query with two `name:` fields cross-assigns those captures and produces
* garbage bindings (e.g. `save: save`). Reading the node via the existing
* `swiftMethodConfig.extractParameters` / `extractReturnType` extractors
* — which already handle the grammar correctly for the legacy parse path
* — yields the right name→type pairs. This mirrors how receiver and arity
* metadata are synthesized in `captures.ts` instead of queried.
*
* - **Parameter bindings** anchor inside the function body so the
* binding lands in the Function scope: `func f(u: User) { u.save() }`
* → `u: User` visible in f's body.
* - **Return-type binding** anchors at the function node and carries
* `@type-binding.return`, which `swiftBindingScopeFor` hoists to the
* Module scope so `propagateImportedReturnTypes` mirrors it across
* files and callers see `let u = getUser(); u.save()`.
*/
import type { Capture, CaptureMatch } from 'gitnexus-shared';
import { nodeToCapture, syntheticCapture, type SyntaxNode } from '../../utils/ast-helpers.js';
import { swiftMethodConfig } from '../../method-extractors/configs/swift.js';
const NAMED_FUNCTION_NODE_TYPES = new Set([
'function_declaration',
'protocol_function_declaration',
]);
export function synthesizeSwiftSignatureBindings(fnNode: SyntaxNode): CaptureMatch[] {
const out: CaptureMatch[] = [];
// ── Parameter bindings (anchor in the body for Function-scope landing) ──
const params = swiftMethodConfig.extractParameters?.(fnNode) ?? [];
if (params.length > 0) {
const bodyNode = fnNode.childForFieldName('body');
// Anchor params inside the body when present; for bodyless protocol
// requirements there is no Function scope to bind locals into, so skip.
if (bodyNode !== null) {
for (const p of params) {
if (p.type === null || p.name === '') continue;
out.push(buildBindingMatch(bodyNode, '@type-binding.parameter', p.name, p.type));
}
}
}
// ── Return-type binding (function name → return type, hoisted to Module) ──
// Only named functions have a name to bind; init/deinit have no return.
if (NAMED_FUNCTION_NODE_TYPES.has(fnNode.type)) {
const funcName = swiftMethodConfig.extractName?.(fnNode);
const returnType = swiftMethodConfig.extractReturnType?.(fnNode);
if (
funcName !== undefined &&
funcName !== '' &&
returnType !== undefined &&
returnType !== ''
) {
out.push(buildBindingMatch(fnNode, '@type-binding.return', funcName, returnType));
}
}
return out;
}
function buildBindingMatch(
anchorNode: SyntaxNode,
sourceTag: '@type-binding.parameter' | '@type-binding.return',
name: string,
typeText: string,
): CaptureMatch {
const m: Record<string, Capture> = {
[sourceTag]: nodeToCapture(sourceTag, anchorNode),
'@type-binding.name': syntheticCapture('@type-binding.name', anchorNode, name),
'@type-binding.type': syntheticCapture('@type-binding.type', anchorNode, typeText),
};
return m;
}
@@ -1,79 +0,0 @@
/**
* Trivial / no-op-ish hooks for the Swift provider. Kept together
* because each is a few lines and they share a theme: they make the
* provider's choice explicit rather than relying on "absence == default"
* so reviewers don't have to re-derive the analysis.
*/
import type {
CaptureMatch,
ParsedImport,
Scope,
ScopeId,
ScopeTree,
TypeRef,
} from 'gitnexus-shared';
// ─── bindingScopeFor ──────────────────────────────────────────────────────
/** Swift uses the central extractor's "innermost enclosing scope" default
* for most declarations: class-body declarations attach to the Class
* scope, function-body locals to the Function scope.
*
* Exception: **function return-type bindings** (`@type-binding.return`)
* must hoist to the Module scope. The default auto-hoist promotes only
* one level (Function → its parent). For top-level functions the parent
* is already the Module so the default works, but for methods declared
* inside a class/struct/extension the parent is the Class — the return
* binding would get stuck there, invisible to:
* - chain-follow's parent-chain walk (`let u = getUser(); u.save()`);
* - cross-file `propagateImportedReturnTypes`, which reads only
* `sourceModule.typeBindings`.
* Walking to Module restores both. Mirrors `csharpBindingScopeFor`. */
export function swiftBindingScopeFor(
decl: CaptureMatch,
innermost: Scope,
tree: ScopeTree,
): ScopeId | null {
if (decl['@type-binding.return'] !== undefined) {
let cur: Scope | undefined = innermost;
while (cur !== undefined && cur.kind !== 'Module') {
const parentId: ScopeId | null = cur.parent ?? null;
if (parentId === null) break;
cur = tree.getScope(parentId);
}
if (cur !== undefined && cur.kind === 'Module') return cur.id;
}
return null;
}
// ─── importOwningScope ────────────────────────────────────────────────────
/** Swift imports only appear at file (module) scope and bring a whole
* module into view there. Attach the import to the Module scope; for any
* other innermost scope delegate to the default (returns null). */
export function swiftImportOwningScope(
_imp: ParsedImport,
innermost: Scope,
_tree: ScopeTree,
): ScopeId | null {
if (innermost.kind === 'Module') return innermost.id;
return null;
}
// ─── receiverBinding ──────────────────────────────────────────────────────
/** Look up `self` (or `super`) in the function scope's type bindings.
*
* `self` / `super` are synthesized as type bindings on instance methods
* during capture emission (`receiver-binding.ts`) — `self` for every
* method inside a class/struct/extension/protocol body, and `super`
* additionally for methods of a class with a declared superclass. This
* hook returns a non-null `TypeRef` for instance-method bodies.
*
* Returns `null` for static methods (no `self` synthesized), free
* functions (no enclosing type), and non-Function scopes. */
export function swiftReceiverBinding(functionScope: Scope): TypeRef | null {
if (functionScope.kind !== 'Function') return null;
return functionScope.typeBindings.get('self') ?? functionScope.typeBindings.get('super') ?? null;
}
@@ -1,104 +0,0 @@
/**
* Swift SPM-target file grouping for the registry-primary same-module
* hooks (`implicit-imports.ts`, `target-siblings.ts`,
* `sibling-type-bindings.ts`).
*
* A Swift module is an SPM *target* — a directory *subtree*
* (`Sources/<Target>/…`), not a single immediate directory. Grouping by
* the immediate containing directory (the prior `containingDir` proxy)
* drops cross-directory same-module edges and can mis-resolve a
* constructor call to a wrong same-simple-named type in another target.
*
* This module duplicates the legacy `groupSwiftFilesByTarget`
* (`languages/swift.ts`) semantics **verbatim** so the registry-primary
* path matches legacy SPM-subtree grouping without touching the legacy
* pipeline (hard constraint: legacy stays byte-identical). The SPM target
* map is threaded in via the `resolutionConfig` channel
* (`loadSwiftPackageConfig` → `resolutionConfig` → these hooks); see
* `scope-resolver.ts` and `scope-resolution/pipeline/run.ts`.
*
* NOTE: This intentionally differs from the import-config module's
* leading-`startsWith` (`import-resolvers/configs/swift.ts`): that module
* fans a file out to EVERY matching target (a nested file can belong to
* multiple configured target dirs there), whereas legacy module grouping
* assigns each file to the FIRST matching target only (legacy `break`s) —
* one bucket per file. Do not copy the import-config behavior here.
*/
import type { SwiftPackageConfig } from '../../language-config.js';
const DEFAULT_TARGET = '__default__';
/**
* Group `items` by SPM target subtree, replicating legacy
* `groupSwiftFilesByTarget` semantics exactly:
*
* - `targets` null/empty (no scanned source dir found) → ALL items go to
* a single `__default__` bucket (single-Xcode-project assumption).
* - Otherwise: a file matches a target when its normalized path either
* starts with `<targetDir>/` (`indexOf === 0`) OR contains it at a `/`
* boundary (`norm[idx - 1] === '/'`). Each file is assigned to the
* FIRST matching target only (one bucket per file, no fan-out).
* - Files matching no target fall into the `__default__` bucket.
*
* `targets` is `name → directory` (the `SwiftPackageConfig.targets` map).
*/
export function groupSwiftFilesBySpmTarget<T>(
items: readonly T[],
getPath: (item: T) => string,
targets: ReadonlyMap<string, string> | null,
): Map<string, T[]> {
// No SPM config -> single target (common for Xcode projects).
if (targets === null || targets.size === 0) {
return new Map([[DEFAULT_TARGET, [...items]]]);
}
// Pre-convert target dirs to normalized prefix format once.
const targetPrefixes = [...targets.entries()].map(([name, dir]) => ({
name,
prefix: dir.replace(/\\/g, '/') + '/',
}));
const groups = new Map<string, T[]>();
const defaultGroup: T[] = [];
for (const item of items) {
const rawPath = getPath(item);
const normalized = rawPath.includes('\\') ? rawPath.replace(/\\/g, '/') : rawPath;
let assigned = false;
for (const { name, prefix } of targetPrefixes) {
const idx = normalized.indexOf(prefix);
if (idx === 0 || (idx > 0 && normalized[idx - 1] === '/')) {
let group = groups.get(name);
if (group === undefined) {
group = [];
groups.set(name, group);
}
group.push(item);
assigned = true;
break; // FIRST match only — one bucket per file, no fan-out.
}
}
if (!assigned) defaultGroup.push(item);
}
if (defaultGroup.length > 0) groups.set(DEFAULT_TARGET, defaultGroup);
return groups;
}
/**
* Duck-type the opaque `resolutionConfig` (loaded by
* `loadSwiftPackageConfig` and threaded through the orchestrator) into the
* SPM `targets` map, or `null` when no Swift package config is present.
*
* Uses structural duck-typing (no `instanceof`) because the value crosses
* the `unknown`-typed `resolutionConfig` channel and may be `null`,
* `undefined`, or a config object whose `targets` is a `Map<string,string>`.
*/
export function coerceSwiftTargets(resolutionConfig: unknown): ReadonlyMap<string, string> | null {
const config = resolutionConfig as Partial<SwiftPackageConfig> | null | undefined;
if (config != null && config.targets instanceof Map) {
return config.targets;
}
return null;
}
@@ -1,89 +0,0 @@
/**
* Swift same-module (SPM target) implicit visibility for the
* `populateNamespaceSiblings` hook.
*
* Swift gives every file in a module access to every other file's
* top-level declarations WITHOUT any `import` statement (whole-module
* visibility). This is the Swift analogue of Go's same-package sibling
* visibility — `populateGoPackageSiblings` is the template.
*
* Module identity: Swift has no in-source `package X` marker. The SPM
* target is a directory subtree (`Sources/<Target>/…`). Module membership
* is threaded in via the SPM target map (`ctx.resolutionConfig` →
* `coerceSwiftTargets`) and grouped by `groupSwiftFilesBySpmTarget`,
* replicating legacy `wireSwiftImplicitImports`'s `groupSwiftFilesByTarget`:
* files are grouped by SPM target subtree when a package config is present,
* else ALL Swift files form one module (`__default__`,
* single-Xcode-project assumption). Every `.swift` file in the same target
* sees its siblings' top-level defs.
*
* Bindings are added through the append-only `bindingAugmentations`
* channel (Contract Invariant I8) with `origin: 'namespace'`, exactly
* like the Go implementation — `indexes.bindings` is frozen post-
* finalize and must not be mutated.
*/
import type { BindingRef, ParsedFile, ScopeId, SymbolDefinition } from 'gitnexus-shared';
import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js';
import { coerceSwiftTargets, groupSwiftFilesBySpmTarget } from './target-grouping.js';
export function populateSwiftTargetSiblings(
parsedFiles: readonly ParsedFile[],
indexes: ScopeResolutionIndexes,
ctx: {
readonly fileContents: ReadonlyMap<string, string>;
readonly resolutionConfig?: unknown;
},
): void {
// Group files by SPM target subtree (the module). No-source-dir → all
// files in one `__default__` bucket.
const targets = coerceSwiftTargets(ctx.resolutionConfig);
const filesByTarget = groupSwiftFilesBySpmTarget(
parsedFiles,
(parsed) => parsed.filePath,
targets,
);
const augmentations = indexes.bindingAugmentations as Map<ScopeId, Map<string, BindingRef[]>>;
for (const [, group] of filesByTarget) {
if (group.length < 2) continue; // no siblings to share
const siblings = group.map((parsed) => ({
filePath: parsed.filePath,
defs: [...parsed.localDefs] as SymbolDefinition[],
}));
for (const target of siblings) {
for (const receiver of siblings) {
if (receiver.filePath === target.filePath) continue; // no self-reference
const receiverModule = indexes.moduleScopes.byFilePath.get(receiver.filePath);
if (receiverModule === undefined) continue;
for (const def of target.defs) {
const name = def.qualifiedName?.split('.').pop() ?? def.qualifiedName ?? '';
if (name === '') continue;
const bucket = getAugmentationBucket(augmentations, receiverModule, name);
if (bucket.some((b) => b.def.nodeId === def.nodeId)) continue;
bucket.push({ def, origin: 'namespace' });
}
}
}
}
}
function getAugmentationBucket(
augmentations: Map<ScopeId, Map<string, BindingRef[]>>,
scopeId: ScopeId,
name: string,
): BindingRef[] {
let scopeBindings = augmentations.get(scopeId);
if (scopeBindings === undefined) {
scopeBindings = new Map<string, BindingRef[]>();
augmentations.set(scopeId, scopeBindings);
}
let bucketArr = scopeBindings.get(name);
if (bucketArr === undefined) {
bucketArr = [];
scopeBindings.set(name, bucketArr);
}
return bucketArr;
}
@@ -45,11 +45,6 @@ import {
javascriptCallConfig,
} from '../call-extractors/configs/typescript-javascript.js';
import { createHeritageExtractor } from '../heritage-extractors/generic.js';
import {
ARRAY_METHOD_HOC_BLOCKLIST_SET,
DEFAULT_EXPORT_IDENTIFIER_BLOCKLIST_SET,
deriveDefaultExportHocName,
} from '../ts-js-hoc-utils.js';
import {
emitTsScopeCaptures,
interpretTsImport,
@@ -100,7 +95,6 @@ import {
*/
const tsExtractFunctionName = (
node: SyntaxNode,
filePath?: string,
): { funcName: string | null; label: NodeLabel } | null => {
if (node.type !== 'arrow_function' && node.type !== 'function_expression') return null;
@@ -147,64 +141,28 @@ const tsExtractFunctionName = (
// `arguments`, grandparent is `call_expression`, great-grandparent is
// `variable_declarator`. Walk the chain up and take the variable's name
// — the meaningful identifier the developer wrote on the LHS. Mirrors
// the four registry-primary patterns in `typescript/query.ts`.
//
// NOTE: Excludes common array methods (map, filter, reduce, etc.) to avoid
// false positives like `const x = arr.map(a => ...)` being classified as
// Function when it's actually a Const holding an array.
// the four registry-primary patterns in `typescript/query.ts`. The
// wrapping callee (`forwardRef`, `memo`, `React.memo`, `useCallback`,
// user-defined HOCs) is intentionally NOT constrained: any function
// call whose result is bound to a const and whose first/positional
// argument is an arrow takes the const's name. Chained array-method
// calls (`const x = arr.find((y) => p(y))`) match too and produce a
// mostly-harmless `Function:x` (consumed as a value, never invoked),
// accepted as a small false-positive cost vs. the much larger gain of
// capturing the React UI-component idiom.
if (parent.type === 'arguments') {
const callExpr = parent.parent;
if (!callExpr || callExpr.type !== 'call_expression') {
return { funcName: null, label: 'Function' };
}
// Check if callee is a member_expression calling an array method
const callee = callExpr.childForFieldName?.('function');
if (callee?.type === 'member_expression') {
const property = callee.childForFieldName?.('property');
if (
property?.type === 'property_identifier' &&
ARRAY_METHOD_HOC_BLOCKLIST_SET.has(property.text)
) {
return { funcName: null, label: 'Function' };
}
}
const declarator = callExpr.parent;
// Existing path: const X = HOC(arrow)
if (declarator?.type === 'variable_declarator') {
const nameNode = declarator.childForFieldName?.('name');
if (nameNode?.type === 'identifier') {
return { funcName: nameNode.text, label: 'Function' };
}
if (!declarator || declarator.type !== 'variable_declarator') {
return { funcName: null, label: 'Function' };
}
// export default HOC(arrow) — name it from the file, not the wrapper.
// This keeps route handlers and wrapped defaults navigable without
// collapsing every file onto names like `memo` or `defineEventHandler`.
if (declarator?.type === 'export_statement') {
if (callee?.type === 'identifier') {
if (DEFAULT_EXPORT_IDENTIFIER_BLOCKLIST_SET.has(callee.text)) {
return { funcName: null, label: 'Function' };
}
return {
funcName: filePath ? deriveDefaultExportHocName(filePath) : null,
label: 'Function',
};
}
// Member-expression callees like React.memo keep the same file-derived
// name, with array-like helpers excluded above.
if (callee?.type === 'member_expression') {
return {
funcName: filePath ? deriveDefaultExportHocName(filePath) : null,
label: 'Function',
};
}
return { funcName: null, label: 'Function' };
const nameNode = declarator.childForFieldName?.('name');
if (nameNode?.type === 'identifier') {
return { funcName: nameNode.text, label: 'Function' };
}
return { funcName: null, label: 'Function' };
}
@@ -24,7 +24,47 @@
*/
import type { SyntaxNode } from '../../utils/ast-helpers.js';
import { ARRAY_CALLBACK_METHODS } from '../../ts-js-hoc-utils.js';
/**
* Array prototype higher-order methods whose result is a value, not a
* function. A callback passed to one of these is an anonymous callback,
* never a top-level function definition. Identifier-callee HOCs
* (`forwardRef(...)`, `useCallback(...)`, custom factories) are
* deliberately NOT listed — they keep their `Function` classification.
*
* Trade-off (unchanged from before #1876): a custom *fluent-API* member
* call with a callback whose method name is not in this set
* (`qb.where(x => …)`) still classifies as `Function`. There is no clean
* syntactic line beyond the well-known Array surface, so the set is
* intentionally closed and easy to extend.
*
* Receiver-blind, by design: the match keys on the method NAME only, never
* the receiver type (tree-sitter has no type information here). So an in-set
* name on a NON-array receiver — `Map`/`Set` `.forEach`, an RxJS
* `observable.map(…)`, a query builder `.sort(…)`, a lodash chain
* `.filter(…)` — is ALSO treated as a callback and has its
* `@declaration.function` dropped. This is an accepted limitation, not a
* regression: those bindings hold the call's *result value*, not a callable,
* so a value def is the correct classification anyway. The only genuine loss
* is a bespoke DSL whose in-set-named method returns something callable —
* rare enough to accept rather than guard with type inference. Pinned by the
* "in-set method on a non-array receiver" case in `*-captures.test.ts`.
*/
export const ARRAY_CALLBACK_METHODS: ReadonlySet<string> = new Set([
'map',
'filter',
'find',
'findIndex',
'findLast',
'findLastIndex',
'forEach',
'reduce',
'reduceRight',
'some',
'every',
'flatMap',
'sort',
]);
/**
* True when `node` (an `arrow_function` / `function_expression`) is the
@@ -37,9 +77,9 @@ import { ARRAY_CALLBACK_METHODS } from '../../ts-js-hoc-utils.js';
* (`forwardRef(() => …)` — callee is an `identifier`, not a
* `member_expression`), so neither is ever suppressed.
*
* The helper itself only handles direct `member_expression` callees. Broader
* shapes like `(arr.map)(cb)` and `arr['map'](cb)` are now filtered at the
* query layer, so this emit-side check stays focused on the direct fallback.
* Intentional non-suppressing gaps (preserve current behavior, no
* regression): parenthesized callee `(arr.map)(cb)` (`parenthesized_expression`)
* and computed callee `arr['map'](cb)` (`subscript_expression`).
*/
export function isArrayMethodCallbackArrow(node: SyntaxNode): boolean {
const args = node.parent;
@@ -40,11 +40,6 @@ import { computeTsArityMetadata } from './arity-metadata.js';
import { isArrayMethodCallbackArrow } from './array-callback.js';
import { getTreeSitterBufferSize } from '../../constants.js';
import { parseSourceSafe } from '../../../tree-sitter/safe-parse.js';
import {
deriveDefaultExportHocName,
isBlockedDefaultExportHoc,
isDefaultExportHocFunctionNode,
} from '../../ts-js-hoc-utils.js';
/** tree-sitter-typescript node types for function-like scopes that may
* carry a synthesized `this` binding. Kept in sync with the
@@ -275,24 +270,6 @@ export function emitTsScopeCaptures(
if (arrowNode !== null && isArrayMethodCallbackArrow(arrowNode)) {
continue;
}
if (arrowNode !== null && isBlockedDefaultExportHoc(arrowNode)) {
continue;
}
}
if (fnDeclAnchor !== undefined) {
const fnNode = findFunctionNode(
tree.rootNode,
fnDeclAnchor.range,
groupedNodes['@declaration.function'],
);
if (fnNode !== null && isDefaultExportHocFunctionNode(fnNode)) {
grouped['@declaration.name'] = syntheticCapture(
'@declaration.name',
fnNode,
deriveDefaultExportHocName(filePath),
);
}
}
// Synthesize arity metadata on function-like declaration anchors
@@ -53,10 +53,6 @@
import Parser from 'tree-sitter';
import TS from 'tree-sitter-typescript';
import {
ARRAY_METHOD_NOT_ANY_OF_PREDICATE,
DEFAULT_EXPORT_IDENTIFIER_NOT_ANY_OF_PREDICATE,
} from '../../ts-js-hoc-utils.js';
// tree-sitter-typescript exports both `typescript` and `tsx` grammars on
// the default export. The package's `.d.ts` types the default export
@@ -281,16 +277,10 @@ const TYPESCRIPT_SCOPE_QUERY = `
;; via \`(filePath, type, qualifiedName)\` — second wins. Acceptable;
;; multi-arrow-callback APIs are rare (\`new Promise(executor)\` is the
;; main one and takes a single executor).
;;
;; NOTE: Split into identifier vs member_expression patterns. Member
;; expressions are filtered with a blocklist of common array methods
;; (map, filter, reduce, etc.) to avoid false positives like
;; \`const x = arr.map(a => ...)\` being classified as Function.
(lexical_declaration
(variable_declarator
name: (identifier) @declaration.name
value: (call_expression
function: (identifier)
arguments: (arguments
(arrow_function) @declaration.function))))
@@ -298,35 +288,13 @@ const TYPESCRIPT_SCOPE_QUERY = `
(variable_declarator
name: (identifier) @declaration.name
value: (call_expression
function: (identifier)
arguments: (arguments
(function_expression) @declaration.function))))
(lexical_declaration
(variable_declarator
name: (identifier) @declaration.name
value: (call_expression
function: (member_expression
property: (property_identifier) @callee)
arguments: (arguments
(arrow_function) @declaration.function)))
${ARRAY_METHOD_NOT_ANY_OF_PREDICATE})
(lexical_declaration
(variable_declarator
name: (identifier) @declaration.name
value: (call_expression
function: (member_expression
property: (property_identifier) @callee)
arguments: (arguments
(function_expression) @declaration.function)))
${ARRAY_METHOD_NOT_ANY_OF_PREDICATE})
(variable_declaration
(variable_declarator
name: (identifier) @declaration.name
value: (call_expression
function: (identifier)
arguments: (arguments
(arrow_function) @declaration.function))))
@@ -334,64 +302,9 @@ const TYPESCRIPT_SCOPE_QUERY = `
(variable_declarator
name: (identifier) @declaration.name
value: (call_expression
function: (identifier)
arguments: (arguments
(function_expression) @declaration.function))))
(variable_declaration
(variable_declarator
name: (identifier) @declaration.name
value: (call_expression
function: (member_expression
property: (property_identifier) @callee)
arguments: (arguments
(arrow_function) @declaration.function)))
${ARRAY_METHOD_NOT_ANY_OF_PREDICATE})
(variable_declaration
(variable_declarator
name: (identifier) @declaration.name
value: (call_expression
function: (member_expression
property: (property_identifier) @callee)
arguments: (arguments
(function_expression) @declaration.function)))
${ARRAY_METHOD_NOT_ANY_OF_PREDICATE})
;; HOC-wrapped default exports: \`export default defineEventHandler(async (e) => { ... })\`.
;; The emit phase rewrites @declaration.name to a file-derived name so
;; wrappers like \`defineEventHandler\` / \`React.memo\` do not collapse
;; unrelated modules onto the same symbol name.
((export_statement
value: (call_expression
function: (identifier) @hoc
arguments: (arguments
(arrow_function) @declaration.function)))
${DEFAULT_EXPORT_IDENTIFIER_NOT_ANY_OF_PREDICATE})
((export_statement
value: (call_expression
function: (identifier) @hoc
arguments: (arguments
(function_expression) @declaration.function)))
${DEFAULT_EXPORT_IDENTIFIER_NOT_ANY_OF_PREDICATE})
((export_statement
value: (call_expression
function: (member_expression
property: (property_identifier) @callee)
arguments: (arguments
(arrow_function) @declaration.function)))
${ARRAY_METHOD_NOT_ANY_OF_PREDICATE})
((export_statement
value: (call_expression
function: (member_expression
property: (property_identifier) @callee)
arguments: (arguments
(function_expression) @declaration.function)))
${ARRAY_METHOD_NOT_ANY_OF_PREDICATE})
;; Method definitions — regular + private (#field) methods.
(method_definition
name: (property_identifier) @declaration.name) @declaration.method
@@ -52,6 +52,7 @@ const TYPE_DECL_NODE_TYPES = new Set([
'class_declaration',
'abstract_class_declaration',
'class',
'class_expression',
'interface_declaration',
]);
@@ -17,6 +17,7 @@ import type { SyntaxNode } from '../../utils/ast-helpers.js';
/** Type node types that represent a return type in function/getter/setter signatures. */
const TYPE_NODE_TYPES = new Set([
'type_identifier',
'generic_type',
'function_type',
'nullable_type',
'void_type',
@@ -113,6 +113,7 @@ function extractPhpReturnType(node: SyntaxNode): string | undefined {
'named_type',
'union_type',
'optional_type',
'nullable_type',
'intersection_type',
]);
@@ -61,7 +61,6 @@ export interface MethodExtractor {
* Return null to fall through to the generic extractor. */
extractFunctionName?(
node: SyntaxNode,
filePath?: string,
): { funcName: string | null; label: import('gitnexus-shared').NodeLabel } | null;
}
@@ -99,6 +98,5 @@ export interface MethodExtractionConfig {
* Passed through to the MethodExtractor by createMethodExtractor. */
extractFunctionName?: (
node: SyntaxNode,
filePath?: string,
) => { funcName: string | null; label: import('gitnexus-shared').NodeLabel } | null;
}
@@ -52,7 +52,6 @@ import type {
ReferenceSite,
ScopeId,
ScopeTree,
TypeRef,
} from 'gitnexus-shared';
export interface ScopeResolutionIndexes {
@@ -88,51 +87,6 @@ export interface ScopeResolutionIndexes {
* shared map gives those workspace-wide names one entry each instead of
* O(scopes × defs) per-scope augmentation. */
readonly workspaceFqnBindings: ReadonlyMap<string, readonly BindingRef[]>;
/** Workspace-level *type* binding lookup — the typeBindings analogue of
* `workspaceFqnBindings`. Holds names that are type-visible from every file
* (e.g. C# global/default-namespace method return-type bindings, keyed by
* the bound name). The C# language spec makes the unnamed global namespace a
* single declaration space whose members are available from inside named
* namespaces too — so this channel is consulted scope-independently by the
* typeBindings chain-walkers (`findReceiverTypeBinding`,
* `followChainPostFinalize`) as a final fallback after the per-scope chain.
* Routing global types here gives them one shared entry instead of the
* O(scopes × defs) per-file `Scope.typeBindings` copy that OOM'd large
* no-namespace solutions (#1871) — mirroring how Roslyn resolves against a
* single `Compilation.GlobalNamespace` symbol rather than per-file copies.
* Populated post-finalize by `populateCsharpNamespaceSiblings`; most
* languages leave it empty. */
readonly workspaceTypeBindings: ReadonlyMap<string, TypeRef>;
/** Per-namespace class/def binding lookup — the namespace-scoped analogue of
* `workspaceFqnBindings`. Outer key is the namespace name (e.g. `App.Models`),
* inner key is the simple name. Unlike the flat workspace channels (which are
* visible from *every* file — correct only for the global/default namespace),
* named-namespace types are visible only within that namespace and to files
* that import it, so this channel is consulted through an accessibility gate
* (`accessibleNamespacesByScope`) rather than unconditionally. Routing named
* siblings here gives them one entry per def instead of the O(files × defs)
* per-scope augmentation that OOM'd large single-namespace solutions (#1871).
* Populated post-finalize by language namespace-sibling hooks; most languages
* leave it empty. */
readonly namespaceFqnBindings: ReadonlyMap<string, ReadonlyMap<string, readonly BindingRef[]>>;
/** Per-namespace *type* binding lookup — the namespace-scoped analogue of
* `workspaceTypeBindings`. Outer key is the namespace name, inner key is the
* bound name (e.g. a method name mapping to its return TypeRef). Consulted by
* the typeBindings chain-walkers (`findReceiverTypeBinding`,
* `followChainPostFinalize`) through the `accessibleNamespacesByScope` gate
* after the per-scope chain and the flat `workspaceTypeBindings` miss.
* Populated post-finalize; most languages leave it empty. */
readonly namespaceTypeBindings: ReadonlyMap<string, ReadonlyMap<string, TypeRef>>;
/** Accessibility gate for the per-namespace channels: maps a module ScopeId to
* the namespace names type-visible from that file — its own declared
* namespace(s) plus every imported/`using`d namespace (and dotted prefixes).
* This is the same per-file accessible-namespace set the C# hook already
* derives (`expandedNamespaces`), materialized so the language-neutral walkers
* can consult `namespaceFqnBindings` / `namespaceTypeBindings` for exactly the
* namespaces a file can see — preserving namespace visibility semantics
* without the per-file binding copy. Empty when no language populates the
* namespace channels. */
readonly accessibleNamespacesByScope: ReadonlyMap<ScopeId, readonly string[]>;
/** Pre-resolution usage facts; consumed by the resolution phase. */
readonly referenceSites: readonly ReferenceSite[];
/** SCC condensation of the file-level import graph — callers that want
@@ -12,12 +12,10 @@ import { yieldToEventLoop } from './utils/event-loop.js';
import { parseSourceSafe } from '../tree-sitter/safe-parse.js';
import { isVerboseIngestionEnabled } from './utils/verbose.js';
import {
buildConcreteTypedefDefinitionRanges,
getDefinitionNodeFromCaptures,
findEnclosingClassInfo,
findObjectLiteralBindingInfo,
getLabelFromCaptures,
isSuppressedConcreteTypedefDuplicate,
CLASS_CONTAINER_TYPES,
type SyntaxNode,
type EnclosingClassInfo,
@@ -68,11 +66,6 @@ import {
getTreeSitterContentByteLength,
TREE_SITTER_MAX_BUFFER,
} from './constants.js';
import {
ARRAY_METHOD_HOC_BLOCKLIST_SET,
DEFAULT_EXPORT_IDENTIFIER_BLOCKLIST_SET,
deriveDefaultExportHocName,
} from './ts-js-hoc-utils.js';
export type FileProgressCallback = (current: number, total: number, filePath: string) => void;
@@ -488,7 +481,6 @@ const processParsingSequential = async (
logger.warn({ queryError }, `Query error for ${file.path}:`);
continue;
}
const concreteTypedefRanges = buildConcreteTypedefDefinitionRanges(matches);
// Build per-file type environment for FieldExtractor context (lightweight — skipped if no fieldExtractor).
//
@@ -500,7 +492,6 @@ const processParsingSequential = async (
// lifecycle and flush-site ownership rules.
const typeEnv = provider.fieldExtractor
? buildTypeEnv(tree, language, {
filePath: file.path,
enclosingFunctionFinder: provider.enclosingFunctionFinder,
extractFunctionName: provider.methodExtractor?.extractFunctionName,
})
@@ -513,8 +504,6 @@ const processParsingSequential = async (
captureMap[c.name] = c.node;
});
if (isSuppressedConcreteTypedefDuplicate(captureMap, concreteTypedefRanges)) return;
const definitionNodeForRange = getDefinitionNodeFromCaptures(captureMap);
const definitionNode = getDefinitionNodeFromCaptures(captureMap);
const defaultNodeLabel = getLabelFromCaptures(captureMap, provider);
@@ -546,49 +535,9 @@ const processParsingSequential = async (
) {
return;
}
const exportDefaultCall =
nodeLabel === 'Function' && definitionNode?.type === 'export_statement'
? definitionNode.namedChildren.find((child) => child.type === 'call_expression')
: undefined;
const defaultExportHocName = (() => {
if (exportDefaultCall === undefined) return null;
const argList = exportDefaultCall.childForFieldName?.('arguments');
const callback = argList?.namedChildren.find(
(child) => child.type === 'arrow_function' || child.type === 'function_expression',
);
if (callback === undefined) return null;
const callee = exportDefaultCall.childForFieldName?.('function');
if (
callee?.type === 'identifier' &&
DEFAULT_EXPORT_IDENTIFIER_BLOCKLIST_SET.has(callee.text)
) {
return null;
}
if (callee?.type === 'member_expression') {
const property = callee.childForFieldName?.('property');
if (
property?.type === 'property_identifier' &&
ARRAY_METHOD_HOC_BLOCKLIST_SET.has(property.text)
) {
return null;
}
}
return deriveDefaultExportHocName(file.path);
})();
// Synthesize name for constructors without explicit @name capture (e.g. Swift init)
if (
!nameNode &&
nodeLabel !== 'Constructor' &&
!extractedClassSymbol &&
!defaultExportHocName
) {
return;
}
const nodeName =
extractedClassSymbol?.name ?? defaultExportHocName ?? (nameNode ? nameNode.text : 'init');
if (!nameNode && nodeLabel !== 'Constructor' && !extractedClassSymbol) return;
const nodeName = extractedClassSymbol?.name ?? (nameNode ? nameNode.text : 'init');
const startLine = definitionNodeForRange
? definitionNodeForRange.startPosition.row + lineOffset
@@ -879,28 +828,23 @@ const processParsingSequential = async (
qualifiedName: qualifiedTypeName,
});
const fileId = generateId('File', file.path);
const relId = generateId('DEFINES', `${fileId}->${nodeId}`);
const relationship: GraphRelationship = {
id: relId,
sourceId: fileId,
targetId: nodeId,
type: 'DEFINES',
confidence: 1.0,
reason: '',
};
graph.addRelationship(relationship);
// ── HAS_METHOD / HAS_PROPERTY: link member to enclosing class ──
const ownerIdForMemberEdge = enclosingClassId ?? objectLiteralOwnerInfo?.ownerId ?? null;
// Only emit File -> Symbol DEFINES for top-level symbols. Class members
// are reachable via Class -> Member (HAS_METHOD / HAS_PROPERTY), so a
// direct File -> Member edge would bypass the class in the graph and
// produce a flat radial layout instead of the correct File->Class->Member
// hierarchy (issue #1944).
if (!ownerIdForMemberEdge) {
const fileId = generateId('File', file.path);
const relId = generateId('DEFINES', `${fileId}->${nodeId}`);
const relationship: GraphRelationship = {
id: relId,
sourceId: fileId,
targetId: nodeId,
type: 'DEFINES',
confidence: 1.0,
reason: '',
};
graph.addRelationship(relationship);
}
if (ownerIdForMemberEdge) {
const memberEdgeType = nodeLabel === 'Property' ? 'HAS_PROPERTY' : 'HAS_METHOD';
graph.addRelationship({
@@ -49,11 +49,7 @@ import { type PipelineProgress, getLanguageFromFilename } from 'gitnexus-shared'
import { isRegistryPrimary } from '../registry-primary-flag.js';
import { readFileContents } from '../filesystem-walker.js';
import { isLanguageAvailable } from '../../tree-sitter/parser-loader.js';
import {
createWorkerPool,
workerPoolDisabledByEnv,
WorkerPoolInitializationError,
} from '../workers/worker-pool.js';
import { createWorkerPool, WorkerPoolInitializationError } from '../workers/worker-pool.js';
import type { WorkerPool } from '../workers/worker-pool.js';
import type {
ExtractedAssignment,
@@ -128,76 +124,6 @@ function resolveChunkByteBudget(options?: PipelineOptions): number {
type ScannedFile = { path: string; size: number };
type ProgressFn = (progress: PipelineProgress) => void;
/**
* Handle a worker-pool startup failure by FAILING FAST with the captured cause
* (#1741). The pool self-heals *transient* worker crashes on its own — a
* bounded, jittered startup restart loop (see worker-pool.ts) — so this is
* reached only when that self-heal is EXHAUSTED, or a deterministic crash-loop
* was detected, or the pool could not even be constructed. In every such case
* the workers genuinely cannot start.
*
* Rather than silently degrade to the ~10× slower sequential parser — which
* masked a worker-startup regression as a 2-hour "stuck" run in #1741 (rc99:
* the failure was a dropped `logger.warn` and an unbounded sequential grind) —
* GitNexus surfaces the real crash and aborts. An operator who genuinely wants
* sequential parsing asks for it explicitly with `--workers 0`.
*
* The decision is automatic: NO `--allow-sequential-fallback` or pool-sizing
* flag participates. The pool's own crash classification (`crashClass` on
* WorkerPoolInitializationError) only sharpens the message.
*
* @throws always — an actionable Error carrying the captured worker crash.
* @internal Exported for unit tests; production callers are the parse loop's
* two worker-startup catch sites below.
*/
export function handleWorkerStartupFailure(err: Error): never {
const isInit = err instanceof WorkerPoolInitializationError;
const readinessFailures = isInit ? err.readinessFailures : [];
const crashClass = isInit ? err.crashClass : undefined;
// Surface the real cause verbatim: readiness failures for an init crash, or
// the construction error message (e.g. "Worker script not found: …") when the
// pool never got to spawn workers.
const failureDetail =
readinessFailures.length > 0
? ` Underlying worker failure(s): ${readinessFailures.join(' | ')}`
: isInit
? ''
: ` Underlying error: ${err.message}`;
// Always surface the real crash — never let a startup failure pass silently.
logger.error(
{ err: err.message, readinessFailures, crashClass },
'Worker pool failed to start — workers could not start (bounded self-heal exhausted).',
);
const cause =
crashClass === 'deterministic-startup'
? `every worker crashed identically during startup (a deterministic ` +
`crash-loop — retrying cannot help), so the pool has no usable workers.`
: isInit
? `workers exhausted the bounded startup retry budget without reporting ` +
`ready, so the pool has no usable workers.`
: `the worker pool could not be constructed.`;
// Class-aware fix hint: a missing/broken native binding is the likely cause
// when workers crashed during init, but it is the WRONG guess for a pool that
// never constructed (commonly a missing build / unresolvable worker path).
const fixHint = isInit
? `Fix the worker startup failure shown above (often a missing/broken native ` +
`binding or a top-of-script import error in parse-worker).`
: `Fix the worker pool construction error shown above (commonly a missing ` +
`build, so dist/ has no parse-worker, or an unresolvable worker path).`;
throw new Error(
`Worker pool failed to start: ${cause}${failureDetail}\n\n` +
`GitNexus will NOT silently fall back to the (much slower) sequential ` +
`parser and hide this crash — that masked a worker-startup regression as ` +
`a 2-hour "stuck" run in #1741. Options:\n` +
` • ${fixHint}\n` +
` • Re-run with --workers 0 to parse sequentially without the worker pool.`,
);
}
/**
* Chunked parse + resolve loop.
*
@@ -348,30 +274,14 @@ export async function runChunkedParseAndResolve(
// intentionally NOT created before parse-cache lookup: a warm-cache
// all-hit run should replay cached worker output without loading
// parse-worker.js or any tree-sitter/N-API native bindings.
// `--workers 0` (workerPoolSize === 0) and `GITNEXUS_WORKER_POOL_SIZE=0` both
// mean "no pool, parse sequentially". The env channel is consulted ONLY when
// no explicit `--workers <N>` was given, so an explicit positive size always
// wins over an ambient env=0 (#1741). Without this, env=0 built a size-0 pool
// that failed fast with a fabricated "retry budget exhausted" crash.
const envDisablesWorkers = options?.workerPoolSize === undefined && workerPoolDisabledByEnv();
const meetsWorkerThreshold =
totalParseable >= MIN_FILES_FOR_WORKERS || totalBytes >= MIN_BYTES_FOR_WORKERS;
// Log only when env=0 actually skips a pool we'd otherwise have used, so the
// undocumented (possibly accidental) env=0 case is observable instead of a
// silent degrade — small repos go sequential anyway and need no notice.
if (envDisablesWorkers && meetsWorkerThreshold) {
logger.warn(
'GITNEXUS_WORKER_POOL_SIZE=0 → parsing sequentially; unset it or pass --workers <N> to use the worker pool.',
);
}
const shouldUseWorkers =
!options?.skipWorkers &&
options?.workerPoolSize !== 0 &&
!envDisablesWorkers &&
meetsWorkerThreshold;
(totalParseable >= MIN_FILES_FOR_WORKERS || totalBytes >= MIN_BYTES_FOR_WORKERS);
let workerPool: WorkerPool | undefined;
let workerPoolDisabled = false;
const getOrCreateWorkerPool = (): WorkerPool | undefined => {
if (!shouldUseWorkers) return undefined;
if (!shouldUseWorkers || workerPoolDisabled) return undefined;
if (workerPool) return workerPool;
try {
// U20.U3 test-only injection: integration tests pass a custom
@@ -405,11 +315,12 @@ export async function runChunkedParseAndResolve(
workerPool = createWorkerPool(workerUrl, options?.workerPoolSize);
return workerPool;
} catch (err) {
// Pool *construction* failed (e.g. the worker script is missing — a
// broken install). Fail fast with the cause rather than silently
// degrading to the slow sequential parser (#1741); `--workers 0` is the
// explicit opt-out for anyone who genuinely wants sequential parsing.
handleWorkerStartupFailure(err as Error);
workerPoolDisabled = true;
logger.warn(
{ err: (err as Error).message },
'Worker pool creation failed, using sequential fallback:',
);
return undefined;
}
};
@@ -625,15 +536,28 @@ export async function runChunkedParseAndResolve(
);
} catch (err) {
if (!(err instanceof WorkerPoolInitializationError)) throw err;
// Every worker crashed during startup and the pool's bounded
// self-heal (jittered restart, deterministic crash-loop detection —
// see worker-pool.ts) was exhausted. Fail fast with the captured
// cause rather than silently degrading to the ~10× slower sequential
// parser, which masked this exact regression as a 2-hour "stuck" run
// in #1741. The failed (zero-worker) pool is torn down by the outer
// finally. `--workers 0` is the explicit opt-in to sequential.
logger.warn(
{
err: err.message,
readinessFailures: err.readinessFailures,
},
'Worker pool initialization failed, using sequential fallback:',
);
rawResults.length = 0;
handleWorkerStartupFailure(err); // always throws
workerPoolDisabled = true;
const failedPool = workerPool;
workerPool = undefined;
await failedPool?.terminate().catch(() => undefined);
chunkWorkerData = await processParsing(
graph,
chunkFiles,
symbolTable,
astCache,
scopeTreeCache,
progressForChunk,
undefined,
undefined,
);
}
// Persist the raw results for this chunk hash. Sequential path
// doesn't populate rawResults (it writes directly to graph), so
@@ -82,7 +82,6 @@ export const MIGRATED_LANGUAGES: ReadonlySet<SupportedLanguages> = new Set<Suppo
SupportedLanguages.Rust,
SupportedLanguages.Ruby,
SupportedLanguages.Cobol,
SupportedLanguages.Swift,
]);
/**
@@ -139,19 +139,7 @@
* once per workspace at resolve time), and merging would create a
* god-interface that complicates future migrations.
*
* - **I8 — Binding-channel lifecycle.** Post-finalize binding lookup
* fans across several channels (`lookupBindingsAt` /
* `findReceiverTypeBinding` consult them in precedence order):
* `indexes.bindings` (frozen finalize output), `Scope.bindings`
* (lexical local, first-tier shadowing), `indexes.bindingAugmentations`
* (per-scope append-only), `indexes.workspaceFqnBindings` +
* `indexes.workspaceTypeBindings` (scope-independent / global, consulted
* unconditionally), and `indexes.namespaceFqnBindings` +
* `indexes.namespaceTypeBindings` (per-namespace, consulted only for the
* namespaces in `indexes.accessibleNamespacesByScope` for the caller's
* module). All but `indexes.bindings` are mutable post-finalize and
* populated by hooks; only `indexes.bindings` is frozen.
*
* - **I8 — Two-channel binding lifecycle.**
* `indexes.bindings` is the **finalize-output channel**. After
* `finalizeScopeModel` returns, its inner `BindingRef[]` arrays
* are deep-frozen by `materializeBindings` and MUST NOT be
@@ -471,36 +459,6 @@ export interface ScopeResolver {
nodeLookup: GraphNodeLookup,
) => void;
/**
* Optional hook to emit IMPORTS edges that no syntactic import
* statement produces. Some languages grant files implicit visibility
* of one another within a compilation unit (e.g. every file in a
* build target sees its siblings' top-level declarations without an
* explicit import). The generic import pipeline only emits File→File
* IMPORTS edges from finalized `ImportEdge`s, so a language with this
* implicit-visibility rule has no edge to emit through that path.
*
* Runs immediately after `emitHeritageEdges` (so it shares the same
* pre-MRO surface: writable graph, parsedFiles, nodeLookup). Must be
* idempotent — the orchestrator may invoke it more than once during
* re-resolution. Implementations dedup their own emissions.
*
* `resolutionConfig` is the opaque per-workspace value returned by
* `loadResolutionConfig` (same channel threaded into `resolveImportTarget`).
* Swift uses it to group same-module files by the SPM target subtree;
* languages that don't need per-workspace config ignore the trailing
* parameter (it is optional so existing impls keep compiling).
*
* Default: undefined (cross-file visibility requires an explicit
* import; the finalized-ImportEdge pipeline covers it).
*/
readonly emitImplicitImportEdges?: (
graph: KnowledgeGraph,
parsedFiles: readonly ParsedFile[],
nodeLookup: GraphNodeLookup,
resolutionConfig?: unknown,
) => void;
/**
* Mutate `parsed.localDefs[i].ownerId` to point at the structural
* owner. Python's rule: methods (Function defs whose parent scope
@@ -625,16 +583,6 @@ export interface ScopeResolver {
*/
readonly allowGlobalFreeCallFallback?: boolean;
/**
* When true, a constructor-form call `Type(...)` links to the Class def
* itself rather than its explicit Constructor def. Default
* (undefined/false) targets the explicit Constructor when one exists,
* else falls back to the Class. Languages whose call graph models
* `Type(...)` as a reference to the type (not its initializer) — e.g.
* Swift — opt in.
*/
readonly constructorCallTargetsClass?: boolean;
/**
* Optional per-slot conversion-rank function for overload resolution.
* When provided, `narrowOverloadCandidates` uses ranked scoring as a
@@ -819,12 +767,6 @@ export interface ScopeResolver {
* itself; the cache is opt-in for hooks that need AST-level
* facts beyond what `ParsedFile` exposes. */
readonly treeCache?: { get(filePath: string): unknown };
/** Opaque per-workspace value from `loadResolutionConfig` (same
* channel threaded into `resolveImportTarget`). Swift uses it to
* group same-module siblings by the SPM target subtree; languages
* that don't need per-workspace config ignore it. Optional so
* existing impls keep compiling. */
readonly resolutionConfig?: unknown;
},
) => void;
@@ -868,20 +810,12 @@ export interface ScopeResolver {
* `NewUser → User` mirrored from the target package). Runs after
* `populateNamespaceSiblings` and before `propagateImportedReturnTypes`
* so the SCC-ordered pass sees the mirrored bindings.
*
* `resolutionConfig` is the opaque per-workspace value returned by
* `loadResolutionConfig` (same channel threaded into `resolveImportTarget`).
* Swift uses it to group same-module sibling files by the SPM target
* subtree; languages that don't need per-workspace config ignore the
* trailing parameter (it is optional so existing impls keep compiling).
*
* Default: undefined (no namespace typeBinding mirroring).
*/
readonly mirrorNamespaceTypeBindings?: (
parsedFiles: readonly ParsedFile[],
indexes: ScopeResolutionIndexes,
workspaceIndex: import('../../scope-resolution/workspace-index.js').WorkspaceResolutionIndex,
resolutionConfig?: unknown,
) => void;
/**
@@ -58,9 +58,6 @@ export function emitFreeCallFallback(
workspaceIndex: WorkspaceResolutionIndex,
options: {
readonly allowGlobalFallback?: boolean;
/** When true, `Type(...)` constructor calls link to the Class def
* itself rather than its explicit Constructor. Swift opts in. */
readonly constructorCallTargetsClass?: boolean;
readonly isFileLocalDef?: (def: SymbolDefinition) => boolean;
readonly isCallableVisibleFromCaller?: (ctx: {
readonly callerParsed: ParsedFile;
@@ -97,11 +94,6 @@ export function emitFreeCallFallback(
// per call site. Same name + callable-kind filter that the previous scan
// applied (see pickUniqueGlobalCallable JSDoc). Cost: O(|defs|) once.
const globalCallablesBySimpleName = buildGlobalCallableIndex(scopes);
// Sibling index for constructor-form class fallback. Built once here so
// pickUniqueGlobalClass is O(1)-per-site rather than re-scanning
// defs.byId.values() at every constructor call. Same simple-name keying
// and class-like kind filter the previous per-site scan applied.
const globalClassesBySimpleName = buildGlobalClassIndex(scopes);
for (const parsed of parsedFiles) {
for (const site of parsed.referenceSites) {
@@ -116,27 +108,7 @@ export function emitFreeCallFallback(
if (site.callForm === 'constructor') {
const classDef = findClassBindingInScope(site.inScope, site.name, scopes);
if (classDef !== undefined) {
// Most languages link `Type(...)` to the explicit Constructor def
// when one exists (else the Class). Languages that model the call
// as a reference to the type itself opt into
// `constructorCallTargetsClass` and always link to the Class.
fnDef =
options.constructorCallTargetsClass === true
? classDef
: pickConstructorOrClass(classDef, workspaceIndex, scopes);
} else if (options.allowGlobalFallback === true) {
// The constructed type may live in a sibling/imported file that is
// not in the call-site's lexical scope-chain bindings. Fall back to
// a unique workspace-wide Class def by simple name (gated on the
// same global-fallback opt-in as free calls). Then target the
// Class or its Constructor per the language's preference.
const globalClass = pickUniqueGlobalClass(site.name, globalClassesBySimpleName);
if (globalClass !== undefined) {
fnDef =
options.constructorCallTargetsClass === true
? globalClass
: pickConstructorOrClass(globalClass, workspaceIndex, scopes);
}
fnDef = pickConstructorOrClass(classDef, workspaceIndex, scopes);
}
}
// Implicit-this overload narrowing: an unqualified call inside
@@ -144,13 +116,11 @@ export function emitFreeCallFallback(
// enclosing class. When the workspace has multiple methods of
// the same name in a single class, choose the best match by
// arity + argument types.
let fnDefFromImplicitThis = false;
if (fnDef === undefined) {
fnDef = pickImplicitThisOverload(site, scopes, workspaceIndex, model, {
conversionRankFn: options.conversionRankFn,
constraintCompatibility: options.constraintCompatibility,
});
fnDefFromImplicitThis = fnDef !== undefined;
}
// Scope-chain callable lookup. First-match preserves scope-chain
// precedence (local shadows import). When a conversion-rank function
@@ -366,19 +336,6 @@ export function emitFreeCallFallback(
);
}
if (fnDef === undefined) continue;
if (
(fnDefFromImplicitThis || fnDef.type === 'Method' || fnDef.type === 'Constructor') &&
options.isCallableVisibleFromCaller !== undefined &&
!options.isCallableVisibleFromCaller({
callerParsed: parsed,
candidate: fnDef,
callerScope: site.inScope,
scopes,
})
) {
handledSites.add(siteKey(parsed.filePath, site));
continue;
}
const callerGraphId = resolveCallerGraphId(site.inScope, scopes, nodeLookup);
if (callerGraphId === undefined) continue;
const tgtGraphId = resolveDefGraphId(fnDef.filePath, fnDef, nodeLookup);
@@ -486,46 +443,6 @@ function buildGlobalCallableIndex(
return out;
}
/**
* Build a `simpleName -> class-like defs` index from `scopes.defs` once per
* pass — the structural sibling of `buildGlobalCallableIndex`, consumed by
* `pickUniqueGlobalClass` so constructor-form fallback is O(1)-per-site
* instead of O(|defs|).
*
* **Kind filter (KTD5 — KEEP `'Interface'`):** the set is
* `Class | Struct | Interface`, matching the idiomatic class-like set used
* elsewhere in the scope-resolution bridge (`graph-bridge/ids.ts`,
* `node-lookup.ts`). This is a behavior-PRESERVING perf refactor for all 8
* `allowGlobalFreeCallFallback` languages — the previous per-site scan used
* exactly this filter. Excluding Swift `protocol` (`Interface`) defs because
* protocols aren't instantiable is a *separate* Swift-semantics question with
* its own test; dropping `Interface` here would be a deliberate
* behavior-changing edit, not part of U5.
*
* Bucket insertion order follows `defs.byId.values()` iteration order, so the
* downstream "keep first" / ambiguity ordering in `pickUniqueGlobalClass` is
* byte-identical to the old linear scan (equivalence verified).
*
* Exported for unit testing — language-agnostic logic, exercised via synthetic
* stubs in `pick-unique-global-class.test.ts`.
*/
export function buildGlobalClassIndex(
scopes: ScopeResolutionIndexes,
): ReadonlyMap<string, readonly SymbolDefinition[]> {
const out = new Map<string, SymbolDefinition[]>();
for (const def of scopes.defs.byId.values()) {
if (def.type !== 'Class' && def.type !== 'Struct' && def.type !== 'Interface') continue;
const qualified = def.qualifiedName;
if (qualified === undefined || qualified.length === 0) continue;
const dot = qualified.lastIndexOf('.');
const simple = dot === -1 ? qualified : qualified.slice(dot + 1);
const bucket = out.get(simple);
if (bucket) bucket.push(def);
else out.set(simple, [def]);
}
return out;
}
function pickUniqueGlobalCallable(
name: string,
model: SemanticModel,
@@ -680,40 +597,6 @@ function pickConstructorOrClass(
return classDef;
}
/** Find a unique workspace-wide class-like def by simple name, for a
* constructor-form call `Type(...)` whose type lives outside the call
* site's lexical bindings (a sibling/imported file). Returns the def
* only when all matches share ONE qualified name — i.e. they are
* fragments of a single logical type (partial classes / extensions
* that re-key onto the same type), which resolve to the same graph
* node. Genuinely distinct types with the same simple name are
* ambiguous and leave the call unresolved rather than guessing. Gated
* by the caller on `allowGlobalFallback`, mirroring
* `pickUniqueGlobalCallable`.
*
* Consumes the once-built `buildGlobalClassIndex` (`simpleName ->
* class-like defs`) so each call site is O(1) rather than O(|defs|).
* The index's `Class | Struct | Interface` kind filter is intentionally
* KEPT (KTD5) — see `buildGlobalClassIndex` for why dropping `Interface`
* would be a separate, behavior-changing Swift-semantics edit.
*
* Exported for unit testing — language-agnostic logic, exercised via
* synthetic stubs in `pick-unique-global-class.test.ts`. The production
* call site is the constructor-form fallback in `emitFreeCallFallback`. */
export function pickUniqueGlobalClass(
name: string,
index: ReadonlyMap<string, readonly SymbolDefinition[]>,
): SymbolDefinition | undefined {
let found: SymbolDefinition | undefined;
for (const def of index.get(name) ?? []) {
// Same qualified name = same logical type (extension / partial-class
// fragment); keep the first and don't treat it as ambiguous.
if (found !== undefined && found.qualifiedName !== def.qualifiedName) return undefined;
if (found === undefined) found = def;
}
return found;
}
/** Walk up from the call-site scope to the enclosing class scope,
* pick a method member by name with overload narrowing on arity +
* argument types. Returns undefined if there's no enclosing class,
@@ -42,12 +42,7 @@
import type { ParsedFile, ScopeId, TypeRef } from 'gitnexus-shared';
import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js';
import type { WorkspaceResolutionIndex } from '../workspace-index.js';
import {
lookupBindingsAt,
namesAtScope,
moduleScopeIdOf,
namespaceTypeBindingFor,
} from '../scope/walkers.js';
import { lookupBindingsAt, namesAtScope } from '../scope/walkers.js';
/**
* Max chain depth for the post-finalize re-follow. Effective end-to-end
@@ -71,9 +66,6 @@ export function followChainPostFinalize(
): TypeRef {
let current = start;
const visited = new Set<string>();
// The caller's module scope is fixed across the walk; resolve it once so the
// accessibility-gated per-namespace fallback below can be consulted cheaply.
const moduleScopeId = moduleScopeIdOf(fromScopeId, scopes);
for (let depth = 0; depth < RECHAIN_MAX_DEPTH; depth++) {
if (current.rawName.includes('.')) return current;
let scopeId: ScopeId | null = fromScopeId;
@@ -86,21 +78,6 @@ export function followChainPostFinalize(
next = undefined;
scopeId = scope.parent;
}
// Scope-independent fallbacks (#1871), mirroring findReceiverTypeBinding's
// precedence: named namespaces accessible from this file
// (`namespaceTypeBindings`, gated by accessibility) first — they lived in the
// chain pre-#1871 and so must outrank the flat global channel — then the
// global/default namespace (`workspaceTypeBindings`, visible everywhere).
// Both live in shared channels rather than each Scope.typeBindings to avoid
// the O(files × names) blow-up.
if (next === undefined) {
const nsHit = namespaceTypeBindingFor(moduleScopeId, current.rawName, scopes);
if (nsHit !== undefined && nsHit !== current) next = nsHit;
}
if (next === undefined) {
const ws = scopes.workspaceTypeBindings?.get(current.rawName);
if (ws !== undefined && ws !== current) next = ws;
}
if (next === undefined) return current;
if (visited.has(next.rawName)) return current;
visited.add(next.rawName);
@@ -24,7 +24,6 @@ import { javascriptScopeResolver } from '../../languages/javascript/scope-resolv
import { kotlinScopeResolver } from '../../languages/kotlin/scope-resolver.js';
import { rubyScopeResolver } from '../../languages/ruby/scope-resolver.js';
import { cobolScopeResolver } from '../../languages/cobol/scope-resolver.js';
import { swiftScopeResolver } from '../../languages/swift/scope-resolver.js';
/** Map of `SupportedLanguages` → `ScopeResolver`. The phase iterates
* this map intersected with `MIGRATED_LANGUAGES` (the per-language
@@ -47,5 +46,4 @@ export const SCOPE_RESOLVERS: ReadonlyMap<SupportedLanguages, ScopeResolver> = n
[SupportedLanguages.Kotlin, kotlinScopeResolver],
[SupportedLanguages.Ruby, rubyScopeResolver],
[SupportedLanguages.Cobol, cobolScopeResolver],
[SupportedLanguages.Swift, swiftScopeResolver],
]);
@@ -314,11 +314,6 @@ export function runScopeResolution(
// heritage clauses. Must run BEFORE `buildMro` so MRO construction sees
// the freshly-emitted IMPLEMENTS edges.
provider.emitHeritageEdges?.(graph, parsedFiles, nodeLookup);
// Implicit IMPORTS-edge hook — for languages whose files have compiler-
// implicit cross-file visibility (no syntactic import statement). The
// finalized-ImportEdge pipeline (`emitImportEdges`) cannot produce these
// because there is no `ImportEdge` to materialize. Idempotent.
provider.emitImplicitImportEdges?.(graph, parsedFiles, nodeLookup, resolutionConfig);
// Rebuild the node lookup after heritage-edge emission. Languages like
// Ruby create Property graph nodes inside `emitHeritageEdges`; those
// nodes must be visible to downstream passes (`emitReceiverBoundCalls`
@@ -361,7 +356,6 @@ export function runScopeResolution(
provider.populateNamespaceSiblings(parsedFiles, indexes, {
fileContents: getFileContents(),
treeCache,
resolutionConfig,
});
}
@@ -371,7 +365,7 @@ export function runScopeResolution(
// propagateImportedReturnTypes so the SCC-ordered pass sees the
// mirrored bindings.
if (provider.mirrorNamespaceTypeBindings !== undefined) {
provider.mirrorNamespaceTypeBindings(parsedFiles, indexes, workspaceIndex, resolutionConfig);
provider.mirrorNamespaceTypeBindings(parsedFiles, indexes, workspaceIndex);
}
// Cross-file return-type propagation (Contract Invariant I3 timing:
@@ -450,7 +444,6 @@ export function runScopeResolution(
workspaceIndex,
{
allowGlobalFallback: provider.allowGlobalFreeCallFallback === true,
constructorCallTargetsClass: provider.constructorCallTargetsClass === true,
isFileLocalDef: provider.isFileLocalDef,
isCallableVisibleFromCaller: provider.isCallableVisibleFromCaller,
resolveAdlCandidates: provider.resolveAdlCandidates,
@@ -90,44 +90,5 @@ export function validateBindingsImmutability(
}
}
// Fourth channel: `workspaceTypeBindings` (scope-independent global typeBindings,
// #1954). TypeRef-valued (no inner arrays), populated once by the hook then read
// through the walker fallback — assert the map itself stays mutable so the hook
// can populate it, mirroring the other shared channels.
if (Object.isFrozen(indexes.workspaceTypeBindings)) {
onWarn(
`binding-immutability: indexes.workspaceTypeBindings is FROZEN — ` +
`the workspace type channel is populated post-finalize and must stay mutable. ` +
`See ScopeResolver Invariant I8.`,
);
violations++;
}
// Fifth/sixth channels: the per-namespace shared maps (#1871 named-namespace
// generalization). `namespaceFqnBindings` carries mutable BindingRef[] buckets
// (hooks `push()`); `namespaceTypeBindings` carries TypeRef values. Both are
// populated post-finalize; freezing an inner bucket/map defeats that.
for (const [ns, inner] of indexes.namespaceFqnBindings) {
for (const [name, bucket] of inner) {
if (Object.isFrozen(bucket)) {
onWarn(
`binding-immutability: indexes.namespaceFqnBindings[${ns}][${name}] is FROZEN — ` +
`per-namespace buckets are mutable by contract. See ScopeResolver Invariant I8.`,
);
violations++;
}
}
}
for (const [ns, inner] of indexes.namespaceTypeBindings) {
if (Object.isFrozen(inner)) {
onWarn(
`binding-immutability: indexes.namespaceTypeBindings[${ns}] is FROZEN — ` +
`per-namespace type maps are populated post-finalize and must stay mutable. ` +
`See ScopeResolver Invariant I8.`,
);
violations++;
}
}
return violations;
}
@@ -56,64 +56,35 @@ export function lookupBindingsAt(
const finalized = scopes.bindings.get(scopeId)?.get(name);
const augmented = scopes.bindingAugmentations.get(scopeId)?.get(name);
const workspace = scopes.workspaceFqnBindings?.get(name);
// Per-namespace channel (#1871 named-namespace generalization). Gated by
// accessibility: only a *module* scope carries an `accessibleNamespacesByScope`
// entry, so this collects nothing at child scopes and at module scopes only for
// the namespaces that file can see. Empty (no entry) for every non-C# bundle,
// so the behavior of the three pre-existing channels is unchanged.
const namespaceRefs = collectNamespaceFqnBindings(scopeId, name, scopes);
const fLen = finalized?.length ?? 0;
const aLen = augmented?.length ?? 0;
const wLen = workspace?.length ?? 0;
const nLen = namespaceRefs?.length ?? 0;
if (fLen === 0 && aLen === 0 && wLen === 0 && nLen === 0) return EMPTY_BINDINGS;
if (aLen === 0 && wLen === 0 && nLen === 0) return finalized!;
if (fLen === 0 && wLen === 0 && nLen === 0) return augmented!;
if (fLen === 0 && aLen === 0 && nLen === 0) return workspace!;
if (fLen === 0 && aLen === 0 && wLen === 0) return namespaceRefs!;
// Merge in precedence order, deduped by `def.nodeId` so the strongest source
// wins duplicate metadata. Named-namespace refs come BEFORE the flat global
// `workspace` channel: pre-#1871 these lived in `bindingAugmentations` (which
// `lookupBindingsAt` already ranks above `workspaceFqnBindings`), so a name in
// both an accessible named namespace and the global namespace must still
// resolve named-first. Order: finalized > augmented > namespace > workspace.
if (fLen === 0 && aLen === 0 && wLen === 0) return EMPTY_BINDINGS;
if (aLen === 0 && wLen === 0) return finalized!;
if (fLen === 0 && wLen === 0) return augmented!;
if (fLen === 0 && aLen === 0) return workspace!;
const seen = new Set<string>();
const out: BindingRef[] = [];
for (const src of [finalized, augmented, namespaceRefs, workspace]) {
if (src === undefined) continue;
for (const r of src) {
if (fLen > 0) {
for (const r of finalized!) {
seen.add(r.def.nodeId);
out.push(r);
}
}
if (aLen > 0) {
for (const r of augmented!) {
if (seen.has(r.def.nodeId)) continue;
seen.add(r.def.nodeId);
out.push(r);
}
}
return out;
}
/**
* Collect `BindingRef`s for `name` from the per-namespace channel
* (`namespaceFqnBindings`) across every namespace accessible from `scopeId`.
* Accessibility comes from `accessibleNamespacesByScope`, which is keyed by
* *module* scope id — so this returns `undefined` at non-module scopes and at
* every scope in a bundle that didn't populate the channel (all non-C# today).
* Language-neutral: keyed only by namespace strings and the index.
*/
function collectNamespaceFqnBindings(
scopeId: ScopeId,
name: string,
scopes: ScopeResolutionIndexes,
): readonly BindingRef[] | undefined {
const namespaces = scopes.accessibleNamespacesByScope?.get(scopeId);
if (namespaces === undefined || namespaces.length === 0) return undefined;
let collected: BindingRef[] | undefined;
for (const ns of namespaces) {
const bucket = scopes.namespaceFqnBindings?.get(ns)?.get(name);
if (bucket !== undefined && bucket.length > 0) {
if (collected === undefined) collected = [];
for (const r of bucket) collected.push(r);
if (wLen > 0) {
for (const r of workspace!) {
if (seen.has(r.def.nodeId)) continue;
out.push(r);
}
}
return collected;
return out;
}
const EMPTY_NAMES: Iterable<string> = Object.freeze([]) as readonly string[];
@@ -182,7 +153,6 @@ export function findReceiverTypeBinding(
): TypeRef | undefined {
let currentId: ScopeId | null = startScope;
const visited = new Set<ScopeId>();
let moduleScopeId: ScopeId | null = null;
while (currentId !== null) {
if (visited.has(currentId)) return undefined;
visited.add(currentId);
@@ -190,67 +160,11 @@ export function findReceiverTypeBinding(
if (scope === undefined) return undefined;
const typeRef = scope.typeBindings.get(receiverName);
if (typeRef !== undefined) return typeRef;
if (scope.kind === 'Module') moduleScopeId = currentId;
currentId = scope.parent;
}
// Fallback 1 — named namespaces accessible from this file (own + `using`d),
// gated by `accessibleNamespacesByScope`. Consulted BEFORE the global channel
// so a more-specific named binding wins, matching the pre-#1871 order where
// these lived in the file's own `Scope.typeBindings` (the chain, above the
// global fallback). Shared-channel routing avoids the O(files × names) blow-up.
const named = namespaceTypeBindingFor(moduleScopeId, receiverName, scopes);
if (named !== undefined) return named;
// Fallback 2 — global/default namespace: C# global types are visible from
// every file (see `workspaceTypeBindings` doc), so this flat channel is the
// final, unconditional fallback (#1871).
return scopes.workspaceTypeBindings?.get(receiverName);
}
/**
* Resolve a typeBinding for `name` from the per-namespace channel
* (`namespaceTypeBindings`) across the namespaces accessible from `moduleScopeId`.
* First accessible-namespace hit wins. Returns `undefined` when the module has no
* accessibility entry (non-module scope id, or a bundle that didn't populate the
* channel — all non-C# today). Shared by the two typeBindings chain-walkers so
* the named-namespace fallback stays identical between them.
*/
export function namespaceTypeBindingFor(
moduleScopeId: ScopeId | null,
name: string,
scopes: ScopeResolutionIndexes,
): TypeRef | undefined {
if (moduleScopeId === null) return undefined;
const namespaces = scopes.accessibleNamespacesByScope?.get(moduleScopeId);
if (namespaces === undefined) return undefined;
for (const ns of namespaces) {
const hit = scopes.namespaceTypeBindings?.get(ns)?.get(name);
if (hit !== undefined) return hit;
}
return undefined;
}
/**
* Walk the scope chain from `startScope` to its enclosing Module scope id, or
* `null` if none is found. Used by chain-followers that need the module scope to
* consult the accessibility-gated per-namespace channels.
*/
export function moduleScopeIdOf(
startScope: ScopeId,
scopes: ScopeResolutionIndexes,
): ScopeId | null {
let currentId: ScopeId | null = startScope;
const visited = new Set<ScopeId>();
while (currentId !== null) {
if (visited.has(currentId)) return null;
visited.add(currentId);
const scope = scopes.scopeTree.getScope(currentId);
if (scope === undefined) return null;
if (scope.kind === 'Module') return currentId;
currentId = scope.parent;
}
return null;
}
/**
* Look up a class-like binding by name in the given scope's chain.
*
@@ -6,8 +6,6 @@
* compatible with the standard tree-sitter grammars.
*/
import { ARRAY_METHOD_NOT_ANY_OF_PREDICATE } from './ts-js-hoc-utils.js';
// TypeScript queries - works with tree-sitter-typescript
export const TYPESCRIPT_QUERIES = `
(class_declaration
@@ -97,17 +95,10 @@ export const TYPESCRIPT_QUERIES = `
; \`tsExtractFunctionName\` for the resolution logic and the \`query.ts\`
; comment for the full anchor-discipline rationale and the chained-
; array-method trade-off.
;
; NOTE: Excludes member-expression calls to common array methods (map, filter,
; reduce, etc.) to avoid false positives like \`const x = arr.map(a => ...)\`
; being classified as a Function when it's actually a Const holding an array.
; Direct identifier calls and member expressions on non-array-methods (like
; React.memo) are still matched.
(lexical_declaration
(variable_declarator
name: (identifier) @name
value: (call_expression
function: (identifier)
arguments: (arguments
(arrow_function))))) @definition.function
@@ -115,36 +106,14 @@ export const TYPESCRIPT_QUERIES = `
(variable_declarator
name: (identifier) @name
value: (call_expression
function: (identifier)
arguments: (arguments
(function_expression))))) @definition.function
(lexical_declaration
(variable_declarator
name: (identifier) @name
value: (call_expression
function: (member_expression
property: (property_identifier) @callee)
arguments: (arguments
(arrow_function))))
${ARRAY_METHOD_NOT_ANY_OF_PREDICATE}) @definition.function
(lexical_declaration
(variable_declarator
name: (identifier) @name
value: (call_expression
function: (member_expression
property: (property_identifier) @callee)
arguments: (arguments
(function_expression))))
${ARRAY_METHOD_NOT_ANY_OF_PREDICATE}) @definition.function
(export_statement
declaration: (lexical_declaration
(variable_declarator
name: (identifier) @name
value: (call_expression
function: (identifier)
arguments: (arguments
(arrow_function)))))) @definition.function
@@ -153,40 +122,15 @@ export const TYPESCRIPT_QUERIES = `
(variable_declarator
name: (identifier) @name
value: (call_expression
function: (identifier)
arguments: (arguments
(function_expression)))))) @definition.function
(export_statement
declaration: (lexical_declaration
(variable_declarator
name: (identifier) @name
value: (call_expression
function: (member_expression
property: (property_identifier) @callee)
arguments: (arguments
(arrow_function)))))
${ARRAY_METHOD_NOT_ANY_OF_PREDICATE}) @definition.function
(export_statement
declaration: (lexical_declaration
(variable_declarator
name: (identifier) @name
value: (call_expression
function: (member_expression
property: (property_identifier) @callee)
arguments: (arguments
(function_expression)))))
${ARRAY_METHOD_NOT_ANY_OF_PREDICATE}) @definition.function
; \`var X = HOC(...)\` parity with registry-primary. Legacy code (and any
; transpiler output that downlevels \`const\` to \`var\`) hits this shape.
; Same array-method exclusions as const/let patterns above.
(variable_declaration
(variable_declarator
name: (identifier) @name
value: (call_expression
function: (identifier)
arguments: (arguments
(arrow_function))))) @definition.function
@@ -194,60 +138,9 @@ export const TYPESCRIPT_QUERIES = `
(variable_declarator
name: (identifier) @name
value: (call_expression
function: (identifier)
arguments: (arguments
(function_expression))))) @definition.function
(variable_declaration
(variable_declarator
name: (identifier) @name
value: (call_expression
function: (member_expression
property: (property_identifier) @callee)
arguments: (arguments
(arrow_function))))
${ARRAY_METHOD_NOT_ANY_OF_PREDICATE}) @definition.function
(variable_declaration
(variable_declarator
name: (identifier) @name
value: (call_expression
function: (member_expression
property: (property_identifier) @callee)
arguments: (arguments
(function_expression))))
${ARRAY_METHOD_NOT_ANY_OF_PREDICATE}) @definition.function
; HOC-wrapped default exports: \`export default defineEventHandler(async (e) => { ... })\`.
; The worker rewrites the wrapper-derived @name to a file-derived symbol name
; so helpers like \`defineEventHandler\` / \`React.memo\` do not collapse
; unrelated modules onto the same Function name.
(export_statement
value: (call_expression
function: (identifier) @hoc
arguments: (arguments
(arrow_function)))) @definition.function
(export_statement
value: (call_expression
function: (identifier) @hoc
arguments: (arguments
(function_expression)))) @definition.function
(export_statement
value: (call_expression
function: (member_expression
property: (property_identifier) @callee)
arguments: (arguments
(arrow_function)))) @definition.function
(export_statement
value: (call_expression
function: (member_expression
property: (property_identifier) @callee)
arguments: (arguments
(function_expression)))) @definition.function
; Variable/constant declarations (non-function values).
; Overlap with @definition.function patterns is handled by parse-worker dedup.
(lexical_declaration
@@ -436,12 +329,10 @@ export const JAVASCRIPT_QUERIES = `
; / debounce / user-defined HOC factories). Both \`const\` and \`var\` forms
; are mirrored so JS code that uses \`var\` (or transpiler output) gets the
; same attribution as the registry-primary path.
; Excludes common array methods (map, filter, reduce, etc.) to avoid false positives.
(lexical_declaration
(variable_declarator
name: (identifier) @name
value: (call_expression
function: (identifier)
arguments: (arguments
(arrow_function))))) @definition.function
@@ -449,36 +340,14 @@ export const JAVASCRIPT_QUERIES = `
(variable_declarator
name: (identifier) @name
value: (call_expression
function: (identifier)
arguments: (arguments
(function_expression))))) @definition.function
(lexical_declaration
(variable_declarator
name: (identifier) @name
value: (call_expression
function: (member_expression
property: (property_identifier) @callee)
arguments: (arguments
(arrow_function))))
${ARRAY_METHOD_NOT_ANY_OF_PREDICATE}) @definition.function
(lexical_declaration
(variable_declarator
name: (identifier) @name
value: (call_expression
function: (member_expression
property: (property_identifier) @callee)
arguments: (arguments
(function_expression))))
${ARRAY_METHOD_NOT_ANY_OF_PREDICATE}) @definition.function
(export_statement
declaration: (lexical_declaration
(variable_declarator
name: (identifier) @name
value: (call_expression
function: (identifier)
arguments: (arguments
(arrow_function)))))) @definition.function
@@ -487,39 +356,14 @@ export const JAVASCRIPT_QUERIES = `
(variable_declarator
name: (identifier) @name
value: (call_expression
function: (identifier)
arguments: (arguments
(function_expression)))))) @definition.function
(export_statement
declaration: (lexical_declaration
(variable_declarator
name: (identifier) @name
value: (call_expression
function: (member_expression
property: (property_identifier) @callee)
arguments: (arguments
(arrow_function)))))
${ARRAY_METHOD_NOT_ANY_OF_PREDICATE}) @definition.function
(export_statement
declaration: (lexical_declaration
(variable_declarator
name: (identifier) @name
value: (call_expression
function: (member_expression
property: (property_identifier) @callee)
arguments: (arguments
(function_expression)))))
${ARRAY_METHOD_NOT_ANY_OF_PREDICATE}) @definition.function
; \`var X = HOC(...)\` parity with registry-primary.
; Same array-method exclusions as const/let patterns.
(variable_declaration
(variable_declarator
name: (identifier) @name
value: (call_expression
function: (identifier)
arguments: (arguments
(arrow_function))))) @definition.function
@@ -527,57 +371,9 @@ export const JAVASCRIPT_QUERIES = `
(variable_declarator
name: (identifier) @name
value: (call_expression
function: (identifier)
arguments: (arguments
(function_expression))))) @definition.function
(variable_declaration
(variable_declarator
name: (identifier) @name
value: (call_expression
function: (member_expression
property: (property_identifier) @callee)
arguments: (arguments
(arrow_function))))
(#not-any-of? @callee "map" "filter" "reduce" "forEach" "find" "findIndex" "some" "every" "flatMap" "sort" "splice" "slice" "concat" "fill" "copyWithin" "join" "flat" "at" "entries" "keys" "values" "indexOf" "lastIndexOf" "includes" "pop" "push" "shift" "unshift" "reverse" "reduceRight" "toSorted" "toReversed" "toSpliced" "with")) @definition.function
(variable_declaration
(variable_declarator
name: (identifier) @name
value: (call_expression
function: (member_expression
property: (property_identifier) @callee)
arguments: (arguments
(function_expression))))
(#not-any-of? @callee "map" "filter" "reduce" "forEach" "find" "findIndex" "some" "every" "flatMap" "sort" "splice" "slice" "concat" "fill" "copyWithin" "join" "flat" "at" "entries" "keys" "values" "indexOf" "lastIndexOf" "includes" "pop" "push" "shift" "unshift" "reverse" "reduceRight" "toSorted" "toReversed" "toSpliced" "with")) @definition.function
; HOC-wrapped default exports (JS parity with TS patterns above).
(export_statement
value: (call_expression
function: (identifier) @hoc
arguments: (arguments
(arrow_function)))) @definition.function
(export_statement
value: (call_expression
function: (identifier) @hoc
arguments: (arguments
(function_expression)))) @definition.function
(export_statement
value: (call_expression
function: (member_expression
property: (property_identifier) @callee)
arguments: (arguments
(arrow_function)))) @definition.function
(export_statement
value: (call_expression
function: (member_expression
property: (property_identifier) @callee)
arguments: (arguments
(function_expression)))) @definition.function
; Variable/constant declarations (non-function values).
; Overlap with @definition.function patterns is handled by parse-worker dedup.
(lexical_declaration
@@ -810,17 +606,8 @@ export const C_QUERIES = `
; Structs, Unions, Enums, Typedefs
(struct_specifier name: (type_identifier) @name) @definition.struct
(type_definition
type: (struct_specifier
body: (field_declaration_list))
declarator: (type_identifier) @name) @definition.struct
(union_specifier name: (type_identifier) @name) @definition.union
(enum_specifier name: (type_identifier) @name) @definition.enum
(type_definition
type: (enum_specifier
body: (enumerator_list))
declarator: (type_identifier) @name) @definition.enum
(enumerator name: (identifier) @name) @definition.const
(type_definition declarator: (type_identifier) @name) @definition.typedef
; Macros
@@ -918,15 +705,6 @@ export const CPP_QUERIES = `
(enum_specifier name: (type_identifier) @name) @definition.enum
; Typedefs and unions (common in C-style headers and mixed C/C++ code)
(type_definition
type: (struct_specifier
body: (field_declaration_list))
declarator: (type_identifier) @name) @definition.struct
(type_definition
type: (enum_specifier
body: (enumerator_list))
declarator: (type_identifier) @name) @definition.enum
(enumerator name: (identifier) @name) @definition.const
(type_definition declarator: (type_identifier) @name) @definition.typedef
(union_specifier name: (type_identifier) @name) @definition.union
@@ -1,129 +0,0 @@
import path from 'node:path';
import type { SyntaxNode } from './utils/ast-helpers.js';
// Member-expression callees that should never classify a callback-wrapped
// binding as a top-level Function. This covers callback-taking Array methods
// plus a few value-returning methods that share the same AST shape.
export const ARRAY_METHOD_HOC_BLOCKLIST = [
'map',
'filter',
'reduce',
'forEach',
'find',
'findIndex',
'some',
'every',
'flatMap',
'sort',
'splice',
'slice',
'concat',
'fill',
'copyWithin',
'join',
'flat',
'at',
'entries',
'keys',
'values',
'indexOf',
'lastIndexOf',
'includes',
'pop',
'push',
'shift',
'unshift',
'reverse',
'reduceRight',
'toSorted',
'toReversed',
'toSpliced',
'with',
] as const;
export const ARRAY_METHOD_HOC_BLOCKLIST_SET: ReadonlySet<string> = new Set(
ARRAY_METHOD_HOC_BLOCKLIST,
);
// Identifier-callee default exports stay intentionally conservative: only a
// few obvious callback-taking built-ins are suppressed here. Framework HOCs
// like defineEventHandler still pass through and are named from the module.
export const DEFAULT_EXPORT_IDENTIFIER_BLOCKLIST = [
'setTimeout',
'setInterval',
'queueMicrotask',
'requestAnimationFrame',
'requestIdleCallback',
] as const;
export const DEFAULT_EXPORT_IDENTIFIER_BLOCKLIST_SET: ReadonlySet<string> = new Set(
DEFAULT_EXPORT_IDENTIFIER_BLOCKLIST,
);
export const ARRAY_CALLBACK_METHODS: ReadonlySet<string> = new Set([
'map',
'filter',
'find',
'findIndex',
'findLast',
'findLastIndex',
'forEach',
'reduce',
'reduceRight',
'some',
'every',
'flatMap',
'sort',
]);
export function buildNotAnyOfPredicate(captureName: string, values: readonly string[]): string {
return `(#not-any-of? @${captureName} ${values.map((value) => `"${value}"`).join(' ')})`;
}
export const ARRAY_METHOD_NOT_ANY_OF_PREDICATE = buildNotAnyOfPredicate(
'callee',
ARRAY_METHOD_HOC_BLOCKLIST,
);
export const DEFAULT_EXPORT_IDENTIFIER_NOT_ANY_OF_PREDICATE = buildNotAnyOfPredicate(
'hoc',
DEFAULT_EXPORT_IDENTIFIER_BLOCKLIST,
);
export function deriveDefaultExportHocName(filePath: string): string {
const normalized = filePath.replace(/\\/g, '/');
// Use individual path.posix helpers instead of path.posix.parse() to avoid
// triggering the require-safe-parse ESLint rule (which treats any .parse()
// call in src/core/ as a potential unsafe tree-sitter direct-parse).
const ext = path.posix.extname(normalized);
const name = path.posix.basename(normalized, ext);
const dir = path.posix.dirname(normalized);
if (name === 'index') {
const parent = path.posix.basename(dir);
if (parent !== '' && parent !== '.' && parent !== '/') return parent;
}
return name || 'default';
}
export function isDefaultExportHocFunctionNode(node: SyntaxNode): boolean {
const args = node.parent;
if (args === null || args.type !== 'arguments') return false;
const callExpr = args.parent;
if (callExpr === null || callExpr.type !== 'call_expression') return false;
return callExpr.parent?.type === 'export_statement';
}
export function isBlockedDefaultExportHoc(node: SyntaxNode): boolean {
if (!isDefaultExportHocFunctionNode(node)) return false;
const callExpr = node.parent?.parent;
if (callExpr === null || callExpr?.type !== 'call_expression') return false;
const callee = callExpr.childForFieldName?.('function');
return callee?.type === 'identifier' && DEFAULT_EXPORT_IDENTIFIER_BLOCKLIST_SET.has(callee.text);
}
+6 -19
View File
@@ -112,6 +112,7 @@ type PatternOverrides = Map<string, Map<string, PatternOverride[]>>;
* Includes both multi-arm pattern-match branches AND if-statement bodies for null-check narrowing. */
const NARROWING_BRANCH_TYPES = new Set([
'when_entry', // Kotlin when
'switch_block_label', // Java switch (enhanced)
'if_statement', // TS/JS, Java, C/C++
'if_expression', // Kotlin (if is an expression)
'statement_block', // TS/JS: { ... } body of if
@@ -151,11 +152,7 @@ const lookupInEnv = (
callNode: SyntaxNode,
patternOverrides?: PatternOverrides,
enclosingFunctionFinder?: (n: SyntaxNode) => { funcName: string; label: NodeLabel } | null,
extractFunctionNameHook?: (
n: SyntaxNode,
filePath?: string,
) => { funcName: string | null; label: NodeLabel } | null,
filePath?: string,
extractFunctionNameHook?: (n: SyntaxNode) => { funcName: string | null; label: NodeLabel } | null,
): string | undefined => {
// Self/this receiver: resolve to enclosing class name via AST walk
if (varName === 'self' || varName === 'this' || varName === '$this') {
@@ -173,7 +170,6 @@ const lookupInEnv = (
callNode,
enclosingFunctionFinder,
extractFunctionNameHook,
filePath,
);
// Check position-indexed pattern overrides first (e.g., Kotlin when/is smart casts).
@@ -390,17 +386,12 @@ const extractParentClassFromNode = (classNode: SyntaxNode): string | undefined =
const findEnclosingScopeKey = (
node: SyntaxNode,
enclosingFunctionFinder?: (n: SyntaxNode) => { funcName: string; label: NodeLabel } | null,
extractFunctionNameHook?: (
n: SyntaxNode,
filePath?: string,
) => { funcName: string | null; label: NodeLabel } | null,
filePath?: string,
extractFunctionNameHook?: (n: SyntaxNode) => { funcName: string | null; label: NodeLabel } | null,
): string | undefined => {
let current = node.parent;
while (current) {
if (FUNCTION_NODE_TYPES.has(current.type)) {
const funcName =
extractFunctionNameHook?.(current, filePath)?.funcName ?? genericFuncName(current);
const funcName = extractFunctionNameHook?.(current)?.funcName ?? genericFuncName(current);
if (funcName) return `${funcName}@${current.startIndex}`;
}
// Language-specific hook (e.g., Dart function_body → sibling function_signature)
@@ -792,7 +783,6 @@ const resolveFixpointBindings = (
* Uses an options object to allow future extensions without positional parameter sprawl.
*/
export interface BuildTypeEnvOptions {
filePath?: string;
model?: SemanticModel;
parentMap?: ReadonlyMap<string, readonly string[]>;
/** Pre-resolved bindings from upstream files (Phase 14).
@@ -817,10 +807,7 @@ export interface BuildTypeEnvOptions {
* Replaces the generic name-field lookup for languages with non-standard
* AST structures (C/C++ declarator unwrapping, Swift init/deinit, etc.).
* When null is returned or not provided, falls back to node.childForFieldName('name')?.text. */
extractFunctionName?: (
node: SyntaxNode,
filePath?: string,
) => { funcName: string | null; label: NodeLabel } | null;
extractFunctionName?: (node: SyntaxNode) => { funcName: string | null; label: NodeLabel } | null;
}
/** Seed cross-file type bindings into the file scope.
@@ -990,6 +977,7 @@ export const buildTypeEnv = (
(child.type === 'user_type' ||
child.type === 'type_identifier' ||
child.type === 'generic_type' ||
child.type === 'parameterized_type' ||
child.type === 'nullable_type')
) {
fallbackType = child;
@@ -1297,7 +1285,6 @@ export const buildTypeEnv = (
patternOverrides,
options?.enclosingFunctionFinder,
extractFuncNameHook,
options?.filePath,
),
constructorBindings: bindings,
fileScope: () => env.get(FILE_SCOPE) ?? emptyFileScope(),
@@ -142,14 +142,14 @@ const extractInitializer: InitializerExtractor = (
const templateFunc =
func.type === 'template_function'
? func
: func.type === 'qualified_identifier'
: func.type === 'qualified_identifier' || func.type === 'scoped_identifier'
? (func.namedChildren.find((c: SyntaxNode) => c.type === 'template_function') ?? null)
: null;
if (templateFunc) {
const nameNode = templateFunc.firstNamedChild;
if (nameNode) {
const funcName =
nameNode.type === 'qualified_identifier'
nameNode.type === 'qualified_identifier' || nameNode.type === 'scoped_identifier'
? (nameNode.lastNamedChild?.text ?? '')
: nameNode.text;
if (SMART_PTR_FACTORIES.has(funcName)) {
@@ -214,7 +214,7 @@ const scanConstructorBinding: ConstructorBindingScanner = (node) => {
if (!value || value.type !== 'call_expression') return undefined;
const func = value.childForFieldName('function');
if (!func) return undefined;
if (func.type === 'qualified_identifier') {
if (func.type === 'qualified_identifier' || func.type === 'scoped_identifier') {
const last = func.lastNamedChild;
if (!last) return undefined;
const nameNode = declarator.childForFieldName('declarator');
@@ -331,13 +331,17 @@ const extractCppElementTypeFromTypeNode = (
const args = extractCppTemplateTypeArgs(typeNode);
if (args.length >= 1) return pos === 'first' ? args[0] : args[args.length - 1];
}
// type_descriptor wrapper: unwrap and recurse (vector<User>& → vector<User>)
if (typeNode.type === 'type_descriptor') {
// reference/pointer types: unwrap and recurse (vector<User>& → vector<User>)
if (
typeNode.type === 'reference_type' ||
typeNode.type === 'pointer_type' ||
typeNode.type === 'type_descriptor'
) {
const inner = typeNode.lastNamedChild;
if (inner) return extractCppElementTypeFromTypeNode(inner, pos, depth + 1);
}
// qualified types: std::vector<User> → unwrap to template_type child
if (typeNode.type === 'qualified_identifier') {
// qualified/scoped types: std::vector<User> → unwrap to template_type child
if (typeNode.type === 'qualified_identifier' || typeNode.type === 'scoped_type_identifier') {
const inner = typeNode.lastNamedChild;
if (inner) return extractCppElementTypeFromTypeNode(inner, pos, depth + 1);
}
@@ -523,7 +527,7 @@ const detectCppConstructorType: ConstructorTypeDetector = (node, classNames) =>
const nameNode = func.firstNamedChild;
if (!nameNode) return undefined;
let funcName: string;
if (nameNode.type === 'qualified_identifier') {
if (nameNode.type === 'qualified_identifier' || nameNode.type === 'scoped_identifier') {
funcName = nameNode.lastNamedChild?.text ?? '';
} else {
funcName = nameNode.text;
@@ -52,7 +52,7 @@ const extractDeclaration: TypeBindingExtractor = (
const child = node.namedChild(i);
if (!child) continue;
if (!typeNode && child.type !== 'variable_declarator') {
if (!typeNode && child.type !== 'variable_declarator' && child.type !== 'equals_value_clause') {
// First non-declarator child is the type (identifier, implicit_type, generic_name, etc.)
typeNode = child;
}
@@ -67,9 +67,12 @@ const extractDeclaration: TypeBindingExtractor = (
let typeName: string | undefined;
if (typeNode.type === 'implicit_type' && typeNode.text === 'var') {
// Try to infer from initializer: var x = new Foo()
// tree-sitter-c-sharp puts object_creation_expression as a direct child
// tree-sitter-c-sharp may put object_creation_expression as direct child
// or inside equals_value_clause depending on grammar version
if (declarators.length === 1) {
const initializer = findChild(declarators[0], 'object_creation_expression');
const initializer =
findChild(declarators[0], 'object_creation_expression') ??
findChild(declarators[0], 'equals_value_clause')?.firstNamedChild;
if (initializer?.type === 'object_creation_expression') {
const ctorType = initializer.childForFieldName('type');
if (ctorType) typeName = extractSimpleTypeName(ctorType);
@@ -98,7 +101,7 @@ const extractParameter: ParameterExtractor = (node: SyntaxNode, env: Map<string,
typeNode = node.childForFieldName('type');
nameNode = node.childForFieldName('name');
} else {
nameNode = node.childForFieldName('name');
nameNode = node.childForFieldName('name') ?? node.childForFieldName('pattern');
typeNode = node.childForFieldName('type');
}
@@ -128,12 +131,16 @@ const scanConstructorBinding: ConstructorBindingScanner = (node) => {
if (!declarator) return undefined;
const nameNode = declarator.childForFieldName('name') ?? declarator.firstNamedChild;
if (!nameNode || nameNode.type !== 'identifier') return undefined;
// Find the initializer value as a direct child
// Find the initializer value: either inside equals_value_clause or as a direct child
// (tree-sitter-c-sharp puts invocation_expression directly inside variable_declarator)
let value: SyntaxNode | null = null;
for (let i = 0; i < declarator.namedChildCount; i++) {
const child = declarator.namedChild(i);
if (!child) continue;
if (child.type === 'equals_value_clause') {
value = child.firstNamedChild;
break;
}
if (
child.type === 'invocation_expression' ||
child.type === 'object_creation_expression' ||
@@ -464,9 +471,20 @@ const extractPendingAssignment: PendingAssignmentExtractor = (node, scopeEnv) =>
if (!nameNode) continue;
const lhs = nameNode.text;
if (scopeEnv.has(lhs)) continue;
// C# variable_declarator holds the initializer value as a direct named child
const valueNode = child.namedChild(child.namedChildCount - 1);
if (valueNode && valueNode !== nameNode && valueNode.type === 'identifier') {
// C# wraps value in equals_value_clause; fall back to last named child
let evc: SyntaxNode | null = null;
for (let j = 0; j < child.childCount; j++) {
if (child.child(j)?.type === 'equals_value_clause') {
evc = child.child(j);
break;
}
}
const valueNode = evc?.firstNamedChild ?? child.namedChild(child.namedChildCount - 1);
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)
@@ -480,7 +498,7 @@ const extractPendingAssignment: PendingAssignmentExtractor = (node, scopeEnv) =>
// invocation_expression RHS
if (valueNode?.type === 'invocation_expression') {
const funcNode = valueNode.firstNamedChild;
if (funcNode?.type === 'identifier') {
if (funcNode?.type === 'identifier_name' || funcNode?.type === 'identifier') {
return { kind: 'callResult', lhs, callee: funcNode.text };
}
// method call with receiver → methodCallResult: a.GetC()
@@ -497,7 +515,7 @@ const extractPendingAssignment: PendingAssignmentExtractor = (node, scopeEnv) =>
const inner = valueNode.firstNamedChild;
if (inner?.type === 'invocation_expression') {
const funcNode = inner.firstNamedChild;
if (funcNode?.type === 'identifier') {
if (funcNode?.type === 'identifier_name' || funcNode?.type === 'identifier') {
return { kind: 'callResult', lhs, callee: funcNode.text };
}
if (funcNode?.type === 'member_access_expression') {
@@ -547,13 +565,15 @@ export const typeConfig: LanguageTypeConfig = {
const direct = node.childForFieldName('type');
if (direct) return direct;
const wrapped = (() => {
for (let i = 0; i < node.namedChildCount; i++) {
const c = node.namedChild(i);
if (c?.type === 'variable_declaration') return c;
}
return null;
})();
const wrapped =
node.childForFieldName('declaration') ??
(() => {
for (let i = 0; i < node.namedChildCount; i++) {
const c = node.namedChild(i);
if (c?.type === 'variable_declaration') return c;
}
return null;
})();
return wrapped?.childForFieldName('type') ?? null;
},
@@ -145,8 +145,16 @@ const extractDeclaration: TypeBindingExtractor = (
/** Go: parameter → name type */
const extractParameter: ParameterExtractor = (node: SyntaxNode, env: Map<string, string>): void => {
const nameNode = node.childForFieldName('name');
const typeNode = node.childForFieldName('type');
let nameNode: SyntaxNode | null = null;
let typeNode: SyntaxNode | null = null;
if (node.type === 'parameter') {
nameNode = node.childForFieldName('name');
typeNode = node.childForFieldName('type');
} else {
nameNode = node.childForFieldName('name') ?? node.childForFieldName('pattern');
typeNode = node.childForFieldName('type');
}
if (!nameNode || !typeNode) return;
const varName = extractVarName(nameNode);
@@ -87,7 +87,7 @@ const extractJavaParameter: ParameterExtractor = (
nameNode = node.childForFieldName('name');
} else {
// Generic fallback
nameNode = node.childForFieldName('name');
nameNode = node.childForFieldName('name') ?? node.childForFieldName('pattern');
typeNode = node.childForFieldName('type');
}
@@ -382,10 +382,9 @@ const extractKotlinDeclaration: TypeBindingExtractor = (
if (varName && typeName) env.set(varName, typeName);
return;
}
// Fallback: Kotlin property_declaration has no name/type fields (verified by
// real parse, #1920); the name/type are positional children.
const nameNode = findChild(node, 'simple_identifier');
const typeNode = findChild(node, 'user_type');
// Fallback: try direct fields
const nameNode = node.childForFieldName('name') ?? findChild(node, 'simple_identifier');
const typeNode = node.childForFieldName('type') ?? findChild(node, 'user_type');
if (!nameNode || !typeNode) return;
const varName = extractVarName(nameNode);
const typeName = extractSimpleTypeName(typeNode);
@@ -417,7 +416,7 @@ const extractKotlinParameter: ParameterExtractor = (
typeNode = node.childForFieldName('type');
nameNode = node.childForFieldName('name');
} else {
nameNode = node.childForFieldName('name');
nameNode = node.childForFieldName('name') ?? node.childForFieldName('pattern');
typeNode = node.childForFieldName('type');
}
@@ -290,7 +290,7 @@ const extractParameter: ParameterExtractor = (node: SyntaxNode, env: Map<string,
typeNode = node.childForFieldName('type');
nameNode = node.childForFieldName('name');
} else {
nameNode = node.childForFieldName('name');
nameNode = node.childForFieldName('name') ?? node.childForFieldName('pattern');
typeNode = node.childForFieldName('type');
}
@@ -81,7 +81,7 @@ const extractParameter: ParameterExtractor = (node: SyntaxNode, env: Map<string,
nameNode = node.childForFieldName('name');
typeNode = node.childForFieldName('type');
} else {
nameNode = node.childForFieldName('name');
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') {
@@ -391,8 +391,8 @@ const extractPendingAssignment: PendingAssignmentExtractor = (node, scopeEnv) =>
const rhsNode = node.childForFieldName('right');
if (!rhsNode) return undefined;
if (rhsNode.type === 'identifier') return { kind: 'copy', lhs: varName, rhs: rhsNode.text };
// call RHS — Ruby uses method calls for both field access and method calls
if (rhsNode.type === 'call') {
// 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') {
@@ -277,6 +277,16 @@ const extractPendingAssignment: PendingAssignmentExtractor = (node, scopeEnv) =>
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;
};
@@ -400,6 +410,11 @@ const extractRustElementTypeFromTypeNode = (
const elemNode = typeNode.firstNamedChild;
if (elemNode) return extractSimpleTypeName(elemNode);
}
// slice_type: [User] — element is the first child
if (typeNode.type === 'slice_type') {
const elemNode = typeNode.firstNamedChild;
if (elemNode) return extractSimpleTypeName(elemNode);
}
return undefined;
};
@@ -257,7 +257,11 @@ export const extractSimpleTypeName = (typeNode: SyntaxNode, depth = 0): string |
// Generic types: extract the base type (e.g., List<User> → List)
// For nullable wrappers (Optional<User>, Option<User>), unwrap to inner type.
if (typeNode.type === 'generic_type' || typeNode.type === 'generic_name') {
if (
typeNode.type === 'generic_type' ||
typeNode.type === 'parameterized_type' ||
typeNode.type === 'generic_name'
) {
const base =
typeNode.childForFieldName('name') ??
typeNode.childForFieldName('type') ??
@@ -407,20 +411,17 @@ export const TYPED_PARAMETER_TYPES = new Set([
* Note: Go slices/maps use slice_type/map_type, not generic_type — those are
* NOT handled here. Use language-specific extractors for Go container types.
*
* @param typeNode A generic_type / generic_name / user_type AST node (or any
* node — returns [] for non-generic types).
* @param typeNode A generic_type or parameterized_type AST node (or any node —
* returns [] for non-generic types).
* @returns Array of resolved type argument names. Unresolvable arguments are omitted.
*/
export const extractGenericTypeArgs = (typeNode: SyntaxNode, depth = 0): string[] => {
if (depth > 50) return [];
// Unwrap pure wrapper nodes (which carry no type_arguments of their own) that
// may sit above the generic type. `user_type` is intentionally NOT unwrapped
// here: a Kotlin `user_type` can itself carry a `type_arguments` child
// (`List<User>` → user_type > [type_identifier, type_arguments]), so it is
// handled as a generic-bearing node below.
// Unwrap wrapper nodes that may sit above the generic_type
if (
typeNode.type === 'type_annotation' ||
typeNode.type === 'type' ||
typeNode.type === 'user_type' ||
typeNode.type === 'nullable_type' ||
typeNode.type === 'optional_type'
) {
@@ -429,15 +430,11 @@ export const extractGenericTypeArgs = (typeNode: SyntaxNode, depth = 0): string[
return [];
}
// Generic-bearing nodes hold their arguments in a `type_arguments` /
// `type_argument_list` child: generic_type (Java/TypeScript/Rust/Go),
// generic_name (C#), and Kotlin's user_type. Verified against the installed
// grammars by real parse (#1920). A user_type without its own type_arguments
// is unwrapped at the argsNode guard below.
// Only process generic/parameterized type nodes (includes C#'s generic_name)
if (
typeNode.type !== 'generic_type' &&
typeNode.type !== 'generic_name' &&
typeNode.type !== 'user_type'
typeNode.type !== 'parameterized_type' &&
typeNode.type !== 'generic_name'
) {
return [];
}
@@ -451,17 +448,7 @@ export const extractGenericTypeArgs = (typeNode: SyntaxNode, depth = 0): string[
break;
}
}
if (!argsNode) {
// A `user_type` without its own type_arguments wraps an inner type node
// (e.g. user_type > generic_type, or a plain user_type > type_identifier with
// no generics) — recurse into that child. generic_type / generic_name with no
// args simply have no type arguments to report.
if (typeNode.type === 'user_type') {
const inner = typeNode.firstNamedChild;
return inner ? extractGenericTypeArgs(inner, depth + 1) : [];
}
return [];
}
if (!argsNode) return [];
const result: string[] = [];
for (let i = 0; i < argsNode.namedChildCount; i++) {
@@ -51,7 +51,7 @@ const extractDeclaration: TypeBindingExtractor = (
env: Map<string, string>,
): void => {
// Swift property_declaration has pattern and type_annotation
const pattern = findChild(node, 'pattern');
const pattern = node.childForFieldName('pattern') ?? findChild(node, 'pattern');
const typeAnnotation = node.childForFieldName('type') ?? findChild(node, 'type_annotation');
if (!pattern || !typeAnnotation) return;
const varName = extractVarName(pattern) ?? pattern.text;
@@ -65,10 +65,10 @@ const extractParameter: ParameterExtractor = (node: SyntaxNode, env: Map<string,
let typeNode: SyntaxNode | null = null;
if (node.type === 'parameter') {
nameNode = node.childForFieldName('name');
nameNode = node.childForFieldName('name') ?? node.childForFieldName('internal_name');
typeNode = node.childForFieldName('type');
} else {
nameNode = node.childForFieldName('name');
nameNode = node.childForFieldName('name') ?? node.childForFieldName('pattern');
typeNode = node.childForFieldName('type');
}
@@ -90,7 +90,7 @@ const extractInitializer: InitializerExtractor = (
// Skip if has type annotation — extractDeclaration handled it
if (node.childForFieldName('type') || findChild(node, 'type_annotation')) return;
// Find pattern (variable name)
const pattern = findChild(node, 'pattern');
const pattern = node.childForFieldName('pattern') ?? findChild(node, 'pattern');
if (!pattern) return;
const varName = extractVarName(pattern) ?? pattern.text;
if (!varName || env.has(varName)) return;
@@ -139,7 +139,7 @@ const extractInitializer: InitializerExtractor = (
const scanConstructorBinding: ConstructorBindingScanner = (node) => {
if (node.type !== 'property_declaration') return undefined;
if (hasTypeAnnotation(node)) return undefined;
const pattern = findChild(node, 'pattern');
const pattern = node.childForFieldName('pattern') ?? findChild(node, 'pattern');
if (!pattern) return undefined;
const varName = pattern.text;
if (!varName) return undefined;
@@ -300,7 +300,8 @@ const findTsIterableElementType = (
while (current) {
if (TS_FUNCTION_NODE_TYPES.has(current.type)) {
// Search function parameters
const paramsNode = current.childForFieldName('parameters');
const paramsNode =
current.childForFieldName('parameters') ?? current.childForFieldName('formal_parameters');
if (paramsNode) {
for (let i = 0; i < paramsNode.namedChildCount; i++) {
const param = paramsNode.namedChild(i);
@@ -51,51 +51,6 @@ export const getDefinitionNodeFromCaptures = (
return null;
};
type QueryMatchLike = {
captures: Array<{ name: string; node: SyntaxNode }>;
};
const nodeRangeKey = (node: SyntaxNode): string =>
`${node.startPosition.row}:${node.startPosition.column}:${node.endPosition.row}:${node.endPosition.column}`;
const isConcreteTypedefCapture = (captureMap: Record<string, SyntaxNode>): boolean => {
const definitionNode = getDefinitionNodeFromCaptures(captureMap);
return (
definitionNode?.type === 'type_definition' &&
(captureMap['definition.struct'] !== undefined || captureMap['definition.enum'] !== undefined)
);
};
export const buildConcreteTypedefDefinitionRanges = (
matches: readonly QueryMatchLike[],
): Set<string> => {
const ranges = new Set<string>();
for (const match of matches) {
const captureMap: Record<string, SyntaxNode> = {};
for (const capture of match.captures) {
captureMap[capture.name] = capture.node;
}
const definitionNode = getDefinitionNodeFromCaptures(captureMap);
if (definitionNode && isConcreteTypedefCapture(captureMap)) {
ranges.add(nodeRangeKey(definitionNode));
}
}
return ranges;
};
export const isSuppressedConcreteTypedefDuplicate = (
captureMap: Record<string, SyntaxNode>,
concreteTypedefRanges: ReadonlySet<string>,
): boolean => {
const definitionNode = getDefinitionNodeFromCaptures(captureMap);
return (
definitionNode?.type === 'type_definition' &&
captureMap['definition.typedef'] !== undefined &&
concreteTypedefRanges.has(nodeRangeKey(definitionNode))
);
};
/**
* Node types that represent function/method definitions across languages.
* Used by parent-walk in call-processor, parse-worker, and type-env to detect
@@ -242,11 +197,7 @@ export function getLabelFromCaptures(
provider: LanguageProvider,
): NodeLabel | null {
if (captureMap['import'] || captureMap['call']) return null;
const hasDefaultExportHocNameSeed =
captureMap['definition.function'] !== undefined &&
(captureMap['hoc'] !== undefined || captureMap['callee'] !== undefined);
if (!captureMap['name'] && !captureMap['definition.constructor'] && !hasDefaultExportHocNameSeed)
return null;
if (!captureMap['name'] && !captureMap['definition.constructor']) return null;
if (captureMap['definition.function']) {
if (provider.labelOverride) {
@@ -774,23 +725,3 @@ export function findNodeAtRange(
}
return null;
}
/**
* Return the captured node if its type is one of `types`, else null.
*
* The threaded-node equivalent of `findNodeAtRange(root, capture.range, type)`
* for the common case where a tree-sitter query already hands you the matched
* node (`c.node`): the captured node IS the node at that range, so a type check
* is exact and there is no need to re-walk from the tree root (the
* O(matches × rootChildren) hot path #1848 hit). Unlike `findNodeAtRange`, this
* does NOT traverse — the caller must already hold the node; for a multi-type
* call the node must literally be one of `types` (no fallback search).
*
* Used by every language's scope-capture path (go/python/ruby/php/rust/csharp).
*/
export function nodeIfType<T extends SyntaxNode>(
node: T | undefined,
...types: readonly string[]
): T | null {
return node !== undefined && types.includes(node.type) ? node : null;
}
@@ -21,19 +21,6 @@ const LOG_EVERY_N_VERBOSE = 10;
const LOG_EVERY_N_PROFILE = 100;
const DEFAULT_SLOW_MS_VERBOSE = 3_000;
const DEFAULT_SLOW_MS = 5_000;
/**
* Always-on (NOT gated on verbose/profile) threshold above which a single
* file's deferred call resolution earns a `logger.warn`. The verbose
* slow-file profile (above) only fires with `-v`/`GITNEXUS_PROFILE_DEFERRED`;
* a plain `analyze` run that hangs in "Resolving calls" (the #1741 symptom)
* gives the user a frozen progress bar and nothing in the log. This higher
* default (15s — never hit by a healthy file) turns that silence into one
* actionable line naming the expensive file. Override via
* `GITNEXUS_SLOW_FILE_WARN_MS`; the throttle in the caller bounds volume.
*/
const DEFAULT_ALWAYS_ON_SLOW_FILE_WARN_MS = 15_000;
/** Min wall-clock gap between always-on slow-file warnings (throttle). */
export const ALWAYS_ON_SLOW_FILE_WARN_THROTTLE_MS = 30_000;
/** True when deferred-stage timing / progress logs should emit. */
export const isDeferredResolutionProfileEnabled = (): boolean =>
@@ -55,24 +42,6 @@ export const deferredCallFileSlowMs = (): number => {
return isVerboseIngestionEnabled() ? DEFAULT_SLOW_MS_VERBOSE : DEFAULT_SLOW_MS;
};
/**
* Always-on per-file slow threshold (ms) for the `logger.warn` watchdog in
* `processCallsFromExtracted`. Unlike {@link deferredCallFileSlowMs} this is
* NOT gated on verbose/profile — it fires on every run. `0` (or a negative /
* non-finite override) disables the watchdog entirely. Override via
* `GITNEXUS_SLOW_FILE_WARN_MS`.
*/
export const alwaysOnSlowFileWarnMs = (): number => {
const raw = process.env.GITNEXUS_SLOW_FILE_WARN_MS;
if (raw !== undefined) {
const n = Number(raw);
// 0 / negative / NaN → disabled. Use Number() not parseInt (see
// deferredCallFileSlowMs for the '1e9' prefix-parse hazard).
return Number.isFinite(n) && n > 0 ? n : 0;
}
return DEFAULT_ALWAYS_ON_SLOW_FILE_WARN_MS;
};
export const profileNow = (): bigint => process.hrtime.bigint();
export const profileElapsedMs = (start: bigint): number =>
@@ -3,6 +3,7 @@
import { SupportedLanguages } from 'gitnexus-shared';
import type { VariableExtractionConfig } from '../../variable-types.js';
import type { VariableVisibility } from '../../variable-types.js';
import { extractSimpleTypeName } from '../../type-extractors/shared.js';
import type { SyntaxNode } from '../../utils/ast-helpers.js';
/**
@@ -46,6 +47,13 @@ function extractDartVarName(node: SyntaxNode): string | undefined {
}
function extractDartVarType(node: SyntaxNode): string | undefined {
for (let i = 0; i < node.namedChildCount; i++) {
const child = node.namedChild(i);
if (child?.type === 'initialized_variable_definition') {
const typeNode = child.childForFieldName('type');
if (typeNode) return extractSimpleTypeName(typeNode) ?? typeNode.text?.trim();
}
}
// Look for type_identifier directly on the node
for (let i = 0; i < node.namedChildCount; i++) {
const child = node.namedChild(i);
@@ -1,4 +1,4 @@
import { parentPort, threadId } from 'node:worker_threads';
import { parentPort } from 'node:worker_threads';
import Parser from 'tree-sitter';
import JavaScript from 'tree-sitter-javascript';
import TypeScript from 'tree-sitter-typescript';
@@ -20,11 +20,6 @@ import {
getTreeSitterContentByteLength,
TREE_SITTER_MAX_BUFFER,
} from '../constants.js';
import {
ARRAY_METHOD_HOC_BLOCKLIST_SET,
DEFAULT_EXPORT_IDENTIFIER_BLOCKLIST_SET,
deriveDefaultExportHocName,
} from '../ts-js-hoc-utils.js';
import { parseSourceSafe } from '../../tree-sitter/safe-parse.js';
import type { SymbolTableReader } from '../model/symbol-table.js';
import type { ExtractedHeritage } from '../model/heritage-map.js';
@@ -57,7 +52,6 @@ try {
} catch {}
import { getLanguageFromFilename } from 'gitnexus-shared';
import {
buildConcreteTypedefDefinitionRanges,
FUNCTION_NODE_TYPES,
getDefinitionNodeFromCaptures,
findEnclosingClassInfo,
@@ -66,7 +60,6 @@ import {
getLabelFromCaptures,
genericFuncName,
inferFunctionLabel,
isSuppressedConcreteTypedefDuplicate,
CLASS_CONTAINER_TYPES,
type SyntaxNode,
} from '../utils/ast-helpers.js';
@@ -102,28 +95,6 @@ import { extractLaravelRoutes, type ExtractedRoute } from '../route-extractors/l
import { logger } from '../../logger.js';
export type { ExtractedRoute } from '../route-extractors/laravel.js';
// ── Bootstrap-stage diagnostics (#1741) ────────────────────────────────────
// When GITNEXUS_WORKER_BOOTSTRAP=1 (or --verbose sets GITNEXUS_VERBOSE), each
// worker reports its startup stage timings to stderr — which the pool tees
// and captures (worker-pool.ts captureWorkerStderr). This makes a slow or
// crashing startup diagnosable: you can see whether a worker reached
// "grammars loaded", "ready sent", or never emitted a line at all (=> it
// crashed in a native binding load before this code ran). The pool then
// attaches whatever stderr it captured to its readiness-failure message,
// so the operator sees the real cause instead of "did not report ready".
const BOOTSTRAP_LOG =
process.env.GITNEXUS_WORKER_BOOTSTRAP === '1' || process.env.GITNEXUS_VERBOSE === '1';
const bootstrapStart = performance.now();
const bootstrapLog = (stage: string): void => {
if (!BOOTSTRAP_LOG) return;
const ms = Math.round(performance.now() - bootstrapStart);
process.stderr.write(`[parse-worker bootstrap] thread=${threadId} ${stage} (+${ms}ms)\n`);
};
// First line we can emit: every static import above (tree-sitter native
// bindings, language grammars, helper modules) has already resolved by the
// time this module-body statement runs.
bootstrapLog('imports + grammars loaded');
// ============================================================================
// Types for serializable results
// ============================================================================
@@ -617,7 +588,7 @@ const findEnclosingFunctionId = (
let current = node.parent;
while (current) {
if (FUNCTION_NODE_TYPES.has(current.type)) {
const efnResult = provider.methodExtractor?.extractFunctionName?.(current, filePath);
const efnResult = provider.methodExtractor?.extractFunctionName?.(current);
const funcName = efnResult?.funcName ?? genericFuncName(current);
const label = efnResult?.label ?? inferFunctionLabel(current.type);
if (funcName) {
@@ -1145,7 +1116,6 @@ const processFileGroup = (
);
continue;
}
const concreteTypedefRanges = buildConcreteTypedefDefinitionRanges(matches);
const provider = getProvider(language);
@@ -1204,7 +1174,6 @@ const processFileGroup = (
// Constructor bindings are verified against the SymbolTable in processCallsFromExtracted.
const parentMap: ReadonlyMap<string, readonly string[]> = fileParentMap;
const typeEnv = buildTypeEnv(tree, language, {
filePath: file.path,
parentMap,
enclosingFunctionFinder: provider?.enclosingFunctionFinder,
extractFunctionName: provider?.methodExtractor?.extractFunctionName,
@@ -1251,8 +1220,6 @@ const processFileGroup = (
captureMap[c.name] = c.node;
}
if (isSuppressedConcreteTypedefDuplicate(captureMap, concreteTypedefRanges)) continue;
// Extract import paths before skipping
if (captureMap['import'] && captureMap['import.source']) {
const rawImportPath = preprocessImportPath(
@@ -1590,20 +1557,16 @@ const processFileGroup = (
? { isReadonly: routedFieldInfo.isReadonly }
: {}),
});
// Only emit File -> Property DEFINES for top-level properties
// (issue #1944); class members are reached via HAS_PROPERTY.
if (!propEnclosingClassId) {
const fileId = generateId('File', file.path);
const relId = generateId('DEFINES', `${fileId}->${nodeId}`);
result.relationships.push({
id: relId,
sourceId: fileId,
targetId: nodeId,
type: 'DEFINES',
confidence: 1.0,
reason: '',
});
}
const fileId = generateId('File', file.path);
const relId = generateId('DEFINES', `${fileId}->${nodeId}`);
result.relationships.push({
id: relId,
sourceId: fileId,
targetId: nodeId,
type: 'DEFINES',
confidence: 1.0,
reason: '',
});
if (propEnclosingClassId) {
result.relationships.push({
id: generateId('HAS_PROPERTY', `${propEnclosingClassId}->${nodeId}`),
@@ -1750,47 +1713,9 @@ const processFileGroup = (
processedDefinitionNodes.add(definitionNode.startIndex);
}
const exportDefaultCall =
nodeLabel === 'Function' && definitionNode?.type === 'export_statement'
? definitionNode.namedChildren.find((child) => child.type === 'call_expression')
: undefined;
const defaultExportHocName = (() => {
if (exportDefaultCall === undefined) return null;
const argList = exportDefaultCall.childForFieldName?.('arguments');
const callback = argList?.namedChildren.find(
(child) => child.type === 'arrow_function' || child.type === 'function_expression',
);
if (callback === undefined) return null;
const callee = exportDefaultCall.childForFieldName?.('function');
if (
callee?.type === 'identifier' &&
DEFAULT_EXPORT_IDENTIFIER_BLOCKLIST_SET.has(callee.text)
)
return null;
if (callee?.type === 'member_expression') {
const property = callee.childForFieldName?.('property');
if (
property?.type === 'property_identifier' &&
ARRAY_METHOD_HOC_BLOCKLIST_SET.has(property.text)
)
return null;
}
return deriveDefaultExportHocName(file.path);
})();
// Synthesize name for constructors without explicit @name capture (e.g. Swift init)
if (
!nameNode &&
nodeLabel !== 'Constructor' &&
!extractedClassSymbol &&
!defaultExportHocName
)
continue;
const nodeName =
extractedClassSymbol?.name ?? defaultExportHocName ?? (nameNode ? nameNode.text : 'init');
if (!nameNode && nodeLabel !== 'Constructor' && !extractedClassSymbol) continue;
const nodeName = extractedClassSymbol?.name ?? (nameNode ? nameNode.text : 'init');
const startLine = definitionNode
? definitionNode.startPosition.row + lineOffset
: nameNode
@@ -2098,19 +2023,16 @@ const processFileGroup = (
: {}),
});
// Only emit File -> Symbol DEFINES for top-level symbols (issue #1944).
if (ownerId === undefined) {
const fileId = generateId('File', file.path);
const relId = generateId('DEFINES', `${fileId}->${nodeId}`);
result.relationships.push({
id: relId,
sourceId: fileId,
targetId: nodeId,
type: 'DEFINES',
confidence: 1.0,
reason: '',
});
}
const fileId = generateId('File', file.path);
const relId = generateId('DEFINES', `${fileId}->${nodeId}`);
result.relationships.push({
id: relId,
sourceId: fileId,
targetId: nodeId,
type: 'DEFINES',
confidence: 1.0,
reason: '',
});
// ── HAS_METHOD / HAS_PROPERTY: link member to enclosing class ──
if (ownerId !== undefined) {
@@ -2262,7 +2184,6 @@ const mergeResult = (target: ParseWorkerResult, src: ParseWorkerResult) => {
// `WORKER_READY_TIMEOUT_MS` (5s), so emitting it AFTER all top-of-script
// init (imports, native binding loads, type-env setup) completes is the
// load-bearing signal that this worker is ready for dispatch.
bootstrapLog('ready sent');
parentPort!.postMessage({ type: 'ready' });
// Module-scope `TextDecoder` for sub-batch content. The pool sends each
@@ -2293,12 +2214,7 @@ function decodeSubBatchFiles(
}));
}
let firstTaskLogged = false;
parentPort!.on('message', (msg: WorkerIncomingMessage) => {
if (!firstTaskLogged) {
firstTaskLogged = true;
bootstrapLog('first task received');
}
try {
// Sub-batch mode: { type: 'sub-batch', files: [...] }
if (msg.type === 'sub-batch') {
@@ -235,33 +235,17 @@ export class WorkerPoolDispatchError extends Error {
}
}
/**
* How a total worker-startup failure was classified by the pool's bounded
* self-heal (#1741). Lets the caller render an accurate cause without
* inspecting any operator flag:
* - 'deterministic-startup': ≥2 fresh workers crashed with the SAME signature
* before any reached ready (e.g. a missing native binding) — retrying is
* futile, so the pool short-circuited fast.
* - 'transient-exhausted': workers crashed variably and exhausted the bounded
* startup retry budget without ever reaching ready.
*/
export type StartupCrashClass = 'deterministic-startup' | 'transient-exhausted';
export class WorkerPoolInitializationError extends WorkerPoolDispatchError {
readonly readinessFailures: readonly string[];
/** Pool's automatic classification of the startup crash (#1741). */
readonly crashClass: StartupCrashClass;
constructor(
message: string,
quarantinedPaths: readonly string[] = [],
readinessFailures: readonly string[] = [],
crashClass: StartupCrashClass = 'transient-exhausted',
) {
super(message, quarantinedPaths);
this.name = 'WorkerPoolInitializationError';
this.readinessFailures = readinessFailures;
this.crashClass = crashClass;
}
}
@@ -347,99 +331,6 @@ const WORKER_READY_TIMEOUT_MS = 5_000;
*/
const DEFAULT_POOL_SIZE_CAP = 16;
// ── Self-healing startup restart policy (#1741) ──────────────────────────────
// A worker that crashes during top-of-script init (broken native binding, bad
// import) is retried a BOUNDED number of times with jittered backoff before
// its slot is dropped, so a transient blip self-heals with no operator
// intervention. The bound is the whole point of #1741: recovery must never
// become a silent, unbounded "stuck" run. When the budget is exhausted (or a
// deterministic crash-loop is detected), the slot is dropped; if every slot is
// dropped the first dispatch fails fast with the captured cause.
/** Retries beyond the first attempt, per slot, to bring a startup worker ready. */
const STARTUP_RESTART_BUDGET = 2;
const RESTART_BACKOFF_BASE_MS = 250;
const RESTART_BACKOFF_CAP_MS = 2_000;
/**
* When this many freshly-spawned workers crash with the SAME crash signature
* before ANY worker reaches the `{type:'ready'}` handshake, the failure is
* deterministic (the #1741 missing-binding case: every worker prints a
* byte-identical native-binding stack). The pool stops retrying immediately
* instead of burning every slot's budget, and fails fast with the cause.
*/
const DETERMINISTIC_STARTUP_FINGERPRINT_THRESHOLD = 2;
/**
* Capped exponential backoff with FULL jitter (AWS "Exponential Backoff And
* Jitter"): random(0, min(CAP, BASE·2^attempt)). Full jitter de-synchronizes
* the N workers that crash near-simultaneously on a shared startup fault so
* their respawns don't re-storm in lockstep (Google SRE thundering herd).
*/
function startupBackoffMs(attempt: number): number {
const ceil = Math.min(RESTART_BACKOFF_CAP_MS, RESTART_BACKOFF_BASE_MS * 2 ** attempt);
return Math.floor(Math.random() * (ceil + 1));
}
/**
* Sleep used between startup self-heal retries. The timer is intentionally NOT
* `unref`'d: a pending retry is necessary work, so it must keep the event loop
* alive long enough to actually respawn — otherwise a pool whose only live work
* is a startup backoff could let the process exit mid-recovery (#1741). To
* avoid wedging shutdown, the timer registers a cancel function in `pending`;
* `terminate()` invokes those cancels to `clearTimeout` and resolve early, and
* a normally-fired timer removes its own cancel. `aborted()` is checked once up
* front; the CALLER re-checks after wake (it owns the terminated/deterministic
* decision) — this function does not itself re-evaluate abort on wake.
*/
function abortableSleep(
ms: number,
aborted: () => boolean,
pending: Set<() => void>,
): Promise<void> {
return new Promise<void>((resolve) => {
if (ms <= 0 || aborted()) {
resolve();
return;
}
// `cancel` is registered so terminate() can clear a pending backoff; it is
// also the timer's own callback, so a normally-fired sleep self-deregisters.
const cancel = () => {
clearTimeout(timer);
pending.delete(cancel);
resolve();
};
const timer = setTimeout(cancel, ms);
pending.add(cancel);
});
}
/**
* Normalize a worker crash message into a stable signature so two instances of
* the SAME deterministic crash compare equal while unrelated crashes don't.
* Strips hex addresses, digit runs (pids / line numbers / timestamps) and
* absolute paths. Best-effort by design: the deterministic classification's
* correctness rests on the STRUCTURAL signal (zero workers ever ready + startup
* budget exhausted), so an imperfect signature only changes how fast the
* short-circuit fires, never whether the pool ultimately fails fast. Even a
* stderr-less crash normalizes its "exited with code N" message to a stable
* key, so the empty-stderr timing case still groups.
*
* @internal Exported for unit tests; production callers are in this module.
*/
export function crashSignature(message: string): string {
return (
message
.replace(/0x[0-9a-fA-F]+/g, '0xADDR') // 0x-prefixed addresses
// Windows backslash paths (optional drive letter), e.g. C:\Users\ci\Temp\w-7f3a.js
.replace(/(?:[A-Za-z]:)?(?:\\[^\s\\'"]+)+/g, '\\PATH')
.replace(/(?:\/[^\s:'"]+)+/g, '/PATH') // POSIX paths
.replace(/\b[0-9a-fA-F]{6,}\b/g, 'HEX') // bare hex runs (ASLR addrs / backtrace tokens)
.replace(/[0-9]+/g, 'N') // pids / line numbers / exit codes / timestamps
.replace(/\s+/g, ' ')
.trim()
.slice(0, 300)
);
}
function positiveInteger(value: unknown): number | undefined {
const parsed = typeof value === 'string' ? Number(value) : value;
return typeof parsed === 'number' && Number.isFinite(parsed) && parsed > 0
@@ -498,39 +389,12 @@ export function resolveWorkerPoolOptions(
};
}
/**
* The pool size requested via the `GITNEXUS_WORKER_POOL_SIZE` env var, or
* `undefined` when unset, empty/whitespace, or invalid. Module-internal sizing
* reader consumed by {@link resolveAutoPoolSize} (the env override) and
* {@link workerPoolDisabledByEnv} (the sequential-routing gate). Reads only —
* never mutates `process.env`. Empty/whitespace is treated as *unset* (falls
* through to the auto formula), not as 0 — an empty assignment (`export
* GITNEXUS_WORKER_POOL_SIZE=`) is an accident, not a request for zero workers;
* only a literal `0` disables the pool.
*/
function envWorkerPoolSize(): number | undefined {
const raw = process.env.GITNEXUS_WORKER_POOL_SIZE;
if (raw === undefined || raw.trim() === '') return undefined;
return nonNegativeInteger(raw);
}
/**
* True when the operator explicitly disabled the worker pool via
* `GITNEXUS_WORKER_POOL_SIZE=0` — the env-channel equivalent of `--workers 0`.
* The parse phase's `shouldUseWorkers` gate consults this (only when no
* explicit `--workers <N>` was passed) to route to sequential parsing instead
* of constructing a useless size-0 pool that would fail fast on a phantom
* crash (#1741). An explicit positive `--workers N` always wins.
*/
export function workerPoolDisabledByEnv(): boolean {
return envWorkerPoolSize() === 0;
}
/**
* Resolve the auto-default worker pool size when no explicit `poolSize`
* arg is passed to `createWorkerPool`. Precedence:
*
* 1. `GITNEXUS_WORKER_POOL_SIZE` env var (operator override).
* 1. `GITNEXUS_WORKER_POOL_SIZE` env var (operator override; set by
* `--workers <N>` on the CLI).
* 2. `os.cpus().length - 1`, clamped to `[1, DEFAULT_POOL_SIZE_CAP]`.
*
* The cap exists because past ~16 workers the main-thread merge /
@@ -543,7 +407,7 @@ export function workerPoolDisabledByEnv(): boolean {
* on the env / default.
*/
export function resolveAutoPoolSize(): number {
const envOverride = envWorkerPoolSize();
const envOverride = nonNegativeInteger(process.env.GITNEXUS_WORKER_POOL_SIZE);
if (envOverride !== undefined) return envOverride;
// Prefer os.availableParallelism (Node 18.14+) so cgroup CPU limits
// (containers, taskset-restricted runtimes, CI runners with explicit
@@ -558,55 +422,6 @@ export function resolveAutoPoolSize(): number {
return Math.min(DEFAULT_POOL_SIZE_CAP, Math.max(1, cores - 1));
}
/**
* Max characters of a worker's stderr retained for crash diagnostics. A
* native-binding load failure or a top-of-script throw prints a stack to
* stderr; we keep the tail so `waitForWorkerReady` can attach the real
* reason to its rejection instead of the generic "did not report ready".
*/
const WORKER_STDERR_TAIL_LIMIT = 4000;
/**
* Per-worker captured stderr tail. Populated only for workers spawned with
* `{ stderr: true }` (the production factory below). Test-injected workers
* via `workerFactory` typically inherit the parent's stderr and have no
* `worker.stderr` stream — those are simply skipped (empty tail). A WeakMap
* so the buffer is released when the worker is GC'd.
*/
const workerStderrTails = new WeakMap<Worker, { text: string }>();
/**
* Tee a worker's stderr into a bounded in-memory tail (for surfacing the
* real crash on a startup failure) while still mirroring it to the parent
* process's stderr — preserving the live-diagnostics behavior workers had
* when they inherited stderr, before `{ stderr: true }` redirected it to a
* stream. No-op when the worker has no `stderr` stream (test factories).
*/
function captureWorkerStderr(worker: Worker): void {
const stream = worker.stderr;
if (!stream) return;
const buf = { text: '' };
workerStderrTails.set(worker, buf);
stream.on('data', (chunk: Buffer | string) => {
const s = typeof chunk === 'string' ? chunk : chunk.toString('utf8');
process.stderr.write(s);
buf.text = (buf.text + s).slice(-WORKER_STDERR_TAIL_LIMIT);
});
// A stderr stream error must never crash the pool.
stream.on('error', () => undefined);
}
/** Captured stderr tail for a worker, trimmed; '' when nothing was captured. */
function workerStderrTail(worker: Worker): string {
return workerStderrTails.get(worker)?.text.trim() ?? '';
}
/** Append the worker's captured stderr to a readiness-failure message. */
function withStderr(worker: Worker, message: string): string {
const tail = workerStderrTail(worker);
return tail ? `${message}. Worker stderr:\n${tail}` : message;
}
/**
* Wait for a freshly-spawned replacement worker to emit the
* `{type:'ready'}` handshake from `parse-worker.ts` before treating its
@@ -643,27 +458,16 @@ function waitForWorkerReady(worker: Worker): Promise<void> {
};
const onError = (err: Error) => {
cleanup();
// The 'error' event carries the real top-of-script exception; enrich it
// with the worker's stderr tail (native-binding stacks land there).
reject(new Error(withStderr(worker, err.message)));
reject(err);
};
const onExit = (code: number) => {
cleanup();
reject(
new Error(
withStderr(worker, `Replacement worker exited with code ${code} before reporting ready`),
),
);
reject(new Error(`Replacement worker exited with code ${code} before reporting ready`));
};
const onMessageError = (err: Error) => {
cleanup();
reject(
new Error(
withStderr(
worker,
`Replacement worker emitted messageerror before reporting ready: ${err.message}`,
),
),
new Error(`Replacement worker emitted messageerror before reporting ready: ${err.message}`),
);
};
// `timer` is declared after `cleanup` so the cleanup closure can reference
@@ -673,10 +477,7 @@ function waitForWorkerReady(worker: Worker): Promise<void> {
cleanup();
reject(
new Error(
withStderr(
worker,
`Replacement worker did not report ready within ${WORKER_READY_TIMEOUT_MS}ms — likely crashed during top-of-script init`,
),
`Replacement worker did not report ready within ${WORKER_READY_TIMEOUT_MS}ms — likely crashed during top-of-script init`,
),
);
}, WORKER_READY_TIMEOUT_MS);
@@ -795,18 +596,7 @@ export const createWorkerPool = (
const size = poolSize ?? resolveAutoPoolSize();
const poolOptions = resolveWorkerPoolOptions(options, size);
// Production factory spawns with `{ stderr: true }` so a worker's crash
// output is redirected to a `worker.stderr` stream we can tee + capture
// (see captureWorkerStderr) and attach to readiness-failure messages —
// instead of the generic "did not report ready" that hid the real cause
// in #1741. Test factories (workerFactory) are used verbatim.
const spawnWorker = options?.workerFactory ?? ((url: URL) => new Worker(url, { stderr: true }));
/** Spawn + wire stderr capture in one step (used by all spawn sites). */
const spawnAndCapture = (url: URL): Worker => {
const worker = spawnWorker(url);
captureWorkerStderr(worker);
return worker;
};
const spawnWorker = options?.workerFactory ?? ((url: URL) => new Worker(url));
const workers: (Worker | undefined)[] = new Array(size);
type RetiredWorkerRecord = {
worker: Worker;
@@ -842,9 +632,6 @@ export const createWorkerPool = (
const slotGenerations: number[] = new Array(size).fill(0);
let poolBroken = false;
let poolFailure: Error | undefined;
// Set by `terminate()` (below). Also read by the self-healing startup loop so
// a terminate during startup aborts pending backoff/retries (#1741).
let terminated = false;
const terminateTrackedWorkers = async (
liveWorkers: readonly (Worker | undefined)[],
@@ -858,109 +645,44 @@ export const createWorkerPool = (
};
for (let i = 0; i < size; i++) {
workers[i] = spawnAndCapture(workerUrl);
workers[i] = spawnWorker(workerUrl);
activeSlots.add(i);
}
// ── Self-healing startup readiness (#1741) ────────────────────────────────
// Bring every initial slot to readiness with a BOUNDED, jittered retry loop
// instead of dropping it on the first crash. This symmetrizes the gate with
// the runtime `replaceWorker` path (which already respawns a crashed slot),
// and adds genuine self-healing at startup:
// Symmetrize the readiness gate across initial and replacement spawn
// paths. `replaceWorker` already awaits `waitForWorkerReady` per
// replacement so an init-crashing worker is dropped before dispatch
// sees it. The initial-spawn loop above didn't — a worker whose
// top-of-script init crashes (failed tree-sitter native binding,
// missing dependency) would only be noticed at the first dispatch's
// 30s idle timeout, vs the 5s WORKER_READY_TIMEOUT_MS bound that
// replacements enjoy.
//
// - TRANSIENT crash (a one-off OS hiccup / fork throttle): the slot is
// respawned after jittered backoff and retried, up to STARTUP_RESTART_BUDGET
// — so a blip heals itself with no operator intervention.
// - DETERMINISTIC crash-loop (every worker dies with the SAME signature
// before any reaches ready — the #1741 missing-binding case): detected via
// `crashSignature` and short-circuited immediately, so the pool gives up in
// ~1s rather than burning every slot's budget.
//
// When the loop exhausts, the slot is dropped from `activeSlots`. If EVERY
// slot is dropped, the first dispatch throws WorkerPoolInitializationError
// carrying the captured crash cause + classification — never a silent hang.
// Correctness of the deterministic short-circuit rests on the STRUCTURAL
// signal (zero workers ever ready + budget exhausted), not on signature
// matching alone: a missed match only costs a few seconds of extra retrying.
// Deterministic crash-loop detection (#1741). A crash counts toward
// "deterministic" ONLY after its signature reproduces across a respawn on the
// same slot — so every slot is guaranteed at least one self-heal attempt and
// a simultaneous attempt-0 crash storm (e.g. transient `spawn EAGAIN` under
// fork pressure) cannot be misclassified as deterministic. We short-circuit
// once enough DISTINCT slots have each reproduced: ≥2 normally, or 1 for a
// size-1 pool. Until then the structural floor (every slot exhausts its
// budget) still bounds the worst case, so a missed match only costs retries.
const lastStartupSignature = new Map<number, string>();
const reproducedStartupSlots = new Set<number>();
const deterministicSlotThreshold = Math.min(DETERMINISTIC_STARTUP_FINGERPRINT_THRESHOLD, size);
let deterministicStartupDetected = false;
let anyWorkerReachedReady = false;
// Cancel functions for in-flight startup backoffs (see abortableSleep). The
// backoff timer is ref'd so a retry actually runs; terminate() invokes these
// to clear pending backoffs and resolve their sleeps so the slot loops wake,
// see `terminated`, and give up — instead of the process staying pinned for
// the backoff cap after terminate (#1741).
const pendingStartupTimers = new Set<() => void>();
const bringSlotReady = async (i: number): Promise<void> => {
for (let attempt = 0; ; attempt++) {
const worker = workers[i];
if (!worker) return; // terminated mid-startup
try {
await waitForWorkerReady(worker);
anyWorkerReachedReady = true;
return; // ready — slot stays in activeSlots
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
const sig = crashSignature(msg);
// Same signature as this slot's previous attempt => it survived a
// respawn, so retrying this slot is futile. (First crash has no prior
// signature, so attempt 0 never counts — every slot self-heals once.)
if (lastStartupSignature.get(i) === sig) reproducedStartupSlots.add(i);
lastStartupSignature.set(i, sig);
if (!anyWorkerReachedReady && reproducedStartupSlots.size >= deterministicSlotThreshold) {
deterministicStartupDetected = true;
}
await worker.terminate().catch(() => undefined);
workers[i] = undefined;
const giveUp =
terminated || deterministicStartupDetected || attempt >= STARTUP_RESTART_BUDGET;
if (giveUp) {
initialReadinessFailures.push(msg);
activeSlots.delete(i);
logger.warn(
{ workerIndex: i, attempt, err: msg, deterministic: deterministicStartupDetected },
deterministicStartupDetected
? `Worker ${i} hit a deterministic startup crash-loop; dropping slot without further retries.`
: `Worker ${i} did not report ready after ${attempt + 1} attempt(s); dropping slot.`,
);
return;
}
// Transient: jittered backoff, then respawn the slot and retry.
await abortableSleep(
startupBackoffMs(attempt),
() => terminated || deterministicStartupDetected,
pendingStartupTimers,
);
if (terminated || deterministicStartupDetected) {
initialReadinessFailures.push(msg);
activeSlots.delete(i);
return;
}
logger.warn(
{ workerIndex: i, attempt: attempt + 1 },
`Worker ${i} crashed during startup; respawning slot (self-heal attempt ${attempt + 1}/${STARTUP_RESTART_BUDGET}).`,
);
workers[i] = spawnAndCapture(workerUrl);
}
}
};
// First dispatch awaits this; it settles every slot's bounded retry loop in
// parallel and drops the unrecoverable ones before any dispatch can fire.
// The promise below settles every initial slot in parallel and drops
// unready slots from `activeSlots` before any dispatch can fire.
// `dispatch` awaits it via `initialReadyGate` on first invocation.
// Wrapped in a single `Promise.allSettled` so a slow worker doesn't
// block ready workers from being usable — first dispatch waits for
// all slots' verdicts (good or bad).
const initialReadyGate: Promise<void> = Promise.allSettled(
workers.map((_, i) => bringSlotReady(i)),
workers.map(async (w, i) => {
if (!w) return;
try {
await waitForWorkerReady(w);
} catch (err) {
initialReadinessFailures.push(err instanceof Error ? err.message : String(err));
logger.warn(
{
workerIndex: i,
err: err instanceof Error ? err.message : String(err),
},
`Worker ${i} did not report ready on initial spawn; dropping slot.`,
);
await w.terminate().catch(() => undefined);
workers[i] = undefined;
activeSlots.delete(i);
}
}),
).then(() => undefined);
const dispatch = async <TInput, TResult>(
@@ -990,17 +712,10 @@ export const createWorkerPool = (
initialReadinessFailures.length > 0
? ` after initial ready handshake: ${initialReadinessFailures.join('; ')}`
: '';
// The bounded self-heal exhausted (or short-circuited a deterministic
// crash-loop). Classify automatically so the caller renders the real
// cause without consulting any operator flag (#1741).
const crashClass: StartupCrashClass = deterministicStartupDetected
? 'deterministic-startup'
: 'transient-exhausted';
throw new WorkerPoolInitializationError(
`Worker pool has no active workers${detail}`,
[],
initialReadinessFailures,
crashClass,
);
}
@@ -1142,7 +857,7 @@ export const createWorkerPool = (
): Promise<boolean> => {
await removeWorkerFromSlot(workerIndex, mode, reason);
if (stopped) return false;
const replacement = spawnAndCapture(workerUrl);
const replacement = spawnWorker(workerUrl);
try {
await waitForWorkerReady(replacement);
} catch (err) {
@@ -1867,12 +1582,9 @@ export const createWorkerPool = (
});
};
let terminated = false;
const terminate = async (): Promise<void> => {
terminated = true;
// Cancel any in-flight startup backoff so its ref'd timer doesn't keep the
// event loop alive after terminate; each cancel resolves the awaiting sleep
// and the slot loop then sees `terminated` and gives up (#1741).
for (const cancel of [...pendingStartupTimers]) cancel();
// `.catch(() => undefined)` per-worker matches every other terminate
// site in this file. Without it, a hung/OOM-killed worker's terminate
// rejection escapes `Promise.all` and replaces the original pipeline
@@ -1896,7 +1608,6 @@ export const createWorkerPool = (
quarantined: quarantine.size,
poolBroken,
terminated,
pendingStartupTimers: pendingStartupTimers.size,
slotGenerations: slotGenerations.slice(),
}),
};
+1 -4
View File
@@ -478,10 +478,7 @@ export async function runFullAnalysis(
: p.message || phaseLabel;
progress(p.phase, scaled, message);
},
{
parseCache,
workerPoolSize: options.workerPoolSize,
},
{ parseCache, workerPoolSize: options.workerPoolSize },
);
// ── Phase 2: LadybugDB (60–85%) ──────────────────────────────────
+1 -9
View File
@@ -57,9 +57,6 @@ export async function closeWikiDb(): Promise<void> {
/**
* Get all source files with their exported symbol names and types.
* Includes top-level exports (File→DEFINES→n) and exported class members
* (File→DEFINES→Class→HAS_METHOD/HAS_PROPERTY→n) since class members no
* longer have a direct File→DEFINES edge.
*/
export async function getFilesWithExports(): Promise<FileWithExports[]> {
const rows = await executeQuery(
@@ -68,12 +65,7 @@ export async function getFilesWithExports(): Promise<FileWithExports[]> {
MATCH (f:File)-[:CodeRelation {type: 'DEFINES'}]->(n)
WHERE n.isExported = true
RETURN f.filePath AS filePath, n.name AS name, labels(n)[0] AS type
UNION
MATCH (f:File)-[:CodeRelation {type: 'DEFINES'}]->(c)
-[mr:CodeRelation]->(n)
WHERE mr.type IN ['HAS_METHOD', 'HAS_PROPERTY'] AND n.isExported = true
RETURN f.filePath AS filePath, n.name AS name, labels(n)[0] AS type
ORDER BY filePath
ORDER BY f.filePath
`,
);

Some files were not shown because too many files have changed in this diff Show More