diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml index c34d0f6ec..e9a9b0a54 100644 --- a/.github/workflows/ci-tests.yml +++ b/.github/workflows/ci-tests.yml @@ -12,7 +12,13 @@ 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' @@ -72,7 +78,11 @@ 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' @@ -181,3 +191,60 @@ 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 emitScopeCaptures 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 diff --git a/README.md b/README.md index 39b09b7d2..a5e04e728 100644 --- a/README.md +++ b/README.md @@ -660,6 +660,14 @@ UPSTREAM (what depends on this): Options: `maxDepth`, `minConfidence`, `relationTypes` (`CALLS`, `IMPORTS`, `EXTENDS`, `IMPLEMENTS`), `includeTests`, `limit` (max symbols per depth, default 100), `offset` (pagination start per depth), `summaryOnly` (counts and risk only, omits symbol list) +**Disambiguation** — when several symbols share the target name, `impact` returns a ranked `ambiguous` candidate list instead of guessing. Narrow it with `target_uid` (exact, zero-ambiguity), `file_path`, or `kind` (`Function`, `Class`, `Method`, …). From the CLI these are `--uid`, `--file`, and `--kind`, matching `gitnexus context`: + +```bash +gitnexus impact get_embeddings # → ambiguous: lists ranked candidates +gitnexus impact get_embeddings --file src/embed.py # → resolves to the one in that file +gitnexus impact get_embeddings --uid "Function:src/embed.py:get_embeddings" # exact +``` + ### Process-Grouped Search ``` diff --git a/gitnexus/README.md b/gitnexus/README.md index b313a7f46..e0ba4283e 100644 --- a/gitnexus/README.md +++ b/gitnexus/README.md @@ -170,6 +170,13 @@ gitnexus clean --all --force # Delete all indexes gitnexus wiki [path] # Generate LLM-powered docs from knowledge graph gitnexus wiki --model # Wiki with custom LLM model (default: gpt-4o-mini) +# Direct graph queries — the same tools the MCP server exposes, no MCP daemon needed +gitnexus query "" # Process-grouped hybrid search +gitnexus context [--uid | --file ] # 360° symbol view; flags disambiguate a shared name +gitnexus impact [--uid | --file | --kind ] # Blast radius; flags disambiguate a shared name +gitnexus detect-changes # Map the working-tree diff to affected symbols and execution flows +gitnexus cypher "" # Run a raw Cypher query against the knowledge graph + # Repository groups (multi-repo / monorepo service tracking) gitnexus group create # Create a repository group gitnexus group add # Add a repo to a group. is a hierarchy path (e.g. hr/hiring/backend); is the repo's name from the registry (see `gitnexus list`) diff --git a/gitnexus/bench/python-scope/baseline-fingerprint.txt b/gitnexus/bench/python-scope/baseline-fingerprint.txt new file mode 100644 index 000000000..fdf5b2835 --- /dev/null +++ b/gitnexus/bench/python-scope/baseline-fingerprint.txt @@ -0,0 +1 @@ +f2b4376f30dab76f3befc9cbd3d7cc2bf1afbd7329a5e953439083e005de4a7c diff --git a/gitnexus/bench/python-scope/baseline-import-target-fingerprint.txt b/gitnexus/bench/python-scope/baseline-import-target-fingerprint.txt new file mode 100644 index 000000000..6eb828738 --- /dev/null +++ b/gitnexus/bench/python-scope/baseline-import-target-fingerprint.txt @@ -0,0 +1 @@ +d51ea9edd1902fc20dd888f3fc51907c8119a4692b92c32a427801ac7f41d451 diff --git a/gitnexus/bench/python-scope/import-target-fingerprint.mjs b/gitnexus/bench/python-scope/import-target-fingerprint.mjs new file mode 100644 index 000000000..36fefe17c --- /dev/null +++ b/gitnexus/bench/python-scope/import-target-fingerprint.mjs @@ -0,0 +1,202 @@ +/** + * 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'); +} diff --git a/gitnexus/bench/python-scope/measure.mjs b/gitnexus/bench/python-scope/measure.mjs new file mode 100644 index 000000000..fa4c72a35 --- /dev/null +++ b/gitnexus/bench/python-scope/measure.mjs @@ -0,0 +1,216 @@ +/** + * 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'); +} diff --git a/gitnexus/bench/scope-capture/baselines.json b/gitnexus/bench/scope-capture/baselines.json new file mode 100644 index 000000000..564078557 --- /dev/null +++ b/gitnexus/bench/scope-capture/baselines.json @@ -0,0 +1,27 @@ +{ + "_comment": "Per-language baselines for bench/scope-capture/measure.mjs --check. fingerprint = order-independent sha256 over the lang-resolution/-* 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 + } +} diff --git a/gitnexus/bench/scope-capture/measure.mjs b/gitnexus/bench/scope-capture/measure.mjs new file mode 100644 index 000000000..62fc27ddc --- /dev/null +++ b/gitnexus/bench/scope-capture/measure.mjs @@ -0,0 +1,266 @@ +/** + * 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 — plus python lives in its own + * `bench/python-scope/` harness (richer: it also covers import resolution). + * + * For each language it: + * - times `emitScopeCaptures` 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/-*` 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'; + +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}|`); + 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: ' + `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`, + }, +]; + +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`); +} diff --git a/gitnexus/package-lock.json b/gitnexus/package-lock.json index 4a7575c02..b95c42640 100644 --- a/gitnexus/package-lock.json +++ b/gitnexus/package-lock.json @@ -28,6 +28,7 @@ "jsonc-parser": "^3.3.1", "lru-cache": "^11.0.0", "mnemonist": "^0.40.3", + "node-addon-api": "8.8.0", "onnxruntime-node": "^1.24.0", "pandemonium": "^2.4.0", "pino": "^10.3.1", @@ -3799,9 +3800,9 @@ } }, "node_modules/node-addon-api": { - "version": "8.7.0", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.7.0.tgz", - "integrity": "sha512-9MdFxmkKaOYVTV+XVRG8ArDwwQ77XIgIPyKASB1k3JPq3M8fGQQQE3YpMOrKm6g//Ktx8ivZr8xo1Qmtqub+GA==", + "version": "8.8.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.8.0.tgz", + "integrity": "sha512-c5Ko1fZJIJmzhFIkhRN76WTq+fC6tWnGy9CXA0fA+XygsWZmEwG8vmbkNqxMyoaa0Tin4djul49NzdVcJJcjeA==", "license": "MIT", "engines": { "node": "^18 || ^20 || >= 21" diff --git a/gitnexus/src/cli/help-i18n.ts b/gitnexus/src/cli/help-i18n.ts index 8d620fc6f..5fb42dbe1 100644 --- a/gitnexus/src/cli/help-i18n.ts +++ b/gitnexus/src/cli/help-i18n.ts @@ -101,6 +101,9 @@ const OPTION_DESCRIPTION_KEYS = { 'context|--content': 'help.option.content', 'impact|-d, --direction ': 'help.option.impact.direction', 'impact|-r, --repo ': 'help.option.repo.target', + 'impact|-u, --uid ': 'help.option.context.uid', + 'impact|-f, --file ': 'help.option.context.file', + 'impact|--kind ': 'help.option.impact.kind', 'impact|--depth ': 'help.option.impact.depth', 'impact|--include-tests': 'help.option.impact.includeTests', 'impact|--limit ': 'help.option.impact.limit', diff --git a/gitnexus/src/cli/i18n/en.ts b/gitnexus/src/cli/i18n/en.ts index 51cbc2816..e4778fb58 100644 --- a/gitnexus/src/cli/i18n/en.ts +++ b/gitnexus/src/cli/i18n/en.ts @@ -43,8 +43,11 @@ export const en = { 'tool.noIndexed': 'GitNexus: No indexed repositories found. Run: gitnexus analyze', 'tool.usage.query': 'Usage: gitnexus query ', 'tool.usage.context': 'Usage: gitnexus context [--uid ] [--file ]', - 'tool.usage.impact': 'Usage: gitnexus impact [--direction upstream|downstream]', + 'tool.usage.impact': + 'Usage: gitnexus impact [--uid ] [--file ] [--kind ] [--direction upstream|downstream]', 'tool.usage.cypher': 'Usage: gitnexus cypher ', + 'tool.warn.unknownKind': + "--kind '{{kind}}' is not a known symbol kind (e.g. Function, Class, Method); it will not narrow the result.", 'tool.detectChanges.noChanges': 'No changes detected.', 'tool.detectChanges.changesSummary': 'Changes: {{files}} files, {{symbols}} symbols', 'tool.detectChanges.affectedProcesses': 'Affected processes: {{count}}', @@ -213,6 +216,8 @@ export const en = { 'help.option.repo.target': 'Target repository', 'help.option.context.uid': 'Direct symbol UID (zero-ambiguity lookup)', 'help.option.context.file': 'File path to disambiguate common names', + 'help.option.impact.kind': + 'Kind filter to disambiguate common names (e.g. Function, Class, Method)', 'help.option.impact.direction': 'upstream (dependants) or downstream (dependencies)', 'help.option.impact.depth': 'Max relationship depth (default: 3)', 'help.option.impact.includeTests': 'Include test files in results', diff --git a/gitnexus/src/cli/i18n/zh-CN.ts b/gitnexus/src/cli/i18n/zh-CN.ts index 2a1374fc6..cd488de0a 100644 --- a/gitnexus/src/cli/i18n/zh-CN.ts +++ b/gitnexus/src/cli/i18n/zh-CN.ts @@ -47,8 +47,11 @@ export const zhCN = { 'tool.noIndexed': 'GitNexus:未找到已索引仓库。请运行:gitnexus analyze', 'tool.usage.query': '用法:gitnexus query <搜索词>', 'tool.usage.context': '用法:gitnexus context <符号名> [--uid ] [--file <路径>]', - 'tool.usage.impact': '用法:gitnexus impact <符号名> [--direction upstream|downstream]', + 'tool.usage.impact': + '用法:gitnexus impact <符号名> [--uid ] [--file <路径>] [--kind <类型>] [--direction upstream|downstream]', 'tool.usage.cypher': '用法:gitnexus cypher ', + 'tool.warn.unknownKind': + "--kind '{{kind}}' 不是已知的符号类型(如 Function、Class、Method),不会用于缩小结果范围。", 'tool.detectChanges.noChanges': '未检测到变更。', 'tool.detectChanges.changesSummary': '变更:{{files}} 个文件,{{symbols}} 个符号', 'tool.detectChanges.affectedProcesses': '受影响流程:{{count}}', @@ -199,6 +202,7 @@ export const zhCN = { 'help.option.repo.target': '目标仓库', 'help.option.context.uid': '直接符号 UID(零歧义查找)', 'help.option.context.file': '用于消除常见名称歧义的文件路径', + 'help.option.impact.kind': '用于消除常见名称歧义的类型过滤(如 Function、Class、Method)', 'help.option.impact.direction': 'upstream(依赖它的项)或 downstream(它依赖的项)', 'help.option.impact.depth': '最大关系遍历深度(默认:3)', 'help.option.impact.includeTests': '在结果中包含测试文件', diff --git a/gitnexus/src/cli/index.ts b/gitnexus/src/cli/index.ts index 67b34387a..b599f7e9e 100644 --- a/gitnexus/src/cli/index.ts +++ b/gitnexus/src/cli/index.ts @@ -219,10 +219,16 @@ program .action(createLbugLazyAction(() => import('./tool.js'), 'contextCommand')); program - .command('impact ') + .command('impact [target]') .description('Blast radius analysis: what breaks if you change a symbol') .option('-d, --direction ', 'upstream (dependants) or downstream (dependencies)', 'upstream') .option('-r, --repo ', 'Target repository') + .option('-u, --uid ', 'Direct symbol UID (zero-ambiguity lookup)') + .option('-f, --file ', 'File path to disambiguate common names') + .option( + '--kind ', + 'Kind filter to disambiguate common names (e.g. Function, Class, Method)', + ) .option('--depth ', 'Max relationship depth (default: 3)') .option('--include-tests', 'Include test files in results') .option('--limit ', 'Max symbols per depth level (default: 100)') diff --git a/gitnexus/src/cli/tool.ts b/gitnexus/src/cli/tool.ts index 0e70c3970..5801677b9 100644 --- a/gitnexus/src/cli/tool.ts +++ b/gitnexus/src/cli/tool.ts @@ -16,8 +16,8 @@ */ import { writeSync } from 'node:fs'; -import { LocalBackend } from '../mcp/local/local-backend.js'; -import { cliErrorKey } from './cli-message.js'; +import { LocalBackend, VALID_NODE_LABELS } from '../mcp/local/local-backend.js'; +import { cliErrorKey, cliWarnKey } from './cli-message.js'; import { formatDetectChangesResult } from './detect-changes-format.js'; let _backend: LocalBackend | null = null; @@ -94,6 +94,11 @@ export async function contextCommand( content?: boolean; }, ): Promise { + // Reject a `--`-prefixed uid swallowed from a following flag (see impactCommand). + if (options?.uid?.startsWith('--')) { + cliErrorKey('tool.usage.context'); + process.exit(1); + } if (!name?.trim() && !options?.uid) { cliErrorKey('tool.usage.context'); process.exit(1); @@ -111,10 +116,13 @@ export async function contextCommand( } export async function impactCommand( - target: string, + target?: string, options?: { direction?: string; repo?: string; + uid?: string; + file?: string; + kind?: string; depth?: string; includeTests?: boolean; limit?: string; @@ -122,10 +130,25 @@ export async function impactCommand( summaryOnly?: boolean; }, ): Promise { - if (!target?.trim()) { + // A `--`-prefixed uid means Commander swallowed a following flag as the uid + // value (e.g. `impact --uid --file x` → uid === '--file'). Reject it rather + // than forwarding a garbage uid that would silently resolve to not-found. + if (options?.uid?.startsWith('--')) { cliErrorKey('tool.usage.impact'); process.exit(1); } + // Target is an optional positional: a uid alone is enough to resolve (parity + // with `context [name]`). Only error when neither a target nor a uid is given. + if (!target?.trim() && !options?.uid) { + cliErrorKey('tool.usage.impact'); + process.exit(1); + } + // Soft-validate --kind: an unknown kind is a no-op hint (the backend scores + // it but it matches nothing), so warn and proceed rather than rejecting — + // parity with the lenient MCP surface and forward-compatible with new labels. + if (options?.kind && !VALID_NODE_LABELS.has(options.kind)) { + cliWarnKey('tool.warn.unknownKind', { kind: options.kind }); + } try { const backend = await getBackend(); @@ -134,7 +157,10 @@ export async function impactCommand( const parsedLimit = Number.isFinite(rawLimit) ? rawLimit : undefined; const parsedOffset = Number.isFinite(rawOffset) ? rawOffset : undefined; const result = await backend.callTool('impact', { - target, + target: target || undefined, + target_uid: options?.uid, + file_path: options?.file, + kind: options?.kind, direction: options?.direction || 'upstream', maxDepth: options?.depth ? parseInt(options.depth, 10) : undefined, includeTests: options?.includeTests ?? false, diff --git a/gitnexus/src/core/group/extractors/grpc-patterns/node.ts b/gitnexus/src/core/group/extractors/grpc-patterns/node.ts index 033962206..72a237fba 100644 --- a/gitnexus/src/core/group/extractors/grpc-patterns/node.ts +++ b/gitnexus/src/core/group/extractors/grpc-patterns/node.ts @@ -86,16 +86,33 @@ const NEW_QUALIFIED_CTOR_SPEC: PatternSpec> = { // proto loader). Matches either a bare call or an `obj.loadPackageDefinition(...)` // call. Plugin gates the qualified-constructor consumer on this — // structural check avoids materializing `tree.rootNode.text` for every file. -const LOAD_PACKAGE_DEFINITION_SPEC: PatternSpec> = { - meta: {}, - query: ` - (call_expression - function: [ - (identifier) @fn (#eq? @fn "loadPackageDefinition") - (member_expression property: (property_identifier) @fn (#eq? @fn "loadPackageDefinition")) - ]) - `, -}; +// +// These are TWO separate specs, NOT one `function: [ (identifier) ... (member_expression) ... ]` +// alternation. Under the pinned tree-sitter@0.21.1 binding a top-level alternation +// whose branches reuse the same capture name (`@fn`) collapses to one pattern with +// a shared predicate bucket; the second branch's `@fn` is left unbound and its +// `#eq?` is never enforced, so the member-expression branch would match EVERY +// `obj.method(...)` call (e.g. `console.log(...)`) — turning this gate always-on +// and emitting spurious qualified-constructor consumers. Two specs compile to two +// queries with independent predicate buckets; `runCompiledPatterns` concatenates +// their matches, so the `.length > 0` gate still means "either form is present". +const LOAD_PACKAGE_DEFINITION_SPECS: PatternSpec>[] = [ + { + meta: {}, + query: ` + (call_expression + function: (identifier) @fn (#eq? @fn "loadPackageDefinition")) + `, + }, + { + meta: {}, + query: ` + (call_expression + function: (member_expression + property: (property_identifier) @fn (#eq? @fn "loadPackageDefinition"))) + `, + }, +]; interface NodeGrpcPatternBundle { grpcMethod: CompiledPatterns>; @@ -107,11 +124,14 @@ interface NodeGrpcPatternBundle { } function compileBundle(language: unknown, name: string): NodeGrpcPatternBundle { - const mk = (spec: PatternSpec>, suffix: string) => + const mk = ( + spec: PatternSpec> | PatternSpec>[], + suffix: string, + ) => compilePatterns({ name: `${name}-${suffix}`, language, - patterns: [spec], + patterns: Array.isArray(spec) ? spec : [spec], } satisfies LanguagePatterns>); return { grpcMethod: mk(GRPC_METHOD_SPEC, 'grpc-method'), @@ -119,7 +139,7 @@ function compileBundle(language: unknown, name: string): NodeGrpcPatternBundle { getService: mk(GET_SERVICE_SPEC, 'get-service'), newSimpleCtor: mk(NEW_SIMPLE_CTOR_SPEC, 'new-simple-ctor'), newQualifiedCtor: mk(NEW_QUALIFIED_CTOR_SPEC, 'new-qualified-ctor'), - loadPackageDefinition: mk(LOAD_PACKAGE_DEFINITION_SPEC, 'load-package-definition'), + loadPackageDefinition: mk(LOAD_PACKAGE_DEFINITION_SPECS, 'load-package-definition'), }; } diff --git a/gitnexus/src/core/group/extractors/http-patterns/java.ts b/gitnexus/src/core/group/extractors/http-patterns/java.ts index 48da46765..920d499aa 100644 --- a/gitnexus/src/core/group/extractors/http-patterns/java.ts +++ b/gitnexus/src/core/group/extractors/http-patterns/java.ts @@ -19,14 +19,18 @@ import type { * - Spring `RestTemplate.getForObject/...`, `exchange(...)` * - Spring `WebClient.method(HttpMethod.X, ...)`, `WebClient.get().uri(...)` * - OkHttp `new Request.Builder().url("...")` - * - OpenFeign interfaces with Spring MVC method annotations + * - OpenFeign interfaces with Spring MVC method annotations or + * native `@RequestLine("METHOD /path")` annotations * - Java / Apache HttpClient literal request construction * - * The plugin runs two pattern bundles: one to collect class-level - * `@RequestMapping` prefixes keyed by the enclosing class node, and a - * second to match method-level annotations. The `scan` function walks - * up from each matched annotation to find its enclosing class and - * combines the prefix with the method path. + * Every route-defining annotation (class/interface `@RequestMapping` + * prefixes, `@FeignClient(path)` prefixes, `@(Get|...)Mapping` method + * routes and native `@RequestLine`s) is matched by a single consolidated + * query (`JAVA_ROUTE_ANNOTATION_PATTERNS`) in one pass via + * `scanRouteAnnotations`. The `scan` function then walks up from each + * matched method to its enclosing class/interface to combine the prefix + * with the method path. Call-site consumers (RestTemplate, WebClient, + * OkHttp, Java/Apache HttpClient) keep their own focused queries. */ const METHOD_ANNOTATION_TO_HTTP: Record = { @@ -37,20 +41,17 @@ const METHOD_ANNOTATION_TO_HTTP: Record = { PatchMapping: 'PATCH', }; -// ─── Provider: Spring class-level @RequestMapping prefix ────────────── -// Two patterns are needed because the AST shape differs depending on -// whether the annotation uses a positional argument or a named one: +// Each route-defining annotation has two AST shapes — a positional argument +// and a named one — that must both be matched: // @RequestMapping("/api") → (annotation_argument_list (string_literal)) // @RequestMapping(path = "/api") → (annotation_argument_list (element_value_pair key:(identifier) value:(string_literal))) // @RequestMapping(value = "/api") → same as above -// -// The named-argument pattern MUST constrain the `key` field to the route -// member names (`path`/`value`); without it, the query also captures -// non-route attributes such as `produces`, `consumes`, `headers`, `name`, -// `params` (their right-hand string literals would be mis-extracted as -// route prefixes — e.g. `produces = "application/json"` would corrupt -// every method route under that controller). The sibling -// `topic-patterns/java.ts` uses the same `key:` constraint approach. +// For named arguments only the route member keys (`path`/`value`) carry a URL; +// non-route attributes (`produces`, `consumes`, `headers`, `name`, `params`) +// would otherwise be mis-extracted (e.g. `produces = "application/json"` would +// corrupt every route). That key filtering is done in `isRouteMemberKey`, and +// all of these annotations are matched by the one `JAVA_ROUTE_ANNOTATION_PATTERNS` +// query below (see its header for why the filtering lives in JS, not the query). interface SpringRouteBinding { method: string; path: string; @@ -71,9 +72,34 @@ interface SpringTypeInfo { methods: SpringMethodInfo[]; } -// ─── Provider: Spring class/interface-level @RequestMapping prefix ─── -const SPRING_TYPE_PREFIX_PATTERNS = compilePatterns({ - name: 'java-spring-type-prefix', +// ─── Route-defining annotations (one generic query, one pass) ───────── +// Every Java route-mapper annotation shares one shape: an annotation carrying a +// single string argument — positional `"..."` or named `key = "..."` — on a +// class, interface, or method. This SINGLE query matches that shape generically; +// `scanRouteAnnotations` then reads the annotation NAME (`@ann`) and declaration +// kind (`@node.type`) in its for-loop to decide what each match means. Adding a +// new framework annotation that follows this single-string-argument shape is a +// change to that loop (and the lookup maps), not to this query. Annotations with +// a different argument shape — e.g. an array value `@RequestMapping({"/a","/b"})` +// — are out of scope here (as they were for the prior queries) and would need a +// new branch. +// +// Captures (shared across all branches; intentionally framework-agnostic): +// @ann → the annotation name identifier (RequestMapping, GetMapping, RequestLine, …) +// @node → the enclosing declaration (class_declaration | interface_declaration | method_declaration) +// @value → the string-literal argument +// @key → the named-argument member key (absent for the positional shape) +// @member → the method name (method_declaration branches only) +// +// The query carries NO `#eq?` / `#match?` predicates. Under the pinned +// tree-sitter 0.21.x binding a top-level `[ ... ]` alternation compiles to one +// pattern whose text predicates share a single bucket keyed by capture name, and +// a `#match?` against a capture absent from the matched branch evaluates FALSE — +// silently dropping sibling-branch matches. Keeping the query predicate-free +// sidesteps that hazard entirely; all name/key discrimination lives in the +// for-loop, where it reads as straight-line code. +const JAVA_ROUTE_ANNOTATION_PATTERNS = compilePatterns({ + name: 'java-route-annotation', language: Java, patterns: [ { @@ -83,36 +109,44 @@ const SPRING_TYPE_PREFIX_PATTERNS = compilePatterns({ (class_declaration (modifiers (annotation - name: (identifier) @ann (#eq? @ann "RequestMapping") - arguments: (annotation_argument_list (string_literal) @prefix)))) @type + name: (identifier) @ann + arguments: (annotation_argument_list (string_literal) @value)))) @node (interface_declaration (modifiers (annotation - name: (identifier) @ann (#eq? @ann "RequestMapping") - arguments: (annotation_argument_list (string_literal) @prefix)))) @type - ] - `, - }, - { - meta: {}, - query: ` - [ + name: (identifier) @ann + arguments: (annotation_argument_list (string_literal) @value)))) @node (class_declaration (modifiers (annotation - name: (identifier) @ann (#eq? @ann "RequestMapping") + name: (identifier) @ann arguments: (annotation_argument_list (element_value_pair - key: (identifier) @key (#match? @key "^(path|value)$") - value: (string_literal) @prefix))))) @type + key: (identifier) @key + value: (string_literal) @value))))) @node (interface_declaration (modifiers (annotation - name: (identifier) @ann (#eq? @ann "RequestMapping") + name: (identifier) @ann arguments: (annotation_argument_list (element_value_pair - key: (identifier) @key (#match? @key "^(path|value)$") - value: (string_literal) @prefix))))) @type + key: (identifier) @key + value: (string_literal) @value))))) @node + (method_declaration + (modifiers + (annotation + name: (identifier) @ann + arguments: (annotation_argument_list (string_literal) @value))) + name: (identifier) @member) @node + (method_declaration + (modifiers + (annotation + name: (identifier) @ann + arguments: (annotation_argument_list + (element_value_pair + key: (identifier) @key + value: (string_literal) @value)))) + name: (identifier) @member) @node ] `, }, @@ -135,89 +169,41 @@ const SPRING_TYPE_DECLARATION_PATTERNS = compilePatterns({ ], } satisfies LanguagePatterns>); -// ─── Consumer: OpenFeign interface-level prefixes ─────────────────── -// Feign's `name`/`value` attributes identify a service, not an HTTP path, -// so only `path` is used as a URL prefix. `@RequestMapping` on a Feign -// interface is also common and does carry a path prefix. -const FEIGN_INTERFACE_PREFIX_PATTERNS = compilePatterns({ - name: 'java-feign-interface-prefix', - language: Java, - patterns: [ - { - meta: {}, - query: ` - (interface_declaration - (modifiers - (annotation - name: (identifier) @ann (#eq? @ann "FeignClient") - arguments: (annotation_argument_list - (element_value_pair - key: (identifier) @key (#eq? @key "path") - value: (string_literal) @prefix))))) @interface - `, - }, - { - meta: {}, - query: ` - (interface_declaration - (modifiers - (annotation - name: (identifier) @ann (#eq? @ann "RequestMapping") - arguments: (annotation_argument_list (string_literal) @prefix)))) @interface - `, - }, - { - meta: {}, - query: ` - (interface_declaration - (modifiers - (annotation - name: (identifier) @ann (#eq? @ann "RequestMapping") - arguments: (annotation_argument_list - (element_value_pair - key: (identifier) @key (#match? @key "^(path|value)$") - value: (string_literal) @prefix))))) @interface - `, - }, - ], -} satisfies LanguagePatterns>); +// ─── Consumer: OpenFeign `@RequestLine("METHOD /path")` parsing ─────── +// OpenFeign's native annotation pairs an HTTP method and path in a single +// string literal — see https://github.com/OpenFeign/feign#interface-annotations. +// It is method-level only and is mutually exclusive with Spring MVC +// `@GetMapping` / `@PostMapping` etc. on the same method (mixing them +// requires a different Feign Contract — they are not combined). The match +// itself comes from `JAVA_ROUTE_ANNOTATION_PATTERNS`; this regex splits the +// verb from the path of the captured literal. +// +// Examples: +// @RequestLine("GET /users/{id}") +// @RequestLine("POST /users?status=active") +const REQUEST_LINE_VERB_RE = /^\s*(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)\s+(\S.*?)\s*$/i; -// ─── Provider: Spring @(Get|Post|...)Mapping method annotations ─────── -// Same dual-pattern approach: positional vs named argument. The named -// pattern restricts the annotation member name to `path`/`value` to -// avoid capturing unrelated string-valued attributes -// (`produces`, `consumes`, `headers`, `name`, `params`, ...). -const SPRING_METHOD_ROUTE_PATTERNS = compilePatterns({ - name: 'java-spring-method-route', - language: Java, - patterns: [ - { - meta: {}, - query: ` - (method_declaration - (modifiers - (annotation - name: (identifier) @ann (#match? @ann "^(Get|Post|Put|Delete|Patch)Mapping$") - arguments: (annotation_argument_list (string_literal) @path))) - name: (identifier) @method_name) @method - `, - }, - { - meta: {}, - query: ` - (method_declaration - (modifiers - (annotation - name: (identifier) @ann (#match? @ann "^(Get|Post|Put|Delete|Patch)Mapping$") - arguments: (annotation_argument_list - (element_value_pair - key: (identifier) @key (#match? @key "^(path|value)$") - value: (string_literal) @path)))) - name: (identifier) @method_name) @method - `, - }, - ], -} satisfies LanguagePatterns>); +/** + * Parse a Feign `@RequestLine` value into a method + path pair. + * + * `@RequestLine("METHOD /path[?query]")` packs both fields in one string; + * the query portion is dropped because contract IDs are method+path only + * (consistent with how other consumers like RestTemplate/WebClient drop + * query strings when their values are inline literals). + * + * Returns null if the value is not a recognized HTTP verb followed by a + * path beginning with `/`. + */ +function parseRequestLine(raw: string): { method: string; path: string } | null { + const match = REQUEST_LINE_VERB_RE.exec(raw); + if (!match) return null; + const [, verb, rest] = match; + if (typeof verb !== 'string' || typeof rest !== 'string') return null; + const queryIdx = rest.indexOf('?'); + const pathOnly = (queryIdx >= 0 ? rest.slice(0, queryIdx) : rest).trim(); + if (!pathOnly.startsWith('/')) return null; + return { method: verb.toUpperCase(), path: pathOnly }; +} // ─── Consumer: Spring RestTemplate (object-named + method-named) ────── // RestTemplate.getForObject / getForEntity → GET @@ -433,34 +419,132 @@ function hasAnnotation(node: Parser.SyntaxNode, names: string | readonly string[ return false; } -function collectTypePrefixes(tree: Parser.Tree): Map { - const prefixByTypeId = new Map(); - for (const match of runCompiledPatterns(SPRING_TYPE_PREFIX_PATTERNS, tree)) { - const prefixNode = match.captures.prefix; - const typeNode = match.captures.type; - if (!prefixNode || !typeNode) continue; - const prefix = unquoteLiteral(prefixNode.text); - if (prefix !== null) prefixByTypeId.set(typeNode.id, prefix); - } - return prefixByTypeId; +/** + * A named annotation argument contributes a route only when its member key is + * `path` or `value`; a positional argument (no key node) always qualifies. + * This is the JS-side replacement for the in-query `^(path|value)$` filter and + * drops Spring's non-route string attributes (`produces`, `consumes`, + * `headers`, `name`, `params`) that would otherwise be mis-read as routes. + */ +function isRouteMemberKey(keyNode: Parser.SyntaxNode | undefined): boolean { + if (!keyNode) return true; + return keyNode.text === 'path' || keyNode.text === 'value'; } -function collectMethodRoutes(tree: Parser.Tree): Map { - const routesByMethodId = new Map(); - for (const match of runCompiledPatterns(SPRING_METHOD_ROUTE_PATTERNS, tree)) { - const annNode = match.captures.ann; - const pathNode = match.captures.path; - const methodNode = match.captures.method; - if (!annNode || !pathNode || !methodNode) continue; - const httpMethod = METHOD_ANNOTATION_TO_HTTP[annNode.text]; - if (!httpMethod) continue; - const rawPath = unquoteLiteral(pathNode.text); - if (rawPath === null) continue; - const routes = routesByMethodId.get(methodNode.id) ?? []; - routes.push({ method: httpMethod, path: rawPath }); - routesByMethodId.set(methodNode.id, routes); +interface MethodRouteAnnotation { + methodNode: Parser.SyntaxNode; + methodName: string | null; + httpMethod: string; + rawPath: string; +} + +interface RequestLineAnnotation { + methodNode: Parser.SyntaxNode; + methodName: string | null; + parsed: { method: string; path: string }; +} + +interface RouteAnnotationScan { + /** Spring `@RequestMapping` URL prefix per class/interface node id (last write wins). */ + prefixByTypeId: Map; + /** OpenFeign interface prefix per interface node id; `@FeignClient(path)` wins over `@RequestMapping`. */ + feignPrefixByInterfaceId: Map; + /** One entry per resolved Spring `@(Get|...)Mapping` route — a method with N mappings yields N entries. */ + methodRoutes: MethodRouteAnnotation[]; + /** One entry per OpenFeign `@RequestLine` whose value parses to a verb + path. */ + requestLines: RequestLineAnnotation[]; +} + +/** + * Resolve every Java route-defining annotation in a single tree-sitter pass. + * + * The generic `JAVA_ROUTE_ANNOTATION_PATTERNS` query yields one match per + * annotation-carrying-a-string-argument on any class / interface / method. This + * loop reads the annotation name and declaration kind to decide what each match + * means, ignoring annotations it does not recognise. The HTTP verb map + * (`METHOD_ANNOTATION_TO_HTTP`) and the `path`/`value` key filter + * (`isRouteMemberKey`) live here rather than in the query (see its header). + */ +function scanRouteAnnotations(tree: Parser.Tree): RouteAnnotationScan { + const matches = runCompiledPatterns(JAVA_ROUTE_ANNOTATION_PATTERNS, tree); + + // The two prefix maps intentionally diverge for the same interface node: + // `prefixByTypeId` feeds the Spring *provider* path (class prefix + + // collectSpringTypes cross-file inheritance), while `feignPrefixByInterfaceId` + // feeds the OpenFeign *consumer* path in scan(). An interface carrying both + // `@RequestMapping` and `@FeignClient(path)` lands a different value in each. + const prefixByTypeId = new Map(); + const feignPrefixByInterfaceId = new Map(); + const methodRoutes: MethodRouteAnnotation[] = []; + const requestLines: RequestLineAnnotation[] = []; + // Interface `@RequestMapping` prefixes rank below `@FeignClient(path)`; + // collect them and apply only after the FeignClient pass below. + const interfaceRequestMappingPrefixes: Array<{ id: number; prefix: string }> = []; + + for (const { captures } of matches) { + const annNode = captures.ann; + const node = captures.node; + const valueNode = captures.value; + if (!annNode || !node || !valueNode) continue; + const ann = annNode.text; + const keyNode = captures.key; // undefined for the positional shape + + if (node.type === 'method_declaration') { + // Method-level: a Spring `@(Get|...)Mapping` route, or native `@RequestLine`. + const httpMethod = METHOD_ANNOTATION_TO_HTTP[ann]; + if (httpMethod) { + if (!isRouteMemberKey(keyNode)) continue; + const rawPath = unquoteLiteral(valueNode.text); + if (rawPath !== null) { + methodRoutes.push({ + methodNode: node, + methodName: captures.member?.text ?? null, + httpMethod, + rawPath, + }); + } + } else if (ann === 'RequestLine') { + // Feign packs verb + path in one literal; its only named argument is `value`. + if (keyNode && keyNode.text !== 'value') continue; + const raw = unquoteLiteral(valueNode.text); + const parsed = raw !== null ? parseRequestLine(raw) : null; + if (parsed) { + requestLines.push({ + methodNode: node, + methodName: captures.member?.text ?? null, + parsed, + }); + } + } + continue; + } + + // Type-level (class or interface): a Spring `@RequestMapping` URL prefix, or + // — on an interface — an OpenFeign `@FeignClient(path = "...")` prefix. + if (ann === 'RequestMapping') { + if (!isRouteMemberKey(keyNode)) continue; + const prefix = unquoteLiteral(valueNode.text); + if (prefix !== null) { + prefixByTypeId.set(node.id, prefix); + if (node.type === 'interface_declaration') { + interfaceRequestMappingPrefixes.push({ id: node.id, prefix }); + } + } + } else if (ann === 'FeignClient' && node.type === 'interface_declaration') { + // Feign's `name`/`value` identify a service, not a path — only `path` is a prefix. + if (!keyNode || keyNode.text !== 'path') continue; + const prefix = unquoteLiteral(valueNode.text); + if (prefix !== null && !feignPrefixByInterfaceId.has(node.id)) { + feignPrefixByInterfaceId.set(node.id, prefix); + } + } } - return routesByMethodId; + + for (const { id, prefix } of interfaceRequestMappingPrefixes) { + if (!feignPrefixByInterfaceId.has(id)) feignPrefixByInterfaceId.set(id, prefix); + } + + return { prefixByTypeId, feignPrefixByInterfaceId, methodRoutes, requestLines }; } function collectDirectMethods(typeNode: Parser.SyntaxNode): Parser.SyntaxNode[] { @@ -500,8 +584,13 @@ function collectImplementedInterfaces(typeNode: Parser.SyntaxNode): string[] { } function collectSpringTypes(filePath: string, tree: Parser.Tree): SpringTypeInfo[] { - const prefixByTypeId = collectTypePrefixes(tree); - const routesByMethodId = collectMethodRoutes(tree); + const { prefixByTypeId, methodRoutes } = scanRouteAnnotations(tree); + const routesByMethodId = new Map(); + for (const route of methodRoutes) { + const routes = routesByMethodId.get(route.methodNode.id) ?? []; + routes.push({ method: route.httpMethod, path: route.rawPath }); + routesByMethodId.set(route.methodNode.id, routes); + } const out: SpringTypeInfo[] = []; for (const match of runCompiledPatterns(SPRING_TYPE_DECLARATION_PATTERNS, tree)) { @@ -593,57 +682,69 @@ export const JAVA_HTTP_PLUGIN: HttpLanguagePlugin = { scan(tree) { const out: HttpDetection[] = []; - // ─── Providers: Spring class prefix + method annotations ──────── - const prefixByTypeId = collectTypePrefixes(tree); + // ─── Spring providers + OpenFeign consumers (one query pass) ──── + // `scanRouteAnnotations` resolves every route-defining annotation — + // class/interface prefixes, method `@(Get|...)Mapping`s and native + // `@RequestLine`s — from a single `matches()` pass over the tree. + const { prefixByTypeId, feignPrefixByInterfaceId, methodRoutes, requestLines } = + scanRouteAnnotations(tree); - const feignPrefixByInterfaceId = new Map(); - for (const match of runCompiledPatterns(FEIGN_INTERFACE_PREFIX_PATTERNS, tree)) { - const prefixNode = match.captures.prefix; - const interfaceNode = match.captures.interface; - if (!prefixNode || !interfaceNode) continue; - const prefix = unquoteLiteral(prefixNode.text); - if (prefix !== null && !feignPrefixByInterfaceId.has(interfaceNode.id)) - feignPrefixByInterfaceId.set(interfaceNode.id, prefix); - } - - for (const match of runCompiledPatterns(SPRING_METHOD_ROUTE_PATTERNS, tree)) { - const annNode = match.captures.ann; - const pathNode = match.captures.path; - const nameNode = match.captures.method_name; - const methodNode = match.captures.method; - if (!annNode || !pathNode || !methodNode) continue; - const httpMethod = METHOD_ANNOTATION_TO_HTTP[annNode.text]; - if (!httpMethod) continue; - const rawPath = unquoteLiteral(pathNode.text); - if (rawPath === null) continue; - const enclosingInterface = findEnclosingInterface(methodNode); + // A `@(Get|...)Mapping` inside a `@FeignClient` interface is an OpenFeign + // *consumer* (it describes a remote call); the same annotation inside a + // class is a Spring *provider*. A mapping on a non-Feign interface has no + // enclosing class and is dropped here — interface→controller inheritance is + // handled by `scanProject`. + for (const route of methodRoutes) { + const enclosingInterface = findEnclosingInterface(route.methodNode); if (enclosingInterface && hasAnnotation(enclosingInterface, 'FeignClient')) { const prefix = feignPrefixByInterfaceId.get(enclosingInterface.id) ?? ''; - const fullPath = joinPath(prefix, rawPath); out.push({ role: 'consumer', framework: 'openfeign', - method: httpMethod, - path: fullPath, - name: nameNode?.text ?? null, + method: route.httpMethod, + path: joinPath(prefix, route.rawPath), + name: route.methodName, confidence: 0.7, }); continue; } - const enclosingClass = findEnclosingClass(methodNode); + const enclosingClass = findEnclosingClass(route.methodNode); if (!enclosingClass) continue; const prefix = prefixByTypeId.get(enclosingClass.id) ?? ''; - const fullPath = joinPath(prefix, rawPath); out.push({ role: 'provider', framework: 'spring', - method: httpMethod, - path: fullPath, - name: nameNode?.text ?? null, + method: route.httpMethod, + path: joinPath(prefix, route.rawPath), + name: route.methodName, confidence: 0.8, }); } + // Native OpenFeign `@RequestLine("METHOD /path")`. Method-level only and + // always declared on an interface (Feign builds a proxy from the interface). + // We do NOT require an enclosing `@FeignClient`: `@RequestLine` is a core + // `feign.*` annotation used with `Feign.builder()`, whereas `@FeignClient` + // is the Spring Cloud variant that uses Spring MVC annotations instead — the + // two are effectively mutually exclusive, so requiring `@FeignClient` here + // would miss the annotation's primary use. The `RequestLine` name is itself + // a strong, framework-specific signal, so a structural interface check is + // enough to keep false positives away. A `@FeignClient(path=...)` prefix is + // still applied when present (rare, but harmless). + for (const requestLine of requestLines) { + const enclosingInterface = findEnclosingInterface(requestLine.methodNode); + if (!enclosingInterface) continue; + const prefix = feignPrefixByInterfaceId.get(enclosingInterface.id) ?? ''; + out.push({ + role: 'consumer', + framework: 'openfeign', + method: requestLine.parsed.method, + path: joinPath(prefix, requestLine.parsed.path), + name: requestLine.methodName, + confidence: 0.75, + }); + } + // ─── Consumers: RestTemplate ──────────────────────────────────── for (const match of runCompiledPatterns(REST_TEMPLATE_PATTERNS, tree)) { const methodNode = match.captures.method; diff --git a/gitnexus/src/core/group/extractors/http-patterns/python.ts b/gitnexus/src/core/group/extractors/http-patterns/python.ts index 5dc314d35..d467cdb34 100644 --- a/gitnexus/src/core/group/extractors/http-patterns/python.ts +++ b/gitnexus/src/core/group/extractors/http-patterns/python.ts @@ -622,8 +622,9 @@ interface PythonRepoContext { /** Strip `.py` and return the bare basename (e.g. `api/users.py` → `users`). */ function fileShortKey(rel: string): string { - const slash = rel.lastIndexOf('/'); - const file = slash >= 0 ? rel.slice(slash + 1) : rel; + const normalized = rel.replace(/\\/g, '/'); + const slash = normalized.lastIndexOf('/'); + const file = slash >= 0 ? normalized.slice(slash + 1) : normalized; return file.endsWith('.py') ? file.slice(0, -3) : file; } @@ -633,7 +634,8 @@ function fileShortKey(rel: string): string { * case callers should fall back to the short key. */ function fileLongKey(rel: string): string { - const noExt = rel.endsWith('.py') ? rel.slice(0, -3) : rel; + const normalized = rel.replace(/\\/g, '/'); + const noExt = normalized.endsWith('.py') ? normalized.slice(0, -3) : normalized; const lastSlash = noExt.lastIndexOf('/'); if (lastSlash < 0) return ''; const beforeLast = noExt.slice(0, lastSlash); diff --git a/gitnexus/src/core/ingestion/csharp-namespace-gate.ts b/gitnexus/src/core/ingestion/csharp-namespace-gate.ts new file mode 100644 index 000000000..df806bf2b --- /dev/null +++ b/gitnexus/src/core/ingestion/csharp-namespace-gate.ts @@ -0,0 +1,154 @@ +/** + * Pure predicates gating C# `using` suffix-fallback resolution so BCL usings + * (e.g. `System.Threading.Tasks`) can't match a coincidentally-named local + * file (#1881). + * + * Lives in the shared `ingestion/` layer — NOT under `languages/csharp/` — so + * BOTH the registry-primary scope resolver (`languages/csharp/import-target.ts`) + * and the legacy DAG resolver (`import-resolvers/csharp.ts`) can import it + * without an `import-resolvers/ -> languages/` dependency inversion (#5). + */ + +import type { CSharpNamespaceEvidence } from './language-config.js'; + +/** + * Top-level namespace segments that clearly belong to the BCL / runtime / a + * ubiquitous third-party package — i.e. roots a normal repo does NOT declare. + * These stay gated even when the namespace scan is truncated, so a single + * unreadable file / capped subtree can't silently re-enable BCL→local suffix + * matches repo-wide (#1881). A repo that legitimately declares one of these + * roots is still allowed via the alignment escape hatch below. + */ +const CSHARP_EXTERNAL_ROOTS: ReadonlySet = new Set([ + // .NET BCL / runtime + 'System', + 'Microsoft', + 'Windows', + 'Mono', + // ubiquitous third-party NuGet roots + 'Newtonsoft', + 'Serilog', + 'AutoMapper', + 'MediatR', + 'Polly', + 'FluentValidation', + 'Grpc', + 'Google', + 'Azure', + 'Amazon', + 'AWSSDK', + // common test frameworks + 'Xunit', + 'NUnit', + 'Moq', + 'FluentAssertions', + 'NSubstitute', + 'Shouldly', +]); + +/** Whether `targetRaw`'s top-level segment is a clearly-external root. */ +function isExternalRoot(targetRaw: string): boolean { + const dot = targetRaw.indexOf('.'); + const top = dot === -1 ? targetRaw : targetRaw.slice(0, dot); + return CSHARP_EXTERNAL_ROOTS.has(top); +} + +/** + * Whether the unanchored suffix fallback may run for `targetRaw`. + * + * Fails OPEN when the namespace scan was truncated (large repos must not + * silently lose legitimate edges, #1881 #11) and when no evidence was + * threaded at all (preserves legacy permissive behavior). The truncation + * fail-open is carved out for clearly-external roots (BCL / well-known + * packages) that the repo does not declare, so one incomplete scan can't + * re-open the #1881 hole repo-wide. Otherwise defers to + * {@link importAlignsWithDeclaredNamespaces}. + */ +export function csharpSuffixFallbackAllowed( + targetRaw: string, + evidence: CSharpNamespaceEvidence | undefined, +): boolean { + if (evidence === undefined) return true; + if (evidence.truncated) { + // Keep clearly-external roots blocked through truncation UNLESS the repo + // actually declares an aligning namespace (the alignment check is the + // escape hatch — a repo that declares `namespace System;` still resolves). + if ( + isExternalRoot(targetRaw) && + !importAlignsWithDeclaredNamespaces( + targetRaw, + evidence.declaredNamespaces, + evidence.rootNamespaces, + ) + ) { + return false; + } + return true; + } + return importAlignsWithDeclaredNamespaces( + targetRaw, + evidence.declaredNamespaces, + evidence.rootNamespaces, + ); +} + +/** True when `targetRaw` plausibly refers to a namespace declared in-repo. */ +export function importAlignsWithDeclaredNamespaces( + targetRaw: string, + declaredNamespaces: ReadonlySet | undefined, + rootNamespaces?: ReadonlySet, +): boolean { + if (declaredNamespaces === undefined || declaredNamespaces.size === 0) return false; + + // Exact: the import IS a declared in-repo namespace. + if (declaredNamespaces.has(targetRaw)) return true; + + // Child-of: the import's IMMEDIATE parent namespace is declared in-repo. + // Anchoring on the direct parent — not "any declared prefix" — is what stops + // a declared BCL prefix from green-lighting an unrelated BCL using: a repo + // that declares `namespace System;` must NOT make `using + // System.Threading.Tasks;` resolve to a coincidental local `Tasks.cs`, + // because the import's parent `System.Threading` is not itself declared + // (#1881). The case this still allows is a type / `using static` import under + // a declared namespace laid out without its full path on disk, e.g. + // `using static MyApp.Utils.Logger;` when `MyApp.Utils` is declared. + const lastDot = targetRaw.lastIndexOf('.'); + if (lastDot > 0 && declaredNamespaces.has(targetRaw.slice(0, lastDot))) return true; + + // Ancestor-of: the import is a strict prefix of some declared namespace + // (e.g. `using MyApp;` when `MyApp.Models` is declared). Only honored when + // the import also sits at or above an in-repo root namespace, so a BCL prefix + // can't qualify merely because a file declares something deeper under it + // (e.g. `System.Threading.Tasks.Extensions`) (#1881). + const childPrefix = targetRaw + '.'; + for (const ns of declaredNamespaces) { + if (ns.startsWith(childPrefix)) { + return isAtOrAboveInRepoRoot(targetRaw, declaredNamespaces, rootNamespaces); + } + } + return false; +} + +function isAtOrAboveInRepoRoot( + targetRaw: string, + declaredNamespaces: ReadonlySet, + rootNamespaces: ReadonlySet | undefined, +): boolean { + const descendantPrefix = targetRaw + '.'; + if (rootNamespaces !== undefined && rootNamespaces.size > 0) { + for (const root of rootNamespaces) { + // targetRaw equals a root, or is an ancestor of one (e.g. `using MyApp;` + // for csproj RootNamespace `MyApp.Core`). + if (root === targetRaw || root.startsWith(descendantPrefix)) return true; + } + return false; + } + // No explicit roots (e.g. no csproj): treat the top-level segment of each + // declared namespace as the implied root. + for (const ns of declaredNamespaces) { + const dot = ns.indexOf('.'); + const top = dot === -1 ? ns : ns.slice(0, dot); + if (top === targetRaw) return true; + } + return false; +} diff --git a/gitnexus/src/core/ingestion/import-resolvers/configs/csharp.ts b/gitnexus/src/core/ingestion/import-resolvers/configs/csharp.ts index cb5f77145..d99dbd35b 100644 --- a/gitnexus/src/core/ingestion/import-resolvers/configs/csharp.ts +++ b/gitnexus/src/core/ingestion/import-resolvers/configs/csharp.ts @@ -7,27 +7,45 @@ import { SupportedLanguages } from 'gitnexus-shared'; import type { ImportResolutionConfig, ImportResolverStrategy } from '../types.js'; import { createStandardStrategy } from '../standard.js'; import { resolveCSharpImportInternal, resolveCSharpNamespaceDir } from '../csharp.js'; +import { csharpSuffixFallbackAllowed } from '../../csharp-namespace-gate.js'; /** C# namespace-based resolution strategy via .csproj configs. */ export const csharpNamespaceStrategy: ImportResolverStrategy = (rawImportPath, _filePath, ctx) => { const csharpConfigs = ctx.configs.csharpConfigs; - if (csharpConfigs.length > 0) { - const resolvedFiles = resolveCSharpImportInternal( - rawImportPath, - csharpConfigs, - ctx.normalizedFileList, - ctx.allFileList, - ctx.index, - ); - if (resolvedFiles.length > 1) { - const dirSuffix = resolveCSharpNamespaceDir(rawImportPath, csharpConfigs); - if (dirSuffix) { - return { kind: 'package', files: resolvedFiles, dirSuffix }; - } + const evidence = ctx.configs.csharpNamespaces; + if (csharpConfigs.length === 0) { + // No csproj → there's no namespace→directory mapping to apply, so the + // generic strategy would normally take over. But that generic suffix match + // is UNGATED: it re-introduces the BCL→local spurious match the #1881 gate + // exists to stop. Mirror the registry leg's no-csproj path — defer to the + // generic strategy ONLY for imports that align with an in-repo declared + // namespace; for everything else (BCL usings) return an authoritative empty + // result that STOPS the chain (#2 parity). With no evidence threaded the + // gate fails open, so behavior is unchanged when the scan didn't run. + if (!csharpSuffixFallbackAllowed(rawImportPath, evidence)) { + return { kind: 'files', files: [] }; } - if (resolvedFiles.length > 0) return { kind: 'files', files: resolvedFiles }; + return null; } - return null; + + const resolvedFiles = resolveCSharpImportInternal( + rawImportPath, + csharpConfigs, + ctx.normalizedFileList, + ctx.allFileList, + ctx.index, + evidence, + ); + if (resolvedFiles.length > 1) { + const dirSuffix = resolveCSharpNamespaceDir(rawImportPath, csharpConfigs); + if (dirSuffix) { + return { kind: 'package', files: resolvedFiles, dirSuffix }; + } + } + // Authoritative once csproj configs exist: return even an empty result to + // STOP the chain, so the generic suffix fallback can't re-introduce the + // gated BCL→local match this resolver just suppressed (#1881). + return { kind: 'files', files: resolvedFiles }; }; export const csharpImportConfig: ImportResolutionConfig = { diff --git a/gitnexus/src/core/ingestion/import-resolvers/csharp.ts b/gitnexus/src/core/ingestion/import-resolvers/csharp.ts index 79548d7f6..2ce21274f 100644 --- a/gitnexus/src/core/ingestion/import-resolvers/csharp.ts +++ b/gitnexus/src/core/ingestion/import-resolvers/csharp.ts @@ -7,11 +7,16 @@ import type { SuffixIndex } from './utils.js'; import { suffixResolve } from './utils.js'; -import type { CSharpProjectConfig } from '../language-config.js'; +import type { CSharpProjectConfig, CSharpNamespaceEvidence } from '../language-config.js'; +import { csharpSuffixFallbackAllowed } from '../csharp-namespace-gate.js'; /** * Resolve a C# using-directive import path to matching .cs files (low-level helper). * Tries single-file match first, then directory match for namespace imports. + * + * The final unanchored suffix fallback is gated on `evidence` so BCL usings + * (e.g. `System.Threading.Tasks`) can't match a coincidentally-named local + * file (#1881). When `evidence` is omitted the fallback stays permissive. */ export function resolveCSharpImportInternal( importPath: string, @@ -19,6 +24,7 @@ export function resolveCSharpImportInternal( normalizedFileList: string[], allFileList: string[], index?: SuffixIndex, + evidence?: CSharpNamespaceEvidence, ): string[] { const namespacePath = importPath.replace(/\./g, '/'); const results: string[] = []; @@ -86,7 +92,11 @@ export function resolveCSharpImportInternal( } } - // Fallback: suffix matching without namespace stripping (single file) + // Fallback: suffix matching without namespace stripping (single file). + // Gated on in-repo declared-namespace evidence (#1881). + if (!csharpSuffixFallbackAllowed(importPath, evidence)) { + return []; + } const pathParts = namespacePath.split('/').filter(Boolean); const fallback = suffixResolve(pathParts, normalizedFileList, allFileList, index); return fallback ? [fallback] : []; diff --git a/gitnexus/src/core/ingestion/import-resolvers/python.ts b/gitnexus/src/core/ingestion/import-resolvers/python.ts index bc1f4fc23..2de11cc55 100644 --- a/gitnexus/src/core/ingestion/import-resolvers/python.ts +++ b/gitnexus/src/core/ingestion/import-resolvers/python.ts @@ -25,7 +25,7 @@ import { tryResolveWithExtensions } from './utils.js'; export function resolvePythonImportInternal( currentFile: string, importPath: string, - allFiles: Set, + allFiles: ReadonlySet, ): string | null { // Relative import — PEP 328 (https://peps.python.org/pep-0328/) if (importPath.startsWith('.')) { diff --git a/gitnexus/src/core/ingestion/import-resolvers/types.ts b/gitnexus/src/core/ingestion/import-resolvers/types.ts index 66e23a79b..864206fc3 100644 --- a/gitnexus/src/core/ingestion/import-resolvers/types.ts +++ b/gitnexus/src/core/ingestion/import-resolvers/types.ts @@ -8,6 +8,7 @@ import type { TsconfigPaths, GoModuleConfig, CSharpProjectConfig, + CSharpNamespaceEvidence, ComposerConfig, } from '../language-config.js'; import type { SwiftPackageConfig } from '../language-config.js'; @@ -32,6 +33,8 @@ export interface ImportConfigs { composerConfig: ComposerConfig | null; swiftPackageConfig: SwiftPackageConfig | null; csharpConfigs: CSharpProjectConfig[]; + /** In-repo namespace evidence gating C# suffix-fallback resolution (#1881). */ + csharpNamespaces?: CSharpNamespaceEvidence; } /** Pre-built lookup structures for import resolution. Build once, reuse across chunks. */ diff --git a/gitnexus/src/core/ingestion/import-resolvers/utils.ts b/gitnexus/src/core/ingestion/import-resolvers/utils.ts index c4d36556c..f92ca0b32 100644 --- a/gitnexus/src/core/ingestion/import-resolvers/utils.ts +++ b/gitnexus/src/core/ingestion/import-resolvers/utils.ts @@ -57,7 +57,10 @@ 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: Set): string | null { +export function tryResolveWithExtensions( + basePath: string, + allFiles: ReadonlySet, +): string | null { for (const ext of EXTENSIONS) { const candidate = basePath + ext; if (allFiles.has(candidate)) return candidate; diff --git a/gitnexus/src/core/ingestion/language-config.ts b/gitnexus/src/core/ingestion/language-config.ts index f51ef57c6..16ad25e43 100644 --- a/gitnexus/src/core/ingestion/language-config.ts +++ b/gitnexus/src/core/ingestion/language-config.ts @@ -1,6 +1,9 @@ import fs from 'fs/promises'; +import { createReadStream } from 'fs'; +import { createInterface } from 'readline'; import path from 'path'; import type { ImportConfigs } from './import-resolvers/types.js'; +import type { CsharpStructureLineScanner } from './languages/csharp/namespace-siblings.js'; import { isDev } from './utils/env.js'; @@ -40,6 +43,44 @@ export interface CSharpProjectConfig { projectDir: string; } +/** + * Declared-namespace evidence used to gate C# suffix-fallback resolution so + * BCL usings (e.g. `System.Threading.Tasks`) can't match a coincidentally- + * named local file (#1881). + */ +export interface CSharpNamespaceEvidence { + /** Every `namespace X.Y` declared in-repo (scan may be capped — see `truncated`). */ + readonly declaredNamespaces?: ReadonlySet; + /** csproj RootNamespace values plus the top-level segment of each declared + * namespace — the anchor set for the parent-namespace gate direction. */ + readonly rootNamespaces?: ReadonlySet; + /** True when the BFS hit its dir/depth cap, so the namespace set may be + * incomplete; the gate fails open (allows) in that case. */ + readonly truncated?: boolean; +} + +/** Result of a single BFS over a repo collecting both csproj configs and + * declared `.cs` namespaces (one disk traversal — see `scanCSharpProject`). */ +export interface CSharpProjectScan { + readonly configs: CSharpProjectConfig[]; + readonly declaredNamespaces: ReadonlySet; + readonly rootNamespaces: ReadonlySet; + readonly truncated: boolean; +} + +/** Project the one-pass {@link CSharpProjectScan} into the + * {@link CSharpNamespaceEvidence} both import-resolution legs thread to the + * #1881 gate — one shape, two carriers (`ImportConfigs.csharpNamespaces` for + * the legacy DAG, `CsharpResolutionConfig.namespaces` for the scope resolver). + * Keeps the field mapping in one place so the two carriers can't drift. */ +export function csharpScanToEvidence(scan: CSharpProjectScan): CSharpNamespaceEvidence { + return { + declaredNamespaces: scan.declaredNamespaces, + rootNamespaces: scan.rootNamespaces, + truncated: scan.truncated, + }; +} + /** Swift Package Manager module config */ export interface SwiftPackageConfig { /** Map of target name -> source directory path (e.g., "SiuperModel" -> "Package/Sources/SiuperModel") */ @@ -141,58 +182,258 @@ export async function loadComposerConfig(repoRoot: string): Promise { - const configs: CSharpProjectConfig[] = []; - // BFS scan for .csproj files up to 5 levels deep, cap at 100 dirs to avoid runaway scanning - const scanQueue: { dir: string; depth: number }[] = [{ dir: repoRoot, depth: 0 }]; - const maxDepth = 5; - const maxDirs = 100; - let dirsScanned = 0; +// BFS bounds shared by the C# project/namespace scan. Sized to comfortably +// exceed normal C# repos so `truncated` stays the rare exception it was meant +// to be: a too-low cap trips `truncated=true` on ordinary repos, which makes +// `csharpSuffixFallbackAllowed` fail OPEN for every import and silently +// disables the #1881 gate. Truncation remains the safety valve for genuinely +// pathological trees (deep generated output, huge monorepos). +const CSHARP_SCAN_MAX_DEPTH = 24; +const CSHARP_SCAN_MAX_DIRS = 20000; +// Bound on in-flight file reads per directory so a directory with thousands of +// `.cs` files can't exhaust file descriptors / spike memory. Mirrors the +// Phase-1 walker's `READ_CONCURRENCY` (see `filesystem-walker.ts`). +const CSHARP_SCAN_READ_CONCURRENCY = 32; +const CSHARP_SCAN_SKIP_DIRS = new Set(['node_modules', '.git', 'bin', 'obj']); +const CSHARP_ROOT_NAMESPACE_RE = /\s*([^<]+)\s*<\/RootNamespace>/; - while (scanQueue.length > 0 && dirsScanned < maxDirs) { +// Declared `namespace` names are extracted with the comment/string-aware +// scanner shared with the scope-resolution namespace-siblings pass +// (`extractCsharpStructureViaScanner`), not a bare regex: a regex matches +// `namespace` inside comments and string literals, seeding the #1881 gate +// with phantom namespaces. Imported lazily (and memoized) so the always-on +// `loadImportConfigs` path — every repo, every language — doesn't eagerly +// pull tree-sitter-c-sharp in via `namespace-siblings.ts` → `query.ts`. +let csharpScannerFactoryPromise: Promise<() => CsharpStructureLineScanner> | undefined; +function getCsharpStructureScannerFactory(): Promise<() => CsharpStructureLineScanner> { + if (csharpScannerFactoryPromise === undefined) { + csharpScannerFactoryPromise = import('./languages/csharp/namespace-siblings.js').then( + (mod) => mod.createCsharpStructureScanner, + ); + } + return csharpScannerFactoryPromise; +} + +/** + * Single BFS over a repo that collects BOTH .csproj configs and the set of + * `namespace` declarations from `.cs` files. + * + * The csproj walk is cheap (a handful of project files); the namespace scan + * is NOT — it opens and reads every `.cs` file in the repo to collect its + * `namespace` declarations. That `.cs` read cost is the price of the #1881 + * gate, not a saving: collapsing the csproj and namespace walks into one BFS + * avoids a second directory traversal, but the per-file `.cs` reads are new + * work this scan introduces. Reads within a directory are issued in bounded + * windows (see below); directories are still visited breadth-first. + */ +export async function scanCSharpProject(repoRoot: string): Promise { + const configs: CSharpProjectConfig[] = []; + const declaredNamespaces = new Set(); + const rootNamespaces = new Set(); + const scanQueue: { dir: string; depth: number }[] = [{ dir: repoRoot, depth: 0 }]; + let dirsScanned = 0; + let truncated = false; + + while (scanQueue.length > 0) { + if (dirsScanned >= CSHARP_SCAN_MAX_DIRS) { + truncated = true; + break; + } const { dir, depth } = scanQueue.shift()!; dirsScanned++; + let entries: import('fs').Dirent[]; try { - const entries = await fs.readdir(dir, { withFileTypes: true }); - for (const entry of entries) { - if (entry.isDirectory() && depth < maxDepth) { - // Skip common non-project directories - if ( - entry.name === 'node_modules' || - entry.name === '.git' || - entry.name === 'bin' || - entry.name === 'obj' - ) - continue; + entries = await fs.readdir(dir, { withFileTypes: true }); + } catch { + // Unreadable directory → its `.cs` namespaces are missed, so the scan is + // incomplete. Mark truncated so the #1881 gate fails OPEN (allows the + // suffix fallback) rather than wrongly blocking an import whose declaring + // namespace lived in the unread subtree (#5). + truncated = true; + continue; + } + // Collect read targets, then issue them in bounded windows (rather than all + // at once) so a directory with thousands of `.cs` files can't exhaust file + // descriptors / spike memory. csproj reads keep entry order (config + // precedence matters); `.cs` namespace results land in shared Sets where + // order is irrelevant. + const csprojNames: string[] = []; + const csNames: string[] = []; + for (const entry of entries) { + if (entry.isDirectory()) { + if (CSHARP_SCAN_SKIP_DIRS.has(entry.name)) continue; + if (depth < CSHARP_SCAN_MAX_DEPTH) { scanQueue.push({ dir: path.join(dir, entry.name), depth: depth + 1 }); + } else { + truncated = true; // a real subtree was pruned at the depth cap } - if (entry.isFile() && entry.name.endsWith('.csproj')) { - try { - const csprojPath = path.join(dir, entry.name); - const content = await fs.readFile(csprojPath, 'utf-8'); - const nsMatch = content.match(/\s*([^<]+)\s*<\/RootNamespace>/); - const rootNamespace = nsMatch ? nsMatch[1].trim() : entry.name.replace(/\.csproj$/, ''); - const projectDir = path.relative(repoRoot, dir).replace(/\\/g, '/'); - configs.push({ rootNamespace, projectDir }); - if (isDev) { - logger.info( - `📦 Loaded C# project: ${entry.name} (namespace: ${rootNamespace}, dir: ${projectDir})`, - ); - } - } catch { - // Can't read .csproj - } + continue; + } + if (!entry.isFile()) continue; + if (entry.name.endsWith('.csproj')) { + csprojNames.push(entry.name); + } else if (entry.name.endsWith('.cs')) { + csNames.push(entry.name); + } + } + for (let i = 0; i < csprojNames.length; i += CSHARP_SCAN_READ_CONCURRENCY) { + const batch = csprojNames.slice(i, i + CSHARP_SCAN_READ_CONCURRENCY); + const settled = await Promise.allSettled( + batch.map((name) => readCsprojConfig(path.join(dir, name), name, repoRoot, dir)), + ); + for (const r of settled) { + const config = r.status === 'fulfilled' ? r.value : null; + if (config) { + configs.push(config); + rootNamespaces.add(config.rootNamespace); } } - } catch { - // Can't read directory + } + for (let i = 0; i < csNames.length; i += CSHARP_SCAN_READ_CONCURRENCY) { + const batch = csNames.slice(i, i + CSHARP_SCAN_READ_CONCURRENCY); + const settled = await Promise.allSettled( + batch.map((name) => + collectDeclaredNamespaces(path.join(dir, name), declaredNamespaces, rootNamespaces), + ), + ); + // A `.cs` that was unreadable (or whose read/scan unexpectedly rejected) + // leaves its namespaces uncollected → mark truncated to fail the #1881 + // gate OPEN rather than wrongly suppress an import. The scan streams each + // file, so file size no longer trips truncation. + for (const r of settled) { + if (r.status !== 'fulfilled' || r.value === 'truncated') truncated = true; + } } } - return configs; + + if (truncated) { + // Surface the fail-open so an incomplete scan (dir/depth cap, or an + // unreadable directory or `.cs` file) silently disabling the #1881 gate + // repo-wide is observable (#4) rather than a mystery edge regression. + logger.warn( + `[csharp] namespace scan of ${repoRoot} truncated (dir cap ${CSHARP_SCAN_MAX_DIRS}, depth cap ${CSHARP_SCAN_MAX_DEPTH}, an unreadable directory, or an unreadable .cs file); the #1881 suffix-fallback gate fails open for unmatched usings`, + ); + } + return { configs, declaredNamespaces, rootNamespaces, truncated }; +} + +// Generous soft budget for locating ``: a real .csproj declares +// it in the first PropertyGroup near the top, so this is only reached by a +// pathological project file with a huge leading ItemGroup and no early +// RootNamespace. On hit we OMIT the config rather than guess a root (Codex F4). +const CSPROJ_ROOT_SCAN_MAX_BYTES = 4 * 1024 * 1024; +// Overlap kept across stream chunks so a `` tag straddling a +// chunk boundary is still matched (the tag + a short namespace value fit well +// within this window). +const CSPROJ_TAG_OVERLAP = 512; + +/** + * Stream a `.csproj` just far enough to find ``, in constant + * memory and without a stat-then-read filesystem race. Returns the namespace + * when found; otherwise `rootNamespace: null` with `capHit` distinguishing a + * genuine read-to-EOF absence (`false`) from "not found within the soft budget" + * (`true`) — so the caller never synthesizes a wrong filename root for a late + * tag (Codex F4). + */ +async function findCsprojRootNamespace( + csprojPath: string, +): Promise<{ rootNamespace: string | null; capHit: boolean }> { + const stream = createReadStream(csprojPath, { encoding: 'utf-8' }); + let window = ''; + let bytesRead = 0; + try { + for await (const chunk of stream) { + const text = chunk as string; + bytesRead += text.length; + window = + (window.length > CSPROJ_TAG_OVERLAP ? window.slice(-CSPROJ_TAG_OVERLAP) : window) + text; + const match = window.match(CSHARP_ROOT_NAMESPACE_RE); + if (match) { + stream.destroy(); + return { rootNamespace: match[1]!.trim(), capHit: false }; + } + if (bytesRead >= CSPROJ_ROOT_SCAN_MAX_BYTES) { + stream.destroy(); + return { rootNamespace: null, capHit: true }; + } + } + } catch { + // Unreadable .csproj: don't guess a filename root either — omit the config. + return { rootNamespace: null, capHit: true }; + } + return { rootNamespace: null, capHit: false }; // read to EOF, tag genuinely absent +} + +async function readCsprojConfig( + csprojPath: string, + fileName: string, + repoRoot: string, + dir: string, +): Promise { + const { rootNamespace: found, capHit } = await findCsprojRootNamespace(csprojPath); + // A late `` we couldn't reach (capHit) or an unreadable file + // must NOT synthesize a filename root — a wrong authoritative root would make + // imports under the real root resolve to nothing and suppress the fallback + // (Codex F4). Omit the config so the no-csproj fallback stays available. Only + // fall back to the filename on a genuine read-to-EOF absence of the tag. + if (capHit) return null; + const rootNamespace = found ?? fileName.replace(/\.csproj$/, ''); + const projectDir = path.relative(repoRoot, dir).replace(/\\/g, '/'); + if (isDev) { + logger.info( + `📦 Loaded C# project: ${fileName} (namespace: ${rootNamespace}, dir: ${projectDir})`, + ); + } + return { rootNamespace, projectDir }; +} + +/** + * Stream one `.cs` file line-by-line and collect its declared `namespace` names + * into the shared Sets. + * + * Streaming (rather than reading the whole file into a string) keeps memory + * constant regardless of file size, so a large generated `.cs` (`*.g.cs`, EF / + * gRPC output) is fully scanned instead of skipped by a per-file size cap — + * which would otherwise trip `truncated` and disable the #1881 gate repo-wide. + * Only the cheap line scan streams here; the tree-sitter PARSE path keeps its + * own size cap. + * + * Returns `'truncated'` when the file could not be read, so the caller marks the + * scan truncated and the #1881 gate fails OPEN rather than wrongly suppress an + * import declared in the unread file. Returns `'ok'` on a complete read. + */ +async function collectDeclaredNamespaces( + filePath: string, + declaredNamespaces: Set, + rootNamespaces: Set, +): Promise<'ok' | 'truncated'> { + const createScanner = await getCsharpStructureScannerFactory(); + const scanner = createScanner(); + try { + // `crlfDelay: Infinity` treats every `\r\n` as a single break; the line + // scanner is terminator-agnostic, so a streamed scan yields the same + // namespaces as scanning the whole file content at once. + const lines = createInterface({ + input: createReadStream(filePath, { encoding: 'utf-8' }), + crlfDelay: Infinity, + }); + for await (const line of lines) { + scanner.pushLine(line); + } + } catch { + return 'truncated'; // unreadable source → signal truncation (fail open) + } + const structure = scanner.result(); + for (const ns of structure.namespaces) { + declaredNamespaces.add(ns); + const dot = ns.indexOf('.'); + rootNamespaces.add(dot === -1 ? ns : ns.slice(0, dot)); + } + // A declaration the scanner could not fully capture (Codex F3) means the + // collected namespaces are an incomplete picture of this file — treat it like + // a truncated read so the #1881 gate fails OPEN rather than over-block an + // import whose namespace was dropped. + return structure.incomplete ? 'truncated' : 'ok'; } export async function loadSwiftPackageConfig(repoRoot: string): Promise { @@ -231,11 +472,13 @@ export async function loadSwiftPackageConfig(repoRoot: string): Promise { + const csharpScan = await scanCSharpProject(repoRoot); return { tsconfigPaths: await loadTsconfigPaths(repoRoot), goModule: await loadGoModulePath(repoRoot), composerConfig: await loadComposerConfig(repoRoot), swiftPackageConfig: await loadSwiftPackageConfig(repoRoot), - csharpConfigs: await loadCSharpProjectConfig(repoRoot), + csharpConfigs: csharpScan.configs, + csharpNamespaces: csharpScanToEvidence(csharpScan), }; } diff --git a/gitnexus/src/core/ingestion/languages/cpp/captures.ts b/gitnexus/src/core/ingestion/languages/cpp/captures.ts index 86384a759..f0e5e9a88 100644 --- a/gitnexus/src/core/ingestion/languages/cpp/captures.ts +++ b/gitnexus/src/core/ingestion/languages/cpp/captures.ts @@ -15,7 +15,7 @@ import { computeCppCallArity, } from './arity-metadata.js'; import { markCppAnonymousNamespaceRange, markFileLocal } from './file-local-linkage.js'; -import { markCppDependentBase } from './two-phase-lookup.js'; +import { markCppDependentBase, markCppDependentPackBase } 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'; @@ -410,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` / `outer::v1::Base` // resolve through V1's simple-name `findClassBindingInScope('Base')`. - emitCppInheritanceCaptures(tree.rootNode, out); + emitCppInheritanceCaptures(tree.rootNode, out, filePath); // ── Detect dependent-base relationships for two-phase template lookup ── // Walk the tree once, finding every `template_declaration` whose @@ -459,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[]): void { +function emitCppInheritanceCaptures(root: SyntaxNode, out: CaptureMatch[], filePath: string): void { const stack: SyntaxNode[] = [root]; while (stack.length > 0) { const node = stack.pop()!; @@ -467,11 +467,15 @@ function emitCppInheritanceCaptures(root: SyntaxNode, out: CaptureMatch[]): void const baseClause = findChildOfType(node, ['base_class_clause']); if (baseClause !== null) { for (const base of iterBaseClasses(baseClause)) { - const baseName = extractBaseLookupName(base); + if (base.isPackExpansion) { + markClassWithPackExpandedBase(filePath, node); + continue; + } + const baseName = extractBaseLookupName(base.node); if (baseName.length === 0) continue; out.push({ - '@reference.inherits': nodeToCapture('@reference.inherits', base), - '@reference.name': syntheticCapture('@reference.name', base, baseName), + '@reference.inherits': nodeToCapture('@reference.inherits', base.node), + '@reference.name': syntheticCapture('@reference.name', base.node, baseName), }); } } @@ -512,9 +516,12 @@ function detectCppDependentBases(root: SyntaxNode, filePath: string): void { const baseClause = findChildOfType(classNode, ['base_class_clause']); if (baseClause !== null) { for (const base of iterBaseClasses(baseClause)) { - if (isBaseDependent(base, params)) { - const baseName = extractBaseLookupName(base); - const baseQualifier = extractBaseLookupQualifier(base); + if (base.isPackExpansion || isBaseDependent(base.node, params)) { + if (base.isPackExpansion) { + markClassWithPackExpandedBase(filePath, classNode); + } + const baseName = extractBaseLookupName(base.node); + const baseQualifier = extractBaseLookupQualifier(base.node); if (baseName !== '') { markCppDependentBase(filePath, className, baseName, baseQualifier); } @@ -563,8 +570,18 @@ function collectTemplateParameterNames(templateDecl: SyntaxNode): Set { 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 { +function* iterBaseClasses(baseClause: SyntaxNode): IterableIterator { for (let i = 0; i < baseClause.childCount; i++) { const child = baseClause.child(i); if (child === null) continue; @@ -575,11 +592,22 @@ function* iterBaseClasses(baseClause: SyntaxNode): IterableIterator child.type === 'template_type' || child.type === 'qualified_identifier' ) { - yield child; + yield { node: child, isPackExpansion: isFollowedByPackExpansion(baseClause, i) }; } } } +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 diff --git a/gitnexus/src/core/ingestion/languages/cpp/two-phase-lookup.ts b/gitnexus/src/core/ingestion/languages/cpp/two-phase-lookup.ts index 928138ade..7ec55bc9a 100644 --- a/gitnexus/src/core/ingestion/languages/cpp/two-phase-lookup.ts +++ b/gitnexus/src/core/ingestion/languages/cpp/two-phase-lookup.ts @@ -46,6 +46,13 @@ import { findEnclosingClassDef } from '../../scope-resolution/scope/walkers.js'; */ const dependentBasesByFile = new Map>>>(); +/** + * 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>(); + /** * Post-`populateOwners` resolution: per-class-nodeId, the set of * dependent-base-class nodeIds. Built by `populateCppDependentBases` @@ -88,9 +95,19 @@ 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(); } @@ -110,7 +127,7 @@ export function clearCppDependentBases(): void { * found (conservative: avoids false associations). */ export function populateCppDependentBases(parsedFiles: readonly ParsedFile[]): void { - if (dependentBasesByFile.size === 0) return; + if (dependentBasesByFile.size === 0 && dependentPackBaseClassesByFile.size === 0) return; // Build workspace-wide index: simpleName → {nodeId, nsPrefix}[] // nsPrefix is the dot-joined namespace path (qualifiedName without the @@ -164,6 +181,16 @@ 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`), compute // the expected namespace prefix and use exact (===) match. Falls back to @@ -270,10 +297,31 @@ 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; +} diff --git a/gitnexus/src/core/ingestion/languages/csharp/captures.ts b/gitnexus/src/core/ingestion/languages/csharp/captures.ts index a090b82f8..5aafe6d19 100644 --- a/gitnexus/src/core/ingestion/languages/csharp/captures.ts +++ b/gitnexus/src/core/ingestion/languages/csharp/captures.ts @@ -17,7 +17,7 @@ */ import type { Capture, CaptureMatch } from 'gitnexus-shared'; -import { findNodeAtRange, nodeToCapture, syntheticCapture } from '../../utils/ast-helpers.js'; +import { nodeIfType, 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,9 +103,16 @@ export function emitCsharpScopeCaptures( // `@`; we put it back so the central extractor's prefix lookups // (`@scope.`, `@declaration.`, …) work. const grouped: Record = {}; + // 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 = {}; 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; @@ -113,8 +120,7 @@ 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 stmtCapture = grouped['@import.statement']; - const stmtNode = findNodeAtRange(tree.rootNode, stmtCapture.range, 'using_directive'); + const stmtNode = nodeIfType(nodeMap['@import.statement'], 'using_directive'); if (stmtNode !== null) { const decomposed = splitUsingDirective(stmtNode); if (decomposed !== null) { @@ -129,8 +135,7 @@ export function emitCsharpScopeCaptures( } if (grouped['@reference.read.member'] !== undefined) { - const anchor = grouped['@reference.read.member']; - const memberNode = findNodeAtRange(tree.rootNode, anchor.range, 'member_access_expression'); + const memberNode = nodeIfType(nodeMap['@reference.read.member'], 'member_access_expression'); if (memberNode === null || !shouldEmitReadMember(memberNode)) { continue; } @@ -144,8 +149,7 @@ export function emitCsharpScopeCaptures( // `@scope.function` matches. if (grouped['@scope.function'] !== undefined) { out.push(grouped); - const anchor = grouped['@scope.function']!; - const fnNode = findFunctionNode(tree.rootNode, anchor.range); + const fnNode = nodeIfType(nodeMap['@scope.function'], ...FUNCTION_NODE_TYPES); if (fnNode !== null) { for (const synth of synthesizeCsharpReceiverBinding(fnNode)) { out.push(synth); @@ -160,8 +164,7 @@ export function emitCsharpScopeCaptures( // the first tag that matches. const declTag = FUNCTION_DECL_TAGS.find((t) => grouped[t] !== undefined); if (declTag !== undefined) { - const anchor = grouped[declTag]!; - const fnNode = findFunctionNode(tree.rootNode, anchor.range); + const fnNode = nodeIfType(nodeMap[declTag], ...FUNCTION_NODE_TYPES); if (fnNode !== null) { const arity = computeCsharpArityMetadata(fnNode); if (arity.parameterCount !== undefined) { @@ -198,10 +201,11 @@ 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 anchor = grouped[callTag]!; - const callNode = - findNodeAtRange(tree.rootNode, anchor.range, 'invocation_expression') ?? - findNodeAtRange(tree.rootNode, anchor.range, 'object_creation_expression'); + const callNode = nodeIfType( + nodeMap[callTag], + 'invocation_expression', + 'object_creation_expression', + ); if (callNode !== null) { const argList = callNode.childForFieldName('arguments'); const args = @@ -240,10 +244,11 @@ export function emitCsharpScopeCaptures( grouped['@declaration.class'] !== undefined || grouped['@declaration.record'] !== undefined ) { - const anchor = grouped['@declaration.class'] ?? grouped['@declaration.record']!; - const typeNode = - findNodeAtRange(tree.rootNode, anchor.range, 'class_declaration') ?? - findNodeAtRange(tree.rootNode, anchor.range, 'record_declaration'); + const typeNode = nodeIfType( + nodeMap['@declaration.class'] ?? nodeMap['@declaration.record'], + 'class_declaration', + 'record_declaration', + ); if (typeNode !== null) { const synth = synthesizePrimaryConstructor(typeNode); if (synth !== null) out.push(synth); @@ -391,14 +396,3 @@ 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; -} diff --git a/gitnexus/src/core/ingestion/languages/csharp/import-target.ts b/gitnexus/src/core/ingestion/languages/csharp/import-target.ts index 8183bd2a4..3745e16de 100644 --- a/gitnexus/src/core/ingestion/languages/csharp/import-target.ts +++ b/gitnexus/src/core/ingestion/languages/csharp/import-target.ts @@ -9,30 +9,112 @@ * match. Cross-file partial-class aggregation runs at graph-bridge * time (Unit 6) via `populateOwners`. * - * The legacy csproj-based `resolveCSharpImportInternal` needs config - * objects the scope-resolver doesn't carry; the Unit 7 parity gate - * will surface cases where the suffix-match diverges from the - * namespace-based resolver and we'll adjust the contract if needed. + * When `.csproj` configs are available, consults the legacy + * namespace-directory resolver first. Both that resolver's suffix + * fallback and the progressive prefix stripping below are gated on + * declared in-repo namespaces so BCL usings like `System.Threading.Tasks` + * cannot spuriously match a local `Tasks.cs` (#1881). * * Returning `null` lets the finalize algorithm mark the edge as * `linkStatus: 'unresolved'`. */ import type { ParsedImport, WorkspaceIndex } from 'gitnexus-shared'; +import type { CSharpProjectConfig, CSharpNamespaceEvidence } from '../../language-config.js'; +import { resolveCSharpImportInternal } from '../../import-resolvers/csharp.js'; +import { buildSuffixIndex, type SuffixIndex } from '../../import-resolvers/utils.js'; +import { csharpSuffixFallbackAllowed } from '../../csharp-namespace-gate.js'; export interface CsharpResolveContext { readonly fromFile: string; readonly allFilePaths: ReadonlySet; + readonly csharpConfigs?: readonly CSharpProjectConfig[]; + readonly namespaces?: CSharpNamespaceEvidence; +} + +/** Normalized file list + suffix index, built once per workspace `allFilePaths`. */ +interface WorkspaceFileIndex { + readonly normalized: string[]; + readonly all: string[]; + readonly index: SuffixIndex; +} + +// Memoize on Set identity: the orchestrator passes the SAME `allFilePaths` +// Set through every `resolveImportTarget` call in a pass, so this rebuilds +// the normalized list + suffix index once instead of once per import (#1881 #2). +const workspaceFileIndexCache = new WeakMap, WorkspaceFileIndex>(); + +function getWorkspaceFileIndex(allFilePaths: ReadonlySet): WorkspaceFileIndex { + const cached = workspaceFileIndexCache.get(allFilePaths); + if (cached) return cached; + const all = [...allFilePaths]; + const normalized = all.map((f) => f.replace(/\\/g, '/')); + const built: WorkspaceFileIndex = { normalized, all, index: buildSuffixIndex(normalized, all) }; + workspaceFileIndexCache.set(allFilePaths, built); + return built; } export function resolveCsharpImportTarget( parsedImport: ParsedImport, workspaceIndex: WorkspaceIndex, ): string | null { - // WorkspaceIndex is `unknown` in the shared contract (Ring 1 - // placeholder). The scope-resolution orchestrator hands us a - // CsharpResolveContext-shaped object; narrow structurally rather - // than via a cast chain so unexpected shapes return null cleanly. + const ctx = narrowContext(workspaceIndex); + if (ctx === null) return null; + if (parsedImport.kind === 'dynamic-unresolved') return null; + if (parsedImport.targetRaw === null || parsedImport.targetRaw === '') return null; + const targetRaw = parsedImport.targetRaw; + const evidence = ctx.namespaces; + + const csharpConfigs = ctx.csharpConfigs ?? []; + if (csharpConfigs.length > 0) { + const { normalized, all, index } = getWorkspaceFileIndex(ctx.allFilePaths); + const fromCsproj = resolveCSharpImportInternal( + targetRaw, + [...csharpConfigs], + normalized, + all, + index, + evidence, + ); + if (fromCsproj.length > 0) return fromCsproj[0]!; + // csproj configs are authoritative: mirror legacy `configs/csharp.ts`, + // which returns an empty result to STOP the chain. Falling through to the + // ungated `resolveDirectMatch` would re-introduce the BCL→local match the + // internal resolver's gate just suppressed (#1881 parity, #2). + return null; + } + + // Namespace path: `System.Collections.Generic` → `System/Collections/Generic`. + const pathLike = targetRaw.replace(/\./g, '/'); + + // Gate the WHOLE no-csproj path on declared in-repo namespaces — the direct + // path/suffix match INCLUDED — so a BCL using can't resolve to a + // coincidentally path-aligned local file (e.g. `Legacy/System/Threading/ + // Tasks.cs` satisfying `using System.Threading.Tasks;`). Running the gate + // before `resolveDirectMatch` mirrors the legacy leg's gate-first ordering + // (`import-resolvers/configs/csharp.ts`), so the two legs are equivalent + // (#1881 parity, Codex F2). The gate keeps its fail-open for + // undefined/truncated evidence, so legitimate edges in unscanned repos are + // unaffected. + if (!csharpSuffixFallbackAllowed(targetRaw, evidence)) { + return null; + } + + // Exact file / nested-suffix / namespace-dir direct-child match. + const direct = resolveDirectMatch(ctx.allFilePaths, pathLike); + if (direct !== null) return direct; + + // Progressive prefix stripping — mirrors csproj's root-namespace mapping + // without the csproj. + return resolveByProgressiveStripping(ctx.allFilePaths, pathLike); +} + +/** + * `WorkspaceIndex` is an opaque `unknown` placeholder in the shared contract; + * the orchestrator hands us a `CsharpResolveContext`-shaped object. Narrow + * structurally rather than via a cast chain so unexpected shapes fail cleanly. + */ +function narrowContext(workspaceIndex: WorkspaceIndex): CsharpResolveContext | null { const ctx = workspaceIndex as CsharpResolveContext | undefined; if ( ctx === undefined || @@ -41,90 +123,78 @@ export function resolveCsharpImportTarget( ) { return null; } - if (parsedImport.kind === 'dynamic-unresolved') return null; - if (parsedImport.targetRaw === null || parsedImport.targetRaw === '') return null; + return ctx; +} - // Namespace path: `System.Collections.Generic` → `System/Collections/Generic`. - const pathLike = parsedImport.targetRaw.replace(/\./g, '/'); - const suffix = `/${pathLike}`; - - // Exact file match: `System/Collections/Generic.cs` (rare but legal). - // Suffix match for nested layouts: `src/lib/System/Collections/Generic.cs`. - // Directory match: first `.cs` file directly inside the namespace dir - // (e.g. `System/Collections/Generic/List.cs` matches namespace Generic). - let exactFile: string | null = null; +/** + * First-pass resolution against the full namespace path: + * exact whole-path file > nested suffix file > first `.cs` directly inside + * the namespace directory. + */ +function resolveDirectMatch(allFilePaths: ReadonlySet, pathLike: string): string | null { + const exactName = `${pathLike}.cs`; + const nestedSuffix = `/${exactName}`; let suffixFile: string | null = null; - let directoryChild: string | null = null; - const dirPrefix = `${pathLike}/`; - const suffixDirPrefix = `/${dirPrefix}`; - - for (const raw of ctx.allFilePaths) { + for (const raw of allFilePaths) { const f = raw.replace(/\\/g, '/'); if (!f.endsWith('.cs')) continue; - if (f === `${pathLike}.cs`) { - exactFile = raw; - break; - } - if (suffixFile === null && f.endsWith(`${suffix}.cs`)) { - suffixFile = raw; - } - if (directoryChild === null) { - // Namespace-to-directory match: pick the first `.cs` directly in - // the namespace dir (not nested deeper). Legacy resolver emits - // all of them; we take one so the scope-resolver contract stays - // single-target. - const atRoot = f.startsWith(dirPrefix); - const atNested = f.includes(suffixDirPrefix); - if (atRoot || atNested) { - const idx = atRoot ? 0 : f.indexOf(suffixDirPrefix) + 1; - const after = f.slice(idx + dirPrefix.length); - if (after.length > 0 && !after.includes('/')) { - directoryChild = raw; - } - } - } + if (f === exactName) return raw; // exact whole-path match wins + if (suffixFile === null && f.endsWith(nestedSuffix)) suffixFile = raw; } - - if (exactFile !== null) return exactFile; if (suffixFile !== null) return suffixFile; - if (directoryChild !== null) return directoryChild; + return findDirectChild(allFilePaths, pathLike); +} - // Progressive prefix stripping — mirrors csproj's root-namespace - // mapping without the csproj. `using CrossFile.Models;` in a repo - // laid out `Models/User.cs` (no `CrossFile/` prefix) works because - // the legacy resolver consults csproj; the scope-resolver layer - // doesn't have csproj, so we try each suffix of the namespace path - // against `.cs` files and directories. - // - // Also handles `using static CrossFile.Models.UserFactory;` — - // strip the leading segment, try `Models/UserFactory.cs`; strip - // two, try `UserFactory.cs`. +/** + * First `.cs` file that lives directly inside the namespace directory + * `dirSegment` (at repo root or nested under a project prefix), not deeper. + * The legacy resolver emits all of them; the scope-resolver contract is + * single-target so we take one. + */ +function findDirectChild(allFilePaths: ReadonlySet, dirSegment: string): string | null { + const dirPrefix = `${dirSegment}/`; + const nestedDirPrefix = `/${dirPrefix}`; + for (const raw of allFilePaths) { + const f = raw.replace(/\\/g, '/'); + if (!f.endsWith('.cs')) continue; + const atRoot = f.startsWith(dirPrefix); + const atNested = f.includes(nestedDirPrefix); + if (!atRoot && !atNested) continue; + const idx = atRoot ? 0 : f.indexOf(nestedDirPrefix) + 1; + const after = f.slice(idx + dirPrefix.length); + if (after.length > 0 && !after.includes('/')) return raw; + } + return null; +} + +/** + * Try each suffix of the namespace path against `.cs` files and directories, + * stripping leading segments one at a time. Models `using CrossFile.Models;` + * resolving to `Models/User.cs` in a repo laid out without the `CrossFile/` + * prefix (the scope-resolver layer has no csproj to consult). + */ +function resolveByProgressiveStripping( + allFilePaths: ReadonlySet, + pathLike: string, +): string | null { const segments = pathLike.split('/').filter(Boolean); for (let skip = 1; skip < segments.length; skip++) { const tail = segments.slice(skip).join('/'); if (tail === '') continue; const tailFile = `${tail}.cs`; const tailSuffix = `/${tailFile}`; - const tailDir = `${tail}/`; - const tailSuffixDir = `/${tailDir}`; - let tailDirectChild: string | null = null; - for (const raw of ctx.allFilePaths) { + let tailFileMatch: string | null = null; + for (const raw of allFilePaths) { const f = raw.replace(/\\/g, '/'); if (!f.endsWith('.cs')) continue; - if (f === tailFile) return raw; - if (f.endsWith(tailSuffix)) return raw; - if (tailDirectChild === null) { - const atRoot = f.startsWith(tailDir); - const atNested = f.includes(tailSuffixDir); - if (atRoot || atNested) { - const idx = atRoot ? 0 : f.indexOf(tailSuffixDir) + 1; - const after = f.slice(idx + tailDir.length); - if (after.length > 0 && !after.includes('/')) tailDirectChild = raw; - } + if (f === tailFile || f.endsWith(tailSuffix)) { + tailFileMatch = raw; + break; } } - if (tailDirectChild !== null) return tailDirectChild; + if (tailFileMatch !== null) return tailFileMatch; + const child = findDirectChild(allFilePaths, tail); + if (child !== null) return child; } - return null; } diff --git a/gitnexus/src/core/ingestion/languages/csharp/namespace-siblings.ts b/gitnexus/src/core/ingestion/languages/csharp/namespace-siblings.ts index f4d29d7e7..454b5341d 100644 --- a/gitnexus/src/core/ingestion/languages/csharp/namespace-siblings.ts +++ b/gitnexus/src/core/ingestion/languages/csharp/namespace-siblings.ts @@ -48,19 +48,62 @@ export interface CsharpFileStructure { /** Dotted paths from `using static X.Y.Z;` (including * `global using static` and aliased `using static A = X.Y.Z;`). */ readonly usingStaticPaths: readonly string[]; + /** True when the scanner saw a `namespace` / `using static` declaration it + * could not fully capture (keyword not at line start, split across lines, or + * an unparseable identifier form). Callers feeding the #1881 gate must treat + * this like a truncated scan and fail OPEN, since a dropped namespace would + * otherwise over-block a legitimate import (Codex F3). Absent/false on a + * cleanly-scanned file. */ + readonly incomplete?: boolean; } +// A dotted C# namespace identifier: each segment is an optional verbatim `@` +// followed by a Unicode letter/`_` and Unicode letters/digits/`_`. The `u` flag +// makes the classes Unicode-aware so `namespace Café.Models;` is captured (the +// old ASCII `[A-Za-z…]` truncated it). The `@` markers are stripped from the +// capture so it matches the tree-sitter AST's `name` text. +const CS_NS_IDENT = String.raw`@?[\p{L}_][\p{L}\p{N}_]*(?:\.@?[\p{L}_][\p{L}\p{N}_]*)*`; + // Line-anchored matchers for the worker-path fallback (see // `extractCsharpStructureViaScanner`). Anchored at line start (after // indentation); the scanner additionally tracks block-comment / string // state across lines so a keyword at the start of a line inside one of // those regions is skipped. -const CS_NAMESPACE_RE = /^[ \t]*namespace[ \t]+([A-Za-z_@][A-Za-z0-9_.]*)/; +const CS_NAMESPACE_RE = new RegExp(String.raw`^[ \t]*namespace[ \t]+(${CS_NS_IDENT})`, 'u'); // `global using static`, plain `using static`, and the aliased // `using static Alias = NS.Type;` form (the AST keeps the RHS path, so // the optional `Alias =` is skipped and only the dotted path captured). -const CS_USING_STATIC_RE = - /^[ \t]*(?:global[ \t]+)?using[ \t]+static[ \t]+(?:[A-Za-z_@][A-Za-z0-9_]*[ \t]*=[ \t]*)?([A-Za-z_@][A-Za-z0-9_.]*)/; +const CS_USING_STATIC_RE = new RegExp( + String.raw`^[ \t]*(?:global[ \t]+)?using[ \t]+static[ \t]+(?:@?[\p{L}_][\p{L}\p{N}_]*[ \t]*=[ \t]*)?(${CS_NS_IDENT})`, + 'u', +); + +// Incompleteness detectors — used ONLY when the precise matchers above failed, +// to flag a declaration the scanner could not capture (so the file fails the +// #1881 gate OPEN instead of silently dropping the namespace). Kept +// high-precision so ordinary files never trip them (which would wrongly disable +// the gate repo-wide): +// - `…_BARE`: the keyword alone on a line (the name is on the next line). +// - `…_AT_START`: a line-start declaration the precise matcher couldn't parse. +// - `CS_NAMESPACE_AFTER_CODE`: a `namespace` keyword right after a `}`/`;`/`{`/`]` +// (real code, NOT a `//` comment), i.e. not at line start. +const CS_NAMESPACE_BARE = /^[ \t]*namespace[ \t]*\r?$/; +const CS_USING_STATIC_BARE = /^[ \t]*(?:global[ \t]+)?using[ \t]+static[ \t]*\r?$/; +const CS_NAMESPACE_AT_START = /^[ \t]*namespace[ \t]+\S/; +const CS_USING_STATIC_AT_START = /^[ \t]*(?:global[ \t]+)?using[ \t]+static[ \t]+\S/; +const CS_NAMESPACE_AFTER_CODE = /[}\];{][ \t]*namespace[ \t]+@?[\p{L}_]/u; + +/** Whether a `code`-state line declares a namespace / using-static the precise + * matchers could not capture — see the detectors above. */ +function looksLikeUncapturedDeclaration(line: string): boolean { + return ( + CS_NAMESPACE_BARE.test(line) || + CS_USING_STATIC_BARE.test(line) || + CS_NAMESPACE_AT_START.test(line) || + CS_USING_STATIC_AT_START.test(line) || + CS_NAMESPACE_AFTER_CODE.test(line) + ); +} /** Multi-line lexical state carried line-to-line by the scanner. */ type CsScanState = 'code' | 'block' | 'verbatim' | 'raw'; @@ -182,26 +225,60 @@ function advanceCsScanState( * AST is a declaration whose keyword is not at the start of a code line * (split across lines, or sharing a line with a comment/string closer). * Mirrors PHP's `extractNamespaceViaScanner` (issue #1741). */ -export function extractCsharpStructureViaScanner(content: string): CsharpFileStructure { +/** Incremental form of {@link extractCsharpStructureViaScanner}: feed lines one + * at a time via `pushLine` (in source order), then read the accumulated + * structure with `result()`. Lets a caller stream a file off disk + * (`createReadStream` + `readline`) and scan it for `namespace` / `using + * static` declarations in CONSTANT memory rather than buffering the whole file + * into a string — the line splitting and per-line matching are identical, so a + * streamed scan yields the same result as scanning the full content. The line + * terminator must be stripped (as `readline` does, or `String.split('\n')`); a + * trailing `\r` on a CRLF line is inert to both the matchers and the lexer. */ +export interface CsharpStructureLineScanner { + pushLine(line: string): void; + result(): CsharpFileStructure; +} + +/** Create a fresh stateful line scanner — see {@link CsharpStructureLineScanner}. */ +export function createCsharpStructureScanner(): CsharpStructureLineScanner { const namespaces: string[] = []; const usingStaticPaths: string[] = []; + let incomplete = false; let state: CsScanState = 'code'; let rawFence = 0; - for (const line of content.split('\n')) { - // Only match when the line START is real code — keywords reached while - // inside a block comment / multi-line string are skipped. - if (state === 'code') { - const ns = CS_NAMESPACE_RE.exec(line); - if (ns !== null) { - namespaces.push(ns[1]!); - } else { - const us = CS_USING_STATIC_RE.exec(line); - if (us !== null) usingStaticPaths.push(us[1]!); + return { + pushLine(line: string): void { + // Only match when the line START is real code — keywords reached while + // inside a block comment / multi-line string are skipped. + if (state === 'code') { + const ns = CS_NAMESPACE_RE.exec(line); + if (ns !== null) { + namespaces.push(ns[1]!.replace(/@/g, '')); + } else { + const us = CS_USING_STATIC_RE.exec(line); + if (us !== null) { + usingStaticPaths.push(us[1]!.replace(/@/g, '')); + } else if (looksLikeUncapturedDeclaration(line)) { + // A declaration the precise matchers couldn't capture → mark the + // file incomplete so the #1881 gate fails OPEN (Codex F3). + incomplete = true; + } + } } - } - [state, rawFence] = advanceCsScanState(line, state, rawFence); - } - return { namespaces, usingStaticPaths }; + [state, rawFence] = advanceCsScanState(line, state, rawFence); + }, + result(): CsharpFileStructure { + return incomplete + ? { namespaces, usingStaticPaths, incomplete } + : { namespaces, usingStaticPaths }; + }, + }; +} + +export function extractCsharpStructureViaScanner(content: string): CsharpFileStructure { + const scanner = createCsharpStructureScanner(); + for (const line of content.split('\n')) scanner.pushLine(line); + return scanner.result(); } /** Build a structural view of a C# file. Prefers `cachedTree` (handed in diff --git a/gitnexus/src/core/ingestion/languages/csharp/resolution-config.ts b/gitnexus/src/core/ingestion/languages/csharp/resolution-config.ts new file mode 100644 index 000000000..9ea232c05 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/csharp/resolution-config.ts @@ -0,0 +1,30 @@ +/** + * Per-workspace config for C# scope-resolution import targeting. + * + * Loaded once per analyze pass via `csharpScopeResolver.loadResolutionConfig` + * and threaded into `resolveCsharpImportTarget`. The pure gate predicates live + * in `../../csharp-namespace-gate.ts` (shared with the legacy DAG resolver). + */ + +import { + scanCSharpProject, + csharpScanToEvidence, + type CSharpProjectConfig, + type CSharpNamespaceEvidence, +} from '../../language-config.js'; + +export interface CsharpResolutionConfig { + readonly csharpConfigs: readonly CSharpProjectConfig[]; + /** In-repo declared-namespace evidence gating suffix-fallback resolution (#1881). */ + readonly namespaces?: CSharpNamespaceEvidence; +} + +export async function loadCsharpResolutionConfig( + repoRoot: string, +): Promise { + const scan = await scanCSharpProject(repoRoot); + return { + csharpConfigs: scan.configs, + namespaces: csharpScanToEvidence(scan), + }; +} diff --git a/gitnexus/src/core/ingestion/languages/csharp/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/csharp/scope-resolver.ts index 5fc4f9c63..0642b6f91 100644 --- a/gitnexus/src/core/ingestion/languages/csharp/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/csharp/scope-resolver.ts @@ -19,6 +19,7 @@ import { type CsharpResolveContext, } from './index.js'; import { populateCsharpNamespaceSiblings } from './namespace-siblings.js'; +import { loadCsharpResolutionConfig, type CsharpResolutionConfig } from './resolution-config.js'; import { unwrapCsharpCollectionAccessor } from './accessor-unwrap.js'; const csharpScopeResolver: ScopeResolver = { @@ -26,8 +27,16 @@ const csharpScopeResolver: ScopeResolver = { languageProvider: csharpProvider, importEdgeReason: 'csharp-scope: using', - resolveImportTarget: (targetRaw, fromFile, allFilePaths) => { - const ws: CsharpResolveContext = { fromFile, allFilePaths }; + loadResolutionConfig: (repoPath) => loadCsharpResolutionConfig(repoPath), + + resolveImportTarget: (targetRaw, fromFile, allFilePaths, resolutionConfig) => { + const config = resolutionConfig as CsharpResolutionConfig | undefined; + const ws: CsharpResolveContext = { + fromFile, + allFilePaths, + csharpConfigs: config?.csharpConfigs, + namespaces: config?.namespaces, + }; // `WorkspaceIndex` is an opaque `unknown` placeholder in the // shared contract, so `ws` passes structurally without a cast. return resolveCsharpImportTarget( diff --git a/gitnexus/src/core/ingestion/languages/go/captures.ts b/gitnexus/src/core/ingestion/languages/go/captures.ts index 73fa61862..5bc38b73e 100644 --- a/gitnexus/src/core/ingestion/languages/go/captures.ts +++ b/gitnexus/src/core/ingestion/languages/go/captures.ts @@ -1,10 +1,5 @@ import type { Capture, CaptureMatch } from 'gitnexus-shared'; -import { - findNodeAtRange, - nodeToCapture, - syntheticCapture, - type SyntaxNode, -} from '../../utils/ast-helpers.js'; +import { nodeToCapture, syntheticCapture, type SyntaxNode } from '../../utils/ast-helpers.js'; import { getGoParser, getGoScopeQuery } from './query.js'; import { recordGoCacheHit, recordGoCacheMiss } from './cache-stats.js'; import { computeGoCallArity, computeGoDeclarationArity } from './arity-metadata.js'; @@ -34,18 +29,29 @@ export function emitGoScopeCaptures( for (const m of rawMatches) { const grouped: Record = {}; + // Parallel tag -> captured SyntaxNode map. The tree-sitter query already + // hands us the matched node as `c.node`; keeping it here lets us derive the + // anchor/relative node by walking LOCALLY (parent chain / own subtree) + // instead of re-walking from tree.rootNode (the O(matches x rootChildren) + // hotpath that made #1848's 250-struct DAO file take ~10s). The captured + // node either IS the node the old findNodeAtRange re-derived, or is a close + // relative reachable by a bounded local walk. + const nodeMap: Record = {}; for (const c of m.captures) { const tag = '@' + c.name; if (tag.startsWith('@_')) continue; // skip anonymous captures grouped[tag] = nodeToCapture(tag, c.node); + nodeMap[tag] = c.node; } if (Object.keys(grouped).length === 0) continue; if (grouped['@import.statement'] !== undefined) { - const anchor = grouped['@import.statement']!; - const importNode = - findNodeAtRange(tree.rootNode, anchor.range, 'import_declaration') ?? - findNodeAtRange(tree.rootNode, anchor.range, 'import_spec'); + // The captured node is the `import_spec`; the original code preferred its + // enclosing `import_declaration` ONLY when that ancestor shares the exact + // same range (which never happens — the declaration always includes the + // `import` keyword prefix — so it falls back to the import_spec itself). + // Replicate that exactly via a local ancestor walk, never from root. + const importNode = resolveImportNode(nodeMap['@import.statement']!); if (importNode !== null) { out.push(...splitGoImportStatement(importNode)); continue; @@ -53,23 +59,33 @@ export function emitGoScopeCaptures( } if (grouped['@scope.function'] !== undefined) { - const scopeCap = grouped['@scope.function']!; + // @scope.function captures function_declaration | method_declaration | + // func_literal. The original looked for a function_declaration or + // method_declaration at the captured range; the captured node IS that + // node for the first two, and a func_literal never coincides in range + // with either, so the lookup yields null for func_literal. + const scopeNode = nodeMap['@scope.function']!; const fnNode = - findNodeAtRange(tree.rootNode, scopeCap.range, 'function_declaration') ?? - findNodeAtRange(tree.rootNode, scopeCap.range, 'method_declaration'); + scopeNode.type === 'function_declaration' || scopeNode.type === 'method_declaration' + ? scopeNode + : null; if (fnNode !== null) { const receiver = synthesizeGoReceiverBinding(fnNode); if (receiver !== null) out.push(receiver); } } - if (isRawMultiAssignTypeBinding(tree.rootNode, grouped)) continue; + if (isRawMultiAssignTypeBinding(nodeMap)) continue; - const declAnchor = grouped['@declaration.function'] ?? grouped['@declaration.method']; - if (declAnchor !== undefined) { + const declAnchorNode = nodeMap['@declaration.function'] ?? nodeMap['@declaration.method']; + if (declAnchorNode !== undefined) { + // @declaration.function / @declaration.method are captured directly on + // the function_declaration / method_declaration node. const fnNode = - findNodeAtRange(tree.rootNode, declAnchor.range, 'function_declaration') ?? - findNodeAtRange(tree.rootNode, declAnchor.range, 'method_declaration'); + declAnchorNode.type === 'function_declaration' || + declAnchorNode.type === 'method_declaration' + ? declAnchorNode + : null; if (fnNode !== null) { const arity = computeGoDeclarationArity(fnNode); if (arity.parameterCount !== undefined) { @@ -98,15 +114,15 @@ export function emitGoScopeCaptures( continue; } - const callAnchor = - grouped['@reference.call.free'] ?? - grouped['@reference.call.member'] ?? - grouped['@reference.call.constructor']; - if (callAnchor !== undefined && grouped['@reference.arity'] === undefined) { - const callNode = - findNodeAtRange(tree.rootNode, callAnchor.range, 'call_expression') ?? - findNodeAtRange(tree.rootNode, callAnchor.range, 'composite_literal'); - if (callNode !== null) { + // @reference.call.free / .member are captured on the call_expression; + // @reference.call.constructor on the composite_literal. The captured node + // IS the node the old findNodeAtRange re-derived for each, so use it. + const callNode = + nodeMap['@reference.call.free'] ?? + nodeMap['@reference.call.member'] ?? + nodeMap['@reference.call.constructor']; + if (callNode !== undefined && grouped['@reference.arity'] === undefined) { + if (callNode.type === 'call_expression' || callNode.type === 'composite_literal') { grouped['@reference.arity'] = syntheticCapture( '@reference.arity', callNode, @@ -146,18 +162,56 @@ export function emitGoScopeCaptures( return out; } -function isRawMultiAssignTypeBinding( - rootNode: SyntaxNode, - grouped: Record, -): boolean { +/** + * Resolve the node passed to `splitGoImportStatement` for an @import.statement + * match. The capture is on the `import_spec`; the original preferred an + * `import_declaration` at the SAME range, else the import_spec. An + * import_declaration always includes the `import` keyword and so never shares + * the spec's exact range — the only candidate is an ancestor, and it can only + * match when ranges coincide. Walk the parent chain (bounded, local) for an + * import_declaration whose range equals the spec's; otherwise return the spec. + */ +function resolveImportNode(importSpec: SyntaxNode): SyntaxNode { + let current: SyntaxNode | null = importSpec.parent; + while (current !== null) { + if (current.type === 'import_declaration') { + if (nodeRangeEquals(current, importSpec)) return current; + break; + } + // import_spec is nested at most under import_declaration -> + // import_spec_list -> import_spec; stop once we leave the import subtree. + if (current.type !== 'import_spec_list') break; + current = current.parent; + } + return importSpec; +} + +/** True iff two nodes occupy the exact same source range. */ +function nodeRangeEquals(a: SyntaxNode, b: SyntaxNode): boolean { + return ( + a.startPosition.row === b.startPosition.row && + a.startPosition.column === b.startPosition.column && + a.endPosition.row === b.endPosition.row && + a.endPosition.column === b.endPosition.column + ); +} + +function isRawMultiAssignTypeBinding(nodeMap: Record): boolean { const anchor = - grouped['@type-binding.constructor'] ?? - grouped['@type-binding.call-return'] ?? - grouped['@type-binding.assertion']; + nodeMap['@type-binding.constructor'] ?? + nodeMap['@type-binding.call-return'] ?? + nodeMap['@type-binding.assertion']; if (anchor === undefined) return false; - const node = findNodeAtRange(rootNode, anchor.range, 'short_var_declaration'); - if (node === null) return false; + // These tags are captured directly ON the short_var_declaration, so the + // captured node IS what the original findNodeAtRange(root, range, + // 'short_var_declaration') re-derived. The var_declaration (var-form) + // variants — @type-binding.assertion (`var x = e.(T)`) and + // @type-binding.call-return (`var x = Func()`) — anchor on a var_declaration + // instead; the old range+type lookup found no short_var_declaration at that + // range and returned null -> false, which this type guard reproduces exactly. + if (anchor.type !== 'short_var_declaration') return false; + const node = anchor; const lhs = node.childForFieldName('left'); const rhs = node.childForFieldName('right'); if (lhs === null) return false; diff --git a/gitnexus/src/core/ingestion/languages/php/captures.ts b/gitnexus/src/core/ingestion/languages/php/captures.ts index 692d87bc1..42e10aa2e 100644 --- a/gitnexus/src/core/ingestion/languages/php/captures.ts +++ b/gitnexus/src/core/ingestion/languages/php/captures.ts @@ -32,7 +32,7 @@ */ import type { Capture, CaptureMatch } from 'gitnexus-shared'; -import { findNodeAtRange, nodeToCapture, syntheticCapture } from '../../utils/ast-helpers.js'; +import { nodeIfType, 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,9 +102,16 @@ 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 = {}; + // 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 = {}; 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; @@ -175,12 +182,7 @@ 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 stmtCapture = grouped['@import.statement']; - const stmtNode = findNodeAtRange( - tree.rootNode, - stmtCapture.range, - 'namespace_use_declaration', - ); + const stmtNode = nodeIfType(nodeMap['@import.statement'], 'namespace_use_declaration'); if (stmtNode !== null) { const decomposed = splitNamespaceUseDeclaration(stmtNode); if (decomposed.length > 0) { @@ -197,8 +199,7 @@ export function emitPhpScopeCaptures( // non-static method-like. Mirrors C#'s `this` / `base` synthesis. if (grouped['@scope.function'] !== undefined) { out.push(grouped); - const anchor = grouped['@scope.function']!; - const fnNode = findFunctionNode(tree.rootNode, anchor.range); + const fnNode = nodeIfType(nodeMap['@scope.function'], ...FUNCTION_NODE_TYPES); if (fnNode !== null) { for (const synth of synthesizePhpReceiverBinding(fnNode)) { out.push(synth); @@ -219,8 +220,7 @@ export function emitPhpScopeCaptures( // registry can narrow overloads. const declTag = FUNCTION_DECL_TAGS.find((t) => grouped[t] !== undefined); if (declTag !== undefined) { - const anchor = grouped[declTag]!; - const fnNode = findFunctionNode(tree.rootNode, anchor.range); + const fnNode = nodeIfType(nodeMap[declTag], ...FUNCTION_NODE_TYPES); if (fnNode !== null) { const arity = computePhpArityMetadata(fnNode); if (arity.parameterCount !== undefined) { @@ -255,13 +255,14 @@ 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 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'); + const callNode = nodeIfType( + nodeMap[callTag], + 'function_call_expression', + 'member_call_expression', + 'nullsafe_member_call_expression', + 'scoped_call_expression', + 'object_creation_expression', + ); if (callNode !== null) { const argList = callNode.childForFieldName('arguments'); const args: SyntaxNode[] = []; @@ -293,15 +294,6 @@ 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 ────────────────────────────────────────────── /** diff --git a/gitnexus/src/core/ingestion/languages/python/captures.ts b/gitnexus/src/core/ingestion/languages/python/captures.ts index 3c20101db..8a15669d2 100644 --- a/gitnexus/src/core/ingestion/languages/python/captures.ts +++ b/gitnexus/src/core/ingestion/languages/python/captures.ts @@ -17,7 +17,7 @@ */ import type { Capture, CaptureMatch } from 'gitnexus-shared'; -import { findNodeAtRange, nodeToCapture, syntheticCapture } from '../../utils/ast-helpers.js'; +import { nodeToCapture, syntheticCapture, type SyntaxNode } from '../../utils/ast-helpers.js'; import { splitImportStatement } from './import-decomposer.js'; import { getPythonParser, getPythonScopeQuery } from './query.js'; import { synthesizeReceiverTypeBinding } from './receiver-binding.js'; @@ -66,21 +66,30 @@ export function emitPythonScopeCaptures( // `@`; we put it back so the central extractor's prefix lookups // (`@scope.`, `@declaration.`, …) work. const grouped: Record = {}; + // 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 = {}; 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) { - // 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) { + // `@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') { for (const piece of splitImportStatement(stmtNode)) out.push(piece); } else { // Defensive fallback: emit the raw match. @@ -91,11 +100,11 @@ export function emitPythonScopeCaptures( if (grouped['@scope.function'] !== undefined) { out.push(grouped); - const fnNode = findNodeAtRange( - tree.rootNode, - grouped['@scope.function']!.range, - 'function_definition', - ); + // `@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; if (fnNode !== null) { const synth = synthesizeReceiverTypeBinding(fnNode); if (synth !== null) out.push(synth); @@ -110,7 +119,11 @@ 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']!; - const fnNode = findNodeAtRange(tree.rootNode, anchorCap.range, 'function_definition'); + // `@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; if (fnNode !== null) { if (pythonFunctionDefinitionLabel(fnNode, 'Function') === 'Method') { delete grouped['@declaration.function']; diff --git a/gitnexus/src/core/ingestion/languages/python/import-target.ts b/gitnexus/src/core/ingestion/languages/python/import-target.ts index 3a75868d9..0899353fc 100644 --- a/gitnexus/src/core/ingestion/languages/python/import-target.ts +++ b/gitnexus/src/core/ingestion/languages/python/import-target.ts @@ -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; - /** Mutable `Set` because the legacy `resolvePythonImportInternal` - * chain downstream is typed to accept `Set`. Callers that - * only hold a `ReadonlySet` should copy via `new Set(...)` at the - * adapter boundary. */ - readonly allFilePaths: Set; + /** `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; } export function resolvePythonImportTarget( @@ -31,10 +31,17 @@ 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` 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' || - !((ctx as { allFilePaths?: unknown }).allFilePaths instanceof Set) + typeof (allFilePaths as { has?: unknown } | undefined)?.has !== 'function' || + typeof (allFilePaths as Iterable | undefined)?.[Symbol.iterator] !== 'function' ) { return null; } @@ -103,7 +110,7 @@ export function resolvePythonImportTarget( */ function resolveAbsoluteFromFiles( pathLike: string, - allFilePaths: Set, + allFilePaths: ReadonlySet, fromFile: string, ): string | null { const directFile = `${pathLike}.py`; @@ -145,12 +152,27 @@ function resolveAbsoluteFromFiles( // multi-directory collision repos. const suffixFile = `/${directFile}`; const suffixPkg = `/${directPkg}`; + // Indexed suffix gather. A file matching `…/.py` has basename + // `.py`; one matching `…//__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 raw of allFilePaths) { - const norm = raw.replace(/\\/g, '/'); - if (norm.endsWith(suffixFile) || norm.endsWith(suffixPkg)) { - matches.push({ raw, norm }); - } + 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 `` + // can match `…//__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); } if (matches.length === 0) return null; if (matches.length === 1) return matches[0].raw; @@ -188,7 +210,7 @@ function resolveAbsoluteFromFiles( */ function hasRepoCandidate( leadingSegment: string, - allFilePaths: Set, + allFilePaths: ReadonlySet, fromFile: string, ): boolean { const prefix = `${leadingSegment}/`; @@ -205,15 +227,117 @@ function hasRepoCandidate( ancestorPrefixes.push(`${dirParts.slice(0, i).join('/')}/${leadingSegment}/`); } - 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; - } - } + // 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; } 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 + * (`/__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 + * (`….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 `/`". + */ +interface PythonFileIndex { + readonly normSet: Set; + readonly byBasename: Map; + readonly byInitParent: Map; + readonly dirPrefixes: Set; +} + +const PYTHON_FILE_INDEX_CACHE = new WeakMap, PythonFileIndex>(); + +function getPythonFileIndex(allFilePaths: ReadonlySet): 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(); + const byBasename = new Map(); + const byInitParent = new Map(); + const dirPrefixes = new Set(); + + for (const raw of allFilePaths) { + const norm = raw.replace(/\\/g, '/'); + // Python import resolution only ever queries `.py` paths: module `.py` + // and package `/__init__.py` membership (normSet), `.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; +} diff --git a/gitnexus/src/core/ingestion/languages/python/index-stats.ts b/gitnexus/src/core/ingestion/languages/python/index-stats.ts new file mode 100644 index 000000000..2e3d2ae82 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/python/index-stats.ts @@ -0,0 +1,29 @@ +/** + * 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; +} diff --git a/gitnexus/src/core/ingestion/languages/python/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/python/scope-resolver.ts index 8f0ca23f5..0268f00c1 100644 --- a/gitnexus/src/core/ingestion/languages/python/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/python/scope-resolver.ts @@ -31,12 +31,13 @@ const pythonScopeResolver: ScopeResolver = { importEdgeReason: 'python-scope: import', resolveImportTarget: (targetRaw, 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`. The copy is O(N) but called - // once per import — trivial compared to the parser work. - const ws: PythonResolveContext = { fromFile, allFilePaths: new Set(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 }; // `WorkspaceIndex` is an opaque `unknown` placeholder in the // shared contract, so `ws` passes structurally without a cast. return resolvePythonImportTarget( diff --git a/gitnexus/src/core/ingestion/languages/ruby/captures.ts b/gitnexus/src/core/ingestion/languages/ruby/captures.ts index 577d7b6b5..3cff9dbb3 100644 --- a/gitnexus/src/core/ingestion/languages/ruby/captures.ts +++ b/gitnexus/src/core/ingestion/languages/ruby/captures.ts @@ -1,6 +1,6 @@ import type { Capture, CaptureMatch } from 'gitnexus-shared'; import { - findNodeAtRange, + nodeIfType, nodeToCapture, syntheticCapture, type SyntaxNode, @@ -49,17 +49,24 @@ export function emitRubyScopeCaptures( for (const m of rawMatches) { const grouped: Record = {}; + // 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 = {}; 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 = findNodeAtRange(tree.rootNode, anchor.range, 'call'); + const callNode = nodeIfType(nodeMap['@import.statement'], 'call'); if (callNode !== null) { const decomposed = decomposeRubyImport(callNode, anchor); if (decomposed !== null) { @@ -73,8 +80,7 @@ export function emitRubyScopeCaptures( // Synthesize self receiver bindings for methods inside class/module if (grouped['@scope.function'] !== undefined) { - const scopeCap = grouped['@scope.function']!; - const fnNode = findFunctionNode(tree.rootNode, scopeCap.range); + const fnNode = nodeIfType(nodeMap['@scope.function'], ...FUNCTION_NODE_TYPES); if (fnNode !== null) { const enclosingNode = findEnclosingClassOrModule(fnNode); const receiver = synthesizeRubyReceiverBinding(fnNode, enclosingNode); @@ -86,8 +92,7 @@ export function emitRubyScopeCaptures( // Reclassify declaration.function as declaration.method + attach arity if (grouped['@declaration.function'] !== undefined) { - const anchorCap = grouped['@declaration.function']!; - const fnNode = findFunctionNode(tree.rootNode, anchorCap.range); + const fnNode = nodeIfType(nodeMap['@declaration.function'], ...FUNCTION_NODE_TYPES); if (fnNode !== null) { const enclosingNode = findEnclosingClassOrModule(fnNode); if (enclosingNode !== null) { @@ -135,11 +140,7 @@ 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 = findNodeAtRange( - tree.rootNode, - grouped['@reference.call.free']!.range, - 'call', - ); + const callNode = nodeIfType(nodeMap['@reference.call.free'], 'call'); if (callNode !== null) { const enclosing = findEnclosingClassOrModule(callNode); const ownerName = enclosing?.childForFieldName('name')?.text; @@ -173,11 +174,7 @@ 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 = findNodeAtRange( - tree.rootNode, - grouped['@reference.call.free']!.range, - 'call', - ); + const callNode = nodeIfType(nodeMap['@reference.call.free'], 'call'); if (callNode !== null) { const enclosing = findEnclosingClassOrModule(callNode); const ownerName = enclosing?.childForFieldName('name')?.text; @@ -222,8 +219,7 @@ export function emitRubyScopeCaptures( (t) => grouped[t] !== undefined, ); if (callTag !== undefined && grouped['@reference.arity'] === undefined) { - const anchor = grouped[callTag]!; - const callNode = findNodeAtRange(tree.rootNode, anchor.range, 'call'); + const callNode = nodeIfType(nodeMap[callTag], 'call'); if (callNode !== null) { const arity = computeRubyCallArity(callNode); grouped['@reference.arity'] = syntheticCapture('@reference.arity', callNode, String(arity)); @@ -373,21 +369,37 @@ 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 = `:`, 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(); + 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 ( - out.some( - (m) => - m['@type-binding.return'] !== undefined && - m['@type-binding.name']?.text === methodName && - m['@type-binding.return']?.range.startLine === methodNode.startPosition.row, - ) - ) { + // Skip if a YARD @return already created a return binding for this method. + if (yardReturnKeys.has(`${methodName}:${methodNode.startPosition.row}`)) { continue; } const body = methodNode.childForFieldName('body'); @@ -529,14 +541,6 @@ 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( diff --git a/gitnexus/src/core/ingestion/languages/rust/captures.ts b/gitnexus/src/core/ingestion/languages/rust/captures.ts index 56ce009d7..1bad116c2 100644 --- a/gitnexus/src/core/ingestion/languages/rust/captures.ts +++ b/gitnexus/src/core/ingestion/languages/rust/captures.ts @@ -1,6 +1,6 @@ import type { Capture, CaptureMatch } from 'gitnexus-shared'; import { - findNodeAtRange, + nodeIfType, nodeToCapture, syntheticCapture, type SyntaxNode, @@ -32,17 +32,23 @@ export function emitRustScopeCaptures( for (const m of rawMatches) { const grouped: Record = {}; + // 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 = {}; 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 anchor = grouped['@import.statement']!; - const useNode = findNodeAtRange(tree.rootNode, anchor.range, 'use_declaration'); + const useNode = nodeIfType(nodeMap['@import.statement'], 'use_declaration'); if (useNode !== null) { out.push(...splitRustUseDeclaration(useNode)); continue; @@ -52,8 +58,7 @@ 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 scopeCap = grouped['@scope.function']!; - const fnNode = findNodeAtRange(tree.rootNode, scopeCap.range, 'function_item'); + const fnNode = nodeIfType(nodeMap['@scope.function'], 'function_item'); if (fnNode !== null) { const implNode = findEnclosingImpl(fnNode); cachedImplLookup = { fnNode, implNode }; @@ -65,7 +70,7 @@ export function emitRustScopeCaptures( // Attach declaration arity for functions/methods const declAnchor = grouped['@declaration.function']; if (declAnchor !== undefined) { - const fnNode = findNodeAtRange(tree.rootNode, declAnchor.range, 'function_item'); + const fnNode = nodeIfType(nodeMap['@declaration.function'], 'function_item'); if (fnNode !== null) { const implNode = cachedImplLookup?.fnNode === fnNode @@ -115,7 +120,7 @@ export function emitRustScopeCaptures( grouped['@type-binding.name'] !== undefined ) { const tbReturnAnchor = grouped['@type-binding.return']!; - const fnNode = findNodeAtRange(tree.rootNode, tbReturnAnchor.range, 'function_item'); + const fnNode = nodeIfType(nodeMap['@type-binding.return'], 'function_item'); if (fnNode !== null) { const implNode = findEnclosingImpl(fnNode); if (implNode !== null) { @@ -141,14 +146,12 @@ export function emitRustScopeCaptures( } // Attach call arity for call expressions - 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'); + 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'); if (callNode !== null) { const arity = computeRustCallArity(callNode); grouped['@reference.arity'] = syntheticCapture('@reference.arity', callNode, String(arity)); diff --git a/gitnexus/src/core/ingestion/method-extractors/configs/c-cpp.ts b/gitnexus/src/core/ingestion/method-extractors/configs/c-cpp.ts index e369b3de9..dccc3f52f 100644 --- a/gitnexus/src/core/ingestion/method-extractors/configs/c-cpp.ts +++ b/gitnexus/src/core/ingestion/method-extractors/configs/c-cpp.ts @@ -8,6 +8,7 @@ import type { MethodVisibility, } from '../../method-types.js'; import { hasKeyword } from '../../field-extractors/configs/helpers.js'; +import { classifyCppParameterType } from '../../languages/cpp/arity-metadata.js'; import { extractSimpleTypeName } from '../../type-extractors/shared.js'; import type { SyntaxNode } from '../../utils/ast-helpers.js'; @@ -149,6 +150,11 @@ function extractCppParameters(node: SyntaxNode): ParameterInfo[] { ? (extractSimpleTypeName(typeNode) ?? typeNode.text?.trim() ?? null) : null, rawType: typeNode?.text?.trim() ?? null, + typeClass: classifyCppParameterType( + typeNode?.text?.trim() ?? 'unknown', + declNode?.text, + param.text, + ), isOptional: false, isVariadic: false, }); @@ -164,6 +170,11 @@ function extractCppParameters(node: SyntaxNode): ParameterInfo[] { ? (extractSimpleTypeName(typeNode) ?? typeNode.text?.trim() ?? null) : null, rawType: typeNode?.text?.trim() ?? null, + typeClass: classifyCppParameterType( + typeNode?.text?.trim() ?? 'unknown', + declNode?.text, + param.text, + ), isOptional: true, isVariadic: false, }); @@ -180,6 +191,11 @@ function extractCppParameters(node: SyntaxNode): ParameterInfo[] { ? (extractSimpleTypeName(typeNode) ?? typeNode.text?.trim() ?? null) : null, rawType: typeNode?.text?.trim() ?? null, + typeClass: classifyCppParameterType( + typeNode?.text?.trim() ?? 'unknown', + declNode?.text, + param.text, + ), isOptional: false, isVariadic: true, }); diff --git a/gitnexus/src/core/ingestion/method-types.ts b/gitnexus/src/core/ingestion/method-types.ts index 105b4f251..301941535 100644 --- a/gitnexus/src/core/ingestion/method-types.ts +++ b/gitnexus/src/core/ingestion/method-types.ts @@ -1,6 +1,6 @@ // gitnexus/src/core/ingestion/method-types.ts -import type { SupportedLanguages } from 'gitnexus-shared'; +import type { ParameterTypeClass, SupportedLanguages } from 'gitnexus-shared'; import type { FieldVisibility } from './field-types.js'; import type { SyntaxNode } from './utils/ast-helpers.js'; @@ -14,6 +14,7 @@ export interface ParameterInfo { * Used by typeTagForId for overload disambiguation where generic args matter. * Falls back to `type` when not set. */ rawType?: string | null; + typeClass?: ParameterTypeClass; isOptional: boolean; isVariadic: boolean; } diff --git a/gitnexus/src/core/ingestion/parsing-processor.ts b/gitnexus/src/core/ingestion/parsing-processor.ts index 6b69f75c3..4e0d5a2e5 100644 --- a/gitnexus/src/core/ingestion/parsing-processor.ts +++ b/gitnexus/src/core/ingestion/parsing-processor.ts @@ -30,6 +30,7 @@ import { typeTagForId, constTagForId, buildCollisionGroups, + parameterShapeIdTag, } from './utils/method-props.js'; import { extractTemplateArguments, @@ -665,6 +666,13 @@ const processParsingSequential = async ( cached.groups, ); } + const parameterShapeTag = + nodeLabel === 'Function' || nodeLabel === 'Method' + ? parameterShapeIdTag( + methodProps.parameterTypes as string[] | undefined, + methodProps.parameterTypeClasses as ParameterTypeClass[] | undefined, + ) + : ''; const classTemplateArguments = extractedClassSymbol?.templateArguments ?? provider.classExtractor?.extractTemplateArgumentsFromCapture?.({ @@ -717,7 +725,7 @@ const processParsingSequential = async ( } const nodeId = generateId( nodeLabel, - `${file.path}:${qualifiedName}${classTemplateTag}${arityTag}${constraintsTag}`, + `${file.path}:${qualifiedName}${classTemplateTag}${arityTag}${constraintsTag}${parameterShapeTag}`, ); const classNodeForSymbol = definitionNodeForRange || definitionNode || nameNode; const qualifiedTypeName = diff --git a/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/ids.ts b/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/ids.ts index 8a1bf5a0a..94eec67c0 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/ids.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/ids.ts @@ -17,11 +17,12 @@ * migrate. */ -import type { NodeLabel, ScopeId, SymbolDefinition } from 'gitnexus-shared'; +import type { NodeLabel, ParameterTypeClass, ScopeId, SymbolDefinition } from 'gitnexus-shared'; import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js'; import { generateId } from '../../../../lib/utils.js'; import { qualifiedKey, simpleKey, type GraphNodeLookup } from '../graph-bridge/node-lookup.js'; import { templateConstraintsIdTag } from '../../utils/template-arguments.js'; +import { parameterShapeIdTag } from '../../utils/method-props.js'; /** * Labels that may legitimately ANCHOR a CALLS/ACCESSES edge as the * source ("caller"). A Variable / Property can be the TARGET of an @@ -76,6 +77,7 @@ export function resolveDefGraphId( qualifiedName?: string; type?: NodeLabel; parameterTypes?: readonly string[]; + parameterTypeClasses?: readonly ParameterTypeClass[]; templateArguments?: readonly string[]; templateConstraints?: unknown; }, @@ -102,11 +104,23 @@ export function resolveDefGraphId( const cHit = nodeLookup.get(cKey); if (cHit !== undefined) return cHit; } + if ( + (def.type === 'Function' || def.type === 'Method') && + def.parameterTypes !== undefined && + def.parameterTypeClasses !== undefined + ) { + const shapeTag = parameterShapeIdTag(def.parameterTypes, def.parameterTypeClasses); + if (shapeTag !== '') { + const shapeKey = qualifiedKey(filePath, def.type, `${qn}${shapeTag}`); + const shapeHit = nodeLookup.get(shapeKey); + if (shapeHit !== undefined) return shapeHit; + } + } // Overload disambiguation: when the def carries parameter types, // try the parameter-typed key first so same-name same-arity // overloads route to their distinct graph nodes. if ( - def.type === 'Method' && + (def.type === 'Function' || def.type === 'Method') && def.parameterTypes !== undefined && def.parameterTypes.length > 0 ) { diff --git a/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/node-lookup.ts b/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/node-lookup.ts index 8c29f8f2c..229c83f45 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/node-lookup.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/node-lookup.ts @@ -18,9 +18,10 @@ * format that downstream consumers (queries, edges, MCP) expect. */ -import type { NodeLabel } from 'gitnexus-shared'; +import type { NodeLabel, ParameterTypeClass } from 'gitnexus-shared'; import type { KnowledgeGraph } from '../../../graph/types.js'; import { templateConstraintsIdTag } from '../../utils/template-arguments.js'; +import { parameterShapeIdTag } from '../../utils/method-props.js'; export type GraphNodeLookup = ReadonlyMap; @@ -42,6 +43,10 @@ function parseQualifiedFromId(id: string, label: NodeLabel, filePath: string): s return hash === -1 ? suffix : suffix.slice(0, hash); } +function stripCallableDisambiguatorTags(qualifiedName: string): string { + return qualifiedName.replace(/~shape:.*$/, '').replace(/~c:[a-z0-9]+$/, ''); +} + /** * Build a qualified-key string in a separate keyspace from simple-key * strings. Prefix `` can't appear in a valid filePath on any OS, so @@ -84,7 +89,8 @@ export function buildGraphNodeLookup(graph: KnowledgeGraph): GraphNodeLookup { const qualified = props.qualifiedName ?? parseQualifiedFromId(node.id, node.label, props.filePath); if (qualified !== undefined && qualified.length > 0) { - const qKey = qualifiedKey(props.filePath, node.label, qualified); + const keyQualified = stripCallableDisambiguatorTags(qualified); + const qKey = qualifiedKey(props.filePath, node.label, keyQualified); if (!lookup.has(qKey)) lookup.set(qKey, node.id); // Overload-disambiguating key: include parameter types so two // same-arity overloads (e.g. `Lookup(int)` vs `Lookup(string)`) @@ -93,10 +99,25 @@ export function buildGraphNodeLookup(graph: KnowledgeGraph): GraphNodeLookup { // a parameter-types-suffixed key so resolveDefGraphId can find // the right overload by matching its def's parameterTypes. const pTypes = (props as { parameterTypes?: readonly string[] }).parameterTypes; - if (pTypes !== undefined && pTypes.length > 0 && node.label === 'Method') { - const pKey = qualifiedKey(props.filePath, node.label, `${qualified}~${pTypes.join(',')}`); + if ( + pTypes !== undefined && + pTypes.length > 0 && + (node.label === 'Function' || node.label === 'Method') + ) { + const pKey = qualifiedKey( + props.filePath, + node.label, + `${keyQualified}~${pTypes.join(',')}`, + ); // Each overload is unique — set unconditionally. - lookup.set(pKey, node.id); + if (!lookup.has(pKey)) lookup.set(pKey, node.id); + } + const pClasses = (props as { parameterTypeClasses?: readonly ParameterTypeClass[] }) + .parameterTypeClasses; + const shapeTag = parameterShapeIdTag(pTypes, pClasses); + if (shapeTag !== '' && (node.label === 'Function' || node.label === 'Method')) { + const shapeKey = qualifiedKey(props.filePath, node.label, `${keyQualified}${shapeTag}`); + if (!lookup.has(shapeKey)) lookup.set(shapeKey, node.id); } // SFINAE / `requires`-clause disambiguation (issue #1579) — register // a constraint-fingerprinted key so resolveDefGraphId can locate the @@ -109,7 +130,7 @@ export function buildGraphNodeLookup(graph: KnowledgeGraph): GraphNodeLookup { const cKey = qualifiedKey( props.filePath, node.label, - `${qualified}${templateConstraintsIdTag(tConstraints)}`, + `${keyQualified}${templateConstraintsIdTag(tConstraints)}`, ); lookup.set(cKey, node.id); } @@ -125,7 +146,7 @@ export function buildGraphNodeLookup(graph: KnowledgeGraph): GraphNodeLookup { const tKey = qualifiedKey( props.filePath, node.label, - `${qualified}~${props.templateArguments.join(',')}`, + `${keyQualified}~${props.templateArguments.join(',')}`, ); if (!lookup.has(tKey)) lookup.set(tKey, node.id); } diff --git a/gitnexus/src/core/ingestion/scope-resolution/passes/free-call-fallback.ts b/gitnexus/src/core/ingestion/scope-resolution/passes/free-call-fallback.ts index 306bae73d..1160121ad 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/passes/free-call-fallback.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/passes/free-call-fallback.ts @@ -116,11 +116,13 @@ 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 @@ -336,6 +338,19 @@ 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); diff --git a/gitnexus/src/core/ingestion/scope-resolution/passes/overload-narrowing.ts b/gitnexus/src/core/ingestion/scope-resolution/passes/overload-narrowing.ts index f28cd3185..b69252ce2 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/passes/overload-narrowing.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/passes/overload-narrowing.ts @@ -35,6 +35,11 @@ * candidates whose template constraints provably fail at the * call site. Three-valued; `'unknown'` keeps the candidate * (monotonicity). + * 4d. Conservative C++ template partial-order approximation. When + * template-placeholder overloads remain tied, prefer a candidate + * whose parameter shape is more specialized for the observed + * argument shape (`T*` over `T`, `const T&` over `T`). Unknown or + * incomparable shapes are left ambiguous. * 5. Empty input returns empty output. */ @@ -205,6 +210,15 @@ export function narrowOverloadCandidates( }); } + if (result.length > 1 && argTypes !== undefined && argTypes.length > 0) { + const partiallyOrdered = rankByTemplatePartialOrdering( + result, + argTypes, + hookCtx?.argumentTypeClasses, + ); + if (partiallyOrdered !== undefined) result = partiallyOrdered; + } + return result; } @@ -330,6 +344,109 @@ function pairwiseCompare(a: readonly number[], b: readonly number[]): -1 | 0 | 1 return 0; } +/** + * Closed-table approximation of C++ function-template partial ordering. + * + * Full `[temp.func.order]` requires template argument deduction. GitNexus + * keeps this graph-safe by recognizing only syntactic placeholder shapes + * that the C++ parameter sidecar already preserves: + * - `T*` is more specialized than `T` for pointer arguments. + * + * Anything with unknown argument shape, non-template parameter spelling, or + * incomparable specialized shapes stays ambiguous so callers suppress. The + * placeholder detector is intentionally narrow: lowercase template parameters + * are left ambiguous rather than guessed. + */ +function rankByTemplatePartialOrdering( + candidates: readonly SymbolDefinition[], + argTypes: readonly string[], + argTypeClasses?: readonly ParameterTypeClass[], +): readonly SymbolDefinition[] | undefined { + if (argTypeClasses === undefined) return undefined; + + const viable: Array<{ def: SymbolDefinition; ranks: number[] }> = []; + for (const def of candidates) { + const params = def.parameterTypes; + const paramClasses = def.parameterTypeClasses; + if (params === undefined || paramClasses === undefined) continue; + + const ranks: number[] = []; + let sawTemplateSlot = false; + let ok = true; + for (let i = 0; i < argTypes.length; i++) { + const paramType = parameterTypeAt(params, i); + const paramClass = parameterTypeClassAt(paramClasses, i); + const argClass = argTypeClasses[i]; + if (paramType === undefined || paramClass === undefined || argClass === undefined) { + ok = false; + break; + } + + const rank = templatePartialOrderSlotRank(paramType, paramClass, argClass); + if (rank === undefined) { + ok = false; + break; + } + sawTemplateSlot ||= isTemplatePlaceholder(paramType); + ranks.push(rank); + } + if (ok && sawTemplateSlot) viable.push({ def, ranks }); + } + if (viable.length === 0) return undefined; + if (viable.length !== candidates.length) return []; + if (viable.length <= 1) return viable.map((v) => v.def); + + const dominated = new Set(); + for (let i = 0; i < viable.length; i++) { + if (dominated.has(i)) continue; + for (let j = i + 1; j < viable.length; j++) { + if (dominated.has(j)) continue; + const cmp = compareSpecializationRanks(viable[i].ranks, viable[j].ranks); + if (cmp < 0) dominated.add(j); + else if (cmp > 0) dominated.add(i); + } + } + return viable.filter((_, idx) => !dominated.has(idx)).map((v) => v.def); +} + +function templatePartialOrderSlotRank( + paramType: string, + paramClass: ParameterTypeClass, + argClass: ParameterTypeClass, +): number | undefined { + if (!isTemplatePlaceholder(paramType)) return undefined; + if (argClass.indirection === 'unknown' || paramClass.indirection === 'unknown') { + return undefined; + } + if (isPointerShape(paramClass)) { + return isPointerShape(argClass) ? 3 : undefined; + } + if (paramClass.indirection === 'value') return 1; + return undefined; +} + +function isTemplatePlaceholder(typeName: string): boolean { + return /^[A-Z]\w*$/.test(typeName); +} + +/** + * Higher specialization rank is better. Returns -1 when `a` dominates `b`, + * +1 when `b` dominates `a`, and 0 for ties / incomparable vectors. + */ +function compareSpecializationRanks(a: readonly number[], b: readonly number[]): -1 | 0 | 1 { + let aBetter = false; + let bBetter = false; + const len = Math.min(a.length, b.length); + for (let i = 0; i < len; i++) { + if (a[i] > b[i]) aBetter = true; + else if (b[i] > a[i]) bBetter = true; + if (aBetter && bBetter) return 0; + } + if (aBetter && !bBetter) return -1; + if (bBetter && !aBetter) return 1; + return 0; +} + /** * Detect when >1 candidate share identical `parameterTypes` after the * per-language normalizer has collapsed distinct underlying types. This diff --git a/gitnexus/src/core/ingestion/utils/ast-helpers.ts b/gitnexus/src/core/ingestion/utils/ast-helpers.ts index 98e795686..320a2bc4b 100644 --- a/gitnexus/src/core/ingestion/utils/ast-helpers.ts +++ b/gitnexus/src/core/ingestion/utils/ast-helpers.ts @@ -725,3 +725,23 @@ 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( + node: T | undefined, + ...types: readonly string[] +): T | null { + return node !== undefined && types.includes(node.type) ? node : null; +} diff --git a/gitnexus/src/core/ingestion/utils/method-props.ts b/gitnexus/src/core/ingestion/utils/method-props.ts index 2a08df8b6..b97b5de4b 100644 --- a/gitnexus/src/core/ingestion/utils/method-props.ts +++ b/gitnexus/src/core/ingestion/utils/method-props.ts @@ -1,5 +1,5 @@ import type { MethodInfo } from '../method-types.js'; -import { SupportedLanguages } from 'gitnexus-shared'; +import { SupportedLanguages, type ParameterTypeClass } from 'gitnexus-shared'; /** Languages where class overload signatures are declaration-only contracts * that should collapse to the implementation body's node ID. */ @@ -139,13 +139,58 @@ export function constTagForId( return ''; } +/** + * Disambiguate function-template overloads whose normalized parameter types + * intentionally collapse to the same placeholder token (`T`, `U`, ...), but + * whose C++ sidecar shape is semantically different (`T` vs `T*` / `T&`). + * + * Kept intentionally narrow: concrete types already use the existing raw-type + * overload tag, and non-template languages should not acquire sidecar-shaped + * IDs. + */ +export function parameterShapeIdTag( + parameterTypes?: readonly string[], + parameterTypeClasses?: readonly ParameterTypeClass[], +): string { + if ( + parameterTypes === undefined || + parameterTypeClasses === undefined || + parameterTypes.length === 0 + ) { + return ''; + } + let hasTemplatePlaceholder = false; + let hasDisambiguatingShape = false; + const parts: string[] = []; + for (let i = 0; i < parameterTypes.length; i++) { + const type = parameterTypes[i]; + const typeClass = parameterTypeClasses[i]; + if (typeClass === undefined) return ''; + if (/^[A-Z]\w*$/.test(type)) hasTemplatePlaceholder = true; + if ( + typeClass.indirection !== 'value' || + typeClass.pointerDepth > 0 || + (typeClass.cv !== 'none' && typeClass.cv !== 'unknown') + ) { + hasDisambiguatingShape = true; + } + parts.push( + `${type}:${typeClass.cv}:${typeClass.indirection}:${typeClass.pointerDepth.toString()}`, + ); + } + if (!hasTemplatePlaceholder || !hasDisambiguatingShape) return ''; + return `~shape:${parts.join('|')}`; +} + /** Convert MethodInfo from methodExtractor into flat properties for a graph node. */ export function buildMethodProps(info: MethodInfo): Record { const types: string[] = []; + const typeClasses: ParameterTypeClass[] = []; let optionalCount = 0; let hasVariadic = false; for (const p of info.parameters) { if (p.type !== null) types.push(p.type); + if (p.typeClass !== undefined) typeClasses.push(p.typeClass); if (p.isOptional) optionalCount++; if (p.isVariadic) hasVariadic = true; } @@ -155,6 +200,9 @@ export function buildMethodProps(info: MethodInfo): Record { ? { requiredParameterCount: info.parameters.length - optionalCount } : {}), ...(types.length > 0 ? { parameterTypes: types } : {}), + ...(typeClasses.length === info.parameters.length && typeClasses.length > 0 + ? { parameterTypeClasses: typeClasses } + : {}), returnType: info.returnType ?? undefined, visibility: info.visibility, isStatic: info.isStatic, diff --git a/gitnexus/src/core/ingestion/workers/parse-worker.ts b/gitnexus/src/core/ingestion/workers/parse-worker.ts index a20d86d92..0a4bc47e9 100644 --- a/gitnexus/src/core/ingestion/workers/parse-worker.ts +++ b/gitnexus/src/core/ingestion/workers/parse-worker.ts @@ -85,6 +85,7 @@ import { typeTagForId, constTagForId, buildCollisionGroups, + parameterShapeIdTag, } from '../utils/method-props.js'; import { extractTemplateArguments, templateArgumentsIdTag } from '../utils/template-arguments.js'; import type { LanguageProvider } from '../language-provider.js'; @@ -1814,6 +1815,13 @@ const processFileGroup = ( ); arityTag += constTagForId(defMethodMap, nodeName, arityForId, defMethodInfo, groups); } + const parameterShapeTag = + nodeLabel === 'Function' || nodeLabel === 'Method' + ? parameterShapeIdTag( + methodProps.parameterTypes as string[] | undefined, + methodProps.parameterTypeClasses as ParameterTypeClass[] | undefined, + ) + : ''; const classTemplateArguments = extractedClassSymbol?.templateArguments ?? provider.classExtractor?.extractTemplateArgumentsFromCapture?.({ @@ -1837,7 +1845,7 @@ const processFileGroup = ( : ''; const nodeId = generateId( nodeLabel, - `${file.path}:${qualifiedName}${classTemplateTag}${arityTag}`, + `${file.path}:${qualifiedName}${classTemplateTag}${arityTag}${parameterShapeTag}`, ); const classNodeForSymbol = definitionNode || nameNode; const qualifiedTypeName = diff --git a/gitnexus/src/mcp/local/local-backend.ts b/gitnexus/src/mcp/local/local-backend.ts index 5a9290394..24e68facb 100644 --- a/gitnexus/src/mcp/local/local-backend.ts +++ b/gitnexus/src/mcp/local/local-backend.ts @@ -2920,8 +2920,14 @@ export class LocalBackend { typeof opts.offset === 'number' && Number.isFinite(opts.offset) ? opts.offset : 0; const paginationOffset = Math.max(0, Math.trunc(rawOffset)); const summaryOnly = opts.summaryOnly ?? false; - const relTypeFilter = relationTypes.map((t) => `'${t}'`).join(', '); - const confidenceFilter = minConfidence > 0 ? ` AND r.confidence >= ${minConfidence}` : ''; + // Bind the BFS frontier query's filters as parameters (#1907 review F5): + // node ids and relation types as bound lists, the confidence floor as a + // bound number — no string interpolation reaches the query text. Preserve + // the original "no confidence clause when minConfidence <= 0" behavior: an + // unconditional `>= 0` would wrongly exclude NULL-confidence edges that the + // unfiltered query includes. + const safeMinConfidence = Number.isFinite(minConfidence) ? minConfidence : 0; + const confidenceFilter = safeMinConfidence > 0 ? ' AND r.confidence >= $minConfidence' : ''; const symId = sym.id || sym[0]; @@ -3009,15 +3015,19 @@ export class LocalBackend { for (let depth = 1; depth <= maxDepth && frontier.length > 0; depth++) { const nextFrontier: string[] = []; - // Batch frontier nodes into a single Cypher query per depth level - const idList = frontier.map((id) => `'${id.replace(/'/g, "''")}'`).join(', '); + // Batch frontier nodes into a single Cypher query per depth level. + // ids/types/confidence are bound parameters (see above) — no interpolation. const query = direction === 'upstream' - ? `MATCH (caller)-[r:CodeRelation]->(n) WHERE n.id IN [${idList}] AND r.type IN [${relTypeFilter}]${confidenceFilter} RETURN n.id AS sourceId, caller.id AS id, caller.name AS name, labels(caller)[0] AS type, caller.filePath AS filePath, r.type AS relType, r.confidence AS confidence` - : `MATCH (n)-[r:CodeRelation]->(callee) WHERE n.id IN [${idList}] AND r.type IN [${relTypeFilter}]${confidenceFilter} RETURN n.id AS sourceId, callee.id AS id, callee.name AS name, labels(callee)[0] AS type, callee.filePath AS filePath, r.type AS relType, r.confidence AS confidence`; + ? `MATCH (caller)-[r:CodeRelation]->(n) WHERE n.id IN $frontierIds AND r.type IN $relTypes${confidenceFilter} RETURN n.id AS sourceId, caller.id AS id, caller.name AS name, labels(caller)[0] AS type, caller.filePath AS filePath, r.type AS relType, r.confidence AS confidence` + : `MATCH (n)-[r:CodeRelation]->(callee) WHERE n.id IN $frontierIds AND r.type IN $relTypes${confidenceFilter} RETURN n.id AS sourceId, callee.id AS id, callee.name AS name, labels(callee)[0] AS type, callee.filePath AS filePath, r.type AS relType, r.confidence AS confidence`; try { - const related = await executeQuery(repo.id, query); + const related = await executeParameterized(repo.id, query, { + frontierIds: frontier, + relTypes: relationTypes, + ...(safeMinConfidence > 0 ? { minConfidence: safeMinConfidence } : {}), + }); for (const rel of related) { const relId = rel.id || rel[1]; diff --git a/gitnexus/test/fixtures/csharp-captures-golden/expected-captures.json b/gitnexus/test/fixtures/csharp-captures-golden/expected-captures.json new file mode 100644 index 000000000..6a8dead8c --- /dev/null +++ b/gitnexus/test/fixtures/csharp-captures-golden/expected-captures.json @@ -0,0 +1,634 @@ +{ + "csharp-alias-imports/Models/Repo.cs": { + "captureGroups": 12, + "digest": "f46b3ec22a53f7aa3409d001e6c845dd9b17d3634f0f791f6b014bd599884309" + }, + "csharp-alias-imports/Models/User.cs": { + "captureGroups": 12, + "digest": "065ae045a857e4c881c62ea7234a846332b9d8489f539fa4cfad045707eab039" + }, + "csharp-alias-imports/Services/Main.cs": { + "captureGroups": 17, + "digest": "24ce33ab9016af2cb4da1384f4478a3ccde4d856e0c6839222d50e8946a0d9a7" + }, + "csharp-ambiguous/Models/Handler.cs": { + "captureGroups": 7, + "digest": "520efd0a0ac67137dbae921eb0d27d4b0f841fc0582bfe01fb664d54bce2fd06" + }, + "csharp-ambiguous/Models/IProcessor.cs": { + "captureGroups": 6, + "digest": "d38477ce3dd97a87f38359da966cb7445588fb5c17bdaa7a25bc6f23e8d9d1ec" + }, + "csharp-ambiguous/Other/Handler.cs": { + "captureGroups": 7, + "digest": "119e87ba0c830530fdf6fb04d592501c6c552e1051613ebc65c1c1fb0da1ce47" + }, + "csharp-ambiguous/Other/IProcessor.cs": { + "captureGroups": 6, + "digest": "ceae1be5aae89de4eec68c5a64195728a7ae434a9b2cab2eaab617b912c3433d" + }, + "csharp-ambiguous/Services/UserHandler.cs": { + "captureGroups": 9, + "digest": "f4e4371e9d23c48e9c1a89cc9475bddadffdd3d0bab129690b4b142a4dea94cd" + }, + "csharp-assignment-chain/Models/Repo.cs": { + "captureGroups": 7, + "digest": "65fc6c6688e7f7badead653bbef85fca225b6910eeaacb9f30d03cb28aeb1e26" + }, + "csharp-assignment-chain/Models/User.cs": { + "captureGroups": 7, + "digest": "4f0c929c82e56f41e5b3b4c0aefee2121d7a307ef24535ac998fd7041d0e025b" + }, + "csharp-assignment-chain/Program.cs": { + "captureGroups": 29, + "digest": "81757e8ea3909070375dfea988987f2ae759d9a607498f02ca929112f05533d1" + }, + "csharp-async-binding/Order.cs": { + "captureGroups": 8, + "digest": "20a7e738e7e3248ef9fcdc34b460b899fe1dc882dc1577cabacee49c2617af2b" + }, + "csharp-async-binding/OrderService.cs": { + "captureGroups": 11, + "digest": "8aefaf0ef949e72fd3dff12bbb9fe7b97f1abe7f53cf3f13bb8ba6691da566e9" + }, + "csharp-async-binding/Program.cs": { + "captureGroups": 31, + "digest": "7598dd29b2653f598be6668b0695de630545b90726cbfa8c9fcbe352d0cb20d5" + }, + "csharp-async-binding/User.cs": { + "captureGroups": 8, + "digest": "2e80c42ee70d1c96bb4934fecd04ee696c131ec5984afc6f750f8ccf2be16f03" + }, + "csharp-async-binding/UserService.cs": { + "captureGroups": 11, + "digest": "d51bfff25e0f3a62962dc61a57e8a642529d47691a5ed75bceaac6b37c48848e" + }, + "csharp-call-result-binding/App.cs": { + "captureGroups": 24, + "digest": "da0e6286460e3eb719452574c2c627f3d242394b165525e026926c08b26cb88c" + }, + "csharp-calls/Services/UserService.cs": { + "captureGroups": 10, + "digest": "bea2e451e3d28fd3eb172c6ba99f1fb4827929733edef6aaf49545a830caaa54" + }, + "csharp-calls/Utils/OneArg.cs": { + "captureGroups": 6, + "digest": "4ca5be18e4c5b9e5cebfecc2424e69d4adf8160a857ded2efc28dc4676965a3d" + }, + "csharp-calls/Utils/ZeroArg.cs": { + "captureGroups": 6, + "digest": "02e3a191df1c53fe29d9a463bb65eee0e98ef9d19288678e1fee7ae6c692809d" + }, + "csharp-chain-call/Models/Repo.cs": { + "captureGroups": 7, + "digest": "8bab68d412d077c3c4d48d9f95f51847f06d11391cda73e49d528ce58b494b88" + }, + "csharp-chain-call/Models/User.cs": { + "captureGroups": 7, + "digest": "1bce064128c35d0799914587b2f4afe7ff69f36a3ab6c6bcf0ead801a7171cd0" + }, + "csharp-chain-call/Program.cs": { + "captureGroups": 12, + "digest": "db5c68b0370c594cd006cabb3c836c7a0c5d9ab3a9be8f3cb4420ec76bd31d8a" + }, + "csharp-chain-call/Services/UserService.cs": { + "captureGroups": 10, + "digest": "89b0d36671d7a0392a2b2a719efa6c337713f60e4ed7edb6ef83d27c58fbf588" + }, + "csharp-child-extends-parent/src/App.cs": { + "captureGroups": 12, + "digest": "817c2f4caf130d8af6ca4bbbc44d25c3186b59e1bddf6cb06b93cd99b98abd0c" + }, + "csharp-child-extends-parent/src/Child.cs": { + "captureGroups": 4, + "digest": "18a42204289b0c126ff5eb78497c631c51361b030626639b60e8005d0e4e69c2" + }, + "csharp-child-extends-parent/src/Parent.cs": { + "captureGroups": 7, + "digest": "0d8085667a8b8d406588ce115772ada953373dbe8a7c334f5ae17acd0ac01a64" + }, + "csharp-class-static-field-access/src/Counters.cs": { + "captureGroups": 13, + "digest": "a01b1b506018b475500beac2bc0662c1f77ac04086eb1b075714ed6360cae9d0" + }, + "csharp-collection-accessor/Models/Widget.cs": { + "captureGroups": 7, + "digest": "1289983262ca7a29ad77abb559243eb4ab7dcb7e036563a09989fd4e4898b49c" + }, + "csharp-collection-accessor/Services/Renderer.cs": { + "captureGroups": 15, + "digest": "21a4db0b832bd83307b0fbacd1540854157457c95a81c2e351653ff2009a972d" + }, + "csharp-deep-field-chain/Models.cs": { + "captureGroups": 24, + "digest": "c14f06f17ae5bad8aaf7ed6c5691a30b119aceeea35016e765c12cf60ba22f13" + }, + "csharp-deep-field-chain/Service.cs": { + "captureGroups": 12, + "digest": "0a5a6dfb0a872ebda19856f7e7cf805e1e72000e96ac1db5939c4df0234acc86" + }, + "csharp-dictionary-keys-values/App.cs": { + "captureGroups": 19, + "digest": "b445e081f6556e4f84e45f0ad3b4d6f8a4b64ff24a6e53a7ac2109d80fc3225a" + }, + "csharp-dictionary-keys-values/Repo.cs": { + "captureGroups": 7, + "digest": "15141c737496213d6a3cac0a3078a9a0ce247e95d901825f3555ac3128198db8" + }, + "csharp-dictionary-keys-values/User.cs": { + "captureGroups": 7, + "digest": "49ec4930562a8e12040010b12535a1620aa5ab86e2aab3cad2623e8aec106518" + }, + "csharp-field-types/Models.cs": { + "captureGroups": 16, + "digest": "c66da699df823e98eba7ee05170001ed51a7f4ed464ece95c8bb8aa92c2cb5bc" + }, + "csharp-field-types/Service.cs": { + "captureGroups": 9, + "digest": "c39485169d15a2b74cc3b67417aab92edf7b6c3a2d64e8e4311b14fb7f5a882e" + }, + "csharp-foreach/Models/Repo.cs": { + "captureGroups": 7, + "digest": "65fc6c6688e7f7badead653bbef85fca225b6910eeaacb9f30d03cb28aeb1e26" + }, + "csharp-foreach/Models/User.cs": { + "captureGroups": 7, + "digest": "4f0c929c82e56f41e5b3b4c0aefee2121d7a307ef24535ac998fd7041d0e025b" + }, + "csharp-foreach/Program.cs": { + "captureGroups": 17, + "digest": "44b01246c74a987b8e0938eeda7609b2b3e068b92f1217e33151c20637eb99d7" + }, + "csharp-frozen-binding-collision/App/Program.cs": { + "captureGroups": 17, + "digest": "9e53c57dcddc2ce6d33556c0ab3240cddd7f28647a8d03992c4b082db0f5e5c8" + }, + "csharp-frozen-binding-collision/Models/User.cs": { + "captureGroups": 7, + "digest": "d83de96ab701ee20bfac480d507c2d6f1c1e973fa466a9015f2cdf0d9be0803a" + }, + "csharp-generic-parent-resolution/src/Models/BaseModel.cs": { + "captureGroups": 7, + "digest": "7773394d9ee9598dc3a9c0992c8ccf411071edde9418ab73d1c21eb5037a108a" + }, + "csharp-generic-parent-resolution/src/Models/Repo.cs": { + "captureGroups": 7, + "digest": "03009e02efb0bb1fb33dae6cd15b99c46f2e11a30da79fb8696ec3066c203db0" + }, + "csharp-generic-parent-resolution/src/Models/User.cs": { + "captureGroups": 9, + "digest": "62f80db9064341b9ff5f5761113672ec4ceb369af962a94e3b8b4b34c4e26e9c" + }, + "csharp-generic-type-refs/Program.cs": { + "captureGroups": 21, + "digest": "833526f5f33d9b648fa22f696a3c2fff9ebfd9e733f567fa13c9607d40faba06" + }, + "csharp-grandparent-resolution/Models/A.cs": { + "captureGroups": 9, + "digest": "2687b8e8f0b869d1ea8bdb9cf04dba3845f25ec1699b518114c57c4851b3528d" + }, + "csharp-grandparent-resolution/Models/B.cs": { + "captureGroups": 4, + "digest": "0031e28abc076739af04291521c7c5272201c68c60ce5cef68f259a2c10429d4" + }, + "csharp-grandparent-resolution/Models/C.cs": { + "captureGroups": 4, + "digest": "8f100ee67304e05401c5021167d36fa7a1fbce9188fd8f1d2fdf9ac0e7a6eb7d" + }, + "csharp-grandparent-resolution/Models/Greeting.cs": { + "captureGroups": 7, + "digest": "fa105c430ad5ff6dbf73cd93f219ba42464cab335e8bb3b29a06326e8def17be" + }, + "csharp-grandparent-resolution/Services/App.cs": { + "captureGroups": 13, + "digest": "7e386e4731095480c515b8f4a538ea6eb19800345ce4c0e51d6d9307971f2a97" + }, + "csharp-hello/Hello.cs": { + "captureGroups": 19, + "digest": "a6e6e37315c555af53aed63767a341bb3b1a4086e64d99b4b073037a4bc0284b" + }, + "csharp-interface-default-method/App.cs": { + "captureGroups": 11, + "digest": "3f64ed5bfd0eb0bc62d73b908eebac7025f9e32639aeebc8db9160f3523b521a" + }, + "csharp-interface-default-method/User.cs": { + "captureGroups": 10, + "digest": "747fe9fed63700c0ce233b76d2544ed4cae16d68beb4ea29095d24cf68ff8bbe" + }, + "csharp-interface-default-method/Validator.cs": { + "captureGroups": 7, + "digest": "27f5051d339f63be01534b002271db6efe647955501129b90d6fd18fb3dac18c" + }, + "csharp-interface-dispatch/App.cs": { + "captureGroups": 11, + "digest": "ca1d074837f867a78d9b5e1f197ab967128e89b20d62acdba34bfaa08b01ef6d" + }, + "csharp-interface-dispatch/IRepository.cs": { + "captureGroups": 7, + "digest": "14743965ad586b13254a9618686d601b0dd36f3be97531c9fb0605139e83809d" + }, + "csharp-interface-dispatch/SqlRepository.cs": { + "captureGroups": 11, + "digest": "57cf94afb053e43647c295d3bd5ba9864a56cc75895d5e37194d2df18f1e744a" + }, + "csharp-interface-heritage/src/IAuditableService.cs": { + "captureGroups": 5, + "digest": "eca6d49bcd2d8316ced662b168c4cd379f6f0ce08e591d6fd1ff513e28372a56" + }, + "csharp-interface-heritage/src/IBarService.cs": { + "captureGroups": 6, + "digest": "ebd71b8cd792309f85be2bdccb2c99c8ee55a818ffb92791b8f72fbe6a04e791" + }, + "csharp-interface-heritage/src/IBaseInterface.cs": { + "captureGroups": 6, + "digest": "cfbc24950b74dd10ddb59c25f8aa4d4b83c5e72502c4227f3f6e311e9e06401f" + }, + "csharp-interface-heritage/src/IFooService.cs": { + "captureGroups": 6, + "digest": "19bef355b4992f96f06331ed11d8652b86d25ac1fb5e9cbc2d5d5c0ef85b123d" + }, + "csharp-interface-heritage/src/MyService.cs": { + "captureGroups": 18, + "digest": "c565e684ad7e04915bce9fc3124e51db6b41669a20f29b4bc117317506bd7985" + }, + "csharp-interface-receiver-static/src/ILogger.cs": { + "captureGroups": 6, + "digest": "846b1d46ba9e45334e2e495f42e9d54ce7b86f36e955877b644f9f52bb5f654b" + }, + "csharp-interface-receiver-static/src/Runner.cs": { + "captureGroups": 8, + "digest": "9ab5f0dd02a660099b28b88e289ac2ab724ec7eea662ba535263bd76d17d9bd4" + }, + "csharp-is-pattern/models/Repo.cs": { + "captureGroups": 7, + "digest": "bdbacca40f98981bacda770bc3b594f7346956ec512b017981e5dff2528428fa" + }, + "csharp-is-pattern/models/User.cs": { + "captureGroups": 7, + "digest": "1a3928a23d3787dd0dc3ff8f4508b06ca67b9cac6f2ff9735e61ff2968fa53f6" + }, + "csharp-is-pattern/services/App.cs": { + "captureGroups": 10, + "digest": "669b5e0cbb49ed97ebb0721135f45e2f1e796952c39820d6c03ab9d0d2017aae" + }, + "csharp-large-cache-miss-resolution/Models/User.cs": { + "captureGroups": 7, + "digest": "8902fae6ec1e6e6b2bd81e669208dd1277caea531777c9b1e1fef7492b8536f4" + }, + "csharp-large-cache-miss-resolution/Other/Helper.cs": { + "captureGroups": 4, + "digest": "2b8a0df5cb14c0c7df3d1dac09e25e8536d3d9ef7ae107570f09f92df3e3f554" + }, + "csharp-large-cache-miss-resolution/Services/UserService.cs": { + "captureGroups": 15, + "digest": "711b8e8828d46e47c4ec58fb988f69d119b137610c87d64a0bb0b608855e06a7" + }, + "csharp-local-shadow/App/Main.cs": { + "captureGroups": 12, + "digest": "eabfcf7555db7d72f29e5a4680349ff8b3be3ad2ed4247f41c67814e1d03a93a" + }, + "csharp-local-shadow/Utils/Logger.cs": { + "captureGroups": 8, + "digest": "1b3b9b6fa338689500910b63444c4460392781db52ce875d760d724d7e6d3953" + }, + "csharp-member-calls/Models/User.cs": { + "captureGroups": 7, + "digest": "4f0c929c82e56f41e5b3b4c0aefee2121d7a307ef24535ac998fd7041d0e025b" + }, + "csharp-member-calls/Services/UserService.cs": { + "captureGroups": 12, + "digest": "2bbba859b5b5840dd880fac07de97a289be88f6ea11e0eadca652d66cad0b20a" + }, + "csharp-method-chain-binding/App.cs": { + "captureGroups": 53, + "digest": "09e0bed66a03cddb567b863b8d916b5b2f4210547caf2f8682c266d36102125e" + }, + "csharp-method-enrichment/Animal.cs": { + "captureGroups": 16, + "digest": "a2200a76108b6ef05340718bf7485d762f8004db76191992552cba370eb3999c" + }, + "csharp-method-enrichment/App.cs": { + "captureGroups": 14, + "digest": "e51201af749c9f1f15331f0dafceb9c9b18deb63df8da0eb42164a19bbacffbc" + }, + "csharp-mixed-decl-chain/Models/Repo.cs": { + "captureGroups": 6, + "digest": "6f4b66e81160e277a84b475e3ff9a27e55dfe5c8f03b370b5304ec1405f055de" + }, + "csharp-mixed-decl-chain/Models/User.cs": { + "captureGroups": 6, + "digest": "be74672b6af6192d0b7872dcad9a6ba4af08156aad5cc01c6fe3eb2ee02b4127" + }, + "csharp-mixed-decl-chain/Program.cs": { + "captureGroups": 25, + "digest": "4f25b7d6327c0f9d1f4ca9e8d1af07ced3b6fedbfbb8e4996c73cbfb963ef24e" + }, + "csharp-namespace-as-root-no-trailing-newline/App/Program.cs": { + "captureGroups": 12, + "digest": "de7d96908b66cbabf13203aa6ffa613e7cb463ed98e1073d98efc0d10a26a11b" + }, + "csharp-namespace-as-root-no-trailing-newline/Models/User.cs": { + "captureGroups": 7, + "digest": "c024ea5f5d7dc3ecfe98be3d3b38d18bbe358974bfce69514c2474f5e44d6490" + }, + "csharp-nested-member-foreach/App.cs": { + "captureGroups": 19, + "digest": "e4a193bdab13753422287500c3933ddf8fde1290c2250c3fecc838968f3092e9" + }, + "csharp-nested-member-foreach/Repo.cs": { + "captureGroups": 7, + "digest": "15141c737496213d6a3cac0a3078a9a0ce247e95d901825f3555ac3128198db8" + }, + "csharp-nested-member-foreach/User.cs": { + "captureGroups": 7, + "digest": "49ec4930562a8e12040010b12535a1620aa5ab86e2aab3cad2623e8aec106518" + }, + "csharp-no-csproj/Models/User.cs": { + "captureGroups": 10, + "digest": "861d7c13c4460ccce77d898d7720c7ae0d8eef49af4cee9c8fd09f1f1e59b36f" + }, + "csharp-no-csproj/Services/UserService.cs": { + "captureGroups": 12, + "digest": "a9fae488a7a5ce393554f0c25ccf6d23ea02c78590ec9fcdb1a93e37515c3587" + }, + "csharp-null-check-narrowing/Models/Repo.cs": { + "captureGroups": 7, + "digest": "36f2a415f8e92380a6809045c632fad8185cae70f63d76433a7ffb6dc2f89e72" + }, + "csharp-null-check-narrowing/Models/User.cs": { + "captureGroups": 7, + "digest": "81148127c77341020f9841d665aca8b40868f1121eafdb27ddf0265690a6e31f" + }, + "csharp-null-check-narrowing/Services/App.cs": { + "captureGroups": 29, + "digest": "64c9dfa8df03023edb1728cecd320d8a0cea67ccb5701e26461a26f08f8c544a" + }, + "csharp-null-conditional/App.cs": { + "captureGroups": 16, + "digest": "4c6ac6c2d8e17d4f4b0921dd139947ccb7b4cb950b895e3d6ee939a873918cee" + }, + "csharp-null-conditional/Models/Repo.cs": { + "captureGroups": 7, + "digest": "1bd630cb2c4b938d97ee7416ce1542feacd0b6d40163f76cdec7fcf3df59bd0b" + }, + "csharp-null-conditional/Models/User.cs": { + "captureGroups": 7, + "digest": "4f0c929c82e56f41e5b3b4c0aefee2121d7a307ef24535ac998fd7041d0e025b" + }, + "csharp-optional-params/Services/App.cs": { + "captureGroups": 15, + "digest": "d146e09259dd67a7bc33d1117d4df7a65e760645d024539fcee787db67ed0fdf" + }, + "csharp-overload-dispatch/App.cs": { + "captureGroups": 12, + "digest": "c0ac495ae6dc34db5cdb9f508be050fab06920e8f110b14a8c9f095613a269c5" + }, + "csharp-overload-dispatch/IRepository.cs": { + "captureGroups": 9, + "digest": "5211301516540daf9c54fb3c8ca3b43bddcce4f81a14034056f2bc897ae925a4" + }, + "csharp-overload-dispatch/SqlRepository.cs": { + "captureGroups": 16, + "digest": "7052be187bc4039e4e94c29446c4dae0aed9b288d95c067905a15a33f3d1625d" + }, + "csharp-overload-interface/App/Caller.cs": { + "captureGroups": 13, + "digest": "67439018a3750918b66735e67dc4edcef8ffaacd37d97a9efcc3504c492a1a0a" + }, + "csharp-overload-interface/App/Logger.cs": { + "captureGroups": 10, + "digest": "1f78db08c2a7a8d3402cf3b0f092ec6aa5b6aa9fedc790d088eaffce8e4bd713" + }, + "csharp-overload-interface/Greeting/EnGreeter.cs": { + "captureGroups": 8, + "digest": "f719d5044c243225989e8cbf675d01adeb5cc2023338f320d422ccdab94964ac" + }, + "csharp-overload-interface/Greeting/FrGreeter.cs": { + "captureGroups": 8, + "digest": "4e18d58d5a54a87262e42cd84043dc31cbf6d31609eb5a8b3d61c0e692f3ea08" + }, + "csharp-overload-interface/Greeting/IGreeter.cs": { + "captureGroups": 6, + "digest": "0de9dec9ee677f1f6ce37654af62bcfab81270c12713d806475397936957eb23" + }, + "csharp-overload-param-types/Models/UserService.cs": { + "captureGroups": 27, + "digest": "84cd0161f46cf334a85ed210739df5c40ae8fc7e8d8c7a4cbc3e459d2927ee1c" + }, + "csharp-parent-resolution/src/Models/BaseModel.cs": { + "captureGroups": 7, + "digest": "7eb6d696b2699b0b2ed65dbd77061dc3978a469dd412e6ddab2fc17ea273df1d" + }, + "csharp-parent-resolution/src/Models/ISerializable.cs": { + "captureGroups": 6, + "digest": "93f2d7c639083aa077ba3c9d2dea678d0d6a2aa914e8078307c98f4c68530226" + }, + "csharp-parent-resolution/src/Models/User.cs": { + "captureGroups": 8, + "digest": "ad73f273c29be9536b40c663f6d1864f78b2bca667483d73cc3de4c4900c79ed" + }, + "csharp-pattern-matching/Models/Animal.cs": { + "captureGroups": 17, + "digest": "fda2322e993f129e139d11bffe48e2742459792e90b99b8eb0c9a10da15b5139" + }, + "csharp-pattern-matching/Services/AnimalService.cs": { + "captureGroups": 11, + "digest": "5418fbf243683914fea8eeed02f2ef856b02ba6c7e361e001da4662eb1332359" + }, + "csharp-primary-ctors/App.cs": { + "captureGroups": 14, + "digest": "4c2e190ed675ac78244e0a4230d79e9e4c74f2ef794dd9b8126f6b57a4242f4c" + }, + "csharp-primary-ctors/Models/Person.cs": { + "captureGroups": 5, + "digest": "f11b11a31ad7a691a0369d61ce8c067c2d0755515fce4fee2657b6e444c7f21a" + }, + "csharp-primary-ctors/Models/User.cs": { + "captureGroups": 10, + "digest": "5233b368adda16fb7573e5aedbd3be0692f166eddff9ee987a0be93c7eb1a4e8" + }, + "csharp-proj/Interfaces/IRepository.cs": { + "captureGroups": 12, + "digest": "f3e98fc370c43743c84d82432ed7e5c7039acd0032ee1194d53453ba2c28f132" + }, + "csharp-proj/Models/BaseEntity.cs": { + "captureGroups": 8, + "digest": "633570540177423d352b44b0f5230d7f440f101789d1b92192c0fc360e6c26d3" + }, + "csharp-proj/Models/User.cs": { + "captureGroups": 18, + "digest": "a9ef60d8f0ad5109369df107c4ce20ac472e5a0d018bc7df2350c2ae66607372" + }, + "csharp-proj/Services/UserService.cs": { + "captureGroups": 19, + "digest": "42b79c274955abcd548ca842077e05b0b6b46e324dd29f7a297882ac790ce11f" + }, + "csharp-qualified-types/Data/User.cs": { + "captureGroups": 7, + "digest": "16b056de69cc44d953e4cc702f22986b61d935193c4d6bd9cd9a1adcbe2b3e6c" + }, + "csharp-qualified-types/Services/User.cs": { + "captureGroups": 7, + "digest": "c3a275cbcdcdd71b6611df01710d0e7d201924aada837f35d8c956207a5a17c4" + }, + "csharp-receiver-resolution/App.cs": { + "captureGroups": 18, + "digest": "41bbfa18dc9cce94ce04de96f01381f86e5fedb3d7c6acf3cbcb8de2b45110e8" + }, + "csharp-receiver-resolution/Models/Repo.cs": { + "captureGroups": 7, + "digest": "65fc6c6688e7f7badead653bbef85fca225b6910eeaacb9f30d03cb28aeb1e26" + }, + "csharp-receiver-resolution/Models/User.cs": { + "captureGroups": 7, + "digest": "4f0c929c82e56f41e5b3b4c0aefee2121d7a307ef24535ac998fd7041d0e025b" + }, + "csharp-record-base/src/Models/BaseEntity.cs": { + "captureGroups": 7, + "digest": "e7da2e190dad718eeaa22dad011740fd2086eb2f86331d887e47cd9c5092003c" + }, + "csharp-record-base/src/Models/UserRecord.cs": { + "captureGroups": 9, + "digest": "4b7092ef2ded4e37d2fe1591610259e18b9bd3d92ebe32c90eef6683d9b9a0f6" + }, + "csharp-recursive-pattern/Models/Repo.cs": { + "captureGroups": 8, + "digest": "089242f5a0e1d4338002f9f72788116e285ee24f719e702bc15a1858e7a04d97" + }, + "csharp-recursive-pattern/Models/User.cs": { + "captureGroups": 8, + "digest": "a460c8057d4d5306947c2fec7ef92fd5b8e2e499d99b560f9f9a837075e43795" + }, + "csharp-recursive-pattern/Program.cs": { + "captureGroups": 13, + "digest": "4bb642f82e6de5de638eb9ffb9f8fa79a50e32a8fb5b7f71ee92368b96a350a9" + }, + "csharp-return-type/Models/Repo.cs": { + "captureGroups": 7, + "digest": "ab4a56185a33d8afcbcf9a4c60bb8c6c90be611bc9e61516a768a2c67f4616f5" + }, + "csharp-return-type/Models/User.cs": { + "captureGroups": 19, + "digest": "5f0eebeef76cfcf10180cd431fc14443de0a7c6d501a55a24ae774a94bca2bb7" + }, + "csharp-return-type/Services/App.cs": { + "captureGroups": 15, + "digest": "d3634ba17d4d96216df5fd0d7e967309dd32793636598b2d160fd19a2eea3e25" + }, + "csharp-same-arity-cross-file/App.cs": { + "captureGroups": 47, + "digest": "3b66f87310d7dae98a79a6983996abca827e8b78d37e5a7234e26e9520634c5b" + }, + "csharp-same-arity-cross-file/DbLookup.cs": { + "captureGroups": 11, + "digest": "77916f4d60865d277c624d1a7ecde55fdb10e6354e5dc1d516c80677628dee3a" + }, + "csharp-same-arity-cross-file/Formatter.cs": { + "captureGroups": 11, + "digest": "83c7bbf6774596aa06aa75480a136f442356bf44bf2a048e9f9a561be613d781" + }, + "csharp-same-arity-cross-file/ILookup.cs": { + "captureGroups": 7, + "digest": "ee3c7f29ba0638d2917d364fa39cd80220655b4ee0825eda1d2640e7d9e8c500" + }, + "csharp-self-this-resolution/src/Models/Repo.cs": { + "captureGroups": 7, + "digest": "03009e02efb0bb1fb33dae6cd15b99c46f2e11a30da79fb8696ec3066c203db0" + }, + "csharp-self-this-resolution/src/Models/User.cs": { + "captureGroups": 11, + "digest": "769120fcebccfa2088d22b3a5a1dcabf49ab134fae03688a26b5ca2dafe6aca9" + }, + "csharp-spurious-edges-no-csproj/Legacy/System/Threading/Tasks.cs": { + "captureGroups": 7, + "digest": "41d7afa4d3c8cddaaba2dce5c725964a04cc20c7b3d766cbb163ec3e19ff0080" + }, + "csharp-spurious-edges-no-csproj/Models/User.cs": { + "captureGroups": 5, + "digest": "b9d373709df7aef537bbdf28cf00d9a71b8ef8a1d217180e60c9c70c511d6c64" + }, + "csharp-spurious-edges-no-csproj/Services/OrderService.cs": { + "captureGroups": 14, + "digest": "36e86f9b88e113c1195564ad7d5ded5cbba7250ef0d4b2dfb7187e2fa6b7f9e4" + }, + "csharp-spurious-edges/Legacy/Tasks.cs": { + "captureGroups": 7, + "digest": "6c7f8cd5275bb0d9754190762b6381424fbe86d0718739ab7e40757a0c0cd446" + }, + "csharp-spurious-edges/Models/User.cs": { + "captureGroups": 5, + "digest": "b9d373709df7aef537bbdf28cf00d9a71b8ef8a1d217180e60c9c70c511d6c64" + }, + "csharp-spurious-edges/Services/OrderService.cs": { + "captureGroups": 14, + "digest": "36e86f9b88e113c1195564ad7d5ded5cbba7250ef0d4b2dfb7187e2fa6b7f9e4" + }, + "csharp-struct-overloads/src/Calc.cs": { + "captureGroups": 15, + "digest": "fabf22986700fb7bff0521b504e103583dd2b204b1765a3bbd7b5062d310f9a0" + }, + "csharp-super-resolution/src/Models/BaseModel.cs": { + "captureGroups": 7, + "digest": "3e5355115690e1b80947138c0bb1ef9f1a3a3073371bed6cfb354e7367c0d46e" + }, + "csharp-super-resolution/src/Models/Repo.cs": { + "captureGroups": 7, + "digest": "03009e02efb0bb1fb33dae6cd15b99c46f2e11a30da79fb8696ec3066c203db0" + }, + "csharp-super-resolution/src/Models/User.cs": { + "captureGroups": 9, + "digest": "231c0dabbb2e5d105ee19ae3d043f576315f4ec8764baaa310dda95770175827" + }, + "csharp-switch-pattern/Models/Repo.cs": { + "captureGroups": 7, + "digest": "b539bad52d6d4ace3120ff489d2524717ba6c9cdd9df8f242e6dc2848a9c8731" + }, + "csharp-switch-pattern/Models/User.cs": { + "captureGroups": 7, + "digest": "a16151da571f84e36a531970767568ce684e09bf47a6ec5ce50410e7be675a09" + }, + "csharp-switch-pattern/Program.cs": { + "captureGroups": 12, + "digest": "49dbf7471b0bfbf26b37a82ab0794e74cf6ffcc2a04fed5dc3c9df3bece8e38c" + }, + "csharp-using-static/App/Calculator.cs": { + "captureGroups": 9, + "digest": "9e6b557a1920d5f793e0bc6c6bbdf4dc9d74d7ff50e7b833961ec089e265551f" + }, + "csharp-using-static/Helpers/MathUtils.cs": { + "captureGroups": 6, + "digest": "054eff66bd3582079779542135943e70f9d9f95b03329e45944eb85077290c08" + }, + "csharp-var-foreach/Models/Repo.cs": { + "captureGroups": 7, + "digest": "b539bad52d6d4ace3120ff489d2524717ba6c9cdd9df8f242e6dc2848a9c8731" + }, + "csharp-var-foreach/Models/User.cs": { + "captureGroups": 7, + "digest": "a16151da571f84e36a531970767568ce684e09bf47a6ec5ce50410e7be675a09" + }, + "csharp-var-foreach/Program.cs": { + "captureGroups": 27, + "digest": "9b415bc4739d5ee3e4e538f225a96694391029be69b662de35d9edf47116f345" + }, + "csharp-variadic-resolution/Services/App.cs": { + "captureGroups": 9, + "digest": "51b9c158776e89f6be4225849f31470889c442c6e064f5c38bc32806a691106b" + }, + "csharp-variadic-resolution/Utils/Logger.cs": { + "captureGroups": 7, + "digest": "abc447c521d792d089fefc63ac281c5af0f94dd76dc284e83296f20617ab6b14" + }, + "csharp-write-access/Models.cs": { + "captureGroups": 9, + "digest": "0d65b1d41a42a5a79eecf3a2ff2c7b3585c52dadd479770c2572a146afdc6606" + }, + "csharp-write-access/Service.cs": { + "captureGroups": 10, + "digest": "1782eca84697c9ebd7fe802d1e0e0952d29cef8d8834e826b359388561a41c06" + }, + "synthetic:dao-20": { + "captureGroups": 222, + "digest": "058e8fd3360af32ce483e6be7887ee12cd1a4150904b43f88d1fa7bd9bb7577b" + } +} diff --git a/gitnexus/test/fixtures/go-captures-golden/expected-captures.json b/gitnexus/test/fixtures/go-captures-golden/expected-captures.json new file mode 100644 index 000000000..24a0fc8d1 --- /dev/null +++ b/gitnexus/test/fixtures/go-captures-golden/expected-captures.json @@ -0,0 +1,362 @@ +{ + "go-aliased-package-import/internal/util/log.go": { + "captureGroups": 4, + "digest": "77be0bd9a9df464f42a6cefd0c16064d4ef97e79f5f61faa8df6069989cec9f6" + }, + "go-aliased-package-import/main.go": { + "captureGroups": 7, + "digest": "3eb2e6d441dadede554b271b7a87b7b9ca557ab418bfb9e8524e6ace4c8fc547" + }, + "go-ambiguous/internal/models/handler.go": { + "captureGroups": 9, + "digest": "619c516a5791095bc6380de62fa861364b3f9480f2ec87d4499c2c998f228713" + }, + "go-ambiguous/internal/other/handler.go": { + "captureGroups": 9, + "digest": "08a61721581c4f17741ef0c4ee1c8945235ee7f2aca7d88d2fe122071cd62e6f" + }, + "go-ambiguous/internal/services/user.go": { + "captureGroups": 8, + "digest": "802b81a07c64c01f2381cf33d41cdb1a58f535c0007b5ae151c38cda87f28321" + }, + "go-assignment-chain/cmd/main.go": { + "captureGroups": 50, + "digest": "47ba5fd2ea96ee202b3a3de5c0db75ea18d0889064ed2138d62c67594f7d22e9" + }, + "go-assignment-chain/models/repo.go": { + "captureGroups": 8, + "digest": "1b3acbf48751105d056253488f34159266bdb0b229248c85065e927b72bf4801" + }, + "go-assignment-chain/models/user.go": { + "captureGroups": 8, + "digest": "457c76ebf1c86efac7a7d9f384a8e49acd5649c006e6cc8aa29a59c36f0b102a" + }, + "go-call-result-binding/cmd/main.go": { + "captureGroups": 16, + "digest": "83d611dcee826ec848a0b3fd5a18a98103896d17c353d8d63cd15551039818e9" + }, + "go-call-result-binding/models/user.go": { + "captureGroups": 10, + "digest": "5c31199e148340ee32dd48e32a00003e094be963b1287b9b40c6b4effca12175" + }, + "go-calls/cmd/main.go": { + "captureGroups": 6, + "digest": "deaea55087c2652aa2d39fefa30048d5393cec40f7af1ab13921331b035030d6" + }, + "go-calls/internal/onearg/log.go": { + "captureGroups": 6, + "digest": "a101eafe9f08396bb176cf3cadd3960d752802048e47c0ac9e9ace07244a46fb" + }, + "go-calls/internal/zeroarg/log.go": { + "captureGroups": 5, + "digest": "7b322767a38298de8c6ce99fa6b1d69dda8bbb79704053644bfaa7d2e4473fdb" + }, + "go-chain-call/cmd/main.go": { + "captureGroups": 20, + "digest": "5a8d7de8ae87887902d16f28cb70d31014c96ab5d2eb63cbd6c8be27650fa9a1" + }, + "go-chain-call/models/repo.go": { + "captureGroups": 10, + "digest": "ac4799aae638d528c5c7c01c8b9734fc61e995596a12b12e790dcffab97b4029" + }, + "go-chain-call/models/user.go": { + "captureGroups": 10, + "digest": "5c31199e148340ee32dd48e32a00003e094be963b1287b9b40c6b4effca12175" + }, + "go-child-extends-parent/models/child.go": { + "captureGroups": 3, + "digest": "6fd9fe7b82066f82a93bf5e5024ddf89382091ec04e55648845c8295f13bd412" + }, + "go-child-extends-parent/models/parent.go": { + "captureGroups": 8, + "digest": "454a724f571a5aede89d7657f76ed8e3c9b19bfc18d524141d1b646005f98e49" + }, + "go-child-extends-parent/services/app.go": { + "captureGroups": 10, + "digest": "05f5df0369c90bc6e0da5a8a76e0661be36cddbe913ab2f83da2ee3733a46ec2" + }, + "go-cmd-helper/cmd/server/internal/config/config.go": { + "captureGroups": 5, + "digest": "b10874198d380b0a186fb1e1ee8cedac644eb3b6b624398110a660183e59a0b5" + }, + "go-cmd-helper/cmd/server/main.go": { + "captureGroups": 7, + "digest": "a1f9453bd71926d60e3f148f43b9af813cbd1cccc11b323896a55bdb443f8931" + }, + "go-constructor-type-inference/cmd/main.go": { + "captureGroups": 15, + "digest": "4c496bf5ebaebae8c7480b1826b3285752a79ce50562756ded3b7fe0c6d6b325" + }, + "go-constructor-type-inference/models/repo.go": { + "captureGroups": 8, + "digest": "1b3acbf48751105d056253488f34159266bdb0b229248c85065e927b72bf4801" + }, + "go-constructor-type-inference/models/user.go": { + "captureGroups": 8, + "digest": "457c76ebf1c86efac7a7d9f384a8e49acd5649c006e6cc8aa29a59c36f0b102a" + }, + "go-deep-field-chain/cmd/main.go": { + "captureGroups": 13, + "digest": "ca9cc7ae0f75928b1ea338f42e58cf02502e0c93ce4ea8867258c90543f79d16" + }, + "go-deep-field-chain/models/models.go": { + "captureGroups": 33, + "digest": "0ad1df946f58e446a8dbd8ef13041b6e0177f53293946b955acf3c46684e4295" + }, + "go-field-types/cmd/main.go": { + "captureGroups": 9, + "digest": "3bc98cf5640568cdff3202f2594fb39d563ee595560cb28d1adb31f264670ceb" + }, + "go-field-types/models/models.go": { + "captureGroups": 22, + "digest": "fbdbf74d927c0ed07f4820c190fc6f2039b74ddead8abb45fc238812dfa4d4ef" + }, + "go-for-call-expr/cmd/main.go": { + "captureGroups": 27, + "digest": "95217f85260d85baeb57638fff470c0a358be92cc49a5f918b084d809f47ba2a" + }, + "go-for-call-expr/models/repo.go": { + "captureGroups": 14, + "digest": "312e59c1402cb83a4ce51c27866fde6c597f7121098b57ff1d3bd4dc6363834a" + }, + "go-for-call-expr/models/user.go": { + "captureGroups": 14, + "digest": "c442a26c4051c2b7426850a381137507a147c21d631d6535d71ef1035b1506fb" + }, + "go-inc-dec-write-access/main.go": { + "captureGroups": 21, + "digest": "0414398239624e44b1589f6a68f2c19636bf4b3ce74990cee1bd7cbeeb0591e6" + }, + "go-local-shadow/cmd/main.go": { + "captureGroups": 12, + "digest": "1cda9982d9b4e4878208894523f0a9c7a806c6f08a5582d4771205716a1b2733" + }, + "go-local-shadow/internal/utils/utils.go": { + "captureGroups": 6, + "digest": "51e941fa4c7765efc6e472d15a9d7ea31f59b67a6b606be868f4af47e5f54cb1" + }, + "go-make-builtin/main.go": { + "captureGroups": 18, + "digest": "7c56328d8416338ae0075ae7dd9669b16f2035aa353fac7ee1e46109a58e80a6" + }, + "go-make-builtin/models.go": { + "captureGroups": 15, + "digest": "4d5628f66471f1ad1180a47b797195506d190ddc84f61f6c57f2e22afd754c1e" + }, + "go-map-range/main.go": { + "captureGroups": 10, + "digest": "7f3580a0e7858e6eb3176e0b5e1bec4867a2d5d07f2b216a04a3de79ff96170b" + }, + "go-map-range/models/repo.go": { + "captureGroups": 9, + "digest": "6cbc4422fb287007735b4c58b5e9c84bbd6b8f6082e3b4d5fe48c306e6acc75b" + }, + "go-map-range/models/user.go": { + "captureGroups": 9, + "digest": "7e4dbc05ad1de859cd3103c27cddb60566d95a8a8354c752e8c75f84d7ca055c" + }, + "go-member-calls/cmd/main.go": { + "captureGroups": 11, + "digest": "e46f6d1dff39943f8f89c05e0d28f61f8471cdc729c91f01ca693a38a244b8ba" + }, + "go-member-calls/models/user.go": { + "captureGroups": 8, + "digest": "457c76ebf1c86efac7a7d9f384a8e49acd5649c006e6cc8aa29a59c36f0b102a" + }, + "go-method-chain-binding/cmd/main.go": { + "captureGroups": 21, + "digest": "0fff0df5dd77e13e0b9e62dd7f38efc038d33ffd5d891f684289eccbc46ff583" + }, + "go-method-chain-binding/models/user.go": { + "captureGroups": 24, + "digest": "504ea598176dd8e01d759cc54e012735c746f14ec3a660af2c362fc356326f65" + }, + "go-method-enrichment/animal.go": { + "captureGroups": 15, + "digest": "ac0933f59d4a88a25629d02f308c66847fd34ceba5831660fc8bff07109b7708" + }, + "go-method-enrichment/app.go": { + "captureGroups": 15, + "digest": "ac0bdc2e6daf7d4e28fd255fe2edd143e8fab7ff316f4e32e2ca04a3b33f71c2" + }, + "go-mixed-chain/cmd/main.go": { + "captureGroups": 20, + "digest": "3662b803da4f4fc1ac0552a45d3d262bd0db57f97b056614caacea8ad856250e" + }, + "go-mixed-chain/models/models.go": { + "captureGroups": 41, + "digest": "dc2cedaefcd73faf13a9f704a4be8f0bd4140c608cd86480e1f4400fec49dbd1" + }, + "go-multi-assign/app.go": { + "captureGroups": 16, + "digest": "66958a3e86caa54aedd795227dac274cfb3f4b39ab98964e8a7bcbdfc8a08ca6" + }, + "go-multi-assign/models.go": { + "captureGroups": 19, + "digest": "a4d43cf2cd2f7bdbc750a7ee1f5e9c7611e5d1e25f1a46386d0eeb9f73bb7846" + }, + "go-multi-return-inference/cmd/main.go": { + "captureGroups": 36, + "digest": "6229b69cfd15bf1465d770d97486522faac5a91cf8828c424c8c8c989b310224" + }, + "go-multi-return-inference/models/repo.go": { + "captureGroups": 10, + "digest": "ac4799aae638d528c5c7c01c8b9734fc61e995596a12b12e790dcffab97b4029" + }, + "go-multi-return-inference/models/user.go": { + "captureGroups": 10, + "digest": "5c31199e148340ee32dd48e32a00003e094be963b1287b9b40c6b4effca12175" + }, + "go-new-builtin/main.go": { + "captureGroups": 12, + "digest": "1177b99217a42a28b0d768d29e1df4198f0d5ca5f1c2b584d528f5f02354b38f" + }, + "go-new-builtin/models.go": { + "captureGroups": 17, + "digest": "40c9f89942406caf2610f0d1954b66e905c39dd5d617d7359e572445d586c15d" + }, + "go-nullable-receiver/cmd/main.go": { + "captureGroups": 27, + "digest": "7a93b339f8c68ed3882802da21343c1d6b5231ae5cd8dd13eb4c05e2e7716320" + }, + "go-nullable-receiver/models/repo.go": { + "captureGroups": 8, + "digest": "1b3acbf48751105d056253488f34159266bdb0b229248c85065e927b72bf4801" + }, + "go-nullable-receiver/models/user.go": { + "captureGroups": 8, + "digest": "457c76ebf1c86efac7a7d9f384a8e49acd5649c006e6cc8aa29a59c36f0b102a" + }, + "go-parent-resolution/models/base.go": { + "captureGroups": 8, + "digest": "2afaeb50d544a55fe437ef20e2c0de92152d2ba62f2693c329255787bb3d0a02" + }, + "go-parent-resolution/models/user.go": { + "captureGroups": 8, + "digest": "c76ba16343dd94024fdaac10fa7640536966e530d675c0084434f42edb5b5f15" + }, + "go-pkg/cmd/main.go": { + "captureGroups": 14, + "digest": "a7781d23802e876b9ca2951b7ebb317c5df5adf390ae9a57e8e2906af34f3255" + }, + "go-pkg/internal/auth/service.go": { + "captureGroups": 17, + "digest": "2204643b50f486423ee7a5877b2bab7d6334cbe62b14b4435fe4ba8a6465ce92" + }, + "go-pkg/internal/models/admin.go": { + "captureGroups": 13, + "digest": "1a5ec9fd5e752adcfec91cd03b3c2a67c124852228a527002237ec51cd4b39b7" + }, + "go-pkg/internal/models/repository.go": { + "captureGroups": 3, + "digest": "7de6e11a3cf9c89afa9d89fe37dab85b208699f20105fbade223e4f78a63b15c" + }, + "go-pkg/internal/models/user.go": { + "captureGroups": 13, + "digest": "e56fcea1c473866ed06fc0262702e54c72016a9556cc6337330c03cc1f638fe1" + }, + "go-pointer-constructor-inference/cmd/main.go": { + "captureGroups": 15, + "digest": "39f9030e909a37f0e13724f50673dd4a72ba240fb6ac263192ee4feadbc88adf" + }, + "go-pointer-constructor-inference/models/repo.go": { + "captureGroups": 10, + "digest": "ac4799aae638d528c5c7c01c8b9734fc61e995596a12b12e790dcffab97b4029" + }, + "go-pointer-constructor-inference/models/user.go": { + "captureGroups": 10, + "digest": "5c31199e148340ee32dd48e32a00003e094be963b1287b9b40c6b4effca12175" + }, + "go-receiver-method-free-call/example.go": { + "captureGroups": 8, + "digest": "2a3c26672d3b997bdc39644361c550f8cf0749489f0945d210e2fb7f3bca9383" + }, + "go-receiver-method-free-call/util.go": { + "captureGroups": 4, + "digest": "0ac9740c13c851ca101e074bd16422f9da45b8c27db4befbc0c5e7479c2ccdda" + }, + "go-receiver-resolution/cmd/main.go": { + "captureGroups": 13, + "digest": "e92c59312a46972a6083ba489888b550cdd024b809acbbf367ad025efb844a5c" + }, + "go-receiver-resolution/models/repo.go": { + "captureGroups": 8, + "digest": "1b3acbf48751105d056253488f34159266bdb0b229248c85065e927b72bf4801" + }, + "go-receiver-resolution/models/user.go": { + "captureGroups": 8, + "digest": "457c76ebf1c86efac7a7d9f384a8e49acd5649c006e6cc8aa29a59c36f0b102a" + }, + "go-return-type-inference/cmd/main.go": { + "captureGroups": 39, + "digest": "ac8ca3dc1fb7fb4d7a1f947a77e93db890f04d328135dc514f36ba5cd04bc3e1" + }, + "go-return-type-inference/models/repo.go": { + "captureGroups": 16, + "digest": "8c500db82093b4622acbca734f3040e6176000a9d9a4843e2a81e3f2b3daee7c" + }, + "go-return-type-inference/models/user.go": { + "captureGroups": 16, + "digest": "a70b19b9c46a02003f26d0b70fa5368983a791583ec3776e983c733a9283eefa" + }, + "go-same-package-factory/main.go": { + "captureGroups": 14, + "digest": "505d4d279615f3c99457d8cb0f29fdf946b547c6b1fda6d43aa6d4302dd61809" + }, + "go-same-package-factory/repo.go": { + "captureGroups": 8, + "digest": "f8bb213588f517e166b421f19d72cde981153464cc3f3168499769e778c4a22e" + }, + "go-same-package-factory/user.go": { + "captureGroups": 8, + "digest": "4daaad60f35d4519a24cd067baf4cd4bdc421e3dd80b9110a8dfaa8a9e75d9eb" + }, + "go-split-method-owner/main.go": { + "captureGroups": 9, + "digest": "e9c105208ad6ef2f087f758972475aa403294e886751d75113b4ca45fa4e0b8a" + }, + "go-split-method-owner/repo.go": { + "captureGroups": 8, + "digest": "f8bb213588f517e166b421f19d72cde981153464cc3f3168499769e778c4a22e" + }, + "go-split-method-owner/save.go": { + "captureGroups": 6, + "digest": "6e0e1b5521a2fad1cd00252e771926d5caa7930440278968f7f8607821ad339c" + }, + "go-split-method-owner/user.go": { + "captureGroups": 3, + "digest": "827dc0208b47776976313a4560fbd250ab7fa521ae46581213a79fc15fd52ce8" + }, + "go-struct-literals/app.go": { + "captureGroups": 10, + "digest": "97ec29ec0e0dc1f23a804ff6808023a32eafdeb00f194d3bea9b50ed54c7f258" + }, + "go-struct-literals/user.go": { + "captureGroups": 10, + "digest": "89b791a9d150fe341924d54d3173d9f35f72be899d0e7ac03a35d32119b94f8f" + }, + "go-type-assertion/main.go": { + "captureGroups": 11, + "digest": "b8bd327d3965531802a93bc01e3c24969a027540397a1635119ef0b7da46c348" + }, + "go-type-assertion/models.go": { + "captureGroups": 17, + "digest": "3780f8f7c145a15f849ae6958e492db0044e825b81aa8dfd21b9873759fad65e" + }, + "go-variadic-resolution/cmd/main.go": { + "captureGroups": 6, + "digest": "443f9736b9c67df30fe97d8353ddc2584de4c88ba698e62878683d5d77362d89" + }, + "go-variadic-resolution/internal/logger/logger.go": { + "captureGroups": 4, + "digest": "83b987f527f95e360966793148d07e569fa02fdc5e4febc09daa51cfce16b114" + }, + "go-write-access/main.go": { + "captureGroups": 20, + "digest": "92fdad46b6933fd78dcbb19677a720f037306a4f03fedcca9156b54c1c9bf994" + }, + "synthetic:dao-20": { + "captureGroups": 481, + "digest": "1698b5dd78c8094f251b10ab8cacebfbf453f38eddf7233fbc7964e32f04ceeb" + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-template-partial-order-pointer/main.cpp b/gitnexus/test/fixtures/lang-resolution/cpp-template-partial-order-pointer/main.cpp new file mode 100644 index 000000000..56f6f51fd --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-template-partial-order-pointer/main.cpp @@ -0,0 +1,13 @@ +template +void pick(T value) { +} + +template +void pick(T* value) { +} + +void run() { + int n = 0; + int* p = &n; + pick(p); +} diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-template-partial-order-tied/main.cpp b/gitnexus/test/fixtures/lang-resolution/cpp-template-partial-order-tied/main.cpp new file mode 100644 index 000000000..2daba2444 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-template-partial-order-tied/main.cpp @@ -0,0 +1,13 @@ +template +void pick(T* lhs, T rhs) { +} + +template +void pick(T lhs, T* rhs) { +} + +void run() { + int n = 0; + int* p = &n; + pick(p, p); +} diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-variadic-dependent-resolution/main.cpp b/gitnexus/test/fixtures/lang-resolution/cpp-variadic-dependent-resolution/main.cpp new file mode 100644 index 000000000..7b6f376c9 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-variadic-dependent-resolution/main.cpp @@ -0,0 +1,55 @@ +struct B { + void inherited(); +}; + +void sink(int value); +void ambiguous(int value); +void ambiguous(double value); +void helper(); + +namespace tools { +void namespaceHelper(); +} + +using tools::namespaceHelper; + +template +void logMany(int, Ts... xs) { + (sink(xs), ...); +} + +template +void foldAmbiguous(Ts... xs) { + (ambiguous(xs), ...); +} + +template +struct Mix : B... { + void run() { + inherited(); + helper(); + namespaceHelper(); + } +}; + +template +struct Current { + void own(); + + void run() { + own(); + } +}; + +template +struct UnknownSpecialization { + typename T::value_type value; + + void run() { + value.use(); + } +}; + +void callVariadic() { + logMany(1, 2, 3); +} diff --git a/gitnexus/test/fixtures/lang-resolution/csharp-spurious-edges-no-csproj/Legacy/System/Threading/Tasks.cs b/gitnexus/test/fixtures/lang-resolution/csharp-spurious-edges-no-csproj/Legacy/System/Threading/Tasks.cs new file mode 100644 index 000000000..66f19b8cf --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/csharp-spurious-edges-no-csproj/Legacy/System/Threading/Tasks.cs @@ -0,0 +1,10 @@ +// On-disk path (Legacy/System/Threading/Tasks.cs) path-aligns with +// `using System.Threading.Tasks;` but declares an UNRELATED in-repo namespace, +// so the only way an IMPORTS edge forms is the coincidental path — which the +// gate must block in the no-csproj path on BOTH legs (#1881, Codex F2). +namespace MyApp.Legacy; + +public class Tasks +{ + public void Run() { } +} diff --git a/gitnexus/test/fixtures/lang-resolution/csharp-spurious-edges-no-csproj/Models/User.cs b/gitnexus/test/fixtures/lang-resolution/csharp-spurious-edges-no-csproj/Models/User.cs new file mode 100644 index 000000000..9864db4d2 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/csharp-spurious-edges-no-csproj/Models/User.cs @@ -0,0 +1,6 @@ +namespace MyApp.Models; + +public class User +{ + public string Name { get; set; } = ""; +} diff --git a/gitnexus/test/fixtures/lang-resolution/csharp-spurious-edges-no-csproj/Services/OrderService.cs b/gitnexus/test/fixtures/lang-resolution/csharp-spurious-edges-no-csproj/Services/OrderService.cs new file mode 100644 index 000000000..be2217588 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/csharp-spurious-edges-no-csproj/Services/OrderService.cs @@ -0,0 +1,13 @@ +using System.Threading.Tasks; +using MyApp.Models; + +namespace MyApp.Services; + +public class OrderService +{ + public Task ProcessAsync() + { + var user = new User(); + return Task.CompletedTask; + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/csharp-spurious-edges/Legacy/Tasks.cs b/gitnexus/test/fixtures/lang-resolution/csharp-spurious-edges/Legacy/Tasks.cs new file mode 100644 index 000000000..aef1989e3 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/csharp-spurious-edges/Legacy/Tasks.cs @@ -0,0 +1,6 @@ +namespace MyApp.Legacy; + +public class Tasks +{ + public void Run() { } +} diff --git a/gitnexus/test/fixtures/lang-resolution/csharp-spurious-edges/Models/User.cs b/gitnexus/test/fixtures/lang-resolution/csharp-spurious-edges/Models/User.cs new file mode 100644 index 000000000..9864db4d2 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/csharp-spurious-edges/Models/User.cs @@ -0,0 +1,6 @@ +namespace MyApp.Models; + +public class User +{ + public string Name { get; set; } = ""; +} diff --git a/gitnexus/test/fixtures/lang-resolution/csharp-spurious-edges/Services/OrderService.cs b/gitnexus/test/fixtures/lang-resolution/csharp-spurious-edges/Services/OrderService.cs new file mode 100644 index 000000000..be2217588 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/csharp-spurious-edges/Services/OrderService.cs @@ -0,0 +1,13 @@ +using System.Threading.Tasks; +using MyApp.Models; + +namespace MyApp.Services; + +public class OrderService +{ + public Task ProcessAsync() + { + var user = new User(); + return Task.CompletedTask; + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/csharp-spurious-edges/Spurious.csproj b/gitnexus/test/fixtures/lang-resolution/csharp-spurious-edges/Spurious.csproj new file mode 100644 index 000000000..dc7d27504 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/csharp-spurious-edges/Spurious.csproj @@ -0,0 +1,6 @@ + + + net8.0 + MyApp + + diff --git a/gitnexus/test/fixtures/php-captures-golden/expected-captures.json b/gitnexus/test/fixtures/php-captures-golden/expected-captures.json new file mode 100644 index 000000000..04158eeb5 --- /dev/null +++ b/gitnexus/test/fixtures/php-captures-golden/expected-captures.json @@ -0,0 +1,554 @@ +{ + "php-abstract-dispatch/src/Contracts/Repository.php": { + "captureGroups": 10, + "digest": "3459af9360d51aaaba72963fc49bb79edc188e97f9831421d6504c7919b61dc5" + }, + "php-abstract-dispatch/src/Repositories/SqlRepository.php": { + "captureGroups": 13, + "digest": "01093dcbb7c4482e93572c59d091c6ece2332d46badd7d7a5b44fd2f7cad5507" + }, + "php-abstract-dispatch/src/app.php": { + "captureGroups": 10, + "digest": "52ce761f1d53a56034124fa5e674863bce9092c1b523f6d71139c4f335e8b9f0" + }, + "php-alias-imports/app/Models/Repo.php": { + "captureGroups": 14, + "digest": "4fd03e4a19a7a7c2da925f850563b6dccd21b898223c00278bc2753e8841d844" + }, + "php-alias-imports/app/Models/User.php": { + "captureGroups": 14, + "digest": "5a20d985b3f1c04e998feb586e8b9b382ab069829db69b156ab2636867096a93" + }, + "php-alias-imports/app/Services/Main.php": { + "captureGroups": 15, + "digest": "bf8e1b8079956e7ef9ebfcb3ca38f547e9760da5c83856e93163efe6961067b9" + }, + "php-ambiguous/app/Models/Dispatchable.php": { + "captureGroups": 6, + "digest": "d2c8aabf58d4917fd0c9a6ab0a512473a41d449bdd24f7fb5e5cc5e854b5de04" + }, + "php-ambiguous/app/Models/Handler.php": { + "captureGroups": 7, + "digest": "0bb9adefa360767db1741b884bf3265b81ad70bce4cd8391d3049763d5282715" + }, + "php-ambiguous/app/Other/Dispatchable.php": { + "captureGroups": 6, + "digest": "e1c80e8c21166f4156beb3f22d3bffc7c67dc072b3a9d2f454dd2aacaa845a01" + }, + "php-ambiguous/app/Other/Handler.php": { + "captureGroups": 7, + "digest": "f6ad05a50da70b32744792da353d520d63722bca7c0802e603af5eb13a223c5c" + }, + "php-ambiguous/app/Services/UserHandler.php": { + "captureGroups": 10, + "digest": "90054792db28ad77054994b483305ebf82d8a8959a0350a721d4a76438aea0a0" + }, + "php-app/app/Contracts/Loggable.php": { + "captureGroups": 7, + "digest": "46cf7e385db7656514b796908134a50f4a536f3adda64196e17d8c54f5cd5e3a" + }, + "php-app/app/Contracts/Repository.php": { + "captureGroups": 10, + "digest": "40bbdb3fc0ec9b5a9bb8fbdf4a053099ac926951ecd746f2436569add3f0e3d5" + }, + "php-app/app/Enums/UserRole.php": { + "captureGroups": 7, + "digest": "aadf56b87d67191b13f4e3f9fae7e8097f4c8a81d13135645ceb8c5be1063a9b" + }, + "php-app/app/Models/BaseModel.php": { + "captureGroups": 16, + "digest": "3a768a8443ad599d8501e9aa1e56535cb32ba8b13779b837a68781df3442edff" + }, + "php-app/app/Models/User.php": { + "captureGroups": 25, + "digest": "641b8f77fc29b102d2c4d4f04cfea8f5a64f769c0d53da9282c0d9505e1e7dd9" + }, + "php-app/app/Services/UserService.php": { + "captureGroups": 37, + "digest": "4c20f536c2df20a9fc1cac33aa48a06535898dcf4f46c62067c8ffd9deab5dbd" + }, + "php-app/app/Traits/HasTimestamps.php": { + "captureGroups": 11, + "digest": "d114ed0515e28b83b72ead59ab33114ade0b4fc9ac417ce3bd56acab4046371d" + }, + "php-app/app/Traits/SoftDeletes.php": { + "captureGroups": 15, + "digest": "0a697e33e1c98fd092918fb96d72f3fbd0d3c56158c74fe166976fd4f6a5b676" + }, + "php-assignment-chain/app/Models/Repo.php": { + "captureGroups": 7, + "digest": "f083497f2ec111e8d396abeb4117e1495035334c25f050959c89aae5d05f76e3" + }, + "php-assignment-chain/app/Models/User.php": { + "captureGroups": 7, + "digest": "5e6b0b6f2bbe05e8ee310e5a91163b087b846c38a07dc22ab3ba165b939d7691" + }, + "php-assignment-chain/app/Services/AppService.php": { + "captureGroups": 15, + "digest": "36c5358d7f724cc0906e087828c86d9a29fe0629e2bad6105967fe8d671aa6fe" + }, + "php-call-result-binding/App.php": { + "captureGroups": 23, + "digest": "48c7af06cd4c12f07f308d8804c1c3652c2af87d155831fc5ee915fd984737d2" + }, + "php-calls/app/Services/UserService.php": { + "captureGroups": 7, + "digest": "94f1cdfb0c444dc9e8116d1bbf824dc88584046539b2cbc48297729eb49e41a1" + }, + "php-calls/app/Utils/OneArg/log.php": { + "captureGroups": 5, + "digest": "bd3d0b4f7da34edfa86b3e5e9cd6664439baf7cf07318098ac562f48c40f2a72" + }, + "php-calls/app/Utils/ZeroArg/log.php": { + "captureGroups": 4, + "digest": "c9c4d8553aece69141049625b2fd36b0b0d4946eacd8eac6b0e2231922e82d48" + }, + "php-child-extends-parent/src/App.php": { + "captureGroups": 11, + "digest": "0c8a7c7edaa20009b71f22fef98230119a4738d2a21c8b88d64047c3c932fd36" + }, + "php-child-extends-parent/src/Child.php": { + "captureGroups": 4, + "digest": "4943bc0abc98546bb82d98e17260d686805a6aa644aabf2b86ad75dc8334b73b" + }, + "php-child-extends-parent/src/Parent.php": { + "captureGroups": 7, + "digest": "e7bbcd72127b97475851b17cf7fbc5a86786091b2117313dfb24deab8a0d6d09" + }, + "php-constructor-calls/Models/User.php": { + "captureGroups": 14, + "digest": "a7d4f11e20fb687f537d1ca0735f8e85c9ea8a2b29867bb57326f02da3bb63d1" + }, + "php-constructor-calls/app.php": { + "captureGroups": 8, + "digest": "23ef0f05446126892e598872a0b3c3d2f96e3b0dc50e301f1fa784a56ebc81d4" + }, + "php-constructor-promotion-fields/Models.php": { + "captureGroups": 22, + "digest": "ac49ef4de6665d63a0bef9449e1c685f2fd20fbb01c5d26c924ee0d469f614c5" + }, + "php-constructor-promotion-fields/Service.php": { + "captureGroups": 8, + "digest": "d7093850a8c703a5aefbcd584e5bc8e7ac5668e14ff79e27b1db401f72e83073" + }, + "php-constructor-type-inference/app/Models/Repo.php": { + "captureGroups": 7, + "digest": "44921bb2ae5aba27957cd45a7d3e515425f27f8e93af5d6285125b94f5d9ba88" + }, + "php-constructor-type-inference/app/Models/User.php": { + "captureGroups": 7, + "digest": "cec22d127d637784baa722787a0a5e5402970ca2dc23a20f07e644762492d8bd" + }, + "php-constructor-type-inference/app/Services/AppService.php": { + "captureGroups": 15, + "digest": "26181127fe9e9bf04431a7cc801624bde9dd284049627bb7ca0a4c9234141eec" + }, + "php-deep-field-chain/Models.php": { + "captureGroups": 26, + "digest": "f20d780f2bc95ed438047c8e9bb694f5a020454161d8a20f8d5e02be68e4a602" + }, + "php-deep-field-chain/Service.php": { + "captureGroups": 9, + "digest": "0026ff9cbad4125b5c27ce356b7c954d369604c5129460d9a0f51cd1c46afbca" + }, + "php-default-params/app.php": { + "captureGroups": 9, + "digest": "c5b100802ae7d78869f099ab886133d16f865a653e05bdc477e6b4e523accf91" + }, + "php-dynamic-calls/app/Services/Dynamic.php": { + "captureGroups": 52, + "digest": "1a15600d585851ee1f05927c27e6ce6ccaf6a3f56aecf36e27f84a20a65bb9b2" + }, + "php-dynamic-calls/app/Services/OtherTargets.php": { + "captureGroups": 6, + "digest": "f27d95bd651a23664a440e8264d793f30ed46dfea915b18300f51448db025528" + }, + "php-dynamic-calls/app/Services/Targets.php": { + "captureGroups": 31, + "digest": "7f089e347f1f690400afb890637eb960c70ea5afcb843c534c5d80f650e613a2" + }, + "php-field-types/Models.php": { + "captureGroups": 17, + "digest": "23e6855d9ac081c156d7f273a6ca4076c878d900d97822b436d2f190e2272064" + }, + "php-field-types/Service.php": { + "captureGroups": 8, + "digest": "d7093850a8c703a5aefbcd584e5bc8e7ac5668e14ff79e27b1db401f72e83073" + }, + "php-foreach-call-expr/Repo.php": { + "captureGroups": 17, + "digest": "f6082a43eb279f1de8f8ffea93c1a8ee05b7c5b39fdf2eb0a308c8170b7ab7d1" + }, + "php-foreach-call-expr/User.php": { + "captureGroups": 17, + "digest": "799e12a9ebddde34f030e0612724f8a31893d33d45421607e619070036e30937" + }, + "php-foreach-call-expr/main.php": { + "captureGroups": 11, + "digest": "ce332b01f1bbdfaf0e03a3d77f7f708e9f7e5fd41eaa795be8fd2584ab86711a" + }, + "php-foreach-generic/App.php": { + "captureGroups": 16, + "digest": "6f5355bc0d315224e3ce2696f3fac3b067f90771f291ce73e2cfc9c5c9ece59c" + }, + "php-foreach-generic/Repo.php": { + "captureGroups": 13, + "digest": "0ba7bc5bc2387e1f07898a288a4d856349b777c9be12ace6ebd2ccf39124c446" + }, + "php-foreach-generic/User.php": { + "captureGroups": 13, + "digest": "eb34a7e89368d13116de3cb7b0430bc2d1b9c982e3b3f7c9511dae8a73759af6" + }, + "php-foreach-loop/App.php": { + "captureGroups": 10, + "digest": "0a648289cb93a52808d1d6ee8b519fa28c413227607deb0c3d3a9a0cdaa52e02" + }, + "php-foreach-loop/Repo.php": { + "captureGroups": 13, + "digest": "0ba7bc5bc2387e1f07898a288a4d856349b777c9be12ace6ebd2ccf39124c446" + }, + "php-foreach-loop/User.php": { + "captureGroups": 13, + "digest": "eb34a7e89368d13116de3cb7b0430bc2d1b9c982e3b3f7c9511dae8a73759af6" + }, + "php-foreach-member-access/App.php": { + "captureGroups": 14, + "digest": "44e6f810de40d566b5c909d91dcc50de5282fde2f597bf38302edbcb8d825ca3" + }, + "php-foreach-member-access/Repo.php": { + "captureGroups": 13, + "digest": "0ba7bc5bc2387e1f07898a288a4d856349b777c9be12ace6ebd2ccf39124c446" + }, + "php-foreach-member-access/User.php": { + "captureGroups": 13, + "digest": "eb34a7e89368d13116de3cb7b0430bc2d1b9c982e3b3f7c9511dae8a73759af6" + }, + "php-fqn-cross-namespace/app/Models/User.php": { + "captureGroups": 7, + "digest": "56f9e84d179973e9700a0768c291415a7e75795443ebc2940c32bf6665761430" + }, + "php-fqn-cross-namespace/app/Other/User.php": { + "captureGroups": 7, + "digest": "9989dd5b86b705df941fc63dfd7fcbe4520dd1378a16a50b5f3dfd0ebed7bd90" + }, + "php-fqn-cross-namespace/app/Services/Service.php": { + "captureGroups": 15, + "digest": "94a2bec57ccde7aa663ce2027c7f36151d18ee257830663365bc14f9d4d5f703" + }, + "php-grandparent-resolution/app/Models/A.php": { + "captureGroups": 9, + "digest": "47af1c30a957f15a2a5ebbddd80c0214a4f68c15f08699fc81cdbfaefa4a70b5" + }, + "php-grandparent-resolution/app/Models/B.php": { + "captureGroups": 4, + "digest": "d6326a8eb65bfa8da20d9fb0e5ce07c61eeabd5d53cc94faaebbc3dda0274359" + }, + "php-grandparent-resolution/app/Models/C.php": { + "captureGroups": 4, + "digest": "92fb1dabd5e6dc6eb4115d26303c96ba3d5e34ef9d0ccd965f531b5cf34b7390" + }, + "php-grandparent-resolution/app/Models/Greeting.php": { + "captureGroups": 7, + "digest": "dd6504594d3d997d93dd44a0de354f4c7a9d564613596ef2da2e13eef7b012ed" + }, + "php-grandparent-resolution/app/Services/App.php": { + "captureGroups": 12, + "digest": "5d0c3524e1ca41ee57bd05fd0a2831804598fca8079ea4ece38045d9c4bb1113" + }, + "php-grouped-imports/app/Models/Repo.php": { + "captureGroups": 7, + "digest": "82b2bc7323990945f9f3c122812d1ada5cf12d7f2b2e829dbb5e9366bb2b3602" + }, + "php-grouped-imports/app/Models/User.php": { + "captureGroups": 7, + "digest": "4028b426d00caf369e42e1fbfaa1a11440a598fe15734d2eb03cf017ae5f0a1f" + }, + "php-grouped-imports/app/Services/Main.php": { + "captureGroups": 15, + "digest": "65ea5a0c2fd2b523ab1bf2e3f632bd3debeb7613c88496362016f9e590b64972" + }, + "php-local-shadow/app/Services/Main.php": { + "captureGroups": 9, + "digest": "b4b3e35399501d98521dc9d9281a4e5c94449b580cc6cb9b3ed4187e79ee2c07" + }, + "php-local-shadow/app/Utils/Logger.php": { + "captureGroups": 5, + "digest": "7434c0a1b2282daf8d60334a85523d603bffd256eaaf5c241509e72c0a6d1e5d" + }, + "php-member-calls/app/Models/User.php": { + "captureGroups": 7, + "digest": "cec22d127d637784baa722787a0a5e5402970ca2dc23a20f07e644762492d8bd" + }, + "php-member-calls/app/Services/UserService.php": { + "captureGroups": 11, + "digest": "6f6d5d34edd1cd4e32ea77db7e4b07b73de9ca09bb658123455820af8228d0cc" + }, + "php-method-chain-binding/App.php": { + "captureGroups": 48, + "digest": "8cab0917ecbbd65f43046dcc3c03f7af2d8e1a51780d4dbf3f7bc29ccabd2fa2" + }, + "php-method-enrichment/src/Models/Animal.php": { + "captureGroups": 12, + "digest": "f32de8a30558f2678e73c68d7db9c6eab827fd8d5f7655b906a76066c8421bef" + }, + "php-method-enrichment/src/Models/Dog.php": { + "captureGroups": 8, + "digest": "ffe9909633e018ae077b685a83d18dde30d30b818068b7ef6674cbd184ebdf33" + }, + "php-method-enrichment/src/app.php": { + "captureGroups": 11, + "digest": "52791f6945c8c4ae083b816bd3af239bce44f0e97cbf80cdc119f4f366015138" + }, + "php-mro-arity-mismatch/app/Models/ChildModel.php": { + "captureGroups": 15, + "digest": "e007097393563883393a2469befbbe76568fa25dce87d15cc808a877edc0177c" + }, + "php-mro-arity-mismatch/app/Models/Orphan.php": { + "captureGroups": 9, + "digest": "29d2e5732698a9951194a6be3d788e337a142c5ea174c5356d375cf9c9e9ff58" + }, + "php-mro-arity-mismatch/app/Models/ParentModel.php": { + "captureGroups": 12, + "digest": "e16afaad3145133dce0f2de89d0f58eec33492b6a67741013eaf27e35ce6aa07" + }, + "php-mro-arity-mismatch/app/Services/Caller.php": { + "captureGroups": 22, + "digest": "d51fdbcf226f31f9220a506cceb9f538642ff99d238a1828b6b43fce6193f664" + }, + "php-namespace-fallback-isolation/src/App/Caller.php": { + "captureGroups": 13, + "digest": "d5040d068fd8227388da7f25cc471f154adb37cd6bbfb78b5cac00f44bf45734" + }, + "php-namespace-fallback-isolation/src/App/Utils/Caller.php": { + "captureGroups": 8, + "digest": "7f7a357fb5d758f9d4686b072c4d5df82026701ed28bdce042c5c7950a893c41" + }, + "php-namespace-fallback-isolation/src/App/Utils/Format.php": { + "captureGroups": 5, + "digest": "dc33b55ffcef13d75972ca695921ce75c88ea21f37960ad138ee04267f3ccba4" + }, + "php-namespace-fallback-isolation/src/Vendor/Utils/Format.php": { + "captureGroups": 7, + "digest": "eff9d04520000bfc5fd7ae87721965e3000d1b478786f95b4f51e001ad877faf" + }, + "php-nullable-receiver/app/Models/Repo.php": { + "captureGroups": 7, + "digest": "44921bb2ae5aba27957cd45a7d3e515425f27f8e93af5d6285125b94f5d9ba88" + }, + "php-nullable-receiver/app/Models/User.php": { + "captureGroups": 7, + "digest": "cec22d127d637784baa722787a0a5e5402970ca2dc23a20f07e644762492d8bd" + }, + "php-nullable-receiver/app/Services/AppService.php": { + "captureGroups": 13, + "digest": "cea6ae3f9e32a3e0448a279034bcf039b1d5af6334ab1b1ae43d9c55b6ca094d" + }, + "php-overload-dispatch/src/Services/Formatter.php": { + "captureGroups": 7, + "digest": "5a4979e230bd666c0dc35e8e9611adf67d770858d1897ea113610f0698ccea7d" + }, + "php-overload-dispatch/src/Services/FormatterExtended.php": { + "captureGroups": 9, + "digest": "17dadd44959529ba4533df45561025d00be7ae2f0a2503cbde4a69e9b72614c1" + }, + "php-overload-dispatch/src/app.php": { + "captureGroups": 10, + "digest": "d11621fbaec9f5015e61f35b5c2747b9f2da090a346adc5fcc10f191af61f048" + }, + "php-parent-resolution/app/Models/BaseModel.php": { + "captureGroups": 7, + "digest": "6c2081e07e8b2ea2492acfe6dcacb99505faf92749f9c570636434abda9625a1" + }, + "php-parent-resolution/app/Models/Serializable.php": { + "captureGroups": 6, + "digest": "e047de645ae2b8029a4bb31a61bbc556f58da066d346a2e57101d22cb8dcbb6a" + }, + "php-parent-resolution/app/Models/User.php": { + "captureGroups": 8, + "digest": "b34ddf79f02bc1f8a233c91e0d7ad7471cc50912897d619b03c9c60e111de2e6" + }, + "php-parent-vs-trait/app/Auditable.php": { + "captureGroups": 7, + "digest": "e11a6f4e2e3eaac87cf26fe038d370672525ab4d2925125e9494eb0e275cc19c" + }, + "php-parent-vs-trait/app/Base.php": { + "captureGroups": 7, + "digest": "d7f05cb3f8cf09740fd7063932cc4fccb5b6fff553093b64a341e2503ec42f2d" + }, + "php-parent-vs-trait/app/Child.php": { + "captureGroups": 14, + "digest": "8b0149c1d5d54d5d2cb743bbf43d424bc19cf758f4dde04105084c0903117825" + }, + "php-phpdoc-attribute-return-type/Models.php": { + "captureGroups": 11, + "digest": "8c5cd8fb19d4f3964c68533834db49d85eb5f93e04ba067a617f5a6c655a0046" + }, + "php-phpdoc-attribute-return-type/Services.php": { + "captureGroups": 37, + "digest": "d857f9bc514191f222a2e0880c67ab958f2689fc1c6ac0a55af9c8e26fd9c0c0" + }, + "php-phpdoc-return-type/Models.php": { + "captureGroups": 11, + "digest": "8c5cd8fb19d4f3964c68533834db49d85eb5f93e04ba067a617f5a6c655a0046" + }, + "php-phpdoc-return-type/Services.php": { + "captureGroups": 37, + "digest": "17ba0088f086c768d5ab6a07de513669a41787ca5f26c33114e6647ef683c920" + }, + "php-property-promotion/UserService.php": { + "captureGroups": 19, + "digest": "872830776a740cf97c1eafac5e3413aae99e0e951b1b5b46d1aa0df821eac8bc" + }, + "php-receiver-resolution/app/Models/Repo.php": { + "captureGroups": 7, + "digest": "44921bb2ae5aba27957cd45a7d3e515425f27f8e93af5d6285125b94f5d9ba88" + }, + "php-receiver-resolution/app/Models/User.php": { + "captureGroups": 7, + "digest": "cec22d127d637784baa722787a0a5e5402970ca2dc23a20f07e644762492d8bd" + }, + "php-receiver-resolution/app/Services/AppService.php": { + "captureGroups": 13, + "digest": "59783c7af75e9075f00f425984ca1ba7a4c556bb0301e2eb2c3fdcaa94cd547f" + }, + "php-response-shapes/api/items.php": { + "captureGroups": 11, + "digest": "84e64331d581f16103b3b0c8c95819e41c36ca2e16b6186ccfa73a13729577a1" + }, + "php-response-shapes/api/submit.php": { + "captureGroups": 17, + "digest": "f5d1af2ad313f4639fbad558eed65d3fa0c218eefc55843ea9e07156a1554883" + }, + "php-response-shapes/includes/auth.php": { + "captureGroups": 4, + "digest": "7734a9fee1a2a34df7a8857bda37e0930affce7966165023e76c83a71c9f214f" + }, + "php-return-type/app/Models/Repo.php": { + "captureGroups": 7, + "digest": "f04e8cb824fba439cea5fdab461ade906b49953b7e8cd2777e724f8fa66e9b31" + }, + "php-return-type/app/Models/User.php": { + "captureGroups": 14, + "digest": "5b41d9519e2d696436cb5e6a1337619def5882c2bc442af74ac15a8570b6e168" + }, + "php-return-type/app/Services/UserService.php": { + "captureGroups": 17, + "digest": "fcc2d78ac4bdde1ac5179e6623a35299465bcbf9bb016375100bcb636624d043" + }, + "php-self-this-resolution/app/Models/Repo.php": { + "captureGroups": 7, + "digest": "70b05bbb0ad6e8c1ba62d3355dbc1ffa6ac01c9e85ac22e43a07e387eddf401e" + }, + "php-self-this-resolution/app/Models/User.php": { + "captureGroups": 11, + "digest": "86fea63dd23c57956769db89241351af6a86acc69e9c226a8ee859ab531b18ca" + }, + "php-super-resolution/app/Models/BaseModel.php": { + "captureGroups": 7, + "digest": "6c2081e07e8b2ea2492acfe6dcacb99505faf92749f9c570636434abda9625a1" + }, + "php-super-resolution/app/Models/Repo.php": { + "captureGroups": 7, + "digest": "70b05bbb0ad6e8c1ba62d3355dbc1ffa6ac01c9e85ac22e43a07e387eddf401e" + }, + "php-super-resolution/app/Models/User.php": { + "captureGroups": 9, + "digest": "e62c49645b1ef609bb915c355b06379d996c030c3eaacbe40ca49d0d0b323e64" + }, + "php-this-receiver-disambiguation/AdminService.php": { + "captureGroups": 15, + "digest": "db9ba7004ba7b98984307f2b3e1a71154b5a7d60403286d7f308c03ed351b18a" + }, + "php-this-receiver-disambiguation/Models.php": { + "captureGroups": 11, + "digest": "8c5cd8fb19d4f3964c68533834db49d85eb5f93e04ba067a617f5a6c655a0046" + }, + "php-this-receiver-disambiguation/UserService.php": { + "captureGroups": 15, + "digest": "147924d3638edc63eef1a009943fae66a87ac85d02ae533428646c644e81c2bb" + }, + "php-transitive-traits/app/Models/Consumer.php": { + "captureGroups": 17, + "digest": "a447ed654cc0e074c2f0d93b0d371953e823c9a77da559e841741f5b73b97058" + }, + "php-transitive-traits/app/Traits/TraitA.php": { + "captureGroups": 7, + "digest": "53ca5a3b0407c54a2d6ce80a83eee592c32443795a01186bc9c4cac680e050b7" + }, + "php-transitive-traits/app/Traits/TraitB.php": { + "captureGroups": 7, + "digest": "abafc915f22428f94e82ca1450902b6ef0599557e05aea345f388392b8097bf2" + }, + "php-transitive-traits/app/Traits/TraitC.php": { + "captureGroups": 7, + "digest": "9d55a120a79ead7605f2e05c80b36615c228acd5cf83db5417a5efe4c9ed9e7f" + }, + "php-typed-properties/app/Models/UserRepo.php": { + "captureGroups": 11, + "digest": "19089a297be5f83a4a7610a2111a1fb1b84806cda5fa7d6dbb16c9dfc137b6d6" + }, + "php-typed-properties/app/Services/UserService.php": { + "captureGroups": 12, + "digest": "2ec84482a3e2332f3f15ebb3f2d8d01d1d56e62da256127ece571a8c2e0c070c" + }, + "php-typed-property-dedup/app/Models/UserRepo.php": { + "captureGroups": 7, + "digest": "4b0043ea779b33cabc12f79cb4aef32759284137f20838346e9f5ef82076be7b" + }, + "php-typed-property-dedup/app/Services/Mixed.php": { + "captureGroups": 14, + "digest": "9f4ba6bd183c20acaad547b6a713e80498eb70eaabaa7b416650bb64098bde0d" + }, + "php-unresolved-receiver-arity/app/Models/Handler.php": { + "captureGroups": 21, + "digest": "0e45194dc4a1bf420d4325756a780b0574129517e70d83a82c5dd68b921a0f67" + }, + "php-unresolved-receiver-arity/app/Services/Caller.php": { + "captureGroups": 28, + "digest": "a3d316585e67b65ac85a63991735c1324911c709a6d9714f9c3eb4ca6a95f807" + }, + "php-use-function-const/app/Config/constants.php": { + "captureGroups": 2, + "digest": "c045ce0a40c3396cdfbbf0fbbe24792fe2bba6dfba14f739b15a4bb5ae90a778" + }, + "php-use-function-const/app/Models/User.php": { + "captureGroups": 10, + "digest": "ace0bf7cac67791143d870792b7b870ecc04730bbd98249d8113dc246e90113f" + }, + "php-use-function-const/app/Services/Calculator.php": { + "captureGroups": 15, + "digest": "d6996da68f917c3ae6b52b530d166e8266f5053ce6c9b76e1643cb61edcafa38" + }, + "php-use-function-const/app/Utils/helpers.php": { + "captureGroups": 6, + "digest": "98ebf5b3427b39b619e78eaf11a46376eba5ce7fef202508ce169afd8c8e979e" + }, + "php-variadic-arity-minimum/app/Services/Caller.php": { + "captureGroups": 29, + "digest": "d5669fe609abae6b7db19c15c0cb795859e0b4b0570ae83fbe80a392f3775ca8" + }, + "php-variadic-arity-minimum/app/Utils/Logger.php": { + "captureGroups": 18, + "digest": "dbcc7f0bf265a950952c4d192bef14924b29b31326942f4f1600aa8bbe22e9a9" + }, + "php-variadic-resolution/app/Services/AppService.php": { + "captureGroups": 9, + "digest": "8b5298358fba8f578b2470f9d93ffbc1089d1ef3208c1142185880b4dc174751" + }, + "php-variadic-resolution/app/Utils/Logger.php": { + "captureGroups": 7, + "digest": "8e075546630b3057577932dcf8a876b450950a5accd7df3f8a87079cdc401139" + }, + "php-write-access/models.php": { + "captureGroups": 13, + "digest": "b7f00a2f5e9c64d9823d11279d831bf9320c697e69184f368d288348fc9ac39e" + }, + "php-write-access/service.php": { + "captureGroups": 8, + "digest": "5ef6b1687663e4cd9acb880a570fc32388961e735ad38d8ea9906da173971362" + }, + "synthetic:dao-20": { + "captureGroups": 221, + "digest": "e29ee5132bb30bf939508cf3741159856b78881c01447acd290e18b1c5121225" + } +} diff --git a/gitnexus/test/fixtures/python-captures-golden/expected-captures.json b/gitnexus/test/fixtures/python-captures-golden/expected-captures.json new file mode 100644 index 000000000..350e8f8cb --- /dev/null +++ b/gitnexus/test/fixtures/python-captures-golden/expected-captures.json @@ -0,0 +1,758 @@ +{ + "python-abstract-dispatch/app.py": { + "captureGroups": 11, + "digest": "8662c17b0f21fcfa650065abd62f0c9b7e1c65bf8a1f7dce6f2a16bba9df759f" + }, + "python-abstract-dispatch/base.py": { + "captureGroups": 15, + "digest": "9c2891a6143c10cc3d81603c8623e54b6914cd8aaa668e84b59c1602492c0e4b" + }, + "python-abstract-dispatch/impl.py": { + "captureGroups": 14, + "digest": "2a7ec28b431cb4010829bca1f0cadf3b11ef7a67abe16f322fc4329a5b619223" + }, + "python-alias-imports/app.py": { + "captureGroups": 13, + "digest": "ae1dc39a6d300a38bade534e58a1fcf5141eee06c6e903645a80dbce175b4949" + }, + "python-alias-imports/models.py": { + "captureGroups": 11, + "digest": "abf5fcdf7cc473efa5a149319b0fc3d14b3ae91a2e9a14d621243c5ed7fafafa" + }, + "python-ambiguous/models/__init__.py": { + "captureGroups": 0, + "digest": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + "python-ambiguous/models/handler.py": { + "captureGroups": 6, + "digest": "a148084ab160b53bd9a59f12a605447dee85eca1a46abfcca62fc48de56ca3a3" + }, + "python-ambiguous/other/__init__.py": { + "captureGroups": 0, + "digest": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + "python-ambiguous/other/handler.py": { + "captureGroups": 6, + "digest": "41330f66b51bcedb14cdcd4cbb88601e3426e246d547ab7cf060da7c294bc8de" + }, + "python-ambiguous/services/__init__.py": { + "captureGroups": 0, + "digest": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + "python-ambiguous/services/user_handler.py": { + "captureGroups": 7, + "digest": "2a441d1b24e272fdf5c214401d59211b6dff18b56bdaa06148f633ee3a5105e2" + }, + "python-ancestor-import/a/b/c/deep.py": { + "captureGroups": 5, + "digest": "633dd357999ff9124b5c1faba69d0156f4a996693f4cb5dcd05e6cf530d897ea" + }, + "python-ancestor-import/a/utils.py": { + "captureGroups": 3, + "digest": "4d85820bac0e64fb70eceee1454424a75bc3db10f9fc9e7d822e3539ef50c8c1" + }, + "python-ancestor-import/backend/middleware.py": { + "captureGroups": 10, + "digest": "ed9769cc43e5e1b6dcaa5179e1c80f07ef15e75e728e6ae039091454b966fcec" + }, + "python-ancestor-import/backend/services/auth.py": { + "captureGroups": 7, + "digest": "73968a5d120b0a97e95e96b7c0c7433575e8178a832b735b42d5be34c72e2ba3" + }, + "python-assignment-chain/app.py": { + "captureGroups": 27, + "digest": "26ab1b7aacc902373062b7d4a5c88e41619a3352664cf27e3d3cd92012f6a24a" + }, + "python-assignment-chain/repo.py": { + "captureGroups": 6, + "digest": "0d2704cfa14e431395973cc82b582f9fae43a8ce67d5548ab5df62b3fa8d1b7e" + }, + "python-assignment-chain/user.py": { + "captureGroups": 6, + "digest": "4dd0a9d0f5797bae1c281a07145ddcc4162eae1ea16208e85edb74cd98e085f9" + }, + "python-bare-import/models/user.py": { + "captureGroups": 6, + "digest": "3e5160418c4e3b4c0a896bed89e731711aff3a66872d99bb5ebfcbd624d9a3d2" + }, + "python-bare-import/services/auth.py": { + "captureGroups": 8, + "digest": "8b3659bfdc60eedb1a33fd803f5ad23f1458f134884ed600e3fb4a662e19dbf3" + }, + "python-bare-import/services/user.py": { + "captureGroups": 6, + "digest": "0662b53fd75f4892afa665d4dfe51a8dc963971696a370a8b2c4ba124eeda2af" + }, + "python-call-result-binding/app.py": { + "captureGroups": 8, + "digest": "338c3922981604e71ddfc60ad61eba4b17f68ca654644e01add942c729b422cf" + }, + "python-call-result-binding/models.py": { + "captureGroups": 12, + "digest": "a7f11709f76dcc9311554b4fe1dd4e95f5d6434e6232627b4c370f21e194bd69" + }, + "python-call-result-binding/service.py": { + "captureGroups": 7, + "digest": "81f8d5783bfc95a9f83f02abd15f7bbc0869aaca964c62b40653cc05ab73ebcb" + }, + "python-calls/one.py": { + "captureGroups": 3, + "digest": "4eee8157b23b84308163ffc2baa55c56aee3bd97c06365fe9b045f3c5066ad56" + }, + "python-calls/service.py": { + "captureGroups": 6, + "digest": "3d469b47150af229a8deb3de8564ec89ad8529fb19a8fd75588160b2898df38f" + }, + "python-calls/zero.py": { + "captureGroups": 3, + "digest": "49330572d87d1cbfa7db11351b18734bf23fecd686b46e55a788e6b82eca306f" + }, + "python-chain-call/app.py": { + "captureGroups": 9, + "digest": "abc26d2f53c4dc0f091009b120e7869ca74b015bad637c5643fc4fdd69df946e" + }, + "python-chain-call/models/repo.py": { + "captureGroups": 6, + "digest": "54e050e7cecc288e367e97dc59280fd4febb614c7099894591e122901261a130" + }, + "python-chain-call/models/user.py": { + "captureGroups": 6, + "digest": "3e5160418c4e3b4c0a896bed89e731711aff3a66872d99bb5ebfcbd624d9a3d2" + }, + "python-chain-call/service.py": { + "captureGroups": 9, + "digest": "250f907deb4fec54761559db26f0938dc0accee480021cf95bcdd5167d33096f" + }, + "python-child-extends-parent/app.py": { + "captureGroups": 9, + "digest": "0f60d5cd521b0073524b0993e82d5291f86badd5cbefb986cefdf7b0bed64157" + }, + "python-child-extends-parent/child.py": { + "captureGroups": 4, + "digest": "c85d867b0b206cd3e13e8dd94c83b49f13e0f2ddde7acd61785b9755f41d3d14" + }, + "python-child-extends-parent/parent.py": { + "captureGroups": 7, + "digest": "2f30a106769eeda283491fee883b448cd4bbcb5a8be2a587e7c72e2dca2a03f1" + }, + "python-class-annotations/repo.py": { + "captureGroups": 8, + "digest": "f53cefad4e2dd9951fe34eea13822f5c521d9a23c3185b559b390ada7c5e1f3d" + }, + "python-class-annotations/service.py": { + "captureGroups": 11, + "digest": "0b20f98ba8b035c4e065bcfa66df9c955ba98608ea7aece8b998cc62758e5d85" + }, + "python-class-annotations/user.py": { + "captureGroups": 8, + "digest": "38e7b4fe8372de734b28227c926d0bc80f66d2539b7f8d0cc22c1d32bf69821b" + }, + "python-class-attr-export-leak/app.py": { + "captureGroups": 9, + "digest": "e958f924b72ac35d92cb3a959c4bea866fa1020347e96a09e07b7087ce9a231c" + }, + "python-class-attr-export-leak/mod.py": { + "captureGroups": 11, + "digest": "cf175a5a7b577bbf8e76acc004c5360f496d0a10cc5de0a3e31a212bd413b491" + }, + "python-class-body-namespace-import/app.py": { + "captureGroups": 9, + "digest": "58843947b979524df15f256d240b789ac67356499013b9c96bcc9e14108f8eff" + }, + "python-class-body-namespace-import/mod.py": { + "captureGroups": 4, + "digest": "d6ead25daf8f609f1ea2ce79ccf1a88ad3c0ee2d0c9ced0ab13e8f1e729eecb5" + }, + "python-constructor-calls/app.py": { + "captureGroups": 8, + "digest": "e8621bf951ab14634a8ed38a5953ff59434d3406d59e9c16751b6ab9cc40e7e9" + }, + "python-constructor-calls/models.py": { + "captureGroups": 11, + "digest": "3508b40afee5ce512fd9057be671629b8a122c88342ab358cbf69ac418a39e34" + }, + "python-constructor-type-inference/models/repo.py": { + "captureGroups": 12, + "digest": "a076029a7a594b3577cbe3017c60cbd8f376ae80bd1e5216b0469b165f1c9f76" + }, + "python-constructor-type-inference/models/user.py": { + "captureGroups": 12, + "digest": "a7f11709f76dcc9311554b4fe1dd4e95f5d6434e6232627b4c370f21e194bd69" + }, + "python-constructor-type-inference/services/app.py": { + "captureGroups": 13, + "digest": "5306b0960c7b88572e81aa5b203f7e10adbd5d6c8b4c54fc78314624d5864836" + }, + "python-default-params/app.py": { + "captureGroups": 17, + "digest": "408a601fac98bb34b153210a3513e97a5d6d0537f5b02d01fffb4525d24395a3" + }, + "python-dict-items-loop/app.py": { + "captureGroups": 8, + "digest": "98486424c256def8b89730644deb829ac1c1fc5c46d8f86e586ed2d8ab1a07b8" + }, + "python-dict-items-loop/repo.py": { + "captureGroups": 11, + "digest": "a1f088dbd281662c7b0ba9797d07edaef8a2e5a82a6715242eb3b70c29db86aa" + }, + "python-dict-items-loop/user.py": { + "captureGroups": 11, + "digest": "e9398cffc0a70490a3b5f90e3436292e306df36ee51955741d8ee91aa37b2e73" + }, + "python-django-app-imports/accounts/__init__.py": { + "captureGroups": 0, + "digest": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + "python-django-app-imports/accounts/admin.py": { + "captureGroups": 2, + "digest": "392b15be747e2b5cbd3ac5a9e61a7677ffa6ba52e49d3631681e43c557373b5f" + }, + "python-django-app-imports/accounts/apps.py": { + "captureGroups": 5, + "digest": "6fc1529373f9e3183ccd1c9a7013fa2762ef30c36fea7a7afb4ba418aafab9b0" + }, + "python-django-app-imports/accounts/migrations/__init__.py": { + "captureGroups": 0, + "digest": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + "python-django-app-imports/accounts/models.py": { + "captureGroups": 7, + "digest": "35025fe0635eb78a60be6ec7a808aa1db586f49a878f16263307b3a04452b4d5" + }, + "python-django-app-imports/accounts/tests.py": { + "captureGroups": 2, + "digest": "f43159c7b31ee859e91c768bb7a91b541d6cdfc1fec90e668db6443f11ebcc67" + }, + "python-django-app-imports/accounts/views.py": { + "captureGroups": 2, + "digest": "58727216fa6b809839192a0af04b1471e34d9608d9d70c19d4ac6468e9c0529d" + }, + "python-django-app-imports/billing/__init__.py": { + "captureGroups": 0, + "digest": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + "python-django-app-imports/billing/admin.py": { + "captureGroups": 2, + "digest": "392b15be747e2b5cbd3ac5a9e61a7677ffa6ba52e49d3631681e43c557373b5f" + }, + "python-django-app-imports/billing/apps.py": { + "captureGroups": 5, + "digest": "b93de8fe8c56a8ef878b665667fc5fcfa73b526d095228ed4c972aeff9c1ad44" + }, + "python-django-app-imports/billing/migrations/__init__.py": { + "captureGroups": 0, + "digest": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + "python-django-app-imports/billing/models.py": { + "captureGroups": 11, + "digest": "9e7f426103b6c5f53630bddf7c50253bee22c0c018ef85437fdefe0117998053" + }, + "python-django-app-imports/billing/tests.py": { + "captureGroups": 2, + "digest": "f43159c7b31ee859e91c768bb7a91b541d6cdfc1fec90e668db6443f11ebcc67" + }, + "python-django-app-imports/billing/views.py": { + "captureGroups": 2, + "digest": "58727216fa6b809839192a0af04b1471e34d9608d9d70c19d4ac6468e9c0529d" + }, + "python-django-app-imports/config/__init__.py": { + "captureGroups": 0, + "digest": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + "python-django-app-imports/config/asgi.py": { + "captureGroups": 7, + "digest": "79729019cda1dd6f41848365b98416fc610aaa846464bed569573c1fde0892a3" + }, + "python-django-app-imports/config/settings.py": { + "captureGroups": 20, + "digest": "776f9193ac3a1927c8b0900efceb5ac3f16400ddfa7fa5d9617153d0bcf6ea0f" + }, + "python-django-app-imports/config/urls.py": { + "captureGroups": 5, + "digest": "fb7fd5c6bac541e180e7f90bed248eeac937bc0cec7c2d8405f7f9bb20e87840" + }, + "python-django-app-imports/config/wsgi.py": { + "captureGroups": 7, + "digest": "2c57f45963ef158d2ad6a504c5d09fb4f3aa53d6198b91e91f8f824aae1de452" + }, + "python-django-app-imports/manage.py": { + "captureGroups": 10, + "digest": "ba770ed72ce287e7739d1b3ec4165053bfd8aa49f9104682d1a7094c724e3039" + }, + "python-enumerate-loop/app.py": { + "captureGroups": 23, + "digest": "1f528f2084d1f6da954ec8ce7eef0e45f99a0b3ae58502adc262a6caee5aa661" + }, + "python-enumerate-loop/repo.py": { + "captureGroups": 7, + "digest": "1d6eb1cdc661f2463d8e1a499eaa5bfcd9367324f6090c44fe4d59ed02151215" + }, + "python-enumerate-loop/user.py": { + "captureGroups": 12, + "digest": "a7f11709f76dcc9311554b4fe1dd4e95f5d6434e6232627b4c370f21e194bd69" + }, + "python-field-type-disambig/address.py": { + "captureGroups": 8, + "digest": "03076501a92927ff778f3dbcb555aa59e40fc21b1fe367147fd50cc330637a92" + }, + "python-field-type-disambig/service.py": { + "captureGroups": 6, + "digest": "caabe7eb61e0393151c362f3ec18a38b1d23a77176c67814c41ed6a2cfa54005" + }, + "python-field-type-disambig/user.py": { + "captureGroups": 11, + "digest": "d71983e74eb3c6ea73b4c8aa6457a6e6d6383538d5e5757aea9fcf18bcbe560b" + }, + "python-field-types/models.py": { + "captureGroups": 18, + "digest": "d1cf0a8e91042726967b6671ea720f2ddb57c5cfeb917cd942096ed0cb8bb0e8" + }, + "python-field-types/service.py": { + "captureGroups": 6, + "digest": "ad1c6a054f610dc609f3ec963746d49be87567d1bec643c811563bdcedc4469d" + }, + "python-for-call-expr/main.py": { + "captureGroups": 15, + "digest": "5c290b3b34f3f5e9dcdd6ee3ae72ba4223337cd64592330f9a8c6c6f13b2fd2d" + }, + "python-for-call-expr/models.py": { + "captureGroups": 31, + "digest": "f809317e086279962b81038b7ef2053bd3bc96d35bb1dd84a1ad543bc8dfe068" + }, + "python-function-local-import-chain/app.py": { + "captureGroups": 9, + "digest": "ad4d45976ca10c3fc3b7bf498ee23397797368a5d2817311d0e95e943af89199" + }, + "python-function-local-import-chain/svc.py": { + "captureGroups": 11, + "digest": "955591b75260b260c00d4ab4918c16ac344d23b5fffa9c47fbbacdc56c67ae47" + }, + "python-function-local-namespace-import/app.py": { + "captureGroups": 9, + "digest": "45f477cfd7b4991a8ce04239fde92b60b6c015922ac7cc52d5aa42caf5b69c3b" + }, + "python-function-local-namespace-import/svc.py": { + "captureGroups": 4, + "digest": "efe8b8af2d4e7f547a659aecc00399091af2597b47adc7b296bddb10cab2da69" + }, + "python-grandparent-resolution/app.py": { + "captureGroups": 9, + "digest": "509f6937b263ae9eb420bdf916664af3f0a0cf4fce708bb912f259b56e7c0938" + }, + "python-grandparent-resolution/models/__init__.py": { + "captureGroups": 0, + "digest": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + "python-grandparent-resolution/models/a.py": { + "captureGroups": 9, + "digest": "5879a42d6655248623c9aee193fedcae51daa1887d8e597fdd944ad93af053a4" + }, + "python-grandparent-resolution/models/b.py": { + "captureGroups": 4, + "digest": "55d7ad9e04c57cffa63b0bbcd71bfe18277b6369e7f4facb0faf802eea94db91" + }, + "python-grandparent-resolution/models/c.py": { + "captureGroups": 4, + "digest": "1fe0d62c3f332a954e7a7b18dd3c1a6d8d051ecf007d36cb6ecb32d3f7592c38" + }, + "python-grandparent-resolution/models/greeting.py": { + "captureGroups": 7, + "digest": "90a9349f89eb7e7c76123c6b235d510a9f151c9cd7e0db07bd8ae9b99f38ee93" + }, + "python-local-shadow/app.py": { + "captureGroups": 8, + "digest": "b71ed90838b5898310a80843486871ebeb6e1f63d24e0565d4be947c7bb66dc4" + }, + "python-local-shadow/utils.py": { + "captureGroups": 4, + "digest": "c11f7883ea696f30d188f8b8316cfd28746a10b4c7b898807ae21c67a8644ee5" + }, + "python-match-case/app.py": { + "captureGroups": 7, + "digest": "9c1f82b03516fbb9e111f1abcc56fd329bbfe290d3e9c0f31e9c8a84d715a031" + }, + "python-match-case/models/repo.py": { + "captureGroups": 6, + "digest": "54e050e7cecc288e367e97dc59280fd4febb614c7099894591e122901261a130" + }, + "python-match-case/models/user.py": { + "captureGroups": 6, + "digest": "3e5160418c4e3b4c0a896bed89e731711aff3a66872d99bb5ebfcbd624d9a3d2" + }, + "python-mcp-tools/helper1.py": { + "captureGroups": 3, + "digest": "7110e59dc2f17eb91a3055a133ec9243e706df3ec81be7b16b5f5eed1ae5412f" + }, + "python-mcp-tools/helper10.py": { + "captureGroups": 3, + "digest": "78645d6df40eea5a5fe93c795028e71c947632da816b2a0df7ba09c3ec87b209" + }, + "python-mcp-tools/helper11.py": { + "captureGroups": 3, + "digest": "6829e8ce4d64652d636f60771534460a8f110df3bbb09ddf1ec3ffbff892bc3f" + }, + "python-mcp-tools/helper12.py": { + "captureGroups": 3, + "digest": "b02a4035ee0492ab6df26386bc9d60f508b07bb655ab5a598986380940615d1c" + }, + "python-mcp-tools/helper13.py": { + "captureGroups": 3, + "digest": "79464a367f1f1a5819f6b7bc0be14c4bb36f27e9197d71bdd4b541fb0831738e" + }, + "python-mcp-tools/helper14.py": { + "captureGroups": 3, + "digest": "ddbb20f3e0c3fdfff5e97b946ca32c761f584534b5dc7ce74c0fd9fef8e0f1e3" + }, + "python-mcp-tools/helper2.py": { + "captureGroups": 3, + "digest": "0936e364c53dd42c0bb0d2be02d4432a45b1570e8cf7b6c422aeb2952be4e816" + }, + "python-mcp-tools/helper3.py": { + "captureGroups": 3, + "digest": "72a155ec8c5318ba679c51355b1760abbd927356b2503c19aa78a4af645013d1" + }, + "python-mcp-tools/helper4.py": { + "captureGroups": 3, + "digest": "9b253f8b946df5dd072c61cca4985c791de90fcd26b1749ccde0eae6a6cbedd6" + }, + "python-mcp-tools/helper5.py": { + "captureGroups": 3, + "digest": "e20e5fe81bd196ce552040f22e145dad1a784fcd3fe2cdd374939803394b65ce" + }, + "python-mcp-tools/helper6.py": { + "captureGroups": 3, + "digest": "a5f7af11d4c665e802ed1fc9fdbf6ecb8fafcb140067cb1a90f5c1b4c88e4abb" + }, + "python-mcp-tools/helper7.py": { + "captureGroups": 3, + "digest": "efdb60c4a9df5ddaff336e4c9899207cc9a2b9be231f596b9671e6001c8b6038" + }, + "python-mcp-tools/helper8.py": { + "captureGroups": 3, + "digest": "894d62be48b6e8a67266cace71a247521a2e6d1a58ceaa138f9ffbaf829ec492" + }, + "python-mcp-tools/helper9.py": { + "captureGroups": 3, + "digest": "25270485caa12492922ede0e4a2ed1e898f7d7862641e89cbc25ff0d31f77f18" + }, + "python-mcp-tools/server.py": { + "captureGroups": 36, + "digest": "b65b1821625ca03577a06e5c90204a1513706f841386f8806310cd41eb67b9c1" + }, + "python-member-access-for-loop/app.py": { + "captureGroups": 22, + "digest": "ec36378bb69d54cbe0d0a130e90f4122b7bc80191ec09e1af4437abca2d43ee6" + }, + "python-member-access-for-loop/models/repo.py": { + "captureGroups": 6, + "digest": "54e050e7cecc288e367e97dc59280fd4febb614c7099894591e122901261a130" + }, + "python-member-access-for-loop/models/user.py": { + "captureGroups": 6, + "digest": "3e5160418c4e3b4c0a896bed89e731711aff3a66872d99bb5ebfcbd624d9a3d2" + }, + "python-member-calls/app.py": { + "captureGroups": 8, + "digest": "3d5282754e81dc800cf1bf11b414e7986b0133ae6545f242030002ececab4840" + }, + "python-member-calls/user.py": { + "captureGroups": 9, + "digest": "485f993536f52252ad9b06971e99adb391fa95d005217c3563e41c48359fdf56" + }, + "python-method-chain-binding/app.py": { + "captureGroups": 19, + "digest": "01fe4805f59723a5f163d26b7be3ed3e456eb3a093e3df3a8f034cecee22ebb9" + }, + "python-method-chain-binding/models.py": { + "captureGroups": 34, + "digest": "062be597e84f6949477e56194ea5e875a6e1b8870e1af993d8c549d6f3de2d39" + }, + "python-method-enrichment/app.py": { + "captureGroups": 13, + "digest": "88dfd417951b8083184f83da8c1e0c2b19700cfbf1f1ff203a904ebc00f50b30" + }, + "python-method-enrichment/models.py": { + "captureGroups": 23, + "digest": "c177fc004563a84c1ccb5ed9cac366f184a7b5d0badcbfe7b3c204caf23bd533" + }, + "python-module-export-vs-method-collision/app.py": { + "captureGroups": 14, + "digest": "d37707fc868b7b086fd2534356cef6f96d42f0ea4691847858d7a5ad03eabe34" + }, + "python-module-export-vs-method-collision/mod.py": { + "captureGroups": 11, + "digest": "75158050a37ac0f4aa7427f87bfa1bb4d85933b92d023c9448d1fd21a787a3c3" + }, + "python-module-import/app.py": { + "captureGroups": 15, + "digest": "1cda3d492b732bd620b2e158a6967b719433d52b045f9066305ec3ef99973b18" + }, + "python-module-import/auth.py": { + "captureGroups": 11, + "digest": "0b99a5f6add0359f530c27c82f14ceb884849b24461870b8b517ab38a2714ae1" + }, + "python-module-import/models.py": { + "captureGroups": 6, + "digest": "3e5160418c4e3b4c0a896bed89e731711aff3a66872d99bb5ebfcbd624d9a3d2" + }, + "python-multi-level-mro/app.py": { + "captureGroups": 9, + "digest": "98bcec072e85a50303be141212b835322f5f9e53f7fe5d77b23d8ee524623e84" + }, + "python-multi-level-mro/child.py": { + "captureGroups": 4, + "digest": "c85d867b0b206cd3e13e8dd94c83b49f13e0f2ddde7acd61785b9755f41d3d14" + }, + "python-multi-level-mro/grandparent.py": { + "captureGroups": 7, + "digest": "f9f81d3a37c55b3e23bec3774405920afa29c5793c46860a98a06c5d0c7f0980" + }, + "python-multi-level-mro/parent.py": { + "captureGroups": 4, + "digest": "05380e5d4d88a93546a3185c0f6598259cc5a1c271ec08a130cf72cf9107023d" + }, + "python-multi-segment-ancestor-import/backend/auth_utils.py": { + "captureGroups": 6, + "digest": "45f881e9deb28e29d912924a6d108378cc7f5046ed84b0302d9e4b36c5085f8c" + }, + "python-multi-segment-ancestor-import/backend/routers/__init__.py": { + "captureGroups": 0, + "digest": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + "python-multi-segment-ancestor-import/backend/routers/alerts.py": { + "captureGroups": 3, + "digest": "be1e4339d6f7d31c86c60fa950ca455a38b7ec18a223f5b2fef4dadbae0d3328" + }, + "python-multi-segment-ancestor-import/backend/routers/cron.py": { + "captureGroups": 25, + "digest": "1d7405bb1dc911b0cbd03bde1c86a49bc54079a4de9f74c00196e43795ec4bf0" + }, + "python-multi-segment-ancestor-import/backend/services/__init__.py": { + "captureGroups": 0, + "digest": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + "python-multi-segment-ancestor-import/backend/services/alerts.py": { + "captureGroups": 3, + "digest": "bcbe9f5923dda8b54c1cc1a07c38439ce93d5ce13101775c20ff6e4de9afb404" + }, + "python-multi-segment-ancestor-import/backend/services/sync.py": { + "captureGroups": 5, + "digest": "3c8bb78fafe4c0797d9d1ca162911d96f7b6fd3a5a037c26952b7f0287610150" + }, + "python-named-imports/app.py": { + "captureGroups": 5, + "digest": "179248a29a48abba318bece3fe6eeca436db9544126684e203ad27983dc41695" + }, + "python-named-imports/format_prefix.py": { + "captureGroups": 3, + "digest": "199776174b6aa80ea46969db4b427dbd4d6be0d4d6a96d62f05d984556a2b09f" + }, + "python-named-imports/format_upper.py": { + "captureGroups": 4, + "digest": "3f0a4801427bc126dff1d700a91d9133f3e59d3db4db26eccfb3276258cb4fea" + }, + "python-nullable-chain/app.py": { + "captureGroups": 31, + "digest": "d5a8af8929a21808c3570cdc175b8412a5925b41cdace728ffc0179cdb2bc484" + }, + "python-nullable-chain/repo.py": { + "captureGroups": 7, + "digest": "8c79e31ea4557abf29e4e1ec087db258e48afa3f9cc1e637291d0d5d49914980" + }, + "python-nullable-chain/user.py": { + "captureGroups": 7, + "digest": "967ff5732d066c516ad0bfddd87696598ab9c2c4b55c4463641db14af4cdfb78" + }, + "python-nullable-receiver/app.py": { + "captureGroups": 23, + "digest": "7f22953242a4402b31ebcd6219d4976fd706c2775ef60d85f0911900e08cd647" + }, + "python-nullable-receiver/repo.py": { + "captureGroups": 6, + "digest": "0d2704cfa14e431395973cc82b582f9fae43a8ce67d5548ab5df62b3fa8d1b7e" + }, + "python-nullable-receiver/user.py": { + "captureGroups": 6, + "digest": "4dd0a9d0f5797bae1c281a07145ddcc4162eae1ea16208e85edb74cd98e085f9" + }, + "python-overload-dispatch/app.py": { + "captureGroups": 21, + "digest": "37fa5251fc1914d95fab2c6aae6d97b460a47f46c5b74415a86c955f4ed78626" + }, + "python-overload-dispatch/service.py": { + "captureGroups": 28, + "digest": "a873c2cebeb1092fe21b75b2109052fbb66335a378dd5468b32a57a4644ad14b" + }, + "python-parent-resolution/models/__init__.py": { + "captureGroups": 0, + "digest": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + "python-parent-resolution/models/base.py": { + "captureGroups": 7, + "digest": "f0384bd6ecb7d1a9ad2306358917b7295c71ea8f1bcea818fd39c56d2c28c7e9" + }, + "python-parent-resolution/models/user.py": { + "captureGroups": 8, + "digest": "0dbcf175be44961f7a7c02e21167f10063772297633173236ce49f970bb395a4" + }, + "python-pkg/models/base.py": { + "captureGroups": 9, + "digest": "4984ee01b7a9fefe622195f0e4925823c0e62a1714ca2dda5cd8250e5e45fa7c" + }, + "python-pkg/models/user.py": { + "captureGroups": 7, + "digest": "8c340fe8e46b5adf03c82e736822c501983c087b85dc72bdf6d12b75faa843ab" + }, + "python-pkg/services/auth.py": { + "captureGroups": 9, + "digest": "3c9e44e7870643e475a67f562c15a42d58ade2b47a6c43ad665ef73e53e765ff" + }, + "python-pkg/utils/helpers.py": { + "captureGroups": 7, + "digest": "9a12c0b8c2523d4dfd5919e04cfe2bf4e9f6b84fe1a83a146f58996b7d453bb5" + }, + "python-plain-import-alias/app.py": { + "captureGroups": 17, + "digest": "a419c716b0d13787ce1f3cc2adb5ab2ab77604cb8348a9b27aa56443984ab0b2" + }, + "python-plain-import-alias/auth.py": { + "captureGroups": 6, + "digest": "51a9f92e1dae910b08b86f506c174ad2f60dfc1d2d5f66d22838978d7d424b48" + }, + "python-plain-import-alias/models.py": { + "captureGroups": 11, + "digest": "abf5fcdf7cc473efa5a149319b0fc3d14b3ae91a2e9a14d621243c5ed7fafafa" + }, + "python-qualified-constructor/main.py": { + "captureGroups": 9, + "digest": "9e4d51c06d75721e3c07898124ef85139d52d9f20d6df6bae557aff98a3ed207" + }, + "python-qualified-constructor/models.py": { + "captureGroups": 13, + "digest": "94e738bd516d6e1fa1ca130fc231157071c04aa3b2ad74db6dabbacd7b7842a7" + }, + "python-receiver-resolution/app.py": { + "captureGroups": 15, + "digest": "806934204693a06760f3c74e8c68145eda5cf19d7903130646e152ea14fc7a98" + }, + "python-receiver-resolution/repo.py": { + "captureGroups": 6, + "digest": "0d2704cfa14e431395973cc82b582f9fae43a8ce67d5548ab5df62b3fa8d1b7e" + }, + "python-receiver-resolution/user.py": { + "captureGroups": 6, + "digest": "4dd0a9d0f5797bae1c281a07145ddcc4162eae1ea16208e85edb74cd98e085f9" + }, + "python-reexport-chain/app.py": { + "captureGroups": 13, + "digest": "a30e5ce2de19dcb4d099c592e96d2cc86ffb859d8bd55e0eddd13aeeeac67ed8" + }, + "python-reexport-chain/models/__init__.py": { + "captureGroups": 3, + "digest": "ee0d8506fbaaf9cbf3a2f78bd34b7e688eb01aed590408cc2d6c59a4ee3bb4d8" + }, + "python-reexport-chain/models/base.py": { + "captureGroups": 13, + "digest": "4ea6fa3504e37231c8d395259009df5d4adda31e3ea0bfc28cd3c3eabdd68280" + }, + "python-return-type-inference/app.py": { + "captureGroups": 8, + "digest": "741f690b6330491303b9b58cb31027a33600973265b59428facfefbabf0cf7e1" + }, + "python-return-type-inference/models.py": { + "captureGroups": 12, + "digest": "a7f11709f76dcc9311554b4fe1dd4e95f5d6434e6232627b4c370f21e194bd69" + }, + "python-return-type-inference/service.py": { + "captureGroups": 7, + "digest": "81f8d5783bfc95a9f83f02abd15f7bbc0869aaca964c62b40653cc05ab73ebcb" + }, + "python-same-file-method-collision/app.py": { + "captureGroups": 17, + "digest": "cd1e6bd1cea7de20c9317fa650bb0b3492df81d736d24142a0a3f082834c507a" + }, + "python-same-file-method-collision/models.py": { + "captureGroups": 21, + "digest": "3c5ef9ae13442f9c77a61c4fc6b76ebc580cdd5857810cc484f6cb768fa9d727" + }, + "python-same-name-collision/metrics.py": { + "captureGroups": 7, + "digest": "57482d976bc3d4f232064b55f809507a9e00ec4f75ee519691034227c666312e" + }, + "python-same-name-collision/router.py": { + "captureGroups": 10, + "digest": "e9248113f2dbae22dff443551daa131136c69f00ea0fb912a6419fcb1c1e7833" + }, + "python-self-this-resolution/models/repo.py": { + "captureGroups": 7, + "digest": "1d6eb1cdc661f2463d8e1a499eaa5bfcd9367324f6090c44fe4d59ed02151215" + }, + "python-self-this-resolution/models/user.py": { + "captureGroups": 12, + "digest": "b23ad4122f7fdb5b3f9b047582229a9ea001a29452035974112d4f425ef86cf4" + }, + "python-static-class-methods/app.py": { + "captureGroups": 14, + "digest": "b397708c05d101d0d3978313f65184c9f181a179b803d242b1e585ac2605fe4a" + }, + "python-static-class-methods/service.py": { + "captureGroups": 27, + "digest": "9539dd5884b6b5ef3ad14b9d06837a495b4d3908462ec37464524c259a2cbd75" + }, + "python-super-resolution/models/__init__.py": { + "captureGroups": 0, + "digest": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + "python-super-resolution/models/base.py": { + "captureGroups": 7, + "digest": "f0384bd6ecb7d1a9ad2306358917b7295c71ea8f1bcea818fd39c56d2c28c7e9" + }, + "python-super-resolution/models/repo.py": { + "captureGroups": 7, + "digest": "1d6eb1cdc661f2463d8e1a499eaa5bfcd9367324f6090c44fe4d59ed02151215" + }, + "python-super-resolution/models/user.py": { + "captureGroups": 10, + "digest": "2ab193907b46f0fd20f18e0b3b019986ae34f6252bff38145ebb40922bf7eb00" + }, + "python-variadic-resolution/app.py": { + "captureGroups": 5, + "digest": "62b16647599de4db600ece073131025de23bf692d1585d8b7f1bfc3fae2f3dcb" + }, + "python-variadic-resolution/logger.py": { + "captureGroups": 5, + "digest": "5995f1a01f8354c3d854d4f4dc51fb4a2919bdcb22e90eb0a64431eb03e5804c" + }, + "python-walrus-chain/app.py": { + "captureGroups": 33, + "digest": "07590672df6cd4d1069ec346033fa29c116d42c841ec68da8d22afd44effb938" + }, + "python-walrus-chain/repo.py": { + "captureGroups": 7, + "digest": "8c79e31ea4557abf29e4e1ec087db258e48afa3f9cc1e637291d0d5d49914980" + }, + "python-walrus-chain/user.py": { + "captureGroups": 7, + "digest": "967ff5732d066c516ad0bfddd87696598ab9c2c4b55c4463641db14af4cdfb78" + }, + "python-walrus-operator/main.py": { + "captureGroups": 7, + "digest": "4df7ea089c43552ca4ea5a51f8e985d11d351b86949efb2f7ebefdf6a9ffd689" + }, + "python-walrus-operator/models.py": { + "captureGroups": 16, + "digest": "ccd7c8c00bc2fe3bd2bad667e2448591c3508e9eb91a0005a9148a2d3734ac98" + }, + "python-write-access/models.py": { + "captureGroups": 11, + "digest": "d1b714cb6654f83df3bb40830ced44f2950a122270c9e379f732943d57688151" + }, + "python-write-access/service.py": { + "captureGroups": 9, + "digest": "0aa940e3428c35b1324544e9bcc75c166732b63b3e65e6faf460bc3d3a9571ec" + }, + "synthetic:dao-20": { + "captureGroups": 473, + "digest": "27a7e0ea629f7ed002f1bd254126c819125900b27e0818d879e8fdaa8690aab9" + } +} diff --git a/gitnexus/test/fixtures/ruby-captures-golden/expected-captures.json b/gitnexus/test/fixtures/ruby-captures-golden/expected-captures.json new file mode 100644 index 000000000..235436ea5 --- /dev/null +++ b/gitnexus/test/fixtures/ruby-captures-golden/expected-captures.json @@ -0,0 +1,314 @@ +{ + "ruby-ambiguous/lib/user_handler.rb": { + "captureGroups": 9, + "digest": "282d98829c29708081e96207dc0a6d82142400444a1345e162287e6b2c23d019" + }, + "ruby-ambiguous/models/handler.rb": { + "captureGroups": 6, + "digest": "4609ec03a86a4f4f2a93af9dd11437363daf1b3139ebdce366a8f49d47ea71b1" + }, + "ruby-ambiguous/other/handler.rb": { + "captureGroups": 6, + "digest": "d13c8ad253a9310d0bed45730135a947e67dec788686bd08710902eafc307237" + }, + "ruby-app/lib/base_model.rb": { + "captureGroups": 20, + "digest": "999f23c116bf4c0a25834e3fff1b12d5ec1bc5616a29e75716ba753dc253a62c" + }, + "ruby-app/lib/concerns/cacheable.rb": { + "captureGroups": 6, + "digest": "cc879f2fa31b1d50b7ba33f52c6dd68ba0ff070ffbc3c2d0293510f88643b373" + }, + "ruby-app/lib/concerns/loggable.rb": { + "captureGroups": 6, + "digest": "049ea9beaf9c55b55bd836d31af4c8806ce24bb805261ae4d62575b4a19118bc" + }, + "ruby-app/lib/concerns/serializable.rb": { + "captureGroups": 6, + "digest": "5a5a6e8cb7bcd32d828763152b8107e5ee824a5e6244ee55f85925f23e520b02" + }, + "ruby-app/lib/service.rb": { + "captureGroups": 13, + "digest": "49bd37b2138dc17820351c126a7a75622cb564e8dd15a9b5f79fcc2d0844474d" + }, + "ruby-app/lib/user.rb": { + "captureGroups": 23, + "digest": "38b79d3ef8e804c56e3819804129a6fc9c6bbb8d93d2a96413909f4884e99d94" + }, + "ruby-call-result-binding/app.rb": { + "captureGroups": 17, + "digest": "42a4ffb39fa090608cb0f60e8c31d2c51c7515a4ee6ae038e9accbac0d2e5488" + }, + "ruby-calls/lib/one_arg.rb": { + "captureGroups": 7, + "digest": "a04d2c55b5738a824bfa4139925cade1d437bf0a1a91ebf044c5098e2e5f9b58" + }, + "ruby-calls/lib/service.rb": { + "captureGroups": 11, + "digest": "7a4d964547d3ac645274c65fcbceba3f6852286537af2452c87d75bbde3495ab" + }, + "ruby-calls/lib/two_args.rb": { + "captureGroups": 7, + "digest": "533578cb1111c137d4cc660d1667ba43f649799a9fbd6139a20ac389d16477bf" + }, + "ruby-chain-call/lib/app.rb": { + "captureGroups": 13, + "digest": "ea7f8a06ec8cb89b47a3c5ebfce79211a51abf643fe9a48e480729b8323491c4" + }, + "ruby-chain-call/lib/repo.rb": { + "captureGroups": 6, + "digest": "9effd68932555f44968ec89ea1fb0bff4719185c777e856ae167a2289fa72239" + }, + "ruby-chain-call/lib/user.rb": { + "captureGroups": 6, + "digest": "5efcef8e4c0dc82ddaa88cf6ed51c8a79e86e34e06e14c4363373444da5d5292" + }, + "ruby-chain-call/lib/user_service.rb": { + "captureGroups": 11, + "digest": "bfd4e260c15d1e5da32af458a4304ffa21e4e6e22133fa8dd34ad14e50beb0f6" + }, + "ruby-child-extends-parent/lib/app.rb": { + "captureGroups": 12, + "digest": "8a75c4468b2b1f033620e7d233071340c129a06db4b8ca85a54293efdc5b989f" + }, + "ruby-child-extends-parent/lib/child.rb": { + "captureGroups": 5, + "digest": "6b0b88e4101367fb23107124b5ccea5c54632cc39ec272c5816fc6119b680910" + }, + "ruby-child-extends-parent/lib/parent.rb": { + "captureGroups": 6, + "digest": "e0b6dd3034cc87ed2ae1e46da9681e58074f3b2d2778e6584869bf49a3dd6b7e" + }, + "ruby-constant-constructor/app.rb": { + "captureGroups": 7, + "digest": "cb37edd7c73b7759607850b774627ff2ad223458c611171d666cee83e4de99cd" + }, + "ruby-constant-constructor/models.rb": { + "captureGroups": 9, + "digest": "580c49087a657582485e87d528777d2b60e6e29d3d5e652e777c23ba35186f7b" + }, + "ruby-constant-factory-call/admin_service.rb": { + "captureGroups": 9, + "digest": "a62fbc9faded8b59e1bc529d72e6132187a493475d99ff6163cc71cd1de773f4" + }, + "ruby-constant-factory-call/app.rb": { + "captureGroups": 14, + "digest": "f3b9bc3394854593513e17ef3c0e37375aab959d666b4aed4df443377608eb64" + }, + "ruby-constant-factory-call/user_service.rb": { + "captureGroups": 9, + "digest": "d9d74ff4dae50c699e4f4f874c4aa75fa5c27e57ffc4fb170aafca4d333bb2a7" + }, + "ruby-constructor-type-inference/models/repo.rb": { + "captureGroups": 9, + "digest": "b0f3b5728c603a2056613091ac75d5014d99c4cdb2d192285c2707117bb46d0c" + }, + "ruby-constructor-type-inference/models/user.rb": { + "captureGroups": 9, + "digest": "dc7cc04f621b487fd24f417281a6a77b6b5dee234872b98c7d52ea719a991205" + }, + "ruby-constructor-type-inference/services/app.rb": { + "captureGroups": 26, + "digest": "afa655d9a3c7ebea02b529d33904575692a53531a619c3fd34634f0bb43855e4" + }, + "ruby-default-params/app.rb": { + "captureGroups": 7, + "digest": "84779fa164cbc83863f6ae1552ca8ed19f21a18cb410b26ea581132c21d88a6a" + }, + "ruby-field-type-disambig/address.rb": { + "captureGroups": 8, + "digest": "cfb08d28049718393f211454b0c0148aaa82d7093915f0d8650a224d8ff687e3" + }, + "ruby-field-type-disambig/service.rb": { + "captureGroups": 8, + "digest": "bb2449fe1f87d23fca7db7507987d32daae36cbee8166fe9990006934b9d05a5" + }, + "ruby-field-type-disambig/user.rb": { + "captureGroups": 13, + "digest": "7f4ed543946d9e243aa83e122303558aa336cff91e5323db09502999f64298d3" + }, + "ruby-field-types/models.rb": { + "captureGroups": 19, + "digest": "6673d1c2b299edee80c5d7ff0dddb8741e6b03070fbc119a5c1f14598ed8b005" + }, + "ruby-field-types/service.rb": { + "captureGroups": 8, + "digest": "9dcde083c602b85bf41561623dc7e6af16718cd1c8bacf00ac4b8d917a1557cd" + }, + "ruby-for-in-loop/app.rb": { + "captureGroups": 8, + "digest": "b584394fac8f939f3d4d1170c373da4332bfeffa641e64e997d108e85a4c5ec3" + }, + "ruby-for-in-loop/repo.rb": { + "captureGroups": 9, + "digest": "fb4c12f847eca414066c5219a68c2dd48167a174d28788da118814ee8931ccfd" + }, + "ruby-for-in-loop/user.rb": { + "captureGroups": 9, + "digest": "b6f4c241293057f3d4505cfec9d63376d1f598599dc6bf0a38ec0bef242dda5a" + }, + "ruby-grandparent-resolution/lib/app.rb": { + "captureGroups": 10, + "digest": "118450b872d82f6762e0081b85afaff8c28f683395ecd3c1eec7efdf8dd420a6" + }, + "ruby-grandparent-resolution/lib/models/a.rb": { + "captureGroups": 10, + "digest": "83bfdb09dd8a1a0bda06c84092041e257502ca37ae393c57ec2ff43b67d699ae" + }, + "ruby-grandparent-resolution/lib/models/b.rb": { + "captureGroups": 5, + "digest": "fc9e9541d4c3e85be94cc8be6559d6ee25b9d7178aafa7b50848ab00409446dc" + }, + "ruby-grandparent-resolution/lib/models/c.rb": { + "captureGroups": 5, + "digest": "d507a997c8d022705c15d55c3bbc6a6134353c1114a2de4629f4b4b4f9080ca0" + }, + "ruby-grandparent-resolution/lib/models/greeting.rb": { + "captureGroups": 6, + "digest": "a5f34e078d09145f2c2921610af56a1dd8273705d31446002d6a28aff1d32abc" + }, + "ruby-local-shadow/lib/app.rb": { + "captureGroups": 8, + "digest": "6413cadbe85841eb6f06f42e8a7dd3902cc0d2908971b61c1a1a5e0e221afec9" + }, + "ruby-local-shadow/lib/utils.rb": { + "captureGroups": 6, + "digest": "b2c5cce7a167d427de4fea1b955b9ec6401ab70f0fbd61a44fb2051332beb82d" + }, + "ruby-member-calls/lib/app.rb": { + "captureGroups": 12, + "digest": "78f4ec67d744302bf2827c0b61de167285fdb95794a34738898a8cb92d2529a5" + }, + "ruby-member-calls/lib/user.rb": { + "captureGroups": 6, + "digest": "c621dd1859fe820beedf3fb98f4caa483ca262ea6936c8d6253bfabd56b5d557" + }, + "ruby-method-chain-binding/app.rb": { + "captureGroups": 39, + "digest": "54fc82a9a0a67ccff1d5ca3055c93044e79e6cd2972d943f9c2e90f1f2198716" + }, + "ruby-method-enrichment/lib/animal.rb": { + "captureGroups": 27, + "digest": "5feb199009fcc4923665fcd4a8f3173388be58ca6ae53f19de6795cd2521d397" + }, + "ruby-method-enrichment/lib/app.rb": { + "captureGroups": 14, + "digest": "941438b3abc3d34ad79e62eb3bf2eee785ac01fd5034f6f40a6911895d79c9ac" + }, + "ruby-namespaced-constructor/app.rb": { + "captureGroups": 8, + "digest": "8f6555980417465c870a16742eb2d49fcb8a31e26777c11b926180674aa93e97" + }, + "ruby-namespaced-constructor/models/user_service.rb": { + "captureGroups": 12, + "digest": "c10f36dbbbe2be16fc3fccbebb7ee79668ec7a77b75adbd5281ded31893d49de" + }, + "ruby-overload-dispatch/lib/app.rb": { + "captureGroups": 10, + "digest": "288d5386cf37fb76b52a94bc7da6bf8e7843830ebbb01fcd8100d1590c0e3f72" + }, + "ruby-overload-dispatch/lib/formatter.rb": { + "captureGroups": 11, + "digest": "3e210571081b6977ea7d8b23f1678b57bedb2414cf5f6730466bf7b56829857c" + }, + "ruby-parent-resolution/lib/models/base_model.rb": { + "captureGroups": 6, + "digest": "e108bf68441486968cff17aabfc3489fd295f15d7ad32cac8dad9c1b56e6029b" + }, + "ruby-parent-resolution/lib/models/serializable.rb": { + "captureGroups": 6, + "digest": "4b415b1bcb31b0290a01279f86305459f53e43efb388e96d6e7b4bf804bd12d8" + }, + "ruby-parent-resolution/lib/models/user.rb": { + "captureGroups": 8, + "digest": "6bb1c81445d4a3f41b6a23e6a20ee4602a261ee5405c3f463dd81c6741bc8e00" + }, + "ruby-qualified-types/lib/admin/user.rb": { + "captureGroups": 8, + "digest": "1bceb829c2429e1415c296ea97a2475203c3cbf69c07123186f4c277a44b2f9f" + }, + "ruby-qualified-types/lib/services/auth/user.rb": { + "captureGroups": 10, + "digest": "21fbc89550147f66806eb9e506870fcf5f4bec23876bdce069897d905e0ae307" + }, + "ruby-return-type/app.rb": { + "captureGroups": 17, + "digest": "0db7356580a8ac5addec69a3259e5a78028e01b44f5e36316a6f76172929f829" + }, + "ruby-return-type/models.rb": { + "captureGroups": 14, + "digest": "b731080e81701e48bbe0eff87dcb0286d266626037dc1e422cc268ef1a644722" + }, + "ruby-return-type/repo.rb": { + "captureGroups": 14, + "digest": "70875215143713279e9595e89b8c5d7d2c642d0660365772b64daf5b8b487b4f" + }, + "ruby-self-this-resolution/lib/models/repo.rb": { + "captureGroups": 6, + "digest": "9effd68932555f44968ec89ea1fb0bff4719185c777e856ae167a2289fa72239" + }, + "ruby-self-this-resolution/lib/models/user.rb": { + "captureGroups": 10, + "digest": "17c3c937d7d40cffec95393e6b7f2886b44a9117f048fb72eafa88140320c45e" + }, + "ruby-sequential-mixin/lib/account.rb": { + "captureGroups": 27, + "digest": "e426aff3f6a6b4e403a6bf64aa4c15ddea6afe43b18754bfb71103653b1df8b3" + }, + "ruby-sequential-mixin/lib/greetable.rb": { + "captureGroups": 6, + "digest": "f56ff0b87abb7463dcf5fdbe18c1cf74c11595853ff04ff8d929cc8d5cb71ca5" + }, + "ruby-sequential-mixin/lib/logger_mixin.rb": { + "captureGroups": 7, + "digest": "c6edbab52ae7a58d89bd11da498d99a7cf0eaaeab531c56774b1a3fee1c39d1e" + }, + "ruby-sequential-mixin/lib/prepended_override.rb": { + "captureGroups": 9, + "digest": "90bdfeb850c14a1f85ac69b194c4a37db196d4709b503e00a31968c550a66746" + }, + "ruby-sequential-mixin/lib/usage.rb": { + "captureGroups": 14, + "digest": "c8e1cd74bf9fd8db677fc8e2adbcc8b7eb303884d5339aa964350f81167935bc" + }, + "ruby-super-resolution/lib/models/base_model.rb": { + "captureGroups": 6, + "digest": "e108bf68441486968cff17aabfc3489fd295f15d7ad32cac8dad9c1b56e6029b" + }, + "ruby-super-resolution/lib/models/repo.rb": { + "captureGroups": 6, + "digest": "9effd68932555f44968ec89ea1fb0bff4719185c777e856ae167a2289fa72239" + }, + "ruby-super-resolution/lib/models/user.rb": { + "captureGroups": 8, + "digest": "5122d102ff7e2e0b9f1396ee5b6ddab33a866aa3c29af10301dbf673ffaa19af" + }, + "ruby-write-access/models.rb": { + "captureGroups": 13, + "digest": "106fe23a380801055a2ef642f0b92d4552ce074321ebe831601f2cb89a8d8529" + }, + "ruby-write-access/service.rb": { + "captureGroups": 18, + "digest": "b39aef2dd2721626e0c1718ea150455bc54522022ba45ce0d235f2e33e861be9" + }, + "ruby-yard-annotations/models.rb": { + "captureGroups": 14, + "digest": "1906ddedab8b6f352468a13cefd3d018889492b8dc983f854ccbe7a2b84a938f" + }, + "ruby-yard-annotations/service.rb": { + "captureGroups": 13, + "digest": "f0864dc29de46bc9ae3c15cae5714490aac6536d8da0704a353f2300df75bb63" + }, + "ruby-yard-generics/models.rb": { + "captureGroups": 17, + "digest": "3a0722a48ac72ad10a492d10c4728a805990d07789e5b20a239a6c95a914d23f" + }, + "ruby-yard-generics/service.rb": { + "captureGroups": 18, + "digest": "040e4c3a4091238c8b59089f4b4e92d59879dbe5053131395a5e1ac6ad5f1d09" + }, + "synthetic:dao-20": { + "captureGroups": 161, + "digest": "99da729084d5a08425f04078fb633b35b7256e861b67bce095af59cb42e151fe" + } +} diff --git a/gitnexus/test/fixtures/rust-captures-golden/expected-captures.json b/gitnexus/test/fixtures/rust-captures-golden/expected-captures.json new file mode 100644 index 000000000..25690226b --- /dev/null +++ b/gitnexus/test/fixtures/rust-captures-golden/expected-captures.json @@ -0,0 +1,454 @@ +{ + "rust-abstract-dispatch/src/lib.rs": { + "captureGroups": 26, + "digest": "8723f4d9b1c4824be8ec22c28b203113b781fc05cffcd80a6b15efe60a0f927d" + }, + "rust-abstract-dispatch/src/main.rs": { + "captureGroups": 19, + "digest": "f0483e7bed3772013ed48efea6071e4959603e5ceb9b3786e4d334c7a1ccf76a" + }, + "rust-alias-imports/src/main.rs": { + "captureGroups": 19, + "digest": "ffa9d81433d8d4931f07ba0151b2349f0bbf876e7b327a5ffeeaf3ed18a43abf" + }, + "rust-alias-imports/src/models.rs": { + "captureGroups": 21, + "digest": "fa89b58c454471da61f7e1585854f42e2f331517094420cf7f76fdf5b898795e" + }, + "rust-ambiguous/src/main.rs": { + "captureGroups": 12, + "digest": "7d29d164d6f64e2bcac4bba8d4b86d725f696a836341b5265a5dce9543b1691e" + }, + "rust-ambiguous/src/models/handler.rs": { + "captureGroups": 9, + "digest": "5a1729f00b573f6abb214866ae618cd32fe4c166591327f4de782e262af39266" + }, + "rust-ambiguous/src/models/mod.rs": { + "captureGroups": 3, + "digest": "f83985431e11f24a46d28f6c5256dd2e7b6d17a1079dd51bf8c4fe4f9087be11" + }, + "rust-ambiguous/src/other/handler.rs": { + "captureGroups": 9, + "digest": "cbcf55909786187d156c6ce9e94e37602897a3b1515aa80bb15110014f31d048" + }, + "rust-ambiguous/src/other/mod.rs": { + "captureGroups": 3, + "digest": "f83985431e11f24a46d28f6c5256dd2e7b6d17a1079dd51bf8c4fe4f9087be11" + }, + "rust-ambiguous/src/services/mod.rs": { + "captureGroups": 8, + "digest": "225b1a2de0f98873a99c669275ecce6a7f6f0d04f2b50f6a15f7546c06f2e295" + }, + "rust-assignment-chain/src/main.rs": { + "captureGroups": 35, + "digest": "ba2d21b5126b59c0cfe9d99427ee6726eab0e3f2d19022e4b946bddf11730b42" + }, + "rust-assignment-chain/src/repo.rs": { + "captureGroups": 10, + "digest": "24dcd450677a5885db0d93973e431e47b41e6866ad3fc7cb686f7a021000c17e" + }, + "rust-assignment-chain/src/user.rs": { + "captureGroups": 10, + "digest": "a5545efb669e40adf8428af48057cd93bd31c226f7d0952d4dae76e1ee5228f7" + }, + "rust-async-binding/src/main.rs": { + "captureGroups": 33, + "digest": "4c8c0c21c2dffe6efaf91d26e92c1bafee95f940909fa4d5a73ad0c07b5aee2f" + }, + "rust-async-binding/src/repo.rs": { + "captureGroups": 11, + "digest": "43911a94194a6771769f9bad2a81108fa84bb842aaeaf7c1e17f22beb49b9c6e" + }, + "rust-async-binding/src/user.rs": { + "captureGroups": 11, + "digest": "60fc4ac44f58ae67d462e243655b0571392a18de85b9e200e9391b50c941a6c9" + }, + "rust-call-result-binding/src/main.rs": { + "captureGroups": 11, + "digest": "db75d1b522e5d3574037b692e51423f5f71b6bba011aa120e33d0c6fbc6c1033" + }, + "rust-call-result-binding/src/models.rs": { + "captureGroups": 19, + "digest": "550f189e1374dc52599482fb63c8363a7839b4a7514aa6e911c354b3a92926a9" + }, + "rust-calls/src/main.rs": { + "captureGroups": 9, + "digest": "2ec004ce1d77e3aee80ae1332bc0ab64fb366deffbac84f84ff1861286edc4e5" + }, + "rust-calls/src/onearg/mod.rs": { + "captureGroups": 6, + "digest": "5a2b4645b0dcebfb9eada1a5b168b99830316859854d1f9c55654a3ab0211f0c" + }, + "rust-calls/src/zeroarg/mod.rs": { + "captureGroups": 5, + "digest": "aae68fe5023fa93180b05ce8034654bfa1d2d95bc7eea67625e2e3a3cff6ebee" + }, + "rust-chain-call/src/main.rs": { + "captureGroups": 23, + "digest": "c99d5d1f0aaf873eec47268f852fc265e4f1e2d2be08b76be653e49f43ffc5df" + }, + "rust-chain-call/src/models/mod.rs": { + "captureGroups": 3, + "digest": "c96e9c3df9991cd8e40612b0f1a5fc83a42013cf0870458209fe80c41dd5abd1" + }, + "rust-chain-call/src/models/repo.rs": { + "captureGroups": 11, + "digest": "43911a94194a6771769f9bad2a81108fa84bb842aaeaf7c1e17f22beb49b9c6e" + }, + "rust-chain-call/src/models/user.rs": { + "captureGroups": 11, + "digest": "60fc4ac44f58ae67d462e243655b0571392a18de85b9e200e9391b50c941a6c9" + }, + "rust-child-extends-parent/src/child.rs": { + "captureGroups": 12, + "digest": "b357cb50ed1d09f5d218261ed640f73c393f05be36c959e001987d8423ab4194" + }, + "rust-child-extends-parent/src/main.rs": { + "captureGroups": 17, + "digest": "21698d0650c872cb7a2f0849aef47d266ffb6a851d01286174d76f74a25acd1e" + }, + "rust-child-extends-parent/src/parent.rs": { + "captureGroups": 7, + "digest": "89bbfe22788bf85e8fda4ad0d1c8dc14f4ec983624e59b58a68852e093290b25" + }, + "rust-constructor-type-inference/src/main.rs": { + "captureGroups": 21, + "digest": "5cb66a46d7a52800325aab132297e002ccbda7da322ee84d9b95bd7367842c7c" + }, + "rust-constructor-type-inference/src/repo.rs": { + "captureGroups": 15, + "digest": "5c0858427be24f2cc6ae346b9dc8294d85a530bcf2f529829d757b69f768dc65" + }, + "rust-constructor-type-inference/src/user.rs": { + "captureGroups": 15, + "digest": "043cd8b9341ab299750f8d07f1d1ca34b714c4ecf69acba80ec827e93e3852c0" + }, + "rust-deep-field-chain/models.rs": { + "captureGroups": 24, + "digest": "fe28a5861492fc4b20d911e44abc246ec33e4f3305fe4bd7e95c952972f17564" + }, + "rust-deep-field-chain/service.rs": { + "captureGroups": 15, + "digest": "143fcbb0bda81b945ecb1b3a692fb35ccc1e7b2ee1eea64f20ec548b432a53fb" + }, + "rust-default-constructor/src/main.rs": { + "captureGroups": 34, + "digest": "6516c6b21d2cca74b18ee443ca0049228b4832efc551ca23e5cf3098c95d34f4" + }, + "rust-default-constructor/src/repo.rs": { + "captureGroups": 21, + "digest": "968aaf37e492e999c3699fb6eb8f3ee3d021bc112fa205198469e48fd65aeef8" + }, + "rust-default-constructor/src/user.rs": { + "captureGroups": 21, + "digest": "fe375f3a12ea8c05743816c7d218d50fc1898968603a00906194ef1833e247cd" + }, + "rust-err-unwrap/src/error.rs": { + "captureGroups": 9, + "digest": "798c8e01c6e54792ba69e845248efc8abf0cba38fa3d16fb8e0d1f6dd2ad2b7e" + }, + "rust-err-unwrap/src/main.rs": { + "captureGroups": 24, + "digest": "e4c452ce79e6e09f24d77acb9537f38b46afb4c59661192d0a4bc496a2b753bb" + }, + "rust-err-unwrap/src/repo.rs": { + "captureGroups": 9, + "digest": "09f0d2905c2fc05043fa767b277a23b787aad4b9c0fe22bc5569ca9660f7f18a" + }, + "rust-err-unwrap/src/user.rs": { + "captureGroups": 9, + "digest": "a8562a331eb945b4c099a7a9ec6c9b5eed8ff603c9359897b0445ce316a1424e" + }, + "rust-field-types/models.rs": { + "captureGroups": 21, + "digest": "f6c64476fd1b30699d0c35cf7b64cd4210b3ae2158f29ad0faae614b1b218c83" + }, + "rust-field-types/service.rs": { + "captureGroups": 10, + "digest": "03f3b9175fc383450ab97e7e82a3e0feff4ffebdd78f4804717c8f64045c080c" + }, + "rust-for-call-expr/src/main.rs": { + "captureGroups": 24, + "digest": "292eba3d86490a2ee600ebe4d9e19434204b43706af459c25ae82c2b646da5f9" + }, + "rust-for-call-expr/src/repo.rs": { + "captureGroups": 13, + "digest": "2b7e531ed136a976228b8073066f1fd9ba30e9409c10d5708c544a617ad2f31c" + }, + "rust-for-call-expr/src/user.rs": { + "captureGroups": 13, + "digest": "9e5ee9a073c988caace8946b7008e56e7a1cfa852972a28387f8bf0c52b73555" + }, + "rust-for-loop/src/main.rs": { + "captureGroups": 24, + "digest": "9622a9b47737283123a93511231529524ec82a54c5a97c17dc81dcc6e8f4912c" + }, + "rust-for-loop/src/repo.rs": { + "captureGroups": 9, + "digest": "09f0d2905c2fc05043fa767b277a23b787aad4b9c0fe22bc5569ca9660f7f18a" + }, + "rust-for-loop/src/user.rs": { + "captureGroups": 9, + "digest": "a8562a331eb945b4c099a7a9ec6c9b5eed8ff603c9359897b0445ce316a1424e" + }, + "rust-grouped-imports/src/helpers/mod.rs": { + "captureGroups": 13, + "digest": "04ce0efd7458e7ff624e5611d34bbb0c01e77259108e0477895bba61e9568249" + }, + "rust-grouped-imports/src/main.rs": { + "captureGroups": 13, + "digest": "17fff90e2627d094f24ffb7cd06b258d3f69b026b52f892c66132d061d031b1e" + }, + "rust-if-let-unwrap/models/mod.rs": { + "captureGroups": 1, + "digest": "6397af74a6370d7ed3225ca11da5069e16f7620639acd18ad0e86109304cefbb" + }, + "rust-if-let-unwrap/models/repo.rs": { + "captureGroups": 1, + "digest": "afefc4e62e0fa4b3becf9efc796e2afdc1f5b8b3f1566f2a7fa7b0aecf3d6477" + }, + "rust-if-let-unwrap/models/user.rs": { + "captureGroups": 1, + "digest": "abf2026b72dacf36eca93895d2601c900424f30ff7f59f904f0dcf1fa377829c" + }, + "rust-if-let-unwrap/src/main.rs": { + "captureGroups": 15, + "digest": "3a45c99bbfc3ba2117d333127b3031697623bb044a1f04ce5381ff1366fe7468" + }, + "rust-if-let-unwrap/src/repo.rs": { + "captureGroups": 9, + "digest": "09f0d2905c2fc05043fa767b277a23b787aad4b9c0fe22bc5569ca9660f7f18a" + }, + "rust-if-let-unwrap/src/user.rs": { + "captureGroups": 9, + "digest": "a8562a331eb945b4c099a7a9ec6c9b5eed8ff603c9359897b0445ce316a1424e" + }, + "rust-if-let/main.rs": { + "captureGroups": 32, + "digest": "dd9d7112782afac2c55cc9808ea3893551efba8ff4af0c83a55610d5533d24b7" + }, + "rust-if-let/models.rs": { + "captureGroups": 20, + "digest": "d8c1eb57431b915dd5c9055d8451054a454e340c4842628e38e4d46f69471abd" + }, + "rust-iter-for-loop/src/main.rs": { + "captureGroups": 28, + "digest": "7443b6c9231e54de5afd5f6fb52fd0a9709299a650615119a5be68de43287a88" + }, + "rust-iter-for-loop/src/repo.rs": { + "captureGroups": 9, + "digest": "09f0d2905c2fc05043fa767b277a23b787aad4b9c0fe22bc5569ca9660f7f18a" + }, + "rust-iter-for-loop/src/user.rs": { + "captureGroups": 9, + "digest": "a8562a331eb945b4c099a7a9ec6c9b5eed8ff603c9359897b0445ce316a1424e" + }, + "rust-local-shadow/src/main.rs": { + "captureGroups": 15, + "digest": "a9903b31883988b89bf634ea2bda7de522f256d06b0d80a65050a3fb4fae4a80" + }, + "rust-local-shadow/src/utils.rs": { + "captureGroups": 5, + "digest": "8007a597a21f60c078ceaaa96c660d157d4f1c27401345145be0b5b8b1fb4c0d" + }, + "rust-match-unwrap/src/main.rs": { + "captureGroups": 24, + "digest": "1d9ead4663799e035816ed06a17d37e9a98d0fd8090f9dea256ec6f019788648" + }, + "rust-match-unwrap/src/repo.rs": { + "captureGroups": 9, + "digest": "09f0d2905c2fc05043fa767b277a23b787aad4b9c0fe22bc5569ca9660f7f18a" + }, + "rust-match-unwrap/src/user.rs": { + "captureGroups": 9, + "digest": "a8562a331eb945b4c099a7a9ec6c9b5eed8ff603c9359897b0445ce316a1424e" + }, + "rust-member-calls/src/main.rs": { + "captureGroups": 14, + "digest": "9de5ab8411808df29a924b2e854048c6babb1a09b996279aba5c3f7916d03ae0" + }, + "rust-member-calls/src/user.rs": { + "captureGroups": 10, + "digest": "a5545efb669e40adf8428af48057cd93bd31c226f7d0952d4dae76e1ee5228f7" + }, + "rust-method-chain-binding/src/main.rs": { + "captureGroups": 17, + "digest": "81b41ca25c672098dadbd7911c5a981706035b1bdca83eb2ca5f6bdbf759ab6b" + }, + "rust-method-chain-binding/src/models.rs": { + "captureGroups": 34, + "digest": "cd836a2a9c15ab240961d2e15f192f7e33d65eb5ebf2e1a8af2f620a47fe66ae" + }, + "rust-method-enrichment/src/lib.rs": { + "captureGroups": 37, + "digest": "a257cb6d2bf8f4ecc00d3c2880c5103d3d17ce9444f154be2fa283f5ce0a3e47" + }, + "rust-method-enrichment/src/main.rs": { + "captureGroups": 18, + "digest": "3326eb4f82b1559b6afec497dc52cab734e6f3209501a4bd982bf5eab9ec6dba" + }, + "rust-nullable-receiver/src/main.rs": { + "captureGroups": 37, + "digest": "283d8606eb837f2a9e5fdf95a30e3da5b73d4c14d74b94deb03e924dd2b2fde1" + }, + "rust-nullable-receiver/src/repo.rs": { + "captureGroups": 10, + "digest": "24dcd450677a5885db0d93973e431e47b41e6866ad3fc7cb686f7a021000c17e" + }, + "rust-nullable-receiver/src/user.rs": { + "captureGroups": 10, + "digest": "a5545efb669e40adf8428af48057cd93bd31c226f7d0952d4dae76e1ee5228f7" + }, + "rust-option-receiver/src/main.rs": { + "captureGroups": 24, + "digest": "02298528e6fd679850ecaf6ed06017d29b7cac0f55ba2308692c87bd8904f040" + }, + "rust-option-receiver/src/repo.rs": { + "captureGroups": 8, + "digest": "3d32ba72d93e2388f65228366c58276b6d90bb14f55351e4255e6d0067ad63dc" + }, + "rust-option-receiver/src/user.rs": { + "captureGroups": 8, + "digest": "7e1cca87f7cec8a11d3f96a561639a65813d5dc202cf91f515ed65afc5b8df8b" + }, + "rust-parent-resolution/src/lib.rs": { + "captureGroups": 3, + "digest": "141388068614e16d96f27cfdf18ac9001b9e202ce832fe10f38dab990637b3ab" + }, + "rust-parent-resolution/src/serializable.rs": { + "captureGroups": 3, + "digest": "f35d44f44d81e3a0be40f68ba9dbd4bde6f01659fa15b6db34a458ad460f904e" + }, + "rust-parent-resolution/src/user.rs": { + "captureGroups": 12, + "digest": "7c87a84a30e4de06e3bdc6f3adc8310aef09300bb035c77c0a3e6cf08c14c6ac" + }, + "rust-receiver-resolution/src/main.rs": { + "captureGroups": 21, + "digest": "283b9d58dc76d8f48ab2fce4598f5173910f8c59f27c2aa9c1d80a3c86878be8" + }, + "rust-receiver-resolution/src/repo.rs": { + "captureGroups": 10, + "digest": "24dcd450677a5885db0d93973e431e47b41e6866ad3fc7cb686f7a021000c17e" + }, + "rust-receiver-resolution/src/user.rs": { + "captureGroups": 10, + "digest": "a5545efb669e40adf8428af48057cd93bd31c226f7d0952d4dae76e1ee5228f7" + }, + "rust-reexport-chain/src/main.rs": { + "captureGroups": 12, + "digest": "d4ddd026d2159710adb889629e17104bb36d2855f15a37b39dadd1dc4cd6b993" + }, + "rust-reexport-chain/src/models/handler.rs": { + "captureGroups": 11, + "digest": "04091a9f859230d384307beffab59331e960dad13953ef3919ab6b3cfbb585e7" + }, + "rust-reexport-chain/src/models/mod.rs": { + "captureGroups": 3, + "digest": "f83985431e11f24a46d28f6c5256dd2e7b6d17a1079dd51bf8c4fe4f9087be11" + }, + "rust-return-type-inference/src/main.rs": { + "captureGroups": 32, + "digest": "0f47930a3070f27b80e89065c29dfee3ce4c1923d5e66053553d7536bb521a23" + }, + "rust-return-type-inference/src/models.rs": { + "captureGroups": 21, + "digest": "465517463a6f648955d92b6d179eb9f581a5431077c1eb22c4048d6283387458" + }, + "rust-return-type/src/main.rs": { + "captureGroups": 11, + "digest": "2ab4961fb869cf40db597b422b24c7ba7a7b63d97487cea7013e065a32e497f3" + }, + "rust-return-type/src/models.rs": { + "captureGroups": 17, + "digest": "0e3826200f2e6f5b948313369e85ee3a08f18bc8d51fbd1f7283b19a8e01aac8" + }, + "rust-scoped-multi-file/src/main.rs": { + "captureGroups": 17, + "digest": "cbb0ad90a6a6ddcb71afb98a98a172bede7f5c06b5c1d31484a11315a264b311" + }, + "rust-scoped-multi-file/src/models/mod.rs": { + "captureGroups": 5, + "digest": "7346b2cf62e4946b261ed0eb46f2623fb1882f46d832abdc2068012917c7b16a" + }, + "rust-scoped-multi-file/src/models/repo.rs": { + "captureGroups": 18, + "digest": "5fdd3d9d0fa35089cfda53a3e84ac4b788c5dca97b7268c3f634d3bca5ba40d8" + }, + "rust-scoped-multi-file/src/models/user.rs": { + "captureGroups": 18, + "digest": "cc1404f69ca2264fd6ae12aebbd5cc6844eccd68b11595ebe9c1e861d168a86b" + }, + "rust-self-struct-literal/main.rs": { + "captureGroups": 11, + "digest": "3c0beb60f1487a63c60329e0843c1913b6a3854bb1ed3df84e4a07bc16dbd82d" + }, + "rust-self-struct-literal/models.rs": { + "captureGroups": 31, + "digest": "91e3ba35c7dcf31ab8914f052bbec884d0b9f4f0ab991878b084fbf9e4fac192" + }, + "rust-self-this-resolution/src/repo.rs": { + "captureGroups": 10, + "digest": "c182a765b5889287bf2fa6ca0367e33c051fdaa1a7058a6a4e7cafb355722384" + }, + "rust-self-this-resolution/src/user.rs": { + "captureGroups": 16, + "digest": "9354dad2a222a66b46f00ceede6777ba8c0634294c41313f393bc9ebcbbb6aa7" + }, + "rust-struct-destructuring/main.rs": { + "captureGroups": 15, + "digest": "6d96109296b7f5bd709e2704c14c332bfa0bca12453ae289b38a74da2c38124d" + }, + "rust-struct-destructuring/point.rs": { + "captureGroups": 6, + "digest": "a00d6ac5f27b153c813b4d392c45ea47fc5d2a3fc8a9df1332e042a3593a48d5" + }, + "rust-struct-destructuring/vec2.rs": { + "captureGroups": 9, + "digest": "ff25512c9a7c4c58d9e06e8ec55727ba13ca20361e7758b560bf0f53b6aa3a93" + }, + "rust-struct-literal-inference/main.rs": { + "captureGroups": 19, + "digest": "e71269ff626cd0b0655591ee08e48d90e9a1421f3be8d9e8ac23262388a3f7ad" + }, + "rust-struct-literal-inference/models.rs": { + "captureGroups": 26, + "digest": "363f8d0d3948d88b2b656a80db15d6c0c0c43e0f8ecc3e6a504cf8d8d7e6a020" + }, + "rust-struct-literals/app.rs": { + "captureGroups": 12, + "digest": "46c3ea5d181fc61b6ea891adf439310ca9f941c0846a6a62c3a9ae9ac0c112f1" + }, + "rust-struct-literals/user.rs": { + "captureGroups": 11, + "digest": "60fc4ac44f58ae67d462e243655b0571392a18de85b9e200e9391b50c941a6c9" + }, + "rust-traits/src/impls/button.rs": { + "captureGroups": 30, + "digest": "2b4523d8013330f59ce4bd44c7e8c4d8e185e691c5bef08fa8af20a86eed25de" + }, + "rust-traits/src/main.rs": { + "captureGroups": 11, + "digest": "f1b9f72d74467be55a8b7679215b49bcabb4d0fced6080f752672070b32ed93d" + }, + "rust-traits/src/traits/clickable.rs": { + "captureGroups": 3, + "digest": "3ed5b27c172d48f83929715ba92d1030a282f9f1e29ec2fcdd3d7e9efbc54a84" + }, + "rust-traits/src/traits/drawable.rs": { + "captureGroups": 5, + "digest": "1dca39bbc7c1b1b66f1a34730b9a5b4dba04c54ee9d2688255e0fd4e6bc48499" + }, + "rust-write-access/models.rs": { + "captureGroups": 9, + "digest": "660f755fd70cd1796f9da02ad7d65f599dea8029665ee45ecd18cd27919741f3" + }, + "rust-write-access/service.rs": { + "captureGroups": 16, + "digest": "7edc72e15b18ec6cf0b48a592ccb3570f46ac25463640fc6a97d71fa1795b643" + }, + "synthetic:dao-20": { + "captureGroups": 381, + "digest": "4b0262ff8d728d50f3f22772a1691001ee362936f7dd5395d458539cd6ca3411" + } +} diff --git a/gitnexus/test/integration/cli-e2e.test.ts b/gitnexus/test/integration/cli-e2e.test.ts index e9d529643..075f80782 100644 --- a/gitnexus/test/integration/cli-e2e.test.ts +++ b/gitnexus/test/integration/cli-e2e.test.ts @@ -1534,3 +1534,82 @@ describe('CLI end-to-end', () => { }, 35000); }); }); + +// ─── impact disambiguation flags reach the backend at runtime (#1907 U2) ── +// The mocked unit test proves the CLI option → callTool param mapping; this +// proves the flags survive the real Commander → lazy-action → impactCommand → +// callTool chain by spawning the actual CLI. The F2 gap is *flag-forwarding*, +// so a uniquely-named fixture symbol is enough — no ambiguous fixture needed. +// Tests self-skip when the environment cannot index the fixture (e.g. a +// worktree without the built parse-worker); CI validates the real path. +describe('impact disambiguation flags reach the backend (e2e, #1907)', () => { + const SYMBOL = 'formatResponse'; // uniquely named, in mini-repo/src/formatter.ts + let uid: string | undefined; + let symbolFile: string | undefined; + + beforeAll(() => { + // Idempotent: the earlier analyze test may already have indexed mini-repo. + runCli('analyze', MINI_REPO, 60000); + // Derive the real uid + filePath from context so the test is robust to the + // exact uid format rather than hard-coding `Function::`. + const ctx = runCliRaw(['context', SYMBOL, '--repo', 'mini-repo'], MINI_REPO, 30000); + if (ctx.status === 0) { + try { + const parsed = JSON.parse(ctx.stdout.trim()); + uid = parsed?.symbol?.uid; + symbolFile = parsed?.symbol?.filePath; + } catch { + /* leave undefined → tests self-skip below */ + } + } + }); + + it('forwards --uid alone with no positional target (U1 + --uid end-to-end)', () => { + if (!uid) return; // environment could not index — validated in CI + const res = runCliRaw(['impact', '--uid', uid, '--repo', 'mini-repo'], MINI_REPO, 30000); + if (res.status === null) return; + expect(res.status).toBe(0); + const out = JSON.parse(res.stdout.trim()); + expect(out).not.toHaveProperty('error'); + expect(out.target?.id).toBe(uid); + }); + + it('forwards --file: the correct file resolves, a wrong file does not (negative control)', () => { + if (!uid || !symbolFile) return; + + const ok = runCliRaw( + ['impact', SYMBOL, '--file', symbolFile, '--repo', 'mini-repo'], + MINI_REPO, + 30000, + ); + if (ok.status === null) return; + expect(ok.status).toBe(0); + const okOut = JSON.parse(ok.stdout.trim()); + expect(okOut.status).not.toBe('ambiguous'); + expect(okOut.target?.filePath).toBe(symbolFile); + + // Wrong --file hint → CONTAINS matches nothing → must NOT resolve to the + // formatter.ts symbol. Proves the --file value reached the resolver. + const wrong = runCliRaw( + ['impact', SYMBOL, '--file', 'does/not/exist/nowhere.ts', '--repo', 'mini-repo'], + MINI_REPO, + 30000, + ); + if (wrong.status === null) return; + const wrongOut = JSON.parse(wrong.stdout.trim()); + expect(wrongOut.error !== undefined || wrongOut.target?.filePath !== symbolFile).toBe(true); + }); + + it('forwards --kind: exit 0 with the kind hint applied', () => { + if (!uid) return; + const res = runCliRaw( + ['impact', SYMBOL, '--kind', 'Function', '--repo', 'mini-repo'], + MINI_REPO, + 30000, + ); + if (res.status === null) return; + expect(res.status).toBe(0); + const out = JSON.parse(res.stdout.trim()); + expect(out).not.toHaveProperty('error'); + }); +}); diff --git a/gitnexus/test/integration/csharp-scope-capture-tripwire.test.ts b/gitnexus/test/integration/csharp-scope-capture-tripwire.test.ts new file mode 100644 index 000000000..62c987561 --- /dev/null +++ b/gitnexus/test/integration/csharp-scope-capture-tripwire.test.ts @@ -0,0 +1,57 @@ +/** + * C# scope-capture O(n^2) regression tripwire. + * + * NOT gated behind GITNEXUS_BENCH and needs no compiled worker — it runs in + * normal CI and is the actual guard against an O(n^2) re-regression of + * `emitCsharpScopeCaptures` (the path PR #1918 made linear). It calls the + * hotpath directly on a ~400-entity generated source. The O(n) path (threading + * the tree-sitter query's captured node) does this in a few hundred ms; a + * findNodeAtRange-from-root re-regression would take many seconds at this size. + * The budget is a coarse tripwire (huge margin over the fixed path, far below a + * quadratic regression), not a microbenchmark — keep it generous so it never + * flakes on a loaded CI runner. + * + * Mirrors test/integration/python-scope-capture-tripwire.test.ts (issue #1848 / + * PR #1918). + */ +import { describe, it, expect } from 'vitest'; +import { emitCsharpScopeCaptures } from '../../src/core/ingestion/languages/csharp/index.js'; + +describe('C# scope-capture O(n^2) regression tripwire', () => { + /** + * DAO-style source: a namespace + N classes, each carrying fields plus + * getter/setter methods. Reuses the synthetic DAO unit shape from + * bench/scope-capture/measure.mjs. Maximizes top-level children AND member + * matches, which is exactly the O(matches x rootChildren) shape the fix + * removed. + */ + function generateCsharpDaoSource(entityCount: number): string { + let src = 'namespace Generated;\n\n'; + for (let i = 0; i < entityCount; i++) { + src += + `public class Entity${i} {\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`; + } + return src; + } + + it('parses a 400-entity file in well under the O(n^2) tripwire budget', () => { + const ENTITY_COUNT = 400; + const BUDGET_MS = 10_000; // coarse: far over the fixed path, far under a quadratic regression + const src = generateCsharpDaoSource(ENTITY_COUNT); + + emitCsharpScopeCaptures(src, 'tripwire-warmup.cs'); // warm up the parser/query JIT + + const start = Date.now(); + const matches = emitCsharpScopeCaptures(src, 'tripwire.cs'); + const elapsedMs = Date.now() - start; + + // Sanity: the captures are actually produced (each entity emits many capture + // groups), so a fast-but-empty result can't pass. + expect(matches.length).toBeGreaterThan(ENTITY_COUNT * 5); + // The actual regression guard: a re-regression to O(n^2) blows this budget. + expect(elapsedMs).toBeLessThan(BUDGET_MS); + }, 30_000); +}); diff --git a/gitnexus/test/integration/go-pipeline-benchmark.test.ts b/gitnexus/test/integration/go-pipeline-benchmark.test.ts new file mode 100644 index 000000000..273424721 --- /dev/null +++ b/gitnexus/test/integration/go-pipeline-benchmark.test.ts @@ -0,0 +1,456 @@ +/** + * Go ingestion pipeline benchmark. + * + * Generates synthetic Go codebases at increasing scales and measures + * wall-clock time and peak heap through the full pipeline — parsing, + * Go scope capture (emitGoScopeCaptures), package/import resolution, and + * call resolution. + * + * Run: GITNEXUS_BENCH=1 npx vitest run test/integration/go-pipeline-benchmark.test.ts + * + * The first suite ("scales with file count") generates many small files — + * each a struct plus getter/setter/compute methods, the DAO-style shape that + * stresses the Go scope-capture path. Run under vitest it falls back to the + * sequential path (no compiled worker), so it measures parse + scope-capture + * scaling without the worker pool. + * + * The second suite ("worker pool — issue #1848") reproduces the actual bug: + * it points the pool at the COMPILED `dist/.../parse-worker.js` (real + * worker_threads), generates one ~400 KiB generated-DAO file plus padding so + * the 15-file worker threshold trips, and runs with a short sub-batch idle + * timeout. Under the buggy code the worker looks idle while inside + * emitGoScopeCaptures and the file is quarantined; the fix emits progress so + * the file survives. Requires a build first: + * + * (cd gitnexus && npm run build) + * GITNEXUS_BENCH=1 npx vitest run test/integration/go-pipeline-benchmark.test.ts + * + * The worker suite auto-skips if the compiled worker is absent. + */ +import { describe, it, expect } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { runPipelineFromRepo } from '../../src/core/ingestion/pipeline.js'; +import { emitGoScopeCaptures } from '../../src/core/ingestion/languages/go/index.js'; + +const BENCH_ENABLED = process.env.GITNEXUS_BENCH === '1'; + +const MODULE_PATH = 'example.com/go-bench'; + +/** + * The compiled worker the pool spawns. Under vitest, `import.meta.url` + * resolves to `src/`, where no `.js` exists — so we point straight at the + * `dist/` build, the same fallback parse-impl uses. `null` when unbuilt. + */ +const DIST_WORKER_URL = new URL( + '../../dist/core/ingestion/workers/parse-worker.js', + import.meta.url, +); +const DIST_WORKER_AVAILABLE = fs.existsSync(fileURLToPath(DIST_WORKER_URL)); + +interface BenchResult { + fileCount: number; + structCount: number; + packageCount: number; + elapsedMs: number; + peakHeapMB: number; + nodeCount: number; + edgeCount: number; +} + +function generateGoFixture( + fileCount: number, + packageCount: number, +): { dir: string; structCount: number; packageCount: number } { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), `go-bench-${fileCount}-`)); + fs.writeFileSync(path.join(dir, 'go.mod'), `module ${MODULE_PATH}\n\ngo 1.22\n`); + + const packages: string[] = []; + for (let i = 0; i < packageCount; i++) { + packages.push(`pkg${i}`); + } + + const structCount = fileCount; + const createdPackages = new Set(); + + for (let f = 0; f < fileCount; f++) { + const pkg = packages[f % packages.length]; + const pkgDir = path.join(dir, pkg); + if (!createdPackages.has(pkg)) { + fs.mkdirSync(pkgDir, { recursive: true }); + createdPackages.add(pkg); + } + + const structName = `Item${f}`; + + const siblingIdx = (f + 1) % fileCount; + const siblingStruct = `Item${siblingIdx}`; + const siblingPkg = packages[siblingIdx % packages.length]; + + const crossIdx = (f + Math.floor(fileCount / 3)) % fileCount; + const crossStruct = `Item${crossIdx}`; + const crossPkg = packages[crossIdx % packages.length]; + + // Only import packages we actually reference, and never our own. + const imports = new Set(); + if (siblingPkg !== pkg) imports.add(siblingPkg); + if (crossPkg !== pkg) imports.add(crossPkg); + + const importBlock = + imports.size > 0 + ? ['import (', ...[...imports].map((p) => `\t"${MODULE_PATH}/${p}"`), ')', ''] + : []; + + const qualify = (otherPkg: string, name: string) => + otherPkg === pkg ? name : `${otherPkg}.${name}`; + + const siblingRef = qualify(siblingPkg, siblingStruct); + const siblingCtor = qualify(siblingPkg, `New${siblingStruct}`); + const crossRef = qualify(crossPkg, crossStruct); + const crossCtor = qualify(crossPkg, `New${crossStruct}`); + + const content = [ + `package ${pkg}`, + '', + ...importBlock, + `type ${structName} struct {`, + `\tID int64`, + `\tName string`, + `\tEmail string`, + `\tValue float64`, + `}`, + '', + `func New${structName}(id int64, name string) *${structName} {`, + `\treturn &${structName}{ID: id, Name: name}`, + `}`, + '', + `func (i *${structName}) GetID() int64 {`, + `\treturn i.ID`, + `}`, + '', + `func (i *${structName}) SetID(id int64) {`, + `\ti.ID = id`, + `}`, + '', + `func (i *${structName}) GetName() string {`, + `\treturn i.Name`, + `}`, + '', + `func (i *${structName}) SetValue(v float64) {`, + `\ti.Value = v`, + `}`, + '', + `func (i *${structName}) Compute() float64 {`, + `\treturn i.Value * float64(i.ID)`, + `}`, + '', + `func (i *${structName}) Process() *${siblingRef} {`, + `\tsibling := ${siblingCtor}(i.ID, i.Name)`, + `\tsibling.SetValue(i.Compute())`, + `\treturn sibling`, + `}`, + '', + `func (i *${structName}) CrossCall() *${crossRef} {`, + `\tcross := ${crossCtor}(i.ID, i.Name)`, + `\t_ = cross.GetID()`, + `\treturn cross`, + `}`, + '', + ].join('\n'); + + fs.writeFileSync(path.join(pkgDir, `item${f}.go`), content); + } + + return { dir, structCount, packageCount: createdPackages.size }; +} + +async function runBenchmark( + fileCount: number, + packageCount: number, + budgetMs: number, +): Promise { + const { + dir, + structCount, + packageCount: actualPackages, + } = generateGoFixture(fileCount, packageCount); + + let peakHeapMB = 0; + const heapSampler = setInterval(() => { + const heap = process.memoryUsage().heapUsed / 1024 / 1024; + if (heap > peakHeapMB) peakHeapMB = heap; + }, 50); + + let timeoutHandle: ReturnType | undefined; + try { + const start = Date.now(); + const result = await Promise.race([ + runPipelineFromRepo(dir, () => {}, { skipGraphPhases: true }), + new Promise((_, reject) => { + // Hold the handle so the winning (pipeline) path can cancel it in the + // finally — otherwise the timer lingers for up to budgetMs and its + // late rejection surfaces as an unhandled promise rejection. + timeoutHandle = setTimeout( + () => reject(new Error(`Pipeline exceeded ${budgetMs}ms at ${fileCount} files`)), + budgetMs, + ); + }), + ]); + const elapsedMs = Date.now() - start; + + return { + fileCount, + structCount, + packageCount: actualPackages, + elapsedMs, + peakHeapMB: Math.round(peakHeapMB), + nodeCount: result.graph.nodeCount, + edgeCount: result.graph.relationshipCount, + }; + } finally { + if (timeoutHandle !== undefined) clearTimeout(timeoutHandle); + clearInterval(heapSampler); + fs.rmSync(dir, { recursive: true, force: true }); + } +} + +function printResults(label: string, results: BenchResult[]) { + console.log(`\n${label}`); + console.log('┌──────────┬─────────┬──────────┬───────────┬──────────┬───────┬───────┐'); + console.log('│ Files │ Structs │ Packages │ Time (ms) │ Heap MB │ Nodes │ Edges │'); + console.log('├──────────┼─────────┼──────────┼───────────┼──────────┼───────┼───────┤'); + for (const r of results) { + console.log( + `│ ${String(r.fileCount).padStart(8)} │ ${String(r.structCount).padStart(7)} │ ${String(r.packageCount).padStart(8)} │ ${String(r.elapsedMs).padStart(9)} │ ${String(r.peakHeapMB).padStart(8)} │ ${String(r.nodeCount).padStart(5)} │ ${String(r.edgeCount).padStart(5)} │`, + ); + } + console.log('└──────────┴─────────┴──────────┴───────────┴──────────┴───────┴───────┘'); + + if (results.length >= 2) { + console.log('\nScaling ratios (time_ratio / file_ratio):'); + for (let i = 1; i < results.length; i++) { + const fileRatio = results[i].fileCount / results[i - 1].fileCount; + const timeRatio = results[i].elapsedMs / results[i - 1].elapsedMs; + const scaling = timeRatio / fileRatio; + console.log( + ` ${results[i - 1].fileCount} → ${results[i].fileCount}: ${scaling.toFixed(2)}x (${scaling < 1.5 ? 'linear' : scaling < 3 ? 'superlinear' : 'WARNING: quadratic'})`, + ); + } + } +} + +/** + * Mirrors the issue #1848 reproduction fixture: one large generated-DAO Go + * file (`package generated`, struct + 7 methods per entity) plus `padCount` + * trivial files so parse-impl crosses the 15-file worker threshold and the + * real worker pool engages. + */ +function generateGoQuarantineFixture( + entityCount: number, + padCount: number, +): { dir: string; bigFileBytes: number; fileCount: number } { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), `go-bench-1848-${entityCount}-`)); + + const lines = [ + 'package generated', + '', + '// Code generated for GitNexus issue #1848 repro. DO NOT EDIT.', + '', + ]; + for (let i = 0; i < entityCount; i++) { + const n = String(i).padStart(4, '0'); + lines.push(`type DefUserDao${n} struct {`); + lines.push('\tid int64'); + lines.push('\tname string'); + lines.push('\temail string'); + lines.push('\tcreatedAt int64'); + lines.push('}', ''); + lines.push(`func (d *DefUserDao${n}) GetID() int64 { return d.id }`); + lines.push(`func (d *DefUserDao${n}) SetID(id int64) { d.id = id }`); + lines.push(`func (d *DefUserDao${n}) GetName() string { return d.name }`); + lines.push(`func (d *DefUserDao${n}) SetName(name string) { d.name = name }`); + lines.push(`func (d *DefUserDao${n}) GetEmail() string { return d.email }`); + lines.push(`func (d *DefUserDao${n}) SetEmail(email string) { d.email = email }`); + lines.push(`func (d *DefUserDao${n}) Validate() error { return nil }`); + lines.push(''); + } + const bigContent = lines.join('\n'); + fs.writeFileSync(path.join(dir, 'zz_generated.def_userdao.go'), bigContent); + + for (let i = 0; i < padCount; i++) { + const idx = String(i).padStart(2, '0'); + fs.writeFileSync( + path.join(dir, `pad_${idx}.go`), + `package pad${i}\n\nfunc Ping${i}() int { return ${i} }\n`, + ); + } + + return { + dir, + bigFileBytes: Buffer.byteLength(bigContent), + fileCount: 1 + padCount, + }; +} + +describe.skipIf(!BENCH_ENABLED)('Go pipeline benchmark', () => { + it('scales with file count (workers enabled)', async () => { + const scales = [100, 250, 500]; + const results: BenchResult[] = []; + + for (const fileCount of scales) { + const packageCount = Math.max(4, Math.ceil(Math.sqrt(fileCount))); + const result = await runBenchmark(fileCount, packageCount, 180_000); + results.push(result); + console.log( + ` ${fileCount} files: ${result.elapsedMs}ms, ${result.peakHeapMB}MB heap, ${result.nodeCount} nodes, ${result.edgeCount} edges`, + ); + } + + printResults('Go Pipeline — Workers Enabled', results); + + for (let i = 1; i < results.length; i++) { + const fileRatio = results[i].fileCount / results[i - 1].fileCount; + const timeRatio = results[i].elapsedMs / results[i - 1].elapsedMs; + // The scale steps are 2.5x (100->250) and 2x (250->500). A quadratic + // regression makes timeRatio ~= fileRatio^2, i.e. timeRatio/fileRatio ~= + // fileRatio (2.5 and 2.0) — which a `< 3` bound would wave through. The + // O(n) path keeps this ratio ~1 (measured 0.44 and 0.75 post-fix), so a + // `< 1.5` bound (the printResults "linear" boundary) actually fails on a + // re-regression to O(n^2) while leaving comfortable headroom for linear. + expect(timeRatio / fileRatio).toBeLessThan(1.5); + } + }, 300_000); +}); + +describe.skipIf(!BENCH_ENABLED || !DIST_WORKER_AVAILABLE)( + 'Go pipeline benchmark — worker pool (issue #1848)', + () => { + if (BENCH_ENABLED && !DIST_WORKER_AVAILABLE) { + // Surfaced once when the bench is requested but the worker is unbuilt. + console.warn( + `\n[go-bench] Skipping worker-pool suite: compiled worker not found at\n ${fileURLToPath(DIST_WORKER_URL)}\n Build first: (cd gitnexus && npm run build)\n`, + ); + } + + // Tunables mirror run-analyze-repro.sh. 800 entities ≈ 406 KiB — under the + // 512 KiB GITNEXUS_MAX_FILE_SIZE ceiling so the file is parsed, not skipped. + const entityCount = Number(process.env.REPRO_GO_ENTITIES ?? 800); + // 30 s reproduces on the report author's machine; raising it (e.g. 120000) + // is the documented workaround. Kept overridable so the same test can both + // reproduce the bug and confirm the fix. + const subBatchTimeoutMs = Number(process.env.GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS ?? 30_000); + + it('does not quarantine the large generated Go file on sub-batch idle timeout', async () => { + const { dir, bigFileBytes, fileCount } = generateGoQuarantineFixture(entityCount, 14); + + // Sub-batch knobs that force fine chunking onto the worker (env-only — + // there is no PipelineOptions field for these two). Capture the prior + // values before the try; set them as the first statements INSIDE it so + // the finally's restore covers every path that mutated the process env. + const prevMaxBytes = process.env.GITNEXUS_WORKER_SUB_BATCH_MAX_BYTES; + const prevTimeout = process.env.GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS; + + let peakHeapMB = 0; + const heapSampler = setInterval(() => { + const heap = process.memoryUsage().heapUsed / 1024 / 1024; + if (heap > peakHeapMB) peakHeapMB = heap; + }, 50); + + try { + process.env.GITNEXUS_WORKER_SUB_BATCH_MAX_BYTES = '262144'; + process.env.GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS = String(subBatchTimeoutMs); + const start = Date.now(); + const result = await runPipelineFromRepo(dir, () => {}, { + skipGraphPhases: true, + // Real worker_threads against the compiled worker — the surface the + // bug actually lives on. + workerUrlForTest: DIST_WORKER_URL, + // Match the repro's chunking so the byte budget mirrors the issue. + chunkByteBudget: 262144, + parseChunkConcurrency: 1, + }); + const elapsedMs = Date.now() - start; + + // The big file alone emits ≥ entityCount*5 nodes (1 struct + 7 methods + // each). If the worker is quarantined on the idle timeout, those + // vanish — so this threshold is the regression guard. + const survivalFloor = entityCount * 5; + const survived = result.graph.nodeCount >= survivalFloor; + + console.log( + `\nGo Pipeline — Worker Pool (issue #1848)` + + `\n files: ${fileCount} (1 big @ ${Math.round(bigFileBytes / 1024)} KiB + 14 pad)` + + `\n entities: ${entityCount}, sub-batch idle timeout: ${subBatchTimeoutMs}ms` + + `\n elapsed: ${elapsedMs}ms, peak heap: ${Math.round(peakHeapMB)}MB` + + `\n usedWorkerPool: ${result.usedWorkerPool}` + + `\n nodes: ${result.graph.nodeCount} (survival floor ${survivalFloor}) → ${survived ? 'SURVIVED' : 'QUARANTINED (bug reproduced)'}`, + ); + + // Sanity: the worker path must have actually engaged, else the test + // proves nothing about the worker bug. + expect(result.usedWorkerPool).toBe(true); + // The fix: the generated file is fully parsed despite the idle timeout. + expect(result.graph.nodeCount).toBeGreaterThanOrEqual(survivalFloor); + } finally { + clearInterval(heapSampler); + if (prevMaxBytes === undefined) delete process.env.GITNEXUS_WORKER_SUB_BATCH_MAX_BYTES; + else process.env.GITNEXUS_WORKER_SUB_BATCH_MAX_BYTES = prevMaxBytes; + if (prevTimeout === undefined) delete process.env.GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS; + else process.env.GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS = prevTimeout; + fs.rmSync(dir, { recursive: true, force: true }); + } + }, 360_000); + }, +); + +/** + * Unlike the two suites above, this one is NOT gated behind GITNEXUS_BENCH and + * needs no compiled worker — so it runs in normal CI and is the actual guard + * against an O(n^2) re-regression of emitGoScopeCaptures (issue #1848). It calls + * the hotpath directly on a ~400-struct generated source. The O(n) path does + * this in a few hundred ms; the old findNodeAtRange-from-root behaviour took + * ~25s+ at this size. The budget is a coarse tripwire (huge margin over the + * fixed path, far below a quadratic regression), not a microbenchmark — keep it + * generous so it never flakes on a loaded CI runner. + */ +describe('Go scope-capture O(n^2) regression tripwire', () => { + function generateGoStructSource(structCount: number): string { + const lines = ['package generated', '']; + for (let i = 0; i < structCount; i++) { + const n = String(i).padStart(4, '0'); + lines.push( + `type Item${n} struct {`, + '\tid int64', + '\tname string', + '}', + '', + `func (d *Item${n}) GetID() int64 { return d.id }`, + `func (d *Item${n}) SetID(id int64) { d.id = id }`, + `func (d *Item${n}) GetName() string { return d.name }`, + `func (d *Item${n}) Validate() error { return nil }`, + '', + ); + } + return lines.join('\n'); + } + + it('parses a 400-struct file in well under the O(n^2) tripwire budget', () => { + const STRUCT_COUNT = 400; + const BUDGET_MS = 5_000; // coarse: ~20x the fixed path (~250ms), trips a ~20x regression; a quadratic regression at 400 structs is ~25s + const src = generateGoStructSource(STRUCT_COUNT); + + emitGoScopeCaptures(src, 'tripwire-warmup.go'); // warm up the parser/query JIT + + const start = Date.now(); + const matches = emitGoScopeCaptures(src, 'tripwire.go'); + const elapsedMs = Date.now() - start; + + // Sanity: the captures are actually produced (each struct + 4 methods emits + // far more than 10 capture groups), so a fast-but-empty result can't pass. + expect(matches.length).toBeGreaterThan(STRUCT_COUNT * 10); + // The actual regression guard: a re-regression to O(n^2) blows this budget. + expect(elapsedMs).toBeLessThan(BUDGET_MS); + }, 30_000); +}); diff --git a/gitnexus/test/integration/local-backend-calltool.test.ts b/gitnexus/test/integration/local-backend-calltool.test.ts index cd4b34d9c..e2640deab 100644 --- a/gitnexus/test/integration/local-backend-calltool.test.ts +++ b/gitnexus/test/integration/local-backend-calltool.test.ts @@ -302,6 +302,116 @@ withTestLbugDB( } }); }); + + // ─── impact disambiguation + label-scoped resolution (#1907) ───────── + // Covers the disambiguation surface the CLI --uid/--file/--kind flags + // wire through to, and guards the label-scoped resolver against the + // binder failure that motivated the fix. + describe('impact disambiguation (#1907)', () => { + let backend: LocalBackend; + + beforeAll(async () => { + const ext = handle as typeof handle & { _backend?: LocalBackend }; + if (!ext._backend) { + throw new Error( + 'LocalBackend not initialized — afterSetup did not attach _backend to handle', + ); + } + backend = ext._backend; + }); + + it('reports an ambiguous target with disambiguation guidance', async () => { + // Two Methods named 'authenticate' (AuthService + BaseService). + const result = await backend.callTool('impact', { + target: 'authenticate', + direction: 'upstream', + }); + expect(result).not.toHaveProperty('error'); + expect(result.status).toBe('ambiguous'); + expect(result.message).toMatch(/disambiguate/i); + const uids = (result.candidates ?? []).map((c: any) => c.uid); + expect(uids).toContain('method:AuthService.authenticate'); + expect(uids).toContain('method:BaseService.authenticate'); + }); + + it('resolves the ambiguous target via target_uid (the --uid flag path)', async () => { + const result = await backend.callTool('impact', { + target: 'authenticate', + target_uid: 'method:BaseService.authenticate', + direction: 'upstream', + }); + expect(result).not.toHaveProperty('error'); + expect(result.status).not.toBe('ambiguous'); + // target_uid selects the exact symbol, bypassing the name ranker. + expect(result.target?.id).toBe('method:BaseService.authenticate'); + expect(result.target?.filePath).toBe('src/base.ts'); + }); + + it('resolves the ambiguous target via file_path (the --file flag path)', async () => { + const result = await backend.callTool('impact', { + target: 'authenticate', + file_path: 'src/base.ts', + direction: 'upstream', + }); + expect(result).not.toHaveProperty('error'); + expect(result.status).not.toBe('ambiguous'); + expect(result.target?.id).toBe('method:BaseService.authenticate'); + }); + + it('does not crash when a name collides across symbol and non-symbol labels', async () => { + // 'alpha' exists as both a Function and a Tool sharing src/tools.py. + // The Tool node table has no startLine/endLine columns, so the + // resolver's `RETURN n.startLine` projection only binds because the + // candidate set also contains a label that *does* have those columns + // (lenient multi-table binding). This guards that the disambiguation + // path keeps tolerating non-symbol node types — and would catch a + // future naive label-scoping that reintroduces the #1907 binder error + // ("Cannot find property … for n") by matching property-poor tables + // in isolation. + const result = await backend.callTool('impact', { + target: 'alpha', + direction: 'upstream', + }); + expect(result).not.toHaveProperty('error'); + expect(result.status).toBe('ambiguous'); + const uids = (result.candidates ?? []).map((c: any) => c.uid); + expect(uids).toContain('func:alpha'); + expect(uids).toContain('Tool:alpha'); + }); + + it('context resolves the same cross-label collision without crashing', async () => { + const result = await backend.callTool('context', { name: 'alpha' }); + expect(result).not.toHaveProperty('error'); + expect(result.status).toBe('ambiguous'); + const uids = (result.candidates ?? []).map((c: any) => c.uid); + expect(uids).toContain('func:alpha'); + // Assert the non-symbol Tool node stays in the candidate set, not just + // that nothing crashed — a regression that silently dropped Tool from + // the lenient-binding match would otherwise pass the non-crash check. + expect(uids).toContain('Tool:alpha'); + }); + + it('ranks the kind-matching candidate first when kind is supplied (the --kind flag path)', async () => { + // 'alpha' is both a Function (func:alpha) and a Tool (Tool:alpha). + // kind only adds +0.20 in scoreCandidate, so 0.50 + 0.20 = 0.70 stays + // below the 0.95 confident-resolution threshold — the response is still + // ambiguous. What kind buys is ranking: the Function is promoted above + // the non-matching Tool. This exercises the scoreCandidate kind branch + // against a real DB rather than only through the mocked CLI unit test. + const result = await backend.callTool('impact', { + target: 'alpha', + kind: 'Function', + direction: 'upstream', + }); + expect(result).not.toHaveProperty('error'); + expect(result.status).toBe('ambiguous'); + const candidates = result.candidates ?? []; + expect(candidates[0]?.uid).toBe('func:alpha'); + expect(candidates[0]?.kind).toBe('Function'); + const tool = candidates.find((c: any) => c.uid === 'Tool:alpha'); + expect(candidates[0]?.score).toBeGreaterThan(tool?.score); + }); + }); }, { seed: LOCAL_BACKEND_SEED_DATA, @@ -327,3 +437,68 @@ withTestLbugDB( }, }, ); + +// ─── impact BFS bound parameters (#1907 review F5) ─────────────────────── +// Isolated DB (not the shared seed) with a frontier node whose id contains a +// single quote. Under the old string-interpolated query this id had to be +// hand-escaped; the parameterized query (executeParameterized with bound +// $frontierIds/$relTypes) carries it as data. Guards that a quote-bearing id +// traverses without a Prepare/parser error, and that a no-caller symbol +// returns an empty result rather than erroring. +withTestLbugDB( + 'local-backend-impact-param', + (handle) => { + describe('impact BFS bound parameters (#1907 F5)', () => { + let backend: LocalBackend; + + beforeAll(() => { + const ext = handle as typeof handle & { _backend?: LocalBackend }; + if (!ext._backend) { + throw new Error('LocalBackend not initialized — afterSetup did not attach _backend'); + } + backend = ext._backend; + }); + + it('traverses a caller whose id contains a single quote without a query error', async () => { + const result = await backend.callTool('impact', { target: 'sink', direction: 'upstream' }); + expect(result).not.toHaveProperty('error'); + const d1 = result.byDepth?.[1] || result.byDepth?.['1'] || []; + const callerIds = d1.map((d: any) => d.uid ?? d.id); + expect(callerIds).toContain("func:o'd"); + }); + + it('returns an empty result (not an error) for a symbol with no callers', async () => { + const result = await backend.callTool('impact', { + target: 'sink', + direction: 'downstream', + }); + expect(result).not.toHaveProperty('error'); + expect(result.impactedCount).toBe(0); + }); + }); + }, + { + seed: [ + `CREATE (a:Function {id: "func:o'd", name: 'odd', filePath: 'src/q.ts', startLine: 1, endLine: 3, isExported: true, content: 'function odd() {}', description: 'caller with a quote in its id'})`, + `CREATE (b:Function {id: 'func:sink', name: 'sink', filePath: 'src/q.ts', startLine: 5, endLine: 8, isExported: true, content: 'function sink() {}', description: 'callee'})`, + `MATCH (a:Function), (b:Function) WHERE a.id = "func:o'd" AND b.id = 'func:sink' + CREATE (a)-[:CodeRelation {type: 'CALLS', confidence: 1.0, reason: 'direct', step: 0}]->(b)`, + ], + poolAdapter: true, + afterSetup: async (handle) => { + vi.mocked(listRegisteredRepos).mockResolvedValue([ + { + name: 'param-repo', + path: '/param/repo', + storagePath: handle.tmpHandle.dbPath, + indexedAt: new Date().toISOString(), + lastCommit: 'abc123', + stats: { files: 1, nodes: 2, communities: 0, processes: 0 }, + }, + ]); + const backend = new LocalBackend(); + await backend.init(); + (handle as any)._backend = backend; + }, + }, +); diff --git a/gitnexus/test/integration/php-scope-capture-tripwire.test.ts b/gitnexus/test/integration/php-scope-capture-tripwire.test.ts new file mode 100644 index 000000000..2d5817e1f --- /dev/null +++ b/gitnexus/test/integration/php-scope-capture-tripwire.test.ts @@ -0,0 +1,54 @@ +/** + * PHP scope-capture O(n^2) regression tripwire. + * + * NOT gated behind GITNEXUS_BENCH and needs no compiled worker — it runs in + * normal CI and is the actual guard against an O(n^2) re-regression of + * `emitPhpScopeCaptures`. It calls the hotpath directly on a ~400-entity + * generated source. The O(n) path (threading the tree-sitter query's captured + * node) does this in a few hundred ms; the old findNodeAtRange-from-root + * behaviour took multiple seconds+ at this size. The budget is a coarse tripwire + * (huge margin over the fixed path, far below a quadratic regression), not a + * microbenchmark — keep it generous so it never flakes on a loaded CI runner. + * + * Mirrors test/integration/python-scope-capture-tripwire.test.ts (PR #1918). + */ +import { describe, it, expect } from 'vitest'; +import { emitPhpScopeCaptures } from '../../src/core/ingestion/languages/php/index.js'; + +describe('PHP scope-capture O(n^2) regression tripwire', () => { + /** + * DAO-style source: top-level `id; }\n` + + ` function setName($v) { $this->name = $v; }\n}\n\n`; + } + return src; + } + + it('parses a 400-entity file in well under the O(n^2) tripwire budget', () => { + const ENTITY_COUNT = 400; + const BUDGET_MS = 10_000; // coarse: far over the fixed path, far under a quadratic regression + const src = generatePhpDaoSource(ENTITY_COUNT); + + emitPhpScopeCaptures(src, 'tripwire-warmup.php'); // warm up the parser/query JIT + + const start = Date.now(); + const matches = emitPhpScopeCaptures(src, 'tripwire.php'); + const elapsedMs = Date.now() - start; + + // Sanity: the captures are actually produced (each entity emits many capture + // groups), so a fast-but-empty result can't pass. + expect(matches.length).toBeGreaterThan(ENTITY_COUNT * 5); + // The actual regression guard: a re-regression to O(n^2) blows this budget. + expect(elapsedMs).toBeLessThan(BUDGET_MS); + }, 30_000); +}); diff --git a/gitnexus/test/integration/python-import-index-reuse.test.ts b/gitnexus/test/integration/python-import-index-reuse.test.ts new file mode 100644 index 000000000..33e6aba9f --- /dev/null +++ b/gitnexus/test/integration/python-import-index-reuse.test.ts @@ -0,0 +1,87 @@ +/** + * Production-path regression guard for PR #1918 review finding P1. + * + * The Python file index (`getPythonFileIndex` in `import-target.ts`) is + * memoized on the `allFilePaths` Set identity via a WeakMap. The registry- + * primary path reaches it through `pythonScopeResolver.resolveImportTarget` + * (the orchestrator adapter) — NOT by calling `resolvePythonImportTarget` + * directly the way the unit parity test does. Before the fix, that adapter + * copied the set (`new Set(allFilePaths)`) on every import, handing a fresh + * WeakMap key per call so the index rebuilt every import (O(imports × files)). + * + * This test drives the adapter exactly as the orchestrator does and asserts the + * index is built ONCE across many imports on a stable set. It fails (build + * count == number of imports) if the per-import copy is reintroduced. + */ +import { describe, it, expect } from 'vitest'; +import { pythonScopeResolver } from '../../src/core/ingestion/languages/python/scope-resolver.js'; +import { + getPythonFileIndexBuildCount, + resetPythonFileIndexBuildCount, +} from '../../src/core/ingestion/languages/python/index-stats.js'; + +/** + * A synthetic workspace: a real package (`realpkg/__init__.py`, so the + * `hasRepoCandidate` gate passes) plus many unrelated modules. The imports + * below are multi-segment and miss every fast path, so each call reaches both + * `hasRepoCandidate` and `resolveAbsoluteFromFiles` — the two index consumers. + */ +function buildWorkspace(fileCount: number): Set { + const files = new Set(); + for (let i = 0; i < fileCount; i++) { + files.add(`pkg/sub/mod${String(i).padStart(5, '0')}.py`); + } + files.add('realpkg/__init__.py'); + files.add('realpkg/widget.py'); + return files; +} + +describe('Python import resolution — index reuse across imports (PR #1918 P1)', () => { + it('builds the file index once for many imports over a stable file set', () => { + const allFilePaths = buildWorkspace(300); + const fromFile = 'app/main.py'; + const importCount = 300; + + resetPythonFileIndexBuildCount(); + for (let i = 0; i < importCount; i++) { + // Multi-segment, candidate-passing, suffix-miss → reaches the index. + pythonScopeResolver.resolveImportTarget(`realpkg.ghost${i}`, fromFile, allFilePaths); + } + + // The whole point of PR #1918: O(imports + files), not O(imports × files). + // Pre-fix this was 300 (one rebuild per import via the adapter's Set copy). + expect(getPythonFileIndexBuildCount()).toBe(1); + }); + + it('rebuilds once per distinct file set (per-run isolation, no stale reuse)', () => { + const fromFile = 'app/main.py'; + + resetPythonFileIndexBuildCount(); + const setA = buildWorkspace(50); + for (let i = 0; i < 20; i++) { + pythonScopeResolver.resolveImportTarget(`realpkg.ghost${i}`, fromFile, setA); + } + expect(getPythonFileIndexBuildCount()).toBe(1); + + // A different Set instance is a different logical workspace → one more build. + const setB = buildWorkspace(50); + for (let i = 0; i < 20; i++) { + pythonScopeResolver.resolveImportTarget(`realpkg.ghost${i}`, fromFile, setB); + } + expect(getPythonFileIndexBuildCount()).toBe(2); + }); + + it('still resolves real imports correctly (the perf test is not vacuous)', () => { + const allFilePaths = buildWorkspace(20); + const fromFile = 'app/main.py'; + + // Suffix-fallback hit through the adapter: realpkg.widget → realpkg/widget.py. + expect(pythonScopeResolver.resolveImportTarget('realpkg.widget', fromFile, allFilePaths)).toBe( + 'realpkg/widget.py', + ); + // Gated-out / unresolvable import returns null. + expect( + pythonScopeResolver.resolveImportTarget('realpkg.ghost', fromFile, allFilePaths), + ).toBeNull(); + }); +}); diff --git a/gitnexus/test/integration/python-scope-capture-tripwire.test.ts b/gitnexus/test/integration/python-scope-capture-tripwire.test.ts new file mode 100644 index 000000000..bf181af0b --- /dev/null +++ b/gitnexus/test/integration/python-scope-capture-tripwire.test.ts @@ -0,0 +1,72 @@ +/** + * Python scope-capture O(n^2) regression tripwire. + * + * NOT gated behind GITNEXUS_BENCH and needs no compiled worker — it runs in + * normal CI and is the actual guard against an O(n^2) re-regression of + * `emitPythonScopeCaptures`. It calls the hotpath directly on a ~400-entity + * generated source. The O(n) path (threading the tree-sitter query's captured + * node) does this in a few hundred ms; the old findNodeAtRange-from-root + * behaviour took ~25s+ at this size. The budget is a coarse tripwire (huge + * margin over the fixed path, far below a quadratic regression), not a + * microbenchmark — keep it generous so it never flakes on a loaded CI runner. + * + * Mirrors test/integration/go-pipeline-benchmark.test.ts's "O(n^2) regression + * tripwire" suite (issue #1848). + */ +import { describe, it, expect } from 'vitest'; +import { emitPythonScopeCaptures } from '../../src/core/ingestion/languages/python/index.js'; + +describe('Python scope-capture O(n^2) regression tripwire', () => { + /** + * DAO-style source: top-level imports + N classes (each with methods) + N + * module functions. Maximizes top-level children AND function matches, which + * is exactly the O(matches x rootChildren) shape the fix removed. + */ + function generatePythonDaoSource(entityCount: number): string { + const lines: string[] = []; + 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'); + } + + it('parses a 400-entity file in well under the O(n^2) tripwire budget', () => { + const ENTITY_COUNT = 400; + const BUDGET_MS = 10_000; // coarse: ~30x the fixed path, far under a quadratic regression + const src = generatePythonDaoSource(ENTITY_COUNT); + + emitPythonScopeCaptures(src, 'tripwire-warmup.py'); // warm up the parser/query JIT + + const start = Date.now(); + const matches = emitPythonScopeCaptures(src, 'tripwire.py'); + const elapsedMs = Date.now() - start; + + // Sanity: the captures are actually produced (each entity emits many capture + // groups), so a fast-but-empty result can't pass. + expect(matches.length).toBeGreaterThan(ENTITY_COUNT * 10); + // The actual regression guard: a re-regression to O(n^2) blows this budget. + expect(elapsedMs).toBeLessThan(BUDGET_MS); + }, 30_000); +}); diff --git a/gitnexus/test/integration/resolvers/cpp.test.ts b/gitnexus/test/integration/resolvers/cpp.test.ts index a4a9a9fa8..43fcfd738 100644 --- a/gitnexus/test/integration/resolvers/cpp.test.ts +++ b/gitnexus/test/integration/resolvers/cpp.test.ts @@ -350,6 +350,89 @@ describe('C++ variadic call resolution', () => { }); }); +describe('C++ variadic packs and dependent-name resolution (#1894)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'cpp-variadic-dependent-resolution'), + () => {}, + ); + }, 60000); + + it('keeps parameter-pack functions viable when call arity exceeds the fixed prefix', () => { + const calls = getRelationships(result, 'CALLS').filter( + (c) => c.source === 'callVariadic' && c.target === 'logMany', + ); + + expect(calls).toHaveLength(1); + }); + + it('emits one fold-expression edge when the folded callee is unambiguous', () => { + const calls = getRelationships(result, 'CALLS').filter( + (c) => c.source === 'logMany' && c.target === 'sink', + ); + + expect(calls).toHaveLength(1); + }); + + it('emits zero fold-expression edges when overload resolution remains ambiguous', () => { + const calls = getRelationships(result, 'CALLS').filter( + (c) => c.source === 'foldAmbiguous' && c.target === 'ambiguous', + ); + + expect(calls).toHaveLength(0); + }); + + it('does not emit a concrete EXTENDS edge for a pack-expanded base', () => { + const extendsEdges = getRelationships(result, 'EXTENDS').filter( + (e) => e.source === 'Mix' && e.target === 'B', + ); + + expect(extendsEdges).toHaveLength(0); + }); + + it('does not bind unqualified member lookup through a pack-expanded dependent base', () => { + const calls = getRelationships(result, 'CALLS').filter( + (c) => c.source === 'run' && c.target === 'inherited', + ); + + expect(calls).toHaveLength(0); + }); + + it('preserves free helper calls inside a class with a pack-expanded dependent base', () => { + const calls = getRelationships(result, 'CALLS').filter( + (c) => c.source === 'run' && c.target === 'helper', + ); + + expect(calls).toHaveLength(1); + }); + + it('preserves using-declaration namespace helper calls inside a pack-base class', () => { + const calls = getRelationships(result, 'CALLS').filter( + (c) => c.source === 'run' && c.target === 'namespaceHelper', + ); + + expect(calls).toHaveLength(1); + }); + + it('resolves current-instantiation unqualified member calls', () => { + const calls = getRelationships(result, 'CALLS').filter( + (c) => c.source === 'run' && c.target === 'own', + ); + + expect(calls).toHaveLength(1); + }); + + it('keeps unknown-specialization member types unresolved', () => { + const calls = getRelationships(result, 'CALLS').filter( + (c) => c.source === 'run' && c.target === 'use', + ); + + expect(calls).toHaveLength(0); + }); +}); + // --------------------------------------------------------------------------- // Local shadow: same-file definition takes priority over imported name // --------------------------------------------------------------------------- @@ -1473,6 +1556,34 @@ describe('C++ template overload disambiguation (vector vs vector)', }); }); +describe('C++ template partial ordering (#1635)', () => { + it('pick(T*) wins over pick(T) for pointer arguments', async () => { + const result = await runPipelineFromRepo( + path.join(FIXTURES, 'cpp-template-partial-order-pointer'), + () => {}, + ); + + const calls = getRelationships(result, 'CALLS').filter( + (c) => c.source === 'run' && c.target === 'pick', + ); + expect(calls.length).toBe(1); + const target = result.graph.getNode(calls[0].rel.targetId); + expect(target?.properties.startLine).toBe(5); + }); + + it('pick(T*, T) vs pick(T, T*) emits zero CALLS edges when partial ordering is incomparable', async () => { + const result = await runPipelineFromRepo( + path.join(FIXTURES, 'cpp-template-partial-order-tied'), + () => {}, + ); + + const calls = getRelationships(result, 'CALLS').filter( + (c) => c.source === 'run' && c.target === 'pick', + ); + expect(calls.length).toBe(0); + }); +}); + // ── Phase P: C++ template overload cross-file + chain resolution ────────── describe('C++ template overload cross-file and chain resolution', () => { diff --git a/gitnexus/test/integration/resolvers/csharp.test.ts b/gitnexus/test/integration/resolvers/csharp.test.ts index d837c32d3..db65f8d66 100644 --- a/gitnexus/test/integration/resolvers/csharp.test.ts +++ b/gitnexus/test/integration/resolvers/csharp.test.ts @@ -1,7 +1,7 @@ /** * C#: heritage resolution via base_list + ambiguous namespace-import refusal */ -import { describe, expect, beforeAll } from 'vitest'; +import { describe, expect, beforeAll, afterAll, vi } from 'vitest'; import path from 'path'; import { FIXTURES, @@ -2603,3 +2603,158 @@ describe('C# namespace-as-root with no trailing newline (issue #1086)', () => { expect(edge!.rel.reason).toBe('csharp-scope: using'); }); }); + +// --------------------------------------------------------------------------- +// Spurious IMPORTS: BCL usings must not match coincidentally-named local files +// (#1881) +// --------------------------------------------------------------------------- + +describe('C# spurious import edges (#1881)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'csharp-spurious-edges'), () => {}); + }, 60000); + + it('does not emit IMPORTS from System.Threading.Tasks to a local Tasks.cs', () => { + const imports = getRelationships(result, 'IMPORTS'); + const spurious = imports.find( + (e) => + e.sourceFilePath === 'Services/OrderService.cs' && e.targetFilePath === 'Legacy/Tasks.cs', + ); + expect(spurious).toBeUndefined(); + }); + + it('still emits the legitimate in-repo edge OrderService.cs -> Models/User.cs', () => { + // Guards against the negative above passing vacuously: the fixture's + // `using MyApp.Models;` must resolve to a real IMPORTS edge. + const imports = getRelationships(result, 'IMPORTS'); + expect(imports.length).toBeGreaterThan(0); + const legit = imports.find( + (e) => + e.sourceFilePath === 'Services/OrderService.cs' && e.targetFilePath === 'Models/User.cs', + ); + expect(legit).toBeDefined(); + }); +}); + +// --------------------------------------------------------------------------- +// #1881 on the LEGACY DAG leg, forced in-process so it runs under `npm test` +// (not only the CI parity matrix). `isRegistryPrimary` reads `process.env` +// per call with no caching, so stubbing the flag before the pipeline run +// routes C# import resolution through `csharpNamespaceStrategy` (#8). +// --------------------------------------------------------------------------- + +describe('C# spurious import edges — legacy DAG leg (#1881, #8)', () => { + let result: PipelineResult; + + beforeAll(async () => { + vi.stubEnv('REGISTRY_PRIMARY_CSHARP', '0'); + result = await runPipelineFromRepo(path.join(FIXTURES, 'csharp-spurious-edges'), () => {}); + }, 60000); + + afterAll(() => { + vi.unstubAllEnvs(); + }); + + it('does not emit IMPORTS from System.Threading.Tasks to a local Tasks.cs', () => { + const imports = getRelationships(result, 'IMPORTS'); + const spurious = imports.find( + (e) => + e.sourceFilePath === 'Services/OrderService.cs' && e.targetFilePath === 'Legacy/Tasks.cs', + ); + expect(spurious).toBeUndefined(); + }); + + it('still emits the legitimate in-repo edge OrderService.cs -> Models/User.cs', () => { + const imports = getRelationships(result, 'IMPORTS'); + const legit = imports.find( + (e) => + e.sourceFilePath === 'Services/OrderService.cs' && e.targetFilePath === 'Models/User.cs', + ); + expect(legit).toBeDefined(); + }); +}); + +// --------------------------------------------------------------------------- +// #1881 / Codex F2: in the NO-CSPROJ path the registry leg ran an ungated +// direct-match before the gate, so a path-aligned `Legacy/System/Threading/ +// Tasks.cs` satisfied `using System.Threading.Tasks;`. Both legs must now block +// it (gate-first), proving the legs are equivalent. Fixture ships NO .csproj. +// --------------------------------------------------------------------------- + +describe('C# spurious import edges — no-csproj direct-match, registry leg (#1881, Codex F2)', () => { + let result: PipelineResult; + + beforeAll(async () => { + // Pin to the registry leg: only progressive stripping resolves a no-csproj + // namespace import, so the legit-edge assertion below is registry-specific. + // Pinning also keeps this deterministic under the parity matrix's legacy run. + vi.stubEnv('REGISTRY_PRIMARY_CSHARP', '1'); + result = await runPipelineFromRepo( + path.join(FIXTURES, 'csharp-spurious-edges-no-csproj'), + () => {}, + ); + }, 60000); + + afterAll(() => { + vi.unstubAllEnvs(); + }); + + it('does not emit IMPORTS from System.Threading.Tasks to a path-aligned Legacy/System/Threading/Tasks.cs', () => { + const imports = getRelationships(result, 'IMPORTS'); + const spurious = imports.find( + (e) => + e.sourceFilePath === 'Services/OrderService.cs' && + e.targetFilePath === 'Legacy/System/Threading/Tasks.cs', + ); + expect(spurious).toBeUndefined(); + }); + + it('still emits the legitimate in-repo edge OrderService.cs -> Models/User.cs', () => { + const imports = getRelationships(result, 'IMPORTS'); + expect(imports.length).toBeGreaterThan(0); + const legit = imports.find( + (e) => + e.sourceFilePath === 'Services/OrderService.cs' && e.targetFilePath === 'Models/User.cs', + ); + expect(legit).toBeDefined(); + }); +}); + +describe('C# spurious import edges — no-csproj direct-match, legacy DAG leg (#1881, Codex F2, #8)', () => { + let result: PipelineResult; + + beforeAll(async () => { + vi.stubEnv('REGISTRY_PRIMARY_CSHARP', '0'); + result = await runPipelineFromRepo( + path.join(FIXTURES, 'csharp-spurious-edges-no-csproj'), + () => {}, + ); + }, 60000); + + afterAll(() => { + vi.unstubAllEnvs(); + }); + + it('does not emit IMPORTS from System.Threading.Tasks to a path-aligned Legacy/System/Threading/Tasks.cs', () => { + const imports = getRelationships(result, 'IMPORTS'); + const spurious = imports.find( + (e) => + e.sourceFilePath === 'Services/OrderService.cs' && + e.targetFilePath === 'Legacy/System/Threading/Tasks.cs', + ); + expect(spurious).toBeUndefined(); + }); + + it('ingested the fixture so the absence of the spurious edge is meaningful (anti-vacuity)', () => { + // The legacy DAG leg cannot resolve a no-csproj namespace import to a file + // (`using MyApp.Models;` targets a directory of types — only the registry + // leg's progressive stripping resolves it without a csproj RootNamespace, a + // known registry-superiority gap). So the anti-vacuity guard here asserts + // the three fixture files were ingested as graph nodes, proving the spurious + // edge is absent because the gate blocked it — not because nothing parsed. + const files = getNodesByLabel(result, 'File'); + expect(files.length).toBeGreaterThanOrEqual(3); + }); +}); diff --git a/gitnexus/test/integration/resolvers/helpers.ts b/gitnexus/test/integration/resolvers/helpers.ts index f91349d91..d6b1a59cd 100644 --- a/gitnexus/test/integration/resolvers/helpers.ts +++ b/gitnexus/test/integration/resolvers/helpers.ts @@ -313,6 +313,11 @@ const LEGACY_RESOLVER_PARITY_EXPECTED_FAILURES: Readonly B::inherited`. + 'does not bind unqualified member lookup through a pack-expanded dependent base', // User-defined conversion ranking (#1631) builds on the C++ // conversion-rank hook and the registry-primary C++ owner sidecars. // Legacy DAG has no user-defined-conversion sidecar or ranking path. @@ -384,6 +389,10 @@ const LEGACY_RESOLVER_PARITY_EXPECTED_FAILURES: Readonly::g() -> this->f() emits zero CALLS when Inner is two levels deep (ns.a.b) — one-level cap enforced', + // Template partial ordering (#1635) relies on C++ parameter type-class + // sidecars and scope-resolver overload narrowing. The legacy DAG does not + // rank function-template shapes, so it leaves the call unresolved. + 'pick(T*) wins over pick(T) for pointer arguments', ]), }; diff --git a/gitnexus/test/integration/ruby-scope-capture-tripwire.test.ts b/gitnexus/test/integration/ruby-scope-capture-tripwire.test.ts new file mode 100644 index 000000000..651ac3298 --- /dev/null +++ b/gitnexus/test/integration/ruby-scope-capture-tripwire.test.ts @@ -0,0 +1,54 @@ +/** + * Ruby scope-capture O(n^2) regression tripwire. + * + * NOT gated behind GITNEXUS_BENCH and needs no compiled worker — it runs in + * normal CI and is the actual guard against an O(n^2) re-regression of + * `emitRubyScopeCaptures`. It calls the hotpath directly on a ~400-entity + * generated source. The O(n) path (PR #1918, threading the tree-sitter query's + * captured node) does this in a few hundred ms; the old + * findNodeAtRange-from-root behaviour scaled quadratically at this size. The + * budget is a coarse tripwire (huge margin over the fixed path, far below a + * quadratic regression), not a microbenchmark — keep it generous so it never + * flakes on a loaded CI runner. + * + * Mirrors test/integration/python-scope-capture-tripwire.test.ts (issue #1848). + */ +import { describe, it, expect } from 'vitest'; +import { emitRubyScopeCaptures } from '../../src/core/ingestion/languages/ruby/index.js'; + +describe('Ruby scope-capture O(n^2) regression tripwire', () => { + /** + * DAO-style source: N classes, each with two methods. Maximizes top-level + * children AND method matches, which is exactly the O(matches x rootChildren) + * shape the fix removed. Mirrors the `ruby` unit shape in + * bench/scope-capture/measure.mjs. + */ + function generateRubyDaoSource(entityCount: number): string { + let src = ''; + for (let i = 0; i < entityCount; i++) { + src += + `class Entity${i}\n` + + ` def get_id\n @id\n end\n` + + ` def set_name(v)\n @name = v\n end\nend\n\n`; + } + return src; + } + + it('parses a 400-entity file in well under the O(n^2) tripwire budget', () => { + const ENTITY_COUNT = 400; + const BUDGET_MS = 10_000; // coarse: many x the fixed path, far under a quadratic regression + const src = generateRubyDaoSource(ENTITY_COUNT); + + emitRubyScopeCaptures(src, 'tripwire-warmup.rb'); // warm up the parser/query JIT + + const start = Date.now(); + const matches = emitRubyScopeCaptures(src, 'tripwire.rb'); + const elapsedMs = Date.now() - start; + + // Sanity: the captures are actually produced (each entity emits many capture + // groups), so a fast-but-empty result can't pass. + expect(matches.length).toBeGreaterThan(ENTITY_COUNT * 5); + // The actual regression guard: a re-regression to O(n^2) blows this budget. + expect(elapsedMs).toBeLessThan(BUDGET_MS); + }, 30_000); +}); diff --git a/gitnexus/test/integration/rust-scope-capture-tripwire.test.ts b/gitnexus/test/integration/rust-scope-capture-tripwire.test.ts new file mode 100644 index 000000000..8262654d9 --- /dev/null +++ b/gitnexus/test/integration/rust-scope-capture-tripwire.test.ts @@ -0,0 +1,54 @@ +/** + * Rust scope-capture O(n^2) regression tripwire (PR #1918 follow-up). + * + * NOT gated behind GITNEXUS_BENCH and needs no compiled worker — it runs in + * normal CI and is the actual guard against an O(n^2) re-regression of + * `emitRustScopeCaptures`. It calls the hotpath directly on a ~400-entity + * generated source. The O(n) path (threading the tree-sitter query's captured + * node) does this in a few hundred ms; the old findNodeAtRange-from-root + * behaviour took many seconds at this size. The budget is a coarse tripwire + * (huge margin over the fixed path, far below a quadratic regression), not a + * microbenchmark — keep it generous so it never flakes on a loaded CI runner. + * + * Mirrors test/integration/python-scope-capture-tripwire.test.ts (issue #1848). + */ +import { describe, it, expect } from 'vitest'; +import { emitRustScopeCaptures } from '../../src/core/ingestion/languages/rust/index.js'; + +describe('Rust scope-capture O(n^2) regression tripwire', () => { + /** + * DAO-style source: N structs each with an `impl` block of methods. Maximizes + * top-level children AND method matches — exactly the + * O(matches x rootChildren) shape the fix removed. Mirrors the rust `unit` + * shape in bench/scope-capture/measure.mjs. + */ + function generateRustDaoSource(entityCount: number): string { + let src = ''; + for (let i = 0; i < entityCount; i++) { + src += + `struct Entity${i} {\n id: i64,\n name: String,\n}\n\n` + + `impl Entity${i} {\n` + + ` fn get_id(&self) -> i64 { self.id }\n` + + ` fn set_name(&mut self, v: String) { self.name = v; }\n}\n\n`; + } + return src; + } + + it('parses a 400-entity file in well under the O(n^2) tripwire budget', () => { + const ENTITY_COUNT = 400; + const BUDGET_MS = 10_000; // coarse: many x the fixed path, far under a quadratic regression + const src = generateRustDaoSource(ENTITY_COUNT); + + emitRustScopeCaptures(src, 'tripwire-warmup.rs'); // warm up the parser/query JIT + + const start = Date.now(); + const matches = emitRustScopeCaptures(src, 'tripwire.rs'); + const elapsedMs = Date.now() - start; + + // Sanity: the captures are actually produced (each entity emits many capture + // groups), so a fast-but-empty result can't pass. + expect(matches.length).toBeGreaterThan(ENTITY_COUNT * 5); + // The actual regression guard: a re-regression to O(n^2) blows this budget. + expect(elapsedMs).toBeLessThan(BUDGET_MS); + }, 30_000); +}); diff --git a/gitnexus/test/unit/calltool-dispatch.test.ts b/gitnexus/test/unit/calltool-dispatch.test.ts index 55e9b9a6a..f645c1b19 100644 --- a/gitnexus/test/unit/calltool-dispatch.test.ts +++ b/gitnexus/test/unit/calltool-dispatch.test.ts @@ -649,19 +649,26 @@ describe('LocalBackend.callTool', () => { it('impact byDepth items include a processes field (default empty when no processes)', async () => { // Resolver returns target; BFS returns one frontier caller; no STEP_IN_PROCESS rows. - (executeParameterized as any).mockResolvedValue([ - { id: 'func:main', name: 'main', type: 'Function', filePath: 'src/index.ts' }, - ]); - (executeQuery as any).mockResolvedValue([ - { - id: 'func:caller', - name: 'caller', - type: 'Function', - filePath: 'src/uses-main.ts', - relType: 'CALLS', - confidence: 0.9, - }, - ]); + (executeParameterized as any).mockImplementation((_repoId: string, cypher: string) => { + // BFS frontier query is now parameterized (#1907 U3). + if (cypher.includes('r.type IN') && !cypher.includes('STEP_IN_PROCESS')) { + return Promise.resolve([ + { + id: 'func:caller', + name: 'caller', + type: 'Function', + filePath: 'src/uses-main.ts', + relType: 'CALLS', + confidence: 0.9, + }, + ]); + } + // Symbol resolution. + return Promise.resolve([ + { id: 'func:main', name: 'main', type: 'Function', filePath: 'src/index.ts' }, + ]); + }); + (executeQuery as any).mockResolvedValue([]); const result = await backend.callTool('impact', { target: 'main', direction: 'upstream' }); const d1 = result.byDepth?.[1] || result.byDepth?.['1'] || []; @@ -674,6 +681,19 @@ describe('LocalBackend.callTool', () => { it('impact populates byDepth processes when STEP_IN_PROCESS rows exist', async () => { (executeParameterized as any).mockImplementation((_repoId: string, cypher: string) => { + // BFS frontier query is now parameterized (#1907 U3). + if (cypher.includes('r.type IN') && !cypher.includes('STEP_IN_PROCESS')) { + return Promise.resolve([ + { + id: 'func:caller', + name: 'caller', + type: 'Function', + filePath: 'src/uses-main.ts', + relType: 'CALLS', + confidence: 0.9, + }, + ]); + } // Symbol resolver name-lookup if (cypher.includes('WHERE n.name =')) { return Promise.resolve([ @@ -739,6 +759,20 @@ describe('LocalBackend.callTool', () => { it('impact summaryOnly:true skips the per-symbol STEP_IN_PROCESS enrichment pass', async () => { // Resolver returns target; BFS returns one caller; aggregation returns one process row. (executeParameterized as any).mockImplementation((_repoId: string, cypher: string) => { + // BFS frontier query is now parameterized (#1907 U3) — return a caller so + // the per-symbol-skip assertion below is meaningful (not vacuous). + if (cypher.includes('r.type IN') && !cypher.includes('STEP_IN_PROCESS')) { + return Promise.resolve([ + { + id: 'func:caller', + name: 'caller', + type: 'Function', + filePath: 'src/a.ts', + relType: 'CALLS', + confidence: 0.9, + }, + ]); + } if (cypher.includes('WHERE n.name =')) { return Promise.resolve([ { id: 'func:main', name: 'main', type: 'Function', filePath: 'src/index.ts' }, @@ -811,6 +845,19 @@ describe('LocalBackend.callTool', () => { await backend.init(); (executeParameterized as any).mockImplementation((_repoId: string, cypher: string) => { + // BFS frontier query is now parameterized (#1907 U3). + if (cypher.includes('r.type IN') && !cypher.includes('STEP_IN_PROCESS')) { + return Promise.resolve([ + { + id: 'func:caller', + name: 'caller', + type: 'Function', + filePath: 'src/uses-main.ts', + relType: 'CALLS', + confidence: 0.9, + }, + ]); + } // UID resolver if (cypher.includes('WHERE n.id = $uid')) { return Promise.resolve([ diff --git a/gitnexus/test/unit/cli-impact-disambiguation.test.ts b/gitnexus/test/unit/cli-impact-disambiguation.test.ts new file mode 100644 index 000000000..5c20b6bc0 --- /dev/null +++ b/gitnexus/test/unit/cli-impact-disambiguation.test.ts @@ -0,0 +1,140 @@ +/** + * Unit Tests: CLI `impact` disambiguation flag wiring (#1907) + * + * The CLI `impact` command gained --uid / --file / --kind so that, when impact + * reports an `ambiguous` target, users can follow the "disambiguate" guidance + * straight from the terminal (previously only the MCP tool accepted these). + * These tests pin that impactCommand forwards the flags to + * callTool('impact', …) under the backend's parameter names + * (target_uid / file_path / kind) — the same names the MCP impact tool uses. + * + * The LocalBackend is fully mocked: this isolates the CLI option → tool param + * mapping from any graph/DB behaviour. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const { callTool, init } = vi.hoisted(() => ({ + callTool: vi.fn(), + init: vi.fn().mockResolvedValue(true), +})); + +vi.mock('../../src/mcp/local/local-backend.js', () => ({ + LocalBackend: class { + init = init; + callTool = callTool; + }, + // U4: impactCommand imports VALID_NODE_LABELS to soft-validate --kind. + VALID_NODE_LABELS: new Set(['Function', 'Class', 'Interface', 'Method', 'Constructor']), +})); + +// impactCommand prints its result via fs.writeSync(fd 1, …). Silence that so +// the assertion-only test does not write JSON to the runner's stdout. tool.ts +// uses only writeSync from node:fs, so a full mock is safe here (matches the +// pattern in tool-direct-cli.test.ts). +vi.mock('node:fs', () => ({ + writeSync: vi.fn(), +})); + +import { impactCommand } from '../../src/cli/tool.js'; + +describe('CLI impact disambiguation flags (#1907)', () => { + beforeEach(() => { + callTool.mockReset(); + callTool.mockResolvedValue({ status: 'found', impactedCount: 0 }); + }); + + it('forwards --uid/--file/--kind as target_uid/file_path/kind', async () => { + await impactCommand('get_embeddings', { + direction: 'upstream', + uid: 'Function:isma/scripts/ingest_md_file.py:get_embeddings', + file: 'isma/scripts/ingest_md_file.py', + kind: 'Function', + }); + + expect(callTool).toHaveBeenCalledTimes(1); + expect(callTool).toHaveBeenCalledWith( + 'impact', + expect.objectContaining({ + target: 'get_embeddings', + target_uid: 'Function:isma/scripts/ingest_md_file.py:get_embeddings', + file_path: 'isma/scripts/ingest_md_file.py', + kind: 'Function', + direction: 'upstream', + }), + ); + }); + + it('leaves disambiguation params undefined when no flags are supplied', async () => { + await impactCommand('AuthService', { direction: 'upstream' }); + + expect(callTool).toHaveBeenCalledTimes(1); + const params = callTool.mock.calls[0][1] as Record; + expect(params.target).toBe('AuthService'); + expect(params.target_uid).toBeUndefined(); + expect(params.file_path).toBeUndefined(); + expect(params.kind).toBeUndefined(); + }); + + // U1 (#1914 review F1): impact's positional target is now optional, so a uid + // alone resolves — parity with `context [name]`. + it('resolves uid-only with no positional target (parity with context)', async () => { + await impactCommand(undefined, { + direction: 'upstream', + uid: 'Function:src/auth.ts:login', + }); + + expect(callTool).toHaveBeenCalledTimes(1); + const params = callTool.mock.calls[0][1] as Record; + expect(params.target_uid).toBe('Function:src/auth.ts:login'); + expect(params.target).toBeUndefined(); + }); + + it('errors when neither a target nor a uid is provided', async () => { + const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => { + throw new Error('process.exit'); + }) as never); + + await expect(impactCommand(undefined, {})).rejects.toThrow('process.exit'); + expect(exitSpy).toHaveBeenCalledWith(1); + expect(callTool).not.toHaveBeenCalled(); + + exitSpy.mockRestore(); + }); + + it('rejects a --prefixed uid value (a flag swallowed by Commander) without forwarding it', async () => { + const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => { + throw new Error('process.exit'); + }) as never); + + await expect(impactCommand(undefined, { uid: '--file' })).rejects.toThrow('process.exit'); + expect(callTool).not.toHaveBeenCalled(); + + exitSpy.mockRestore(); + }); + + // U4 (#1914 review F3): an unknown --kind warns to stderr but still resolves. + it('warns on an unknown --kind but still forwards the request', async () => { + const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); + + await impactCommand('login', { kind: 'Funktion', direction: 'upstream' }); + + expect(callTool).toHaveBeenCalledTimes(1); + const stderr = stderrSpy.mock.calls.map((c) => String(c[0])).join(''); + expect(stderr).toContain('Funktion'); + expect(stderr).toContain('not a known symbol kind'); + + stderrSpy.mockRestore(); + }); + + it('does not warn for a known --kind', async () => { + const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); + + await impactCommand('login', { kind: 'Function', direction: 'upstream' }); + + expect(callTool).toHaveBeenCalledTimes(1); + const stderr = stderrSpy.mock.calls.map((c) => String(c[0])).join(''); + expect(stderr).not.toContain('not a known symbol kind'); + + stderrSpy.mockRestore(); + }); +}); diff --git a/gitnexus/test/unit/cli-index-help.test.ts b/gitnexus/test/unit/cli-index-help.test.ts index a50937ba7..3beaeff9a 100644 --- a/gitnexus/test/unit/cli-index-help.test.ts +++ b/gitnexus/test/unit/cli-index-help.test.ts @@ -196,13 +196,18 @@ describe('CLI help surface', () => { expect(result.stdout).toContain('--file '); }); - it('impact help keeps repo and include-tests flags', () => { + it('impact help keeps repo, include-tests, and disambiguation flags', () => { const result = runHelp('impact'); expect(result.status).toBe(0); expect(result.stdout).toContain('--depth '); expect(result.stdout).toContain('--include-tests'); expect(result.stdout).toContain('--repo '); + // Disambiguation flags (#1907) — mirror the context help test so a + // missing-flag regression on impact is caught here too. + expect(result.stdout).toContain('--uid '); + expect(result.stdout).toContain('--file '); + expect(result.stdout).toContain('--kind '); }); it('detect-changes help exposes compare scope and base-ref flags', () => { diff --git a/gitnexus/test/unit/csharp-namespace-extraction.test.ts b/gitnexus/test/unit/csharp-namespace-extraction.test.ts index 7f39a061f..4a52e9e11 100644 --- a/gitnexus/test/unit/csharp-namespace-extraction.test.ts +++ b/gitnexus/test/unit/csharp-namespace-extraction.test.ts @@ -96,4 +96,84 @@ describe('extractCsharpStructureViaScanner', () => { const src = `/* header */ class C {}\nnamespace App.Real;`; expect(extractCsharpStructureViaScanner(src).namespaces).toEqual(['App.Real']); }); + + // --- Unicode / @-verbatim identifiers (Codex F3): these must be CAPTURED, not + // truncated/dropped, so the #1881 gate doesn't over-block legitimate imports. + it('captures a Unicode namespace identifier', () => { + const out = extractCsharpStructureViaScanner('namespace Café.Modèles;'); + expect(out.namespaces).toEqual(['Café.Modèles']); + expect(out.incomplete).toBeFalsy(); + }); + + it('captures a non-Latin (Greek) namespace identifier', () => { + expect(extractCsharpStructureViaScanner('namespace Ωμέγα.Models;').namespaces).toEqual([ + 'Ωμέγα.Models', + ]); + }); + + it('strips a leading @ from a verbatim namespace identifier to match the AST', () => { + expect(extractCsharpStructureViaScanner('namespace @namespace.Models;').namespaces).toEqual([ + 'namespace.Models', + ]); + }); + + it('strips a mid-path @ from a verbatim namespace segment', () => { + expect(extractCsharpStructureViaScanner('namespace App.@class.Models;').namespaces).toEqual([ + 'App.class.Models', + ]); + }); + + // --- Forms the line scanner cannot capture must flag `incomplete` so the + // caller fails the #1881 gate OPEN (Codex F3) instead of dropping the namespace. + it('flags `incomplete` for a namespace declaration split across lines', () => { + const out = extractCsharpStructureViaScanner('namespace\n App.Models;'); + expect(out.namespaces).toEqual([]); + expect(out.incomplete).toBe(true); + }); + + it('flags `incomplete` for a namespace keyword not at line start', () => { + const out = extractCsharpStructureViaScanner('class C {} namespace App.Models;'); + expect(out.incomplete).toBe(true); + }); + + it('flags `incomplete` for an attributed same-line namespace', () => { + const out = extractCsharpStructureViaScanner('[Obsolete] namespace App.Legacy;'); + expect(out.incomplete).toBe(true); + }); + + // --- Guards: ordinary / handled forms must NEVER set `incomplete`, or one + // exotic line would wrongly disable the gate repo-wide. + it('does NOT flag `incomplete` for ordinary handled forms', () => { + for (const src of [ + 'namespace App.Models;', + 'namespace App.Services\n{\n}', + 'namespace A.One {}\nnamespace A.Two {}', + 'using static System.Math;\nnamespace App;', + 'global using static App.Utils.Logger;', + 'using static M = App.Utils.MathUtils;', + 'using System.Collections.Generic;\nusing App.Models;', + '\t\tnamespace App.Indented;', + 'public class Global {}', + '', + ]) { + expect(extractCsharpStructureViaScanner(src).incomplete).toBeFalsy(); + } + }); + + it('does NOT flag `incomplete` for a `// namespace` line comment or a namespace mentioned after `//`', () => { + expect( + extractCsharpStructureViaScanner('// namespace Fake.Comment;\nnamespace App.Real;') + .incomplete, + ).toBeFalsy(); + expect( + extractCsharpStructureViaScanner('public class C {} // namespace Foo').incomplete, + ).toBeFalsy(); + }); + + it('does NOT flag `incomplete` for an identifier that merely starts with "namespace"', () => { + // `namespaceManager` is an ordinary identifier, not the keyword. + expect( + extractCsharpStructureViaScanner('var namespaceManager = Get();').incomplete, + ).toBeFalsy(); + }); }); diff --git a/gitnexus/test/unit/group/grpc-extractor.test.ts b/gitnexus/test/unit/group/grpc-extractor.test.ts index 1a4a6ef47..bf1a98033 100644 --- a/gitnexus/test/unit/group/grpc-extractor.test.ts +++ b/gitnexus/test/unit/group/grpc-extractor.test.ts @@ -1135,6 +1135,37 @@ export const authClient = new authProto.auth.v1.AuthService( expect(consumers[0].contractId).toBe('grpc::auth.v1.AuthService/*'); }); + it('test_extract_ts_qualified_ctor_without_loadPackageDefinition_is_ignored', async () => { + // Regression: an unrelated `obj.method(...)` member call must not trip the + // loadPackageDefinition gate. With no loadPackageDefinition call present, a + // qualified `new pkg...Service(...)` constructor must NOT become a consumer. + // Pre-fix, the gate's shared-capture `function: [...]` alternation matched + // every member call, so this spuriously emitted an AuthService consumer. + writeFile( + 'proto/auth.proto', + `syntax = "proto3"; +package auth.v1; +service AuthService { + rpc Login (LoginRequest) returns (LoginResponse); +}`, + ); + writeFile( + 'src/auth.client.ts', + `import * as grpc from '@grpc/grpc-js'; + +logger.info('starting up'); +export const authClient = new authProto.auth.v1.AuthService( + 'localhost:50051', + grpc.credentials.createInsecure(), +);`, + ); + + const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir)); + const consumers = contracts.filter((c) => c.role === 'consumer'); + + expect(consumers).toHaveLength(0); + }); + it('test_extract_ts_duplicate_consumer_patterns_in_one_file_dedupes_deterministically', async () => { writeFile( 'proto/auth.proto', diff --git a/gitnexus/test/unit/group/http-route-extractor.test.ts b/gitnexus/test/unit/group/http-route-extractor.test.ts index 58db213e9..18b2bebcd 100644 --- a/gitnexus/test/unit/group/http-route-extractor.test.ts +++ b/gitnexus/test/unit/group/http-route-extractor.test.ts @@ -1774,6 +1774,336 @@ interface PrecedenceClient { expect(consumers.find((c) => c.contractId === 'http::GET::/rm-path/orders')).toBeUndefined(); }); + it('extracts native @RequestLine consumers on @FeignClient interfaces', async () => { + const dir = path.join(tmpDir, 'java-feign-request-line-basic'); + fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src', 'AiClient.java'), + ` +import org.springframework.cloud.openfeign.FeignClient; +import feign.RequestLine; + +@FeignClient(name = "ai-backend") +interface AiClient { + @RequestLine("POST /ai/summarize") + String summarize(); + + @RequestLine("GET /ai/health") + String health(); +} +`, + ); + + const contracts = await extractor.extract(null, dir, makeRepo(dir)); + const consumers = contracts.filter((c) => c.role === 'consumer'); + + expect( + consumers.find( + (c) => + c.contractId === 'http::POST::/ai/summarize' && + c.meta.framework === 'openfeign' && + c.confidence === 0.75, + ), + ).toBeDefined(); + expect( + consumers.find( + (c) => + c.contractId === 'http::GET::/ai/health' && + c.meta.framework === 'openfeign' && + c.confidence === 0.75, + ), + ).toBeDefined(); + }); + + it('joins @FeignClient(path=...) prefix with @RequestLine paths', async () => { + const dir = path.join(tmpDir, 'java-feign-request-line-prefix'); + fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src', 'OrderClient.java'), + ` +import org.springframework.cloud.openfeign.FeignClient; +import feign.RequestLine; + +@FeignClient(name = "order-service", path = "/api") +interface OrderClient { + @RequestLine("GET /orders/{id}") + OrderDto get(Long id); + + @RequestLine("DELETE /orders/{id}") + void delete(Long id); +} +`, + ); + + const contracts = await extractor.extract(null, dir, makeRepo(dir)); + const consumers = contracts.filter((c) => c.role === 'consumer'); + + expect( + consumers.find((c) => c.contractId === 'http::GET::/api/orders/{param}'), + ).toBeDefined(); + expect( + consumers.find((c) => c.contractId === 'http::DELETE::/api/orders/{param}'), + ).toBeDefined(); + }); + + it('strips query strings from @RequestLine values when forming contract IDs', async () => { + const dir = path.join(tmpDir, 'java-feign-request-line-query'); + fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src', 'SearchClient.java'), + ` +import org.springframework.cloud.openfeign.FeignClient; +import feign.RequestLine; + +@FeignClient(name = "search-service") +interface SearchClient { + @RequestLine("GET /search?q={query}&limit={limit}") + SearchResult search(); +} +`, + ); + + const contracts = await extractor.extract(null, dir, makeRepo(dir)); + const consumers = contracts.filter((c) => c.role === 'consumer'); + + // Query string is dropped — contract ID is method+path only. + expect(consumers.find((c) => c.contractId === 'http::GET::/search')).toBeDefined(); + expect( + consumers.find((c) => c.contractId.includes('?') || c.contractId.includes('limit')), + ).toBeUndefined(); + }); + + it('extracts native @RequestLine on a plain interface without @FeignClient (Feign.builder())', async () => { + // The canonical core-Feign usage: a plain interface with `@RequestLine`, + // wired up via `Feign.builder()`. There is NO `@FeignClient` annotation + // (that is the Spring Cloud variant, which uses Spring MVC annotations and + // is mutually exclusive with `@RequestLine`). This is the shape used by + // real client-jar consumers, so it must be recognized. + const dir = path.join(tmpDir, 'java-request-line-no-feign'); + fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src', 'BigModelClient.java'), + ` +import feign.Headers; +import feign.RequestLine; +import feign.Response; + +public interface BigModelClient { + @RequestLine("POST /ai/summarization") + @Headers("Content-Type: application/json") + Response summarize(); + + @RequestLine("GET /ai/concurrent") + Response concurrent(); +} +`, + ); + + const contracts = await extractor.extract(null, dir, makeRepo(dir)); + const consumers = contracts.filter((c) => c.role === 'consumer'); + + expect( + consumers.find( + (c) => + c.contractId === 'http::POST::/ai/summarization' && + c.meta.framework === 'openfeign' && + c.confidence === 0.75, + ), + ).toBeDefined(); + expect( + consumers.find( + (c) => c.contractId === 'http::GET::/ai/concurrent' && c.meta.framework === 'openfeign', + ), + ).toBeDefined(); + }); + + it('mixes @RequestLine and @GetMapping methods on the same @FeignClient interface', async () => { + const dir = path.join(tmpDir, 'java-feign-mixed-annotations'); + fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src', 'MixedClient.java'), + ` +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.GetMapping; +import feign.RequestLine; + +@FeignClient(name = "mixed-service", path = "/api") +interface MixedClient { + @GetMapping("/spring-style") + String springStyle(); + + @RequestLine("GET /native-style") + String nativeStyle(); +} +`, + ); + + const contracts = await extractor.extract(null, dir, makeRepo(dir)); + const consumers = contracts.filter((c) => c.role === 'consumer'); + + // Both annotation styles produce contracts — they don't conflict. + expect( + consumers.find( + (c) => + c.contractId === 'http::GET::/api/spring-style' && + c.meta.framework === 'openfeign' && + c.confidence === 0.7, + ), + ).toBeDefined(); + expect( + consumers.find( + (c) => + c.contractId === 'http::GET::/api/native-style' && + c.meta.framework === 'openfeign' && + c.confidence === 0.75, + ), + ).toBeDefined(); + }); + + it('extracts @RequestLine values written with the named "value" argument', async () => { + const dir = path.join(tmpDir, 'java-feign-request-line-named'); + fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src', 'NamedArgClient.java'), + ` +import org.springframework.cloud.openfeign.FeignClient; +import feign.RequestLine; + +@FeignClient(name = "named-arg-service") +interface NamedArgClient { + @RequestLine(value = "POST /create") + String create(); +} +`, + ); + + const contracts = await extractor.extract(null, dir, makeRepo(dir)); + const consumers = contracts.filter((c) => c.role === 'consumer'); + + expect( + consumers.find( + (c) => c.contractId === 'http::POST::/create' && c.meta.framework === 'openfeign', + ), + ).toBeDefined(); + }); + + it('ignores @RequestLine whose named argument is not "value"', async () => { + // The consolidated query matches every named annotation argument; the + // scanRouteAnnotations loop drops a @RequestLine whose key is not `value`. + const dir = path.join(tmpDir, 'java-feign-request-line-wrong-key'); + fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src', 'WrongKeyClient.java'), + ` +import org.springframework.cloud.openfeign.FeignClient; +import feign.RequestLine; + +@FeignClient(name = "wrong-key-service") +interface WrongKeyClient { + @RequestLine(name = "GET /should-not-extract") + String shouldNotBeExtracted(); +} +`, + ); + + const contracts = await extractor.extract(null, dir, makeRepo(dir)); + const consumers = contracts.filter((c) => c.role === 'consumer'); + + expect( + consumers.find((c) => c.contractId === 'http::GET::/should-not-extract'), + ).toBeUndefined(); + }); + + it('ignores @RequestLine values that are not a "VERB /path" line', async () => { + // `parseRequestLine` only accepts a recognized HTTP verb followed by a + // path starting with `/`. Malformed values (no verb, no leading-slash + // path, or unknown verb) must be dropped — this guards the relaxed + // (no-@FeignClient) matcher from turning arbitrary `@RequestLine` string + // literals into bogus contracts. + const dir = path.join(tmpDir, 'java-request-line-malformed'); + fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src', 'MalformedClient.java'), + ` +import feign.RequestLine; + +interface MalformedClient { + @RequestLine("not a request line at all") + String noVerb(); + + @RequestLine("GET relative/no/leading/slash") + String noLeadingSlash(); + + @RequestLine("FETCH /unknown-verb") + String unknownVerb(); +} +`, + ); + + const contracts = await extractor.extract(null, dir, makeRepo(dir)); + const consumers = contracts.filter((c) => c.role === 'consumer'); + + // None of the three malformed values should yield a contract. + expect( + consumers.filter((c) => c.symbolRef.filePath.endsWith('MalformedClient.java')), + ).toHaveLength(0); + }); + + it('ignores @RequestLine on a class method (Feign proxies are interfaces only)', async () => { + // The relaxed matcher still requires an enclosing interface: Feign builds + // its proxy from an interface, so a `@RequestLine` on a concrete class + // method is not a Feign call and must not be emitted as a consumer. + const dir = path.join(tmpDir, 'java-request-line-on-class'); + fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src', 'NotAProxy.java'), + ` +import feign.RequestLine; + +class NotAProxy { + @RequestLine("GET /should-not-extract") + String call() { return null; } +} +`, + ); + + const contracts = await extractor.extract(null, dir, makeRepo(dir)); + const consumers = contracts.filter((c) => c.role === 'consumer'); + + expect( + consumers.find((c) => c.contractId === 'http::GET::/should-not-extract'), + ).toBeUndefined(); + }); + + it('prefers @FeignClient(path=...) over @RequestMapping when @RequestMapping appears first', async () => { + // Reverse-order companion to the precedence test above: @FeignClient(path) + // must win even when @RequestMapping is the first annotation in source, + // exercising the deferred interfaceRequestMappingPrefixes apply. + const dir = path.join(tmpDir, 'java-openfeign-prefix-precedence-reversed'); + fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src', 'ReversedPrecedenceClient.java'), + ` +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; + +@RequestMapping("/rm-path") +@FeignClient(name = "order-service", path = "/feign-path") +interface ReversedPrecedenceClient { + @GetMapping("/orders") + OrderDto getOrders(); +} +`, + ); + + const contracts = await extractor.extract(null, dir, makeRepo(dir)); + const consumers = contracts.filter((c) => c.role === 'consumer'); + + expect(consumers.find((c) => c.contractId === 'http::GET::/feign-path/orders')).toBeDefined(); + expect(consumers.find((c) => c.contractId === 'http::GET::/rm-path/orders')).toBeUndefined(); + }); + it('extracts Java and Apache HttpClient literal request construction', async () => { const dir = path.join(tmpDir, 'java-http-client-consumer'); fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); diff --git a/gitnexus/test/unit/impact-batching-grouping.test.ts b/gitnexus/test/unit/impact-batching-grouping.test.ts index 098d72cd7..e600042de 100644 --- a/gitnexus/test/unit/impact-batching-grouping.test.ts +++ b/gitnexus/test/unit/impact-batching-grouping.test.ts @@ -68,30 +68,9 @@ describe('impact: batching and grouping', () => { const chunkSizes: number[] = []; let chunkCallIndex = 0; - executeQueryMock.mockImplementation(async (...args: any[]) => { - const query = typeof args[1] === 'string' ? args[1] : String(args[0] ?? ''); - // Depth traversal query (find related nodes) -- return 250 impacted ids - if (query.includes('r.type IN') && !query.includes('STEP_IN_PROCESS')) { - const res: any[] = []; - for (let i = 0; i < 250; i++) { - res.push({ - id: `node-${i}`, - name: `n${i}`, - filePath: `file-${i}.js`, - relType: 'CALLS', - confidence: null, - }); - } - return res; - } - - // NOTE: process-chunk enrichment previously used executeQuery; our - // implementation now calls executeParameterized for those chunks. We - // still keep this branch to support any legacy calls, but primary - // chunk tracking will be handled via executeParameterizedMock below. - - return []; - }); + // BFS frontier query is now parameterized (#1907 U3) — handled in + // executeParameterizedMock below; executeQuery is unused by the impact path. + executeQueryMock.mockImplementation(async () => []); // Handle parameterized calls (including chunked STEP_IN_PROCESS queries) executeParameterizedMock.mockImplementation(async (...args: any[]) => { @@ -117,6 +96,20 @@ describe('impact: batching and grouping', () => { }, ]; } + // BFS frontier query (parameterized #1907 U3): return the 250 impacted ids. + if (query.includes('r.type IN') && !query.includes('STEP_IN_PROCESS')) { + const res: any[] = []; + for (let i = 0; i < 250; i++) { + res.push({ + id: `node-${i}`, + name: `n${i}`, + filePath: `file-${i}.js`, + relType: 'CALLS', + confidence: null, + }); + } + return res; + } // Default target resolution return [{ id: 'sym1', name: 'Target', filePath: 'f' }]; }); @@ -151,6 +144,19 @@ describe('impact: batching and grouping', () => { executeParameterizedMock.mockImplementation(async (...args: any[]) => { const query = typeof args[1] === 'string' ? args[1] : String(args[0] ?? ''); + // BFS frontier query (parameterized #1907 U3): return 6 impacted nodes. + if (query.includes('r.type IN') && !query.includes('STEP_IN_PROCESS')) { + const res: any[] = []; + for (let i = 0; i < 6; i++) + res.push({ + id: `node-${i}`, + name: `n${i}`, + filePath: `file-${i}.js`, + relType: 'CALLS', + confidence: null, + }); + return res; + } if (!query.includes('STEP_IN_PROCESS')) return [{ id: 'symA', name: 'TargetA', filePath: 'f' }]; // For STEP_IN_PROCESS in this test, return grouping rows @@ -190,25 +196,9 @@ describe('impact: batching and grouping', () => { ]; }); - // Prepare impacted nodes: smaller set for clarity (6 nodes -> chunk size default 100 so single chunk) - executeQueryMock.mockImplementation(async (...args: any[]) => { - const query = typeof args[1] === 'string' ? args[1] : String(args[0] ?? ''); - if (query.includes('r.type IN') && !query.includes('STEP_IN_PROCESS')) { - // return 6 nodes - const res: any[] = []; - for (let i = 0; i < 6; i++) - res.push({ - id: `node-${i}`, - name: `n${i}`, - filePath: `file-${i}.js`, - relType: 'CALLS', - confidence: null, - }); - return res; - } - - return []; - }); + // BFS frontier query is now parameterized (#1907 U3) — handled in + // executeParameterizedMock above; executeQuery is unused by the impact path. + executeQueryMock.mockImplementation(async () => []); const params = { target: 'TargetA', direction: 'downstream', maxDepth: 1 } as any; const res = await (backend as any)._impactImpl(repoHandle, params); @@ -243,23 +233,9 @@ describe('impact: batching and grouping', () => { (backend as any).repos.set(repoHandle.id, repoHandle); (backend as any).ensureInitialized = vi.fn().mockResolvedValue(undefined); - // Depth traversal returns 500 impacted nodes - executeQueryMock.mockImplementation(async (...args: any[]) => { - const query = typeof args[1] === 'string' ? args[1] : String(args[0] ?? ''); - if (query.includes('r.type IN') && !query.includes('STEP_IN_PROCESS')) { - const res: any[] = []; - for (let i = 0; i < 500; i++) - res.push({ - id: `node-${i}`, - name: `n${i}`, - filePath: `file-${i}.js`, - relType: 'CALLS', - confidence: null, - }); - return res; - } - return []; - }); + // BFS frontier query is now parameterized (#1907 U3) — handled in + // executeParameterizedMock below; executeQuery is unused by the impact path. + executeQueryMock.mockImplementation(async () => []); const chunkSizes: number[] = []; @@ -294,6 +270,19 @@ describe('impact: batching and grouping', () => { return [{ name: 'ModuleA' }]; } + // BFS frontier query (parameterized #1907 U3): return 500 impacted nodes. + if (query.includes('r.type IN') && !query.includes('STEP_IN_PROCESS')) { + const res: any[] = []; + for (let i = 0; i < 500; i++) + res.push({ + id: `node-${i}`, + name: `n${i}`, + filePath: `file-${i}.js`, + relType: 'CALLS', + confidence: null, + }); + return res; + } // Default: target resolution return [{ id: 'symX', name: 'TargetX', filePath: 'f' }]; }); diff --git a/gitnexus/test/unit/impact-pagination.test.ts b/gitnexus/test/unit/impact-pagination.test.ts index a9d604c26..163159240 100644 --- a/gitnexus/test/unit/impact-pagination.test.ts +++ b/gitnexus/test/unit/impact-pagination.test.ts @@ -46,30 +46,35 @@ function makeBackend() { return { backend, repoHandle }; } +// The BFS frontier query is now parameterized (bound $frontierIds/$relTypes, +// #1907 U3), so the caller rows come back through executeParameterizedMock +// (matched on `r.type IN`) rather than executeQueryMock. Symbol resolution and +// the label-enrichment UNION still fall through to the default symbol row. function setupMultiDepthHub(d1Count: number, d2Count: number) { let depth = 0; executeParameterizedMock.mockImplementation(async (...args: any[]) => { const query = typeof args[1] === 'string' ? args[1] : String(args[0] ?? ''); if (query.includes('STEP_IN_PROCESS')) return []; if (query.includes('MEMBER_OF')) return []; + if (query.includes('r.type IN')) { + depth++; + const count = depth === 1 ? d1Count : depth === 2 ? d2Count : 0; + const res: any[] = []; + for (let i = 0; i < count; i++) { + res.push({ + id: `d${depth}-caller-${i}`, + name: `d${depth}caller${i}`, + filePath: `src/d${depth}-caller-${i}.ts`, + relType: 'CALLS', + confidence: null, + }); + } + return res; + } return [{ id: 'hub1', name: 'HubSymbol', filePath: 'hub.ts' }]; }); - executeQueryMock.mockImplementation(async () => { - depth++; - const count = depth === 1 ? d1Count : depth === 2 ? d2Count : 0; - const res: any[] = []; - for (let i = 0; i < count; i++) { - res.push({ - id: `d${depth}-caller-${i}`, - name: `d${depth}caller${i}`, - filePath: `src/d${depth}-caller-${i}.ts`, - relType: 'CALLS', - confidence: null, - }); - } - return res; - }); + executeQueryMock.mockImplementation(async () => []); } function setupHubSymbol(count: number) { @@ -77,12 +82,7 @@ function setupHubSymbol(count: number) { const query = typeof args[1] === 'string' ? args[1] : String(args[0] ?? ''); if (query.includes('STEP_IN_PROCESS')) return []; if (query.includes('MEMBER_OF')) return []; - return [{ id: 'hub1', name: 'HubSymbol', filePath: 'hub.ts' }]; - }); - - executeQueryMock.mockImplementation(async (...args: any[]) => { - const query = typeof args[1] === 'string' ? args[1] : String(args[0] ?? ''); - if (query.includes('r.type IN') && !query.includes('STEP_IN_PROCESS')) { + if (query.includes('r.type IN')) { const res: any[] = []; for (let i = 0; i < count; i++) { res.push({ @@ -95,8 +95,10 @@ function setupHubSymbol(count: number) { } return res; } - return []; + return [{ id: 'hub1', name: 'HubSymbol', filePath: 'hub.ts' }]; }); + + executeQueryMock.mockImplementation(async () => []); } describe('impact: pagination and summaryOnly (#414)', () => { diff --git a/gitnexus/test/unit/import-resolver-factory.test.ts b/gitnexus/test/unit/import-resolver-factory.test.ts index 1150f371e..2e22790e0 100644 --- a/gitnexus/test/unit/import-resolver-factory.test.ts +++ b/gitnexus/test/unit/import-resolver-factory.test.ts @@ -352,6 +352,65 @@ describe('csharpNamespaceStrategy', () => { expect(result).toBeNull(); }); + it('no csproj + non-aligned BCL import: stops the chain instead of the ungated standard strategy (#2)', () => { + // Parity with the registry leg's no-csproj path. Without csproj configs the + // generic strategy would suffix-match `System.Threading.Tasks` onto the + // coincidental local `Legacy/Tasks.cs`. The gate sees the import aligns + // with no declared namespace, so the strategy returns an absorbing sentinel + // (`{ kind: 'files', files: [] }`) that STOPS the chain — the standard + // strategy never runs and no spurious edge is emitted. + const ctx = makeCtx(['Services/OrderService.cs', 'Legacy/Tasks.cs'], { + csharpNamespaces: { + declaredNamespaces: new Set(['MyApp.Services', 'MyApp.Legacy']), + rootNamespaces: new Set(['MyApp']), + truncated: false, + }, + }); + const result = csharpNamespaceStrategy( + 'System.Threading.Tasks', + 'Services/OrderService.cs', + ctx, + ); + expect(result).toEqual({ kind: 'files', files: [] }); + }); + + it('no csproj + in-repo-aligned import: keeps delegating to the standard strategy (#2)', () => { + // An import that DOES align with a declared namespace must keep returning + // null so the generic strategy resolves it — legitimate no-csproj behavior + // is unchanged; only non-aligned (BCL) imports are stopped. + const ctx = makeCtx(['Services/OrderService.cs', 'Models/User.cs'], { + csharpNamespaces: { + declaredNamespaces: new Set(['MyApp.Models', 'MyApp.Services']), + rootNamespaces: new Set(['MyApp']), + truncated: false, + }, + }); + const result = csharpNamespaceStrategy('MyApp.Models', 'Services/OrderService.cs', ctx); + expect(result).toBeNull(); + }); + + it('returns an empty files result (chain-stop) for a gated BCL import when csproj configs exist (#1881, #8)', () => { + // Legacy DAG leg of #1881: with csproj configs present, a BCL using like + // `System.Threading.Tasks` must NOT suffix-match the coincidental local + // `Legacy/Tasks.cs`. The strategy returns `{ kind: 'files', files: [] }` + // (absorbing sentinel) to STOP the chain, NOT null — null would let the + // generic suffix fallback re-introduce the spurious edge. + const ctx = makeCtx(['Services/OrderService.cs', 'Legacy/Tasks.cs'], { + csharpConfigs: [{ rootNamespace: 'MyApp', projectDir: '' }], + csharpNamespaces: { + declaredNamespaces: new Set(['MyApp.Services', 'MyApp.Legacy']), + rootNamespaces: new Set(['MyApp']), + truncated: false, + }, + }); + const result = csharpNamespaceStrategy( + 'System.Threading.Tasks', + 'Services/OrderService.cs', + ctx, + ); + expect(result).toEqual({ kind: 'files', files: [] }); + }); + it('csharpImportConfig full chain produces package-kind (strategy-order guard)', () => { const files = ['src/Services/Auth/AuthService.cs', 'src/Services/Auth/TokenService.cs']; const ctx = makeCtx(files, { diff --git a/gitnexus/test/unit/scope-resolution/cpp/cpp-overload-ranking.test.ts b/gitnexus/test/unit/scope-resolution/cpp/cpp-overload-ranking.test.ts index 93cf86dc2..7203e0633 100644 --- a/gitnexus/test/unit/scope-resolution/cpp/cpp-overload-ranking.test.ts +++ b/gitnexus/test/unit/scope-resolution/cpp/cpp-overload-ranking.test.ts @@ -21,6 +21,13 @@ const pointer = (base: string): ParameterTypeClass => ({ pointerDepth: 1, }); +const constRef = (base: string): ParameterTypeClass => ({ + base, + cv: 'const', + indirection: 'lvalue-ref', + pointerDepth: 0, +}); + const ellipsis = (): ParameterTypeClass => ({ base: '...', cv: 'unknown', @@ -143,3 +150,79 @@ describe('narrowOverloadCandidates with C++ pointer-rank sidecars (#1637)', () = expect(result.map((d) => d.nodeId)).toEqual(['log:ellipsis']); }); }); + +describe('narrowOverloadCandidates with C++ template partial ordering (#1635)', () => { + it('selects T* over T for pointer arguments', () => { + const byValue = mkDef('pick:T', ['T'], [value('T')]); + const byPointer = mkDef('pick:T*', ['T'], [pointer('T')]); + + const result = narrowOverloadCandidates([byValue, byPointer], 1, ['int'], { + argumentTypeClasses: [pointer('int')], + }); + + expect(result.map((d) => d.nodeId)).toEqual(['pick:T*']); + }); + + it('keeps const T& versus T ambiguous for value arguments', () => { + const byValue = mkDef('pick:T', ['T'], [value('T')]); + const byReference = mkDef('pick:const-T-ref', ['T'], [constRef('T')]); + + const result = narrowOverloadCandidates([byValue, byReference], 1, ['int'], { + argumentTypeClasses: [value('int')], + }); + + expect(result.map((d) => d.nodeId)).toEqual([]); + }); + + it('suppresses when any surviving candidate cannot participate in ordering', () => { + const concreteSlot = mkDef('pick:T-int', ['T', 'int'], [value('T'), value('int')]); + const pointerSlot = mkDef('pick:T-T*', ['T', 'T'], [value('T'), pointer('T')]); + + const result = narrowOverloadCandidates([concreteSlot, pointerSlot], 2, ['int', 'int'], { + argumentTypeClasses: [pointer('int'), pointer('int')], + }); + + expect(result.map((d) => d.nodeId)).toEqual([]); + }); + + it('suppresses when a surviving candidate lacks parameter sidecars', () => { + const withoutSidecar: SymbolDefinition = { + ...mkDef('pick:no-sidecar', ['T'], [value('T')]), + parameterTypeClasses: undefined, + }; + const byPointer = mkDef('pick:T*', ['T'], [pointer('T')]); + + const result = narrowOverloadCandidates([withoutSidecar, byPointer], 1, ['int'], { + argumentTypeClasses: [pointer('int')], + }); + + expect(result.map((d) => d.nodeId)).toEqual([]); + }); + + it('leaves lowercase template placeholders ambiguous rather than guessing', () => { + const byValue = mkDef('pick:t', ['t'], [value('t')]); + const byPointer = mkDef('pick:t*', ['t'], [pointer('t')]); + + const result = narrowOverloadCandidates([byValue, byPointer], 1, ['int'], { + argumentTypeClasses: [pointer('int')], + }); + + expect(result.map((d) => d.nodeId)).toEqual(['pick:t', 'pick:t*']); + }); + + it('keeps crossed template shapes ambiguous', () => { + const pointerThenValue = mkDef('pick:T*-T', ['T', 'T'], [pointer('T'), value('T')]); + const valueThenPointer = mkDef('pick:T-T*', ['T', 'T'], [value('T'), pointer('T')]); + + const result = narrowOverloadCandidates( + [pointerThenValue, valueThenPointer], + 2, + ['int', 'int'], + { + argumentTypeClasses: [pointer('int'), pointer('int')], + }, + ); + + expect(result.map((d) => d.nodeId)).toEqual(['pick:T*-T', 'pick:T-T*']); + }); +}); diff --git a/gitnexus/test/unit/scope-resolution/csharp/csharp-captures-golden.test.ts b/gitnexus/test/unit/scope-resolution/csharp/csharp-captures-golden.test.ts new file mode 100644 index 000000000..0294ebbd8 --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/csharp/csharp-captures-golden.test.ts @@ -0,0 +1,233 @@ +/** + * Golden capture-parity test for `emitCsharpScopeCaptures` (PR #1918 follow-up). + * + * Pins the exact capture output of `emitCsharpScopeCaptures` across the whole + * `test/fixtures/lang-resolution/csharp-*` corpus plus a synthetic generated-DAO + * source, so any future drift in the C# scope-capture path fails CI rather than + * only being caught by a coarse perf tripwire, the bench fingerprint CI job, or + * pipeline-level resolver tests. + * + * This is a FORWARD-DRIFT guard: it locks in the current (post-#1918, verified) + * linear output as the baseline. It does not independently re-prove the original + * pre-optimization parity — that was established during PR #1918. + * + * Regenerate the golden intentionally with `UPDATE_GOLDEN=1` in the environment. + * + * Per fixture the snapshot stores `{ captureGroups, digest }`: + * - captureGroups: number of capture matches (makes a count change legible) + * - digest: sha256 of a match-grouped, order-sensitive (emission-order) + * canonicalization (see canonicalize* below). Order-sensitivity is safe + * because emitCsharpScopeCaptures output is deterministic, and it makes the + * digest a true byte-identical guard (a reordering refactor is real drift). + * Nothing path/time/id-dependent leaks in. + * + * Pattern: mirrors test/unit/scope-resolution/go/go-captures-golden.test.ts. + */ +import { describe, it, expect } from 'vitest'; +import path from 'path'; +import fs from 'fs'; +import crypto from 'crypto'; +import { emitCsharpScopeCaptures } from '../../../../src/core/ingestion/languages/csharp/index.js'; +import type { CaptureMatch } from 'gitnexus-shared'; + +// This test lives at test/unit/scope-resolution/csharp/, so fixtures are THREE +// levels up (unlike pipeline-graph-golden.test.ts at test/integration/). +const FIXTURE_ROOT = path.resolve(__dirname, '..', '..', '..', 'fixtures', 'lang-resolution'); +const GOLDEN_DIR = path.resolve(__dirname, '..', '..', '..', 'fixtures', 'csharp-captures-golden'); +const GOLDEN_FILE = path.join(GOLDEN_DIR, 'expected-captures.json'); + +const UPDATE = process.env.UPDATE_GOLDEN === '1'; + +interface FixtureSnapshot { + captureGroups: number; + digest: string; +} +type Snapshot = Record; + +/** + * Canonicalize ONE match. A CaptureMatch is a Record (multiple + * captures per match), so we group by match to preserve match identity: + * build one `tag|text|startLine:startCol-endLine:endCol` string per capture, + * sort them within the match, and join. We deliberately do NOT flatten every + * capture into one global list — that would lose match boundaries. + */ +function canonicalizeMatch(match: CaptureMatch): string { + const parts: string[] = []; + 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(';'); +} + +/** Order-sensitive (emission-order) digest of a full capture result (match-grouped). */ +function digestCaptures(matches: readonly CaptureMatch[]): string { + // No cross-match sort: the digest reflects emission order so a reordering + // refactor surfaces as drift. Within-match key order IS normalized + // (canonicalizeMatch sorts), since a CaptureMatch is an unordered Record. + const matchStrings = matches.map(canonicalizeMatch); + return crypto.createHash('sha256').update(matchStrings.join('\n')).digest('hex'); +} + +function snapshotOf(src: string, filePath: string): FixtureSnapshot { + const matches = emitCsharpScopeCaptures(src, filePath); + return { captureGroups: matches.length, digest: digestCaptures(matches) }; +} + +/** All `.cs` files under `lang-resolution/csharp-*`, as sorted repo-relative-ish keys. */ +function collectCsharpFixtures(): { key: string; absPath: string }[] { + const out: { key: string; absPath: string }[] = []; + for (const entry of fs.readdirSync(FIXTURE_ROOT, { withFileTypes: true })) { + if (!entry.isDirectory() || !entry.name.startsWith('csharp-')) 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('.cs')) { + 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; +} + +/** + * Small deterministic generated-DAO source — the #1918 shape at correctness + * scale. Reuses the synthetic DAO unit shape from bench/scope-capture/measure.mjs: + * a namespace with N classes, each carrying fields plus getter/setter methods. + */ +function generateDao(entityCount: number): string { + let src = 'namespace Generated;\n\n'; + for (let i = 0; i < entityCount; i++) { + src += + `public class Entity${i} {\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`; + } + return src; +} + +function buildSnapshot(): Snapshot { + const snap: Snapshot = {}; + for (const { key, absPath } of collectCsharpFixtures()) { + snap[key] = snapshotOf(fs.readFileSync(absPath, 'utf8'), absPath); + } + snap['synthetic:dao-20'] = snapshotOf(generateDao(20), 'zz_generated_dao.cs'); + // Stable key order for deterministic JSON serialization. + return Object.fromEntries( + Object.keys(snap) + .sort() + .map((k) => [k, snap[k]!]), + ); +} + +function formatGolden(snap: Snapshot): string { + return JSON.stringify(snap, null, 2) + '\n'; +} + +/** + * Pure decision for what the golden test should do — extracted so the + * fail-on-missing-in-CI rule is unit-testable without touching the filesystem + * (and can never corrupt the committed golden). A missing golden must NOT + * self-heal in CI; locally it regenerates as a first-run convenience. + */ +type GoldenAction = 'regenerate' | 'compare' | 'fail'; +function resolveGoldenAction(opts: { + update: boolean; + exists: boolean; + isCI: boolean; +}): GoldenAction { + if (opts.update) return 'regenerate'; + if (!opts.exists) return opts.isCI ? 'fail' : 'regenerate'; + return 'compare'; +} + +describe('C# scope captures — golden parity', () => { + it('matches the committed golden snapshot across all csharp-* fixtures + DAO shape', () => { + const snapshot = buildSnapshot(); + + // Read the golden once (no existsSync-then-use, which is a TOCTOU race): + // ENOENT means the golden is missing; reuse `existing` for the compare path. + let existing: string | undefined; + try { + existing = fs.readFileSync(GOLDEN_FILE, 'utf8'); + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err; + } + + const action = resolveGoldenAction({ + update: UPDATE, + exists: existing !== undefined, + isCI: !!process.env.CI, // truthy check: fires on any CI runner, not just CI==='true' + }); + + if (action === 'fail') { + throw new Error( + `[csharp-captures-golden] golden file missing at ${GOLDEN_FILE} in CI. A missing golden must ` + + `not self-heal in CI — regenerate it locally with UPDATE_GOLDEN=1 and commit it.`, + ); + } + + if (action === 'regenerate') { + fs.mkdirSync(GOLDEN_DIR, { recursive: true }); + fs.writeFileSync(GOLDEN_FILE, formatGolden(snapshot), 'utf8'); + console.log( + `[csharp-captures-golden] ${UPDATE ? 'Regenerated' : 'Created'} golden at ${GOLDEN_FILE}`, + ); + return; + } + + const expected: Snapshot = JSON.parse(existing!); + expect( + snapshot, + 'emitCsharpScopeCaptures output drifted from the committed golden. If this drift is intentional ' + + '(or the digest scheme changed), regenerate with ' + + 'UPDATE_GOLDEN=1 npx vitest run test/unit/scope-resolution/csharp/csharp-captures-golden.test.ts', + ).toEqual(expected); + }); + + // The fail-on-missing-in-CI rule, asserted purely (no filesystem mutation). + it.each([ + { update: true, exists: false, isCI: true, expected: 'regenerate' }, + { update: false, exists: false, isCI: true, expected: 'fail' }, + { update: false, exists: false, isCI: false, expected: 'regenerate' }, + { update: false, exists: true, isCI: true, expected: 'compare' }, + { update: false, exists: true, isCI: false, expected: 'compare' }, + ])( + 'resolveGoldenAction($update,$exists,$isCI) -> $expected', + ({ update, exists, isCI, expected }) => { + expect(resolveGoldenAction({ update, exists, isCI })).toBe(expected); + }, + ); + + it('produces a deterministic digest across repeated runs', () => { + const src = generateDao(8); + expect(digestCaptures(emitCsharpScopeCaptures(src, 'a.cs'))).toBe( + digestCaptures(emitCsharpScopeCaptures(src, 'a.cs')), + ); + }); + + it('digest is sensitive to capture-match emission order', () => { + const matches = emitCsharpScopeCaptures(generateDao(6), 'a.cs'); + expect(matches.length).toBeGreaterThan(1); + const reversed = [...matches].reverse(); + // Reordering the emission changes the digest — the true byte-identical guard. + expect(digestCaptures(reversed)).not.toBe(digestCaptures(matches)); + }); + + it('records a capture-group count for every fixture and the DAO shape', () => { + const snapshot = buildSnapshot(); + const fixtureKeys = collectCsharpFixtures().map((f) => f.key); + // Every collected fixture is present in the snapshot. + for (const k of fixtureKeys) expect(snapshot[k]).toBeDefined(); + // The DAO shape (which has symbols) yields a non-empty capture set. + expect(snapshot['synthetic:dao-20']!.captureGroups).toBeGreaterThan(0); + }); +}); diff --git a/gitnexus/test/unit/scope-resolution/csharp/csharp-imports.test.ts b/gitnexus/test/unit/scope-resolution/csharp/csharp-imports.test.ts index 8c69a0b9d..d7b7084dd 100644 --- a/gitnexus/test/unit/scope-resolution/csharp/csharp-imports.test.ts +++ b/gitnexus/test/unit/scope-resolution/csharp/csharp-imports.test.ts @@ -7,9 +7,20 @@ */ import { describe, it, expect } from 'vitest'; +import { promises as fsp } from 'fs'; +import os from 'os'; +import path from 'path'; import { emitCsharpScopeCaptures } from '../../../../src/core/ingestion/languages/csharp/captures.js'; import { interpretCsharpImport } from '../../../../src/core/ingestion/languages/csharp/interpret.js'; import { resolveCsharpImportTarget } from '../../../../src/core/ingestion/languages/csharp/import-target.js'; +import { loadCsharpResolutionConfig } from '../../../../src/core/ingestion/languages/csharp/resolution-config.js'; +import { getMaxFileSizeBytes } from '../../../../src/core/ingestion/utils/max-file-size.js'; +import { + csharpSuffixFallbackAllowed, + importAlignsWithDeclaredNamespaces, +} from '../../../../src/core/ingestion/csharp-namespace-gate.js'; +import { csharpScopeResolver } from '../../../../src/core/ingestion/languages/csharp/scope-resolver.js'; +import type { CSharpProjectConfig } from '../../../../src/core/ingestion/language-config.js'; import type { ParsedImport, WorkspaceIndex } from 'gitnexus-shared'; function importsFor(src: string): ParsedImport[] { @@ -105,8 +116,32 @@ describe('interpretCsharpImport — using flavors', () => { }); describe('resolveCsharpImportTarget — suffix match against .cs files', () => { - function ctx(fromFile: string, paths: string[]): WorkspaceIndex { - return { fromFile, allFilePaths: new Set(paths) } as unknown as WorkspaceIndex; + function ctx( + fromFile: string, + paths: string[], + declaredNamespaces?: ReadonlySet, + extra?: { + rootNamespaces?: ReadonlySet; + truncated?: boolean; + csharpConfigs?: readonly CSharpProjectConfig[]; + }, + ): WorkspaceIndex { + const hasEvidence = + declaredNamespaces !== undefined || + extra?.rootNamespaces !== undefined || + extra?.truncated !== undefined; + return { + fromFile, + allFilePaths: new Set(paths), + csharpConfigs: extra?.csharpConfigs, + namespaces: hasEvidence + ? { + declaredNamespaces, + rootNamespaces: extra?.rootNamespaces, + truncated: extra?.truncated, + } + : undefined, + } as unknown as WorkspaceIndex; } it('resolves `MyApp.Services` to `MyApp/Services/...cs` when a direct child exists', () => { @@ -174,4 +209,582 @@ describe('resolveCsharpImportTarget — suffix match against .cs files', () => { } as unknown as WorkspaceIndex); expect(result).toBe(null); }); + + it('does not map BCL usings to coincidentally-named local files (#1881)', () => { + const parsed: ParsedImport = { + kind: 'namespace', + localName: 'Tasks', + importedName: 'System.Threading.Tasks', + targetRaw: 'System.Threading.Tasks', + }; + const result = resolveCsharpImportTarget( + parsed, + ctx( + 'Services/OrderService.cs', + ['Services/OrderService.cs', 'Tasks.cs', 'Events/OrderCreatedEvent.cs'], + new Set(['MyApp.Services', 'MyApp.Events', 'MyApp.Legacy']), + ), + ); + expect(result).toBe(null); + }); + + it('does not map a BCL using to a coincidentally PATH-ALIGNED local file via direct-match (#1881, Codex F2)', () => { + // The no-csproj direct-match must be gated too: `Legacy/System/Threading/ + // Tasks.cs` path-aligns with `using System.Threading.Tasks;` and would + // satisfy resolveDirectMatch's nested-suffix match — but System.* is not a + // declared in-repo namespace, so the gate (now run FIRST) blocks it. + const parsed: ParsedImport = { + kind: 'namespace', + localName: 'Tasks', + importedName: 'System.Threading.Tasks', + targetRaw: 'System.Threading.Tasks', + }; + const result = resolveCsharpImportTarget( + parsed, + ctx( + 'Services/OrderService.cs', + ['Services/OrderService.cs', 'Legacy/System/Threading/Tasks.cs', 'Models/User.cs'], + new Set(['MyApp.Services', 'MyApp.Legacy', 'MyApp.Models']), + ), + ); + expect(result).toBe(null); + }); + + it('still resolves a legitimate in-repo using via direct-match when evidence is present (Codex F2 guard)', () => { + // Gating the direct-match must NOT over-block a legitimate aligned import: + // `using MyApp.Services;` aligns (exact declared) so the gate passes and the + // namespace-dir direct-child match still resolves. + const parsed: ParsedImport = { + kind: 'namespace', + localName: 'Services', + importedName: 'MyApp.Services', + targetRaw: 'MyApp.Services', + }; + const result = resolveCsharpImportTarget( + parsed, + ctx( + 'MyApp/Program.cs', + ['MyApp/Program.cs', 'MyApp/Services/UserService.cs'], + new Set(['MyApp.Services']), + ), + ); + expect(result).toBe('MyApp/Services/UserService.cs'); + }); + + it('still resolves in-repo namespace imports via progressive stripping', () => { + const parsed: ParsedImport = { + kind: 'namespace', + localName: 'Models', + importedName: 'MyApp.Models', + targetRaw: 'MyApp.Models', + }; + const result = resolveCsharpImportTarget( + parsed, + ctx( + 'Services/UserService.cs', + ['Services/UserService.cs', 'Models/User.cs'], + new Set(['MyApp.Models', 'MyApp.Services']), + ), + ); + expect(result).toBe('Models/User.cs'); + }); + + it('drives the csproj-first branch: resolves via the internal resolver when configs exist (#7)', () => { + const parsed: ParsedImport = { + kind: 'namespace', + localName: 'Models', + importedName: 'MyApp.Models', + targetRaw: 'MyApp.Models', + }; + const result = resolveCsharpImportTarget( + parsed, + ctx( + 'Services/OrderService.cs', + ['Services/OrderService.cs', 'Models/User.cs'], + new Set(['MyApp.Services', 'MyApp.Models']), + { + rootNamespaces: new Set(['MyApp']), + csharpConfigs: [{ rootNamespace: 'MyApp', projectDir: '' }], + }, + ), + ); + expect(result).toBe('Models/User.cs'); + }); + + it('mirrors legacy authority: csproj present + internal-resolver-empty returns null, no ungated direct match (#2)', () => { + // `Foo/Bar.cs` is an exact whole-path match that the ungated + // `resolveDirectMatch` would have returned. With csproj configs present + // and `Foo.Bar` outside the declared namespaces, the legacy strategy + // returns an empty result that STOPS the chain — the registry path must + // now do the same (return null) instead of falling through. + const parsed: ParsedImport = { + kind: 'namespace', + localName: 'Bar', + importedName: 'Foo.Bar', + targetRaw: 'Foo.Bar', + }; + const result = resolveCsharpImportTarget( + parsed, + ctx( + 'Services/OrderService.cs', + ['Services/OrderService.cs', 'Foo/Bar.cs'], + new Set(['MyApp.Models']), + { + rootNamespaces: new Set(['MyApp']), + csharpConfigs: [{ rootNamespace: 'MyApp', projectDir: '' }], + }, + ), + ); + expect(result).toBe(null); + }); + + it('requires the rootNamespaces anchor end-to-end: parent-of import resolves only when anchored (#7)', () => { + // `using MyApp.Core;` is an ancestor of declared `MyApp.Core.Models`. + // The gate opens ONLY when `MyApp.Core` sits at/above an in-repo root, so + // `Core/Thing.cs` resolves with roots {MyApp.Core} but not without them. + const parsed: ParsedImport = { + kind: 'namespace', + localName: 'Core', + importedName: 'MyApp.Core', + targetRaw: 'MyApp.Core', + }; + const anchored = resolveCsharpImportTarget( + parsed, + ctx( + 'Services/OrderService.cs', + ['Services/OrderService.cs', 'Core/Thing.cs'], + new Set(['MyApp.Core.Models']), + { + rootNamespaces: new Set(['MyApp.Core']), + }, + ), + ); + expect(anchored).toBe('Core/Thing.cs'); + + const unanchored = resolveCsharpImportTarget( + parsed, + ctx( + 'Services/OrderService.cs', + ['Services/OrderService.cs', 'Core/Thing.cs'], + new Set(['MyApp.Core.Models']), + ), + ); + expect(unanchored).toBe(null); + }); + + it('a sibling import outside the declared namespaces does not resolve even with roots (#7)', () => { + // `using MyApp.Other;` is neither a child nor an ancestor of the only + // declared namespace `MyApp.Models`, so the gate stays closed and the + // otherwise-matchable `Other/Thing.cs` is left unresolved. + const parsed: ParsedImport = { + kind: 'namespace', + localName: 'Other', + importedName: 'MyApp.Other', + targetRaw: 'MyApp.Other', + }; + const result = resolveCsharpImportTarget( + parsed, + ctx( + 'Services/OrderService.cs', + ['Services/OrderService.cs', 'Other/Thing.cs'], + new Set(['MyApp.Models']), + { + rootNamespaces: new Set(['MyApp']), + }, + ), + ); + expect(result).toBe(null); + }); +}); + +describe('importAlignsWithDeclaredNamespaces — declared-namespace gate (#1881)', () => { + it('matches an exactly-declared namespace', () => { + expect(importAlignsWithDeclaredNamespaces('MyApp.Models', new Set(['MyApp.Models']))).toBe( + true, + ); + }); + + it('child-of: import nested under a declared ancestor namespace', () => { + // `using MyApp.Models.Detail;` when the repo declares `MyApp.Models`. + expect( + importAlignsWithDeclaredNamespaces('MyApp.Models.Detail', new Set(['MyApp.Models'])), + ).toBe(true); + }); + + it('child-of allows a using-static type under a declared namespace (#1)', () => { + // `using static MyApp.Utils.Logger;` — the parent namespace `MyApp.Utils` + // is declared, so the type import aligns even though `MyApp.Utils.Logger` + // itself is not a declared namespace. + expect(importAlignsWithDeclaredNamespaces('MyApp.Utils.Logger', new Set(['MyApp.Utils']))).toBe( + true, + ); + }); + + it('child-of stays anchored: a declared BCL root does NOT qualify a BCL using (#1)', () => { + // A repo that declares `namespace System;` (a shim) must not green-light + // `using System.Threading.Tasks;` — the import's parent `System.Threading` + // is NOT declared, so the only match would be a coincidental local + // `Tasks.cs`. The old "any declared prefix" rule re-opened #1881 here. + expect( + importAlignsWithDeclaredNamespaces( + 'System.Threading.Tasks', + new Set(['System', 'MyApp.Models']), + new Set(['System', 'MyApp']), + ), + ).toBe(false); + }); + + it('parent-of: parent-namespace import resolves against a declared child', () => { + // `using MyApp;` when the repo declares `MyApp.Models` — must still open + // the gate (anchored on the in-repo root namespace `MyApp`). + expect( + importAlignsWithDeclaredNamespaces('MyApp', new Set(['MyApp.Models']), new Set(['MyApp'])), + ).toBe(true); + }); + + it('parent-of works without explicit roots via the top-level declared segment', () => { + expect(importAlignsWithDeclaredNamespaces('MyApp', new Set(['MyApp.Models']))).toBe(true); + }); + + it('parent-of for a multi-segment csproj root (using MyApp; with RootNamespace MyApp.Core)', () => { + expect( + importAlignsWithDeclaredNamespaces( + 'MyApp', + new Set(['MyApp.Core.Models']), + new Set(['MyApp.Core', 'MyApp']), + ), + ).toBe(true); + }); + + it('parent-of stays anchored: a BCL prefix does NOT qualify via a locally-declared sub-namespace (#5)', () => { + // A file declaring `namespace System.Threading.Tasks.Extensions` must not + // open the gate for `using System.Threading.Tasks;`. + const declared = new Set(['System.Threading.Tasks.Extensions', 'MyApp.Models']); + expect( + importAlignsWithDeclaredNamespaces( + 'System.Threading.Tasks', + declared, + new Set(['MyApp', 'System']), + ), + ).toBe(false); + // Same conclusion without explicit roots (top-level segment fallback). + expect(importAlignsWithDeclaredNamespaces('System.Threading.Tasks', declared)).toBe(false); + }); + + it('returns false for an unrelated BCL namespace', () => { + expect( + importAlignsWithDeclaredNamespaces( + 'System.Linq', + new Set(['MyApp.Services']), + new Set(['MyApp']), + ), + ).toBe(false); + }); + + it('returns false for an empty or undefined declared set', () => { + expect(importAlignsWithDeclaredNamespaces('MyApp', new Set())).toBe(false); + expect(importAlignsWithDeclaredNamespaces('MyApp', undefined)).toBe(false); + }); +}); + +describe('csharpSuffixFallbackAllowed — fail-open safety valves (#1881)', () => { + const declared = new Set(['MyApp.Models']); + const roots = new Set(['MyApp']); + + it('blocks a non-aligned import when evidence is present and complete', () => { + // Baseline: with complete evidence, a BCL using that aligns with nothing + // declared in-repo is blocked. + expect( + csharpSuffixFallbackAllowed('System.Threading.Tasks', { + declaredNamespaces: declared, + rootNamespaces: roots, + truncated: false, + }), + ).toBe(false); + }); + + it('fails OPEN (allows) when no evidence was threaded (#7)', () => { + // The exact same import the complete-evidence case blocks must be ALLOWED + // when evidence is undefined — preserving the pre-gate permissive behavior + // for callers that never ran the scan. + expect(csharpSuffixFallbackAllowed('System.Threading.Tasks', undefined)).toBe(true); + }); + + it('keeps a clearly-external BCL root BLOCKED even when the scan was truncated (#1881, Codex F1)', () => { + // A single truncation must NOT silently re-enable BCL→local suffix matches + // repo-wide: System.* stays gated through truncation when the repo does not + // declare it. (This reverses the prior blanket-fail-open for external roots.) + expect( + csharpSuffixFallbackAllowed('System.Threading.Tasks', { + declaredNamespaces: declared, + rootNamespaces: roots, + truncated: true, + }), + ).toBe(false); + }); + + it('fails OPEN for a genuinely local-looking import when the scan was truncated (#6)', () => { + // Non-external roots still fail open under truncation so an incomplete + // (capped/unreadable) scan does not silently drop a legitimate in-repo edge. + expect( + csharpSuffixFallbackAllowed('MyApp.Internal.Widget', { + declaredNamespaces: declared, + rootNamespaces: roots, + truncated: true, + }), + ).toBe(true); + }); + + it('lets an external root fail OPEN through truncation when the repo declares it (escape hatch)', () => { + // If the repo actually declares the (normally-external) root, the alignment + // escape hatch allows the import even under truncation. + expect( + csharpSuffixFallbackAllowed('System.Threading.Tasks', { + declaredNamespaces: new Set(['System.Threading']), + rootNamespaces: new Set(['System']), + truncated: true, + }), + ).toBe(true); + }); +}); + +describe('csharpScopeResolver.resolveImportTarget — config→ctx adapter wiring (#9)', () => { + it('threads resolutionConfig.namespaces into the gate so a BCL using is blocked', () => { + // Exercises the adapter (NOT resolveCsharpImportTarget directly): the + // resolutionConfig that loadResolutionConfig returns must reach the gate as + // ctx.namespaces. With a coincidental local `Tasks.cs` present and + // `System.Threading.Tasks` outside the declared namespaces, the wired + // evidence blocks the spurious edge. + const result = csharpScopeResolver.resolveImportTarget( + 'System.Threading.Tasks', + 'Services/OrderService.cs', + new Set(['Services/OrderService.cs', 'Tasks.cs']), + { + csharpConfigs: [], + namespaces: { + declaredNamespaces: new Set(['MyApp.Services', 'MyApp.Legacy']), + rootNamespaces: new Set(['MyApp']), + truncated: false, + }, + }, + ); + expect(result).toBe(null); + }); + + it('threads csharpConfigs so a csproj-mapped import resolves through the adapter', () => { + // The other half of the wiring: csharpConfigs must reach ctx.csharpConfigs + // so the csproj root-namespace mapping runs. + const result = csharpScopeResolver.resolveImportTarget( + 'MyApp.Models', + 'Services/OrderService.cs', + new Set(['Services/OrderService.cs', 'Models/User.cs']), + { + csharpConfigs: [{ rootNamespace: 'MyApp', projectDir: '' }], + namespaces: { + declaredNamespaces: new Set(['MyApp.Models', 'MyApp.Services']), + rootNamespaces: new Set(['MyApp']), + truncated: false, + }, + }, + ); + expect(result).toBe('Models/User.cs'); + }); +}); + +describe('loadCsharpResolutionConfig — one-pass namespace scan (#1881)', () => { + async function makeTempRepo(files: Record): Promise { + const root = await fsp.mkdtemp(path.join(os.tmpdir(), 'csharp-scan-')); + for (const [rel, content] of Object.entries(files)) { + const full = path.join(root, rel); + await fsp.mkdir(path.dirname(full), { recursive: true }); + await fsp.writeFile(full, content, 'utf-8'); + } + return root; + } + + it('collects file-scoped, block, and multiple-per-file namespaces; skips bin/obj; reads csproj root', async () => { + const root = await makeTempRepo({ + 'App.csproj': + 'MyApp', + 'Scoped.cs': 'namespace Alpha.Scoped;\npublic class A {}', + 'Block.cs': 'namespace Beta.Block\n{\n public class B {}\n}', + 'Multi.cs': 'namespace Gamma.One { }\nnamespace Gamma.Two { }', + 'bin/Generated.cs': 'namespace Should.Skip;', + 'obj/Temp.cs': 'namespace Should.AlsoSkip;', + }); + try { + const config = await loadCsharpResolutionConfig(root); + const ns = config.namespaces!; + expect(ns.truncated).toBe(false); + expect([...ns.declaredNamespaces!].sort()).toEqual([ + 'Alpha.Scoped', + 'Beta.Block', + 'Gamma.One', + 'Gamma.Two', + ]); + expect(ns.declaredNamespaces!.has('Should.Skip')).toBe(false); + expect(ns.declaredNamespaces!.has('Should.AlsoSkip')).toBe(false); + // csproj RootNamespace + top-level segment of each declared namespace. + expect(ns.rootNamespaces!.has('MyApp')).toBe(true); + expect([...ns.rootNamespaces!].sort()).toEqual(['Alpha', 'Beta', 'Gamma', 'MyApp']); + expect(config.csharpConfigs).toHaveLength(1); + expect(config.csharpConfigs[0]!.rootNamespace).toBe('MyApp'); + } finally { + await fsp.rm(root, { recursive: true, force: true }); + } + }); + + it('keeps truncated=false for a realistic-depth layout so the gate stays engaged (#1)', async () => { + // A repo nested ~8 levels deep is well within the production cap + // (CSHARP_SCAN_MAX_DEPTH=24). Were the cap as low as the old value (5), + // this layout would trip `truncated` and disable the #1881 gate for the + // whole repo. Proving truncated===false here pins the gate ON for repos + // of normal depth. + const root = await makeTempRepo({ + 'App.csproj': + 'MyApp', + 'a/b/c/d/e/f/g/h/Deep.cs': 'namespace MyApp.Deep.Feature;', + }); + try { + const config = await loadCsharpResolutionConfig(root); + const ns = config.namespaces!; + expect(ns.truncated).toBe(false); + expect(ns.declaredNamespaces!.has('MyApp.Deep.Feature')).toBe(true); + } finally { + await fsp.rm(root, { recursive: true, force: true }); + } + }); + + it('sets the truncation flag when the depth cap prunes a subtree (#11)', async () => { + // repoRoot is depth 0; the chain below nests one level past the depth cap + // (CSHARP_SCAN_MAX_DEPTH=24) so the deepest dir is pruned, its namespace + // is missed, and the flag trips. Built relative to the real cap — do NOT + // lower the production cap for the test. + const deepChain = Array.from({ length: 25 }, (_, i) => `d${i}`).join('/'); + const root = await makeTempRepo({ + 'Shallow.cs': 'namespace Shallow.Ns;', + [`${deepChain}/Deep.cs`]: 'namespace Deep.Ns;', + }); + try { + const config = await loadCsharpResolutionConfig(root); + const ns = config.namespaces!; + expect(ns.truncated).toBe(true); + expect(ns.declaredNamespaces!.has('Shallow.Ns')).toBe(true); + expect(ns.declaredNamespaces!.has('Deep.Ns')).toBe(false); + } finally { + await fsp.rm(root, { recursive: true, force: true }); + } + }); + + it('streams a large .cs file end-to-end, collecting namespaces past the old size cap (#1881)', async () => { + // The namespace scan streams each file, so a `.cs` far larger than the old + // per-file size cap is read end-to-end in constant memory instead of being + // skipped. A namespace at the START and one at the very END (well past the + // old cap boundary) must BOTH be collected, and `truncated` must stay false + // — a big generated file no longer disables the #1881 gate repo-wide. + const cap = getMaxFileSizeBytes(); + const padLine = '// pad pad pad pad pad pad\n'; + const padding = padLine.repeat(Math.ceil((cap * 3) / padLine.length)); + const huge = `namespace Generated.Head;\n${padding}namespace Generated.Tail { }\n`; + const root = await makeTempRepo({ + 'Hand.cs': 'namespace Hand.Written;', + 'Generated.cs': huge, + }); + try { + const config = await loadCsharpResolutionConfig(root); + const ns = config.namespaces!; + expect(ns.truncated).toBe(false); + expect(ns.declaredNamespaces!.has('Hand.Written')).toBe(true); + expect(ns.declaredNamespaces!.has('Generated.Head')).toBe(true); + expect(ns.declaredNamespaces!.has('Generated.Tail')).toBe(true); + } finally { + await fsp.rm(root, { recursive: true, force: true }); + } + }); + + it('collects a Unicode namespace through the streamed scan, not truncated (Codex F3)', async () => { + // The scanner is now Unicode-aware, so a non-ASCII namespace is captured + // end-to-end instead of being dropped (which would over-block its imports). + const root = await makeTempRepo({ + 'App.csproj': + 'MyApp', + 'Models/Café.cs': 'namespace Café.App;\npublic class Modèle {}', + }); + try { + const config = await loadCsharpResolutionConfig(root); + const ns = config.namespaces!; + expect(ns.truncated).toBe(false); + expect(ns.declaredNamespaces!.has('Café.App')).toBe(true); + } finally { + await fsp.rm(root, { recursive: true, force: true }); + } + }); + + it('marks the scan truncated when a file has an uncaptured namespace form, failing the gate OPEN (Codex F3)', async () => { + // A namespace split across lines is not captured by the line scanner; the + // scan must flag truncated so the dropped namespace fails the #1881 gate + // OPEN rather than over-block an import declared in that file. + const root = await makeTempRepo({ + 'App.csproj': + 'MyApp', + 'Weird.cs': 'namespace\n MyApp.Weird;\npublic class W {}', + }); + try { + const config = await loadCsharpResolutionConfig(root); + const ns = config.namespaces!; + expect(ns.truncated).toBe(true); + // A local-looking import under the dropped namespace fails open (and U1's + // external-root denylist still keeps BCL roots blocked under truncation). + expect(csharpSuffixFallbackAllowed('MyApp.Weird.Thing', ns)).toBe(true); + expect(csharpSuffixFallbackAllowed('System.Threading.Tasks', ns)).toBe(false); + } finally { + await fsp.rm(root, { recursive: true, force: true }); + } + }); + + it('recovers past the old read cap via streaming (Codex F4)', async () => { + // A big leading pushes past the old 512KB read + // cap; the streamed scan reads on until it finds the tag, so the correct + // root is recovered (pre-fix the capped read synthesized the filename 'App'). + const cap = getMaxFileSizeBytes(); + const itemLine = ' \n'; + const bigItemGroup = + ' \n' + + itemLine.repeat(Math.ceil((cap * 2) / itemLine.length)) + + ' \n'; + const csproj = + '\n' + + bigItemGroup + + ' MyApp\n' + + '\n'; + const root = await makeTempRepo({ + 'App.csproj': csproj, + 'Models/User.cs': 'namespace MyApp.Models;\npublic class User {}', + }); + try { + const config = await loadCsharpResolutionConfig(root); + expect(config.csharpConfigs).toHaveLength(1); + expect(config.csharpConfigs[0]!.rootNamespace).toBe('MyApp'); + } finally { + await fsp.rm(root, { recursive: true, force: true }); + } + }); + + it('falls back to the filename root only when is genuinely absent (Codex F4 control)', async () => { + // A genuine read-to-EOF absence still synthesizes the filename root, so a + // .csproj without RootNamespace is unchanged — the fix only avoids guessing + // when the tag was unreachable. + const root = await makeTempRepo({ + 'App.csproj': + 'net8.0', + 'Models/User.cs': 'namespace App.Models;\npublic class User {}', + }); + try { + const config = await loadCsharpResolutionConfig(root); + expect(config.csharpConfigs).toHaveLength(1); + expect(config.csharpConfigs[0]!.rootNamespace).toBe('App'); + } finally { + await fsp.rm(root, { recursive: true, force: true }); + } + }); }); diff --git a/gitnexus/test/unit/scope-resolution/go/go-captures-golden.test.ts b/gitnexus/test/unit/scope-resolution/go/go-captures-golden.test.ts new file mode 100644 index 000000000..1258093f0 --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/go/go-captures-golden.test.ts @@ -0,0 +1,235 @@ +/** + * Golden capture-parity test for `emitGoScopeCaptures` (issue #1848 follow-up). + * + * Pins the exact capture output of `emitGoScopeCaptures` across the whole + * `test/fixtures/lang-resolution/go-*` corpus plus a synthetic generated-DAO + * source, so any future drift in the Go scope-capture path fails CI rather than + * only being caught by a coarse perf tripwire or pipeline-level resolver tests. + * + * This is a FORWARD-DRIFT guard: it locks in the current (post-#1915, verified) + * output as the baseline. It does not independently re-prove the original + * pre-fix parity — that was established during PR #1915. + * + * Regenerate the golden intentionally with `UPDATE_GOLDEN=1` in the environment. + * + * Per fixture the snapshot stores `{ captureGroups, digest }`: + * - captureGroups: number of capture matches (makes a count change legible) + * - digest: sha256 of a match-grouped, order-sensitive (emission-order) + * canonicalization (see canonicalize* below). Order-sensitivity is safe + * because emitGoScopeCaptures output is deterministic, and it makes the + * digest a true byte-identical guard (a reordering refactor is real drift). + * Nothing path/time/id-dependent leaks in. + * + * Pattern: mirrors test/integration/pipeline-graph-golden.test.ts. + */ +import { describe, it, expect } from 'vitest'; +import path from 'path'; +import fs from 'fs'; +import crypto from 'crypto'; +import { emitGoScopeCaptures } from '../../../../src/core/ingestion/languages/go/index.js'; +import type { CaptureMatch } from 'gitnexus-shared'; + +// This test lives at test/unit/scope-resolution/go/, so fixtures are THREE +// levels up (unlike pipeline-graph-golden.test.ts at test/integration/). +const FIXTURE_ROOT = path.resolve(__dirname, '..', '..', '..', 'fixtures', 'lang-resolution'); +const GOLDEN_DIR = path.resolve(__dirname, '..', '..', '..', 'fixtures', 'go-captures-golden'); +const GOLDEN_FILE = path.join(GOLDEN_DIR, 'expected-captures.json'); + +const UPDATE = process.env.UPDATE_GOLDEN === '1'; + +interface FixtureSnapshot { + captureGroups: number; + digest: string; +} +type Snapshot = Record; + +/** + * Canonicalize ONE match. A CaptureMatch is a Record (multiple + * captures per match), so we group by match to preserve match identity: + * build one `tag|text|startLine:startCol-endLine:endCol` string per capture, + * sort them within the match, and join. We deliberately do NOT flatten every + * capture into one global list — that would lose match boundaries. + */ +function canonicalizeMatch(match: CaptureMatch): string { + const parts: string[] = []; + 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(';'); +} + +/** Order-sensitive (emission-order) digest of a full capture result (match-grouped). */ +function digestCaptures(matches: readonly CaptureMatch[]): string { + // No cross-match sort: the digest reflects emission order so a reordering + // refactor surfaces as drift. Within-match key order IS normalized + // (canonicalizeMatch sorts), since a CaptureMatch is an unordered Record. + const matchStrings = matches.map(canonicalizeMatch); + return crypto.createHash('sha256').update(matchStrings.join('\n')).digest('hex'); +} + +function snapshotOf(src: string, filePath: string): FixtureSnapshot { + const matches = emitGoScopeCaptures(src, filePath); + return { captureGroups: matches.length, digest: digestCaptures(matches) }; +} + +/** All `.go` files under `lang-resolution/go-*`, as sorted repo-relative-ish keys. */ +function collectGoFixtures(): { key: string; absPath: string }[] { + const out: { key: string; absPath: string }[] = []; + for (const entry of fs.readdirSync(FIXTURE_ROOT, { withFileTypes: true })) { + if (!entry.isDirectory() || !entry.name.startsWith('go-')) 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('.go')) { + 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; +} + +/** Small deterministic generated-DAO source — the #1848 shape at correctness scale. */ +function generateDao(entityCount: number): string { + const lines = ['package generated', '']; + for (let i = 0; i < entityCount; i++) { + const n = String(i).padStart(4, '0'); + lines.push( + `type DefUserDao${n} struct {`, + '\tid int64', + '\tname string', + '}', + '', + `func (d *DefUserDao${n}) GetID() int64 { return d.id }`, + `func (d *DefUserDao${n}) SetName(name string) { d.name = name }`, + `func (d *DefUserDao${n}) Validate() error { return nil }`, + '', + ); + } + return lines.join('\n'); +} + +function buildSnapshot(): Snapshot { + const snap: Snapshot = {}; + for (const { key, absPath } of collectGoFixtures()) { + snap[key] = snapshotOf(fs.readFileSync(absPath, 'utf8'), absPath); + } + snap['synthetic:dao-20'] = snapshotOf(generateDao(20), 'zz_generated.def_userdao.go'); + // Stable key order for deterministic JSON serialization. + return Object.fromEntries( + Object.keys(snap) + .sort() + .map((k) => [k, snap[k]!]), + ); +} + +function formatGolden(snap: Snapshot): string { + return JSON.stringify(snap, null, 2) + '\n'; +} + +/** + * Pure decision for what the golden test should do — extracted so the + * fail-on-missing-in-CI rule is unit-testable without touching the filesystem + * (and can never corrupt the committed golden). A missing golden must NOT + * self-heal in CI; locally it regenerates as a first-run convenience. + */ +type GoldenAction = 'regenerate' | 'compare' | 'fail'; +function resolveGoldenAction(opts: { + update: boolean; + exists: boolean; + isCI: boolean; +}): GoldenAction { + if (opts.update) return 'regenerate'; + if (!opts.exists) return opts.isCI ? 'fail' : 'regenerate'; + return 'compare'; +} + +describe('Go scope captures — golden parity', () => { + it('matches the committed golden snapshot across all go-* fixtures + DAO shape', () => { + const snapshot = buildSnapshot(); + + // Read the golden once (no existsSync-then-use, which is a TOCTOU race): + // ENOENT means the golden is missing; reuse `existing` for the compare path. + let existing: string | undefined; + try { + existing = fs.readFileSync(GOLDEN_FILE, 'utf8'); + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err; + } + + const action = resolveGoldenAction({ + update: UPDATE, + exists: existing !== undefined, + isCI: !!process.env.CI, // truthy check: fires on any CI runner, not just CI==='true' + }); + + if (action === 'fail') { + throw new Error( + `[go-captures-golden] golden file missing at ${GOLDEN_FILE} in CI. A missing golden must ` + + `not self-heal in CI — regenerate it locally with UPDATE_GOLDEN=1 and commit it.`, + ); + } + + if (action === 'regenerate') { + fs.mkdirSync(GOLDEN_DIR, { recursive: true }); + fs.writeFileSync(GOLDEN_FILE, formatGolden(snapshot), 'utf8'); + console.log( + `[go-captures-golden] ${UPDATE ? 'Regenerated' : 'Created'} golden at ${GOLDEN_FILE}`, + ); + return; + } + + const expected: Snapshot = JSON.parse(existing!); + expect( + snapshot, + 'emitGoScopeCaptures output drifted from the committed golden. If this drift is intentional ' + + '(or the digest scheme changed), regenerate with ' + + 'UPDATE_GOLDEN=1 npx vitest run test/unit/scope-resolution/go/go-captures-golden.test.ts', + ).toEqual(expected); + }); + + // The fail-on-missing-in-CI rule, asserted purely (no filesystem mutation). + it.each([ + { update: true, exists: false, isCI: true, expected: 'regenerate' }, + { update: false, exists: false, isCI: true, expected: 'fail' }, + { update: false, exists: false, isCI: false, expected: 'regenerate' }, + { update: false, exists: true, isCI: true, expected: 'compare' }, + { update: false, exists: true, isCI: false, expected: 'compare' }, + ])( + 'resolveGoldenAction($update,$exists,$isCI) -> $expected', + ({ update, exists, isCI, expected }) => { + expect(resolveGoldenAction({ update, exists, isCI })).toBe(expected); + }, + ); + + it('produces a deterministic digest across repeated runs', () => { + const src = generateDao(8); + expect(digestCaptures(emitGoScopeCaptures(src, 'a.go'))).toBe( + digestCaptures(emitGoScopeCaptures(src, 'a.go')), + ); + }); + + it('digest is sensitive to capture-match emission order', () => { + const matches = emitGoScopeCaptures(generateDao(6), 'a.go'); + expect(matches.length).toBeGreaterThan(1); + const reversed = [...matches].reverse(); + // Reordering the emission changes the digest — the true byte-identical guard. + expect(digestCaptures(reversed)).not.toBe(digestCaptures(matches)); + }); + + it('records a capture-group count for every fixture and the DAO shape', () => { + const snapshot = buildSnapshot(); + const fixtureKeys = collectGoFixtures().map((f) => f.key); + // Every collected fixture is present in the snapshot. + for (const k of fixtureKeys) expect(snapshot[k]).toBeDefined(); + // The DAO shape (which has symbols) yields a non-empty capture set. + expect(snapshot['synthetic:dao-20']!.captureGroups).toBeGreaterThan(0); + }); +}); diff --git a/gitnexus/test/unit/scope-resolution/go/go-captures-smoke.test.ts b/gitnexus/test/unit/scope-resolution/go/go-captures-smoke.test.ts index b921bddc4..cd67dbffb 100644 --- a/gitnexus/test/unit/scope-resolution/go/go-captures-smoke.test.ts +++ b/gitnexus/test/unit/scope-resolution/go/go-captures-smoke.test.ts @@ -62,4 +62,104 @@ func main() { expect(tags).toContain('@reference.read'); expect(tags).toContain('@reference.write'); }); + + // ── Edge shapes the #1915 captured-node refactor reasons about but no + // lang-resolution fixture exercises (issue #1848 follow-up U2). ── + + it('synthesizes a receiver for a method but not for a func_literal scope', () => { + // Source has BOTH a real method and a closure. A weak "no @type-binding.self + // anywhere" assertion would pass even if the method_declaration receiver + // branch regressed (a closure-only fixture has none to lose); asserting the + // method's receiver IS present catches that regression. + const src = ` +package main + +type User struct{ Name string } + +func (u *User) Save() { _ = u.Name } + +func main() { + f := func() int { return 1 } + _ = f() +} +`; + const matches = emitGoScopeCaptures(src, 'main.go'); + // The closure is still captured as a @scope.function... + expect(matches.some((m) => m['@scope.function']?.text.startsWith('func()'))).toBe(true); + // ...and the method's receiver self-binding is synthesized (name + pointer-stripped type)... + const selves = matches.filter((m) => m['@type-binding.self'] !== undefined); + expect(selves).toHaveLength(1); // exactly one — from the method, not the closure + expect(selves[0]!['@type-binding.name']?.text).toBe('u'); + expect(selves[0]!['@type-binding.type']?.text).toBe('User'); + }); + + it('does not drop a var-form type assertion binding', () => { + // `var x int = e.(T)` anchors on a var_declaration, not a short_var_declaration, + // so isRawMultiAssignTypeBinding must NOT filter it (old findNodeAtRange path + // returned null -> false; the new anchor.type guard reproduces that). + const src = ` +package main + +func main() { + var x int = any(1).(int) + _ = x +} +`; + const assertion = emitGoScopeCaptures(src, 'main.go').find( + (m) => m['@type-binding.assertion'] !== undefined, + ); + expect(assertion).toBeDefined(); + expect(assertion!['@type-binding.name']?.text).toBe('x'); + }); + + it('does not drop a var-form call-return binding', () => { + const src = ` +package main + +func NewThing() int { return 1 } + +func main() { + var y = NewThing() + _ = y +} +`; + const callReturn = emitGoScopeCaptures(src, 'main.go').find( + (m) => m['@type-binding.call-return'] !== undefined, + ); + expect(callReturn).toBeDefined(); + expect(callReturn!['@type-binding.name']?.text).toBe('y'); + }); + + it('resolves a single unparenthesized import the same as a grouped one', () => { + // Exercises resolveImportNode's no-import_spec_list parent chain. NOTE: not + // redundant with go-imports.test.ts, which calls splitGoImportStatement + // directly and bypasses captures.ts / resolveImportNode. + const single = emitGoScopeCaptures( + ` +package main + +import "fmt" + +func main() { fmt.Println() } +`, + 'main.go', + ); + const sources = single + .filter((m) => m['@import.source'] !== undefined) + .map((m) => m['@import.source']!.text); + expect(sources).toEqual(['fmt']); + }); + + it('captures a generic function declaration', () => { + const src = ` +package main + +func Map[T any](x T) T { return x } +`; + const decl = emitGoScopeCaptures(src, 'main.go').find( + (m) => m['@declaration.function'] !== undefined, + ); + expect(decl).toBeDefined(); + expect(decl!['@declaration.name']?.text).toBe('Map'); + }); }); diff --git a/gitnexus/test/unit/scope-resolution/php/php-captures-golden.test.ts b/gitnexus/test/unit/scope-resolution/php/php-captures-golden.test.ts new file mode 100644 index 000000000..462caf640 --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/php/php-captures-golden.test.ts @@ -0,0 +1,228 @@ +/** + * Golden capture-parity test for `emitPhpScopeCaptures` (PR #1918 follow-up). + * + * Pins the exact capture output of `emitPhpScopeCaptures` across the whole + * `test/fixtures/lang-resolution/php-*` corpus plus a synthetic generated-DAO + * source, so any future drift in the PHP scope-capture path fails CI rather than + * only being caught by a coarse perf tripwire or pipeline-level resolver tests. + * + * This is a FORWARD-DRIFT guard: it locks in the current (post-#1918, verified) + * output as the baseline. It does not independently re-prove the original + * pre-fix parity — that was established during PR #1918. + * + * Regenerate the golden intentionally with `UPDATE_GOLDEN=1` in the environment. + * + * Per fixture the snapshot stores `{ captureGroups, digest }`: + * - captureGroups: number of capture matches (makes a count change legible) + * - digest: sha256 of a match-grouped, order-sensitive (emission-order) + * canonicalization (see canonicalize* below). Order-sensitivity is safe + * because emitPhpScopeCaptures output is deterministic, and it makes the + * digest a true byte-identical guard (a reordering refactor is real drift). + * Nothing path/time/id-dependent leaks in. + * + * Pattern: mirrors test/unit/scope-resolution/go/go-captures-golden.test.ts. + */ +import { describe, it, expect } from 'vitest'; +import path from 'path'; +import fs from 'fs'; +import crypto from 'crypto'; +import { emitPhpScopeCaptures } from '../../../../src/core/ingestion/languages/php/index.js'; +import type { CaptureMatch } from 'gitnexus-shared'; + +// This test lives at test/unit/scope-resolution/php/, so fixtures are THREE +// levels up (unlike pipeline-graph-golden.test.ts at test/integration/). +const FIXTURE_ROOT = path.resolve(__dirname, '..', '..', '..', 'fixtures', 'lang-resolution'); +const GOLDEN_DIR = path.resolve(__dirname, '..', '..', '..', 'fixtures', 'php-captures-golden'); +const GOLDEN_FILE = path.join(GOLDEN_DIR, 'expected-captures.json'); + +const UPDATE = process.env.UPDATE_GOLDEN === '1'; + +interface FixtureSnapshot { + captureGroups: number; + digest: string; +} +type Snapshot = Record; + +/** + * Canonicalize ONE match. A CaptureMatch is a Record (multiple + * captures per match), so we group by match to preserve match identity: + * build one `tag|text|startLine:startCol-endLine:endCol` string per capture, + * sort them within the match, and join. We deliberately do NOT flatten every + * capture into one global list — that would lose match boundaries. + */ +function canonicalizeMatch(match: CaptureMatch): string { + const parts: string[] = []; + 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(';'); +} + +/** Order-sensitive (emission-order) digest of a full capture result (match-grouped). */ +function digestCaptures(matches: readonly CaptureMatch[]): string { + // No cross-match sort: the digest reflects emission order so a reordering + // refactor surfaces as drift. Within-match key order IS normalized + // (canonicalizeMatch sorts), since a CaptureMatch is an unordered Record. + const matchStrings = matches.map(canonicalizeMatch); + return crypto.createHash('sha256').update(matchStrings.join('\n')).digest('hex'); +} + +function snapshotOf(src: string, filePath: string): FixtureSnapshot { + const matches = emitPhpScopeCaptures(src, filePath); + return { captureGroups: matches.length, digest: digestCaptures(matches) }; +} + +/** All `.php` files under `lang-resolution/php-*`, as sorted repo-relative-ish keys. */ +function collectPhpFixtures(): { key: string; absPath: string }[] { + const out: { key: string; absPath: string }[] = []; + for (const entry of fs.readdirSync(FIXTURE_ROOT, { withFileTypes: true })) { + if (!entry.isDirectory() || !entry.name.startsWith('php-')) 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('.php')) { + 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; +} + +/** Small deterministic generated-DAO source — the #1848 shape at correctness scale. */ +function generateDao(entityCount: number): string { + let src = 'id; }\n` + + ` function setName($v) { $this->name = $v; }\n}\n\n`; + } + return src; +} + +function buildSnapshot(): Snapshot { + const snap: Snapshot = {}; + for (const { key, absPath } of collectPhpFixtures()) { + snap[key] = snapshotOf(fs.readFileSync(absPath, 'utf8'), absPath); + } + snap['synthetic:dao-20'] = snapshotOf(generateDao(20), 'zz_generated_dao.php'); + // Stable key order for deterministic JSON serialization. + return Object.fromEntries( + Object.keys(snap) + .sort() + .map((k) => [k, snap[k]!]), + ); +} + +function formatGolden(snap: Snapshot): string { + return JSON.stringify(snap, null, 2) + '\n'; +} + +/** + * Pure decision for what the golden test should do — extracted so the + * fail-on-missing-in-CI rule is unit-testable without touching the filesystem + * (and can never corrupt the committed golden). A missing golden must NOT + * self-heal in CI; locally it regenerates as a first-run convenience. + */ +type GoldenAction = 'regenerate' | 'compare' | 'fail'; +function resolveGoldenAction(opts: { + update: boolean; + exists: boolean; + isCI: boolean; +}): GoldenAction { + if (opts.update) return 'regenerate'; + if (!opts.exists) return opts.isCI ? 'fail' : 'regenerate'; + return 'compare'; +} + +describe('PHP scope captures — golden parity', () => { + it('matches the committed golden snapshot across all php-* fixtures + DAO shape', () => { + const snapshot = buildSnapshot(); + + // Read the golden once (no existsSync-then-use, which is a TOCTOU race): + // ENOENT means the golden is missing; reuse `existing` for the compare path. + let existing: string | undefined; + try { + existing = fs.readFileSync(GOLDEN_FILE, 'utf8'); + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err; + } + + const action = resolveGoldenAction({ + update: UPDATE, + exists: existing !== undefined, + isCI: !!process.env.CI, // truthy check: fires on any CI runner, not just CI==='true' + }); + + if (action === 'fail') { + throw new Error( + `[php-captures-golden] golden file missing at ${GOLDEN_FILE} in CI. A missing golden must ` + + `not self-heal in CI — regenerate it locally with UPDATE_GOLDEN=1 and commit it.`, + ); + } + + if (action === 'regenerate') { + fs.mkdirSync(GOLDEN_DIR, { recursive: true }); + fs.writeFileSync(GOLDEN_FILE, formatGolden(snapshot), 'utf8'); + console.log( + `[php-captures-golden] ${UPDATE ? 'Regenerated' : 'Created'} golden at ${GOLDEN_FILE}`, + ); + return; + } + + const expected: Snapshot = JSON.parse(existing!); + expect( + snapshot, + 'emitPhpScopeCaptures output drifted from the committed golden. If this drift is intentional ' + + '(or the digest scheme changed), regenerate with ' + + 'UPDATE_GOLDEN=1 npx vitest run test/unit/scope-resolution/php/php-captures-golden.test.ts', + ).toEqual(expected); + }); + + // The fail-on-missing-in-CI rule, asserted purely (no filesystem mutation). + it.each([ + { update: true, exists: false, isCI: true, expected: 'regenerate' }, + { update: false, exists: false, isCI: true, expected: 'fail' }, + { update: false, exists: false, isCI: false, expected: 'regenerate' }, + { update: false, exists: true, isCI: true, expected: 'compare' }, + { update: false, exists: true, isCI: false, expected: 'compare' }, + ])( + 'resolveGoldenAction($update,$exists,$isCI) -> $expected', + ({ update, exists, isCI, expected }) => { + expect(resolveGoldenAction({ update, exists, isCI })).toBe(expected); + }, + ); + + it('produces a deterministic digest across repeated runs', () => { + const src = generateDao(8); + expect(digestCaptures(emitPhpScopeCaptures(src, 'a.php'))).toBe( + digestCaptures(emitPhpScopeCaptures(src, 'a.php')), + ); + }); + + it('digest is sensitive to capture-match emission order', () => { + const matches = emitPhpScopeCaptures(generateDao(6), 'a.php'); + expect(matches.length).toBeGreaterThan(1); + const reversed = [...matches].reverse(); + // Reordering the emission changes the digest — the true byte-identical guard. + expect(digestCaptures(reversed)).not.toBe(digestCaptures(matches)); + }); + + it('records a capture-group count for every fixture and the DAO shape', () => { + const snapshot = buildSnapshot(); + const fixtureKeys = collectPhpFixtures().map((f) => f.key); + // Every collected fixture is present in the snapshot. + for (const k of fixtureKeys) expect(snapshot[k]).toBeDefined(); + // The DAO shape (which has symbols) yields a non-empty capture set. + expect(snapshot['synthetic:dao-20']!.captureGroups).toBeGreaterThan(0); + }); +}); diff --git a/gitnexus/test/unit/scope-resolution/python/python-captures-golden.test.ts b/gitnexus/test/unit/scope-resolution/python/python-captures-golden.test.ts new file mode 100644 index 000000000..f4fb3e756 --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/python/python-captures-golden.test.ts @@ -0,0 +1,188 @@ +/** + * Golden capture-parity test for `emitPythonScopeCaptures`. + * + * Pins the exact capture output of `emitPythonScopeCaptures` across the whole + * `test/fixtures/lang-resolution/python-*` corpus plus a synthetic DAO-style + * source, so any future drift in the Python scope-capture path fails CI rather + * than only being caught by a coarse perf tripwire or pipeline-level resolver + * tests. + * + * This is the correctness anchor for the O(n^2) -> O(n) rewrite of + * emitPythonScopeCaptures (threading the tree-sitter query's captured node + * instead of re-deriving it with findNodeAtRange from the tree root — the same + * fix shipped for Go in #1848). It is a FORWARD-DRIFT guard: it locks in the + * current verified output as the baseline. + * + * Regenerate the golden intentionally with `UPDATE_GOLDEN=1` in the environment. + * + * Per fixture the snapshot stores `{ captureGroups, digest }`: + * - captureGroups: number of capture matches (makes a count change legible) + * - digest: sha256 of a match-grouped, order-independent canonicalization. + * Nothing path/time/id-dependent leaks in. + * + * Pattern: mirrors test/unit/scope-resolution/go/go-captures-golden.test.ts. + */ +import { describe, it, expect } from 'vitest'; +import path from 'path'; +import fs from 'fs'; +import crypto from 'crypto'; +import { emitPythonScopeCaptures } from '../../../../src/core/ingestion/languages/python/index.js'; +import type { CaptureMatch } from 'gitnexus-shared'; + +// This test lives at test/unit/scope-resolution/python/, so fixtures are FOUR +// levels up. +const FIXTURE_ROOT = path.resolve(__dirname, '..', '..', '..', 'fixtures', 'lang-resolution'); +const GOLDEN_DIR = path.resolve(__dirname, '..', '..', '..', 'fixtures', 'python-captures-golden'); +const GOLDEN_FILE = path.join(GOLDEN_DIR, 'expected-captures.json'); + +const UPDATE = process.env.UPDATE_GOLDEN === '1'; + +interface FixtureSnapshot { + captureGroups: number; + digest: string; +} +type Snapshot = Record; + +/** + * Canonicalize ONE match. A CaptureMatch is a Record (multiple + * captures per match), so we group by match to preserve match identity: + * build one `tag|text|startLine:startCol-endLine:endCol` string per capture, + * sort them within the match, and join. We deliberately do NOT flatten every + * capture into one global list — that would lose match boundaries. + */ +function canonicalizeMatch(match: CaptureMatch): string { + const parts: string[] = []; + 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(';'); +} + +/** Order-independent digest of a full capture result (match-grouped). */ +function digestCaptures(matches: readonly CaptureMatch[]): string { + const matchStrings = matches.map(canonicalizeMatch).sort(); + return crypto.createHash('sha256').update(matchStrings.join('\n')).digest('hex'); +} + +function snapshotOf(src: string, filePath: string): FixtureSnapshot { + const matches = emitPythonScopeCaptures(src, filePath); + return { captureGroups: matches.length, digest: digestCaptures(matches) }; +} + +/** All `.py` files under `lang-resolution/python-*`, as sorted repo-relative-ish keys. */ +function collectPythonFixtures(): { key: string; absPath: string }[] { + const out: { key: string; absPath: string }[] = []; + 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; +} + +/** + * Small deterministic generated-DAO source — exercises imports, class scopes, + * methods (@scope.function / @declaration.function / receiver binding), and + * module functions, the shape that stresses the scope-capture path at scale. + */ +function generateDao(entityCount: number): string { + const lines: string[] = []; + for (let i = 0; i < 4; i++) { + lines.push(`from pkg.mod${i} import alpha${i}, beta${i} as b${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`, + ` @classmethod`, + ` def make(cls, id: int):`, + ` return cls(id, "x")`, + '', + `def build_entity${n}(id: int) -> Entity${n}:`, + ` return Entity${n}(id, "x")`, + '', + ); + } + return lines.join('\n'); +} + +function buildSnapshot(): Snapshot { + const snap: Snapshot = {}; + for (const { key, absPath } of collectPythonFixtures()) { + snap[key] = snapshotOf(fs.readFileSync(absPath, 'utf8'), absPath); + } + snap['synthetic:dao-20'] = snapshotOf(generateDao(20), 'zz_generated_dao.py'); + // Stable key order for deterministic JSON serialization. + return Object.fromEntries( + Object.keys(snap) + .sort() + .map((k) => [k, snap[k]!]), + ); +} + +function formatGolden(snap: Snapshot): string { + return JSON.stringify(snap, null, 2) + '\n'; +} + +describe('Python scope captures — golden parity', () => { + it('matches the committed golden snapshot across all python-* fixtures + DAO shape', () => { + const snapshot = buildSnapshot(); + + if (UPDATE || !fs.existsSync(GOLDEN_FILE)) { + fs.mkdirSync(GOLDEN_DIR, { recursive: true }); + fs.writeFileSync(GOLDEN_FILE, formatGolden(snapshot), 'utf8'); + console.log( + `[python-captures-golden] ${UPDATE ? 'Regenerated' : 'Created'} golden at ${GOLDEN_FILE}`, + ); + return; + } + + const expected: Snapshot = JSON.parse(fs.readFileSync(GOLDEN_FILE, 'utf8')); + expect( + snapshot, + 'emitPythonScopeCaptures output drifted from the committed golden. If this drift is ' + + 'intentional, regenerate with UPDATE_GOLDEN=1 npx vitest run ' + + 'test/unit/scope-resolution/python/python-captures-golden.test.ts', + ).toEqual(expected); + }); + + it('produces a deterministic digest across repeated runs', () => { + const src = generateDao(8); + expect(digestCaptures(emitPythonScopeCaptures(src, 'a.py'))).toBe( + digestCaptures(emitPythonScopeCaptures(src, 'a.py')), + ); + }); + + it('digest is independent of capture-match array order', () => { + const matches = emitPythonScopeCaptures(generateDao(6), 'a.py'); + const reversed = [...matches].reverse(); + expect(digestCaptures(reversed)).toBe(digestCaptures(matches)); + }); + + it('records a capture-group count for every fixture and the DAO shape', () => { + const snapshot = buildSnapshot(); + const fixtureKeys = collectPythonFixtures().map((f) => f.key); + for (const k of fixtureKeys) expect(snapshot[k]).toBeDefined(); + expect(snapshot['synthetic:dao-20']!.captureGroups).toBeGreaterThan(0); + }); +}); diff --git a/gitnexus/test/unit/scope-resolution/python/python-import-target-parity.test.ts b/gitnexus/test/unit/scope-resolution/python/python-import-target-parity.test.ts new file mode 100644 index 000000000..f52b8eed2 --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/python/python-import-target-parity.test.ts @@ -0,0 +1,153 @@ +/** + * Parity guard for the memoized file index in `resolvePythonImportTarget` + * (import-target.ts). + * + * The index replaces two per-import O(files) scans (the suffix match in + * `resolveAbsoluteFromFiles` and the package-existence gate in + * `hasRepoCandidate`) with O(1)/O(bucket) lookups. It MUST reproduce the exact + * resolution result — in particular the deterministic tie-break + * (fewest-segments, then lexicographic) and the false-positive gating that the + * import-target.ts comments call out. These cases pin those semantics so an + * index regression fails CI rather than silently changing resolved edges. + */ +import { describe, it, expect } from 'vitest'; +import { resolvePythonImportTarget } from '../../../../src/core/ingestion/languages/python/index.js'; +import type { ParsedImport } from 'gitnexus-shared'; + +function mkImport(targetRaw: string): ParsedImport { + return { kind: 'absolute', targetRaw, isRelative: false, names: [] } as unknown as ParsedImport; +} + +function resolve(fromFile: string, files: string[], targetRaw: string): string | null { + return resolvePythonImportTarget(mkImport(targetRaw), { + fromFile, + allFilePaths: new Set(files), + }); +} + +describe('resolvePythonImportTarget — index parity', () => { + it('direct workspace-root hit wins', () => { + expect( + resolve('app/main.py', ['services/sync.py', 'services/__init__.py'], 'services.sync'), + ).toBe('services/sync.py'); + }); + + it('ancestor walk resolves nested namespace packages', () => { + expect(resolve('backend/routers/cron.py', ['backend/services/sync.py'], 'services.sync')).toBe( + 'backend/services/sync.py', + ); + }); + + it('suffix fallback resolves a nested vendored layout', () => { + expect(resolve('app/main.py', ['pkg/__init__.py', 'vendor/pkg/thing.py'], 'pkg.thing')).toBe( + 'vendor/pkg/thing.py', + ); + }); + + it('suffix tie-break prefers fewest path segments', () => { + expect( + resolve( + 'app/main.py', + ['pkg/__init__.py', 'a/pkg/models.py', 'b/c/pkg/models.py'], + 'pkg.models', + ), + ).toBe('a/pkg/models.py'); + }); + + it('suffix tie-break at equal depth is lexicographic', () => { + expect( + resolve( + 'app/main.py', + ['pkg/__init__.py', 'z/pkg/models.py', 'a/pkg/models.py'], + 'pkg.models', + ), + ).toBe('a/pkg/models.py'); + }); + + it('external dotted import is gated out by hasRepoCandidate (django.apps guard)', () => { + expect(resolve('app/main.py', ['accounts/apps.py'], 'django.apps')).toBeNull(); + }); + + it('does not suffix-match a different package basename (accounts.models vs billing/models.py)', () => { + expect( + resolve('app/main.py', ['accounts/__init__.py', 'billing/models.py'], 'accounts.models'), + ).toBeNull(); + }); + + it('candidate exists but no concrete file resolves to null', () => { + expect(resolve('app/main.py', ['pkg/__init__.py'], 'pkg.ghost')).toBeNull(); + }); + + it('package __init__ suffix resolves', () => { + expect( + resolve('app/main.py', ['pkg/__init__.py', 'x/pkg/subpkg/__init__.py'], 'pkg.subpkg'), + ).toBe('x/pkg/subpkg/__init__.py'); + }); + + it('the index is reused across imports on the same file set (no stale results)', () => { + const files = ['pkg/__init__.py', 'a/pkg/models.py', 'vendor/pkg/thing.py']; + const ctx = { fromFile: 'app/main.py', allFilePaths: new Set(files) }; + expect(resolvePythonImportTarget(mkImport('pkg.models'), ctx)).toBe('a/pkg/models.py'); + expect(resolvePythonImportTarget(mkImport('pkg.thing'), ctx)).toBe('vendor/pkg/thing.py'); + expect(resolvePythonImportTarget(mkImport('pkg.ghost'), ctx)).toBeNull(); + }); + + it('resolves a nested package via the parent-keyed __init__ bucket (PR #1918 P2b)', () => { + // `mypkg` is a candidate (root package), but the real target is nested under + // vendor/. `noise/sub/__init__.py` shares the parent-bucket key (`sub`) yet + // is filtered out by the full-suffix confirm — proving the parent bucket is + // a candidate set, not the answer, and that the result matches the old scan. + const files = ['mypkg/__init__.py', 'vendor/mypkg/sub/__init__.py', 'noise/sub/__init__.py']; + expect(resolve('app/main.py', files, 'mypkg.sub')).toBe('vendor/mypkg/sub/__init__.py'); + }); + + it('resolves an explicit pkg.__init__ import via the module lookup', () => { + // `from pkg.__init__ import x` targets the package init module directly; + // it must still resolve (it goes through the `.py` = `__init__.py` + // bucket, not the parent-keyed package bucket). + expect(resolve('app/main.py', ['pkg/__init__.py', 'pkg/widget.py'], 'pkg.__init__')).toBe( + 'pkg/__init__.py', + ); + }); + + it('reproduces old startsWith gating for absolute paths (PR #1918 P3a)', () => { + // Absolute file set. hasRepoCandidate must NOT gate-pass `pkg` off + // `/repo/pkg/__init__.py` the way the first #1918 index did (its prefix set + // dropped the leading slash). The old full-scan gate did + // `"/repo/pkg/__init__.py".startsWith("repo/pkg/")` === false → blocked, so + // the suffix-only file `/repo/vendor/pkg/thing.py` stays unresolved. + expect( + resolve( + '/repo/app/main.py', + ['/repo/pkg/__init__.py', '/repo/vendor/pkg/thing.py'], + 'pkg.thing', + ), + ).toBeNull(); + + // Control: the SAME shape with repo-relative paths (what production emits) + // gates through and resolves — proving the fix only blocks the absolute-path + // false positive, not the real relative case. + expect( + resolve( + 'repo/app/main.py', + ['repo/pkg/__init__.py', 'repo/vendor/pkg/thing.py'], + 'pkg.thing', + ), + ).toBe('repo/vendor/pkg/thing.py'); + }); + + it('ignores non-.py files in a polyglot file set (PR #1918 P3b)', () => { + // The index is .py-only; sibling .ts/.go files of the same basename must not + // affect resolution. `pkg.models` resolves to the .py, never the .ts/.go. + const files = [ + 'pkg/__init__.py', + 'a/pkg/models.py', + 'a/pkg/models.ts', + 'b/pkg/models.go', + 'a/pkg/helper.ts', + ]; + expect(resolve('app/main.py', files, 'pkg.models')).toBe('a/pkg/models.py'); + // A package whose only file is non-.py is not a repo candidate → null. + expect(resolve('app/main.py', [...files, 'tsonly/widget.ts'], 'tsonly.widget')).toBeNull(); + }); +}); diff --git a/gitnexus/test/unit/scope-resolution/ruby/ruby-captures-golden.test.ts b/gitnexus/test/unit/scope-resolution/ruby/ruby-captures-golden.test.ts new file mode 100644 index 000000000..f9ba4db75 --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/ruby/ruby-captures-golden.test.ts @@ -0,0 +1,254 @@ +/** + * Golden capture-parity test for `emitRubyScopeCaptures` (issue #1848 follow-up, + * PR #1918). + * + * Pins the exact capture output of `emitRubyScopeCaptures` across the whole + * `test/fixtures/lang-resolution/ruby-*` corpus plus a synthetic generated-DAO + * source, so any future drift in the Ruby scope-capture path fails CI rather + * than only being caught by a coarse perf tripwire or the bench-harness + * fingerprint (which is not committed as a test). + * + * This is a FORWARD-DRIFT guard: it locks in the current (post-#1918, verified) + * output as the baseline. It does not independently re-prove the original + * pre-fix parity — that was established during PR #1918. + * + * Regenerate the golden intentionally with `UPDATE_GOLDEN=1` in the environment. + * + * Per fixture the snapshot stores `{ captureGroups, digest }`: + * - captureGroups: number of capture matches (makes a count change legible) + * - digest: sha256 of a match-grouped, order-sensitive (emission-order) + * canonicalization (see canonicalize* below). Order-sensitivity is safe + * because emitRubyScopeCaptures output is deterministic, and it makes the + * digest a true byte-identical guard (a reordering refactor is real drift). + * Nothing path/time/id-dependent leaks in. + * + * Pattern: mirrors test/unit/scope-resolution/go/go-captures-golden.test.ts. + */ +import { describe, it, expect } from 'vitest'; +import path from 'path'; +import fs from 'fs'; +import crypto from 'crypto'; +import { emitRubyScopeCaptures } from '../../../../src/core/ingestion/languages/ruby/index.js'; +import type { CaptureMatch } from 'gitnexus-shared'; + +// This test lives at test/unit/scope-resolution/ruby/, so fixtures are THREE +// levels up (like the sibling go-captures-golden.test.ts). +const FIXTURE_ROOT = path.resolve(__dirname, '..', '..', '..', 'fixtures', 'lang-resolution'); +const GOLDEN_DIR = path.resolve(__dirname, '..', '..', '..', 'fixtures', 'ruby-captures-golden'); +const GOLDEN_FILE = path.join(GOLDEN_DIR, 'expected-captures.json'); + +const UPDATE = process.env.UPDATE_GOLDEN === '1'; + +interface FixtureSnapshot { + captureGroups: number; + digest: string; +} +type Snapshot = Record; + +/** + * Canonicalize ONE match. A CaptureMatch is a Record (multiple + * captures per match), so we group by match to preserve match identity: + * build one `tag|text|startLine:startCol-endLine:endCol` string per capture, + * sort them within the match, and join. We deliberately do NOT flatten every + * capture into one global list — that would lose match boundaries. + */ +function canonicalizeMatch(match: CaptureMatch): string { + const parts: string[] = []; + 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(';'); +} + +/** Order-sensitive (emission-order) digest of a full capture result (match-grouped). */ +function digestCaptures(matches: readonly CaptureMatch[]): string { + // No cross-match sort: the digest reflects emission order so a reordering + // refactor surfaces as drift. Within-match key order IS normalized + // (canonicalizeMatch sorts), since a CaptureMatch is an unordered Record. + const matchStrings = matches.map(canonicalizeMatch); + return crypto.createHash('sha256').update(matchStrings.join('\n')).digest('hex'); +} + +function snapshotOf(src: string, filePath: string): FixtureSnapshot { + const matches = emitRubyScopeCaptures(src, filePath); + return { captureGroups: matches.length, digest: digestCaptures(matches) }; +} + +/** All `.rb` files under `lang-resolution/ruby-*`, as sorted repo-relative-ish keys. */ +function collectRubyFixtures(): { key: string; absPath: string }[] { + const out: { key: string; absPath: string }[] = []; + for (const entry of fs.readdirSync(FIXTURE_ROOT, { withFileTypes: true })) { + if (!entry.isDirectory() || !entry.name.startsWith('ruby-')) 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('.rb')) { + 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; +} + +/** + * Small deterministic generated-DAO source — the #1848 shape at correctness + * scale. Mirrors the `ruby` unit shape in bench/scope-capture/measure.mjs. + */ +function generateDao(entityCount: number): string { + let src = ''; + for (let i = 0; i < entityCount; i++) { + src += + `class Entity${i}\n` + + ` def get_id\n @id\n end\n` + + ` def set_name(v)\n @name = v\n end\nend\n\n`; + } + return src; +} + +function buildSnapshot(): Snapshot { + const snap: Snapshot = {}; + for (const { key, absPath } of collectRubyFixtures()) { + snap[key] = snapshotOf(fs.readFileSync(absPath, 'utf8'), absPath); + } + snap['synthetic:dao-20'] = snapshotOf(generateDao(20), 'zz_generated_dao.rb'); + // Stable key order for deterministic JSON serialization. + return Object.fromEntries( + Object.keys(snap) + .sort() + .map((k) => [k, snap[k]!]), + ); +} + +function formatGolden(snap: Snapshot): string { + return JSON.stringify(snap, null, 2) + '\n'; +} + +/** + * Pure decision for what the golden test should do — extracted so the + * fail-on-missing-in-CI rule is unit-testable without touching the filesystem + * (and can never corrupt the committed golden). A missing golden must NOT + * self-heal in CI; locally it regenerates as a first-run convenience. + */ +type GoldenAction = 'regenerate' | 'compare' | 'fail'; +function resolveGoldenAction(opts: { + update: boolean; + exists: boolean; + isCI: boolean; +}): GoldenAction { + if (opts.update) return 'regenerate'; + if (!opts.exists) return opts.isCI ? 'fail' : 'regenerate'; + return 'compare'; +} + +describe('Ruby scope captures — golden parity', () => { + it('matches the committed golden snapshot across all ruby-* fixtures + DAO shape', () => { + const snapshot = buildSnapshot(); + + // Read the golden once (no existsSync-then-use, which is a TOCTOU race): + // ENOENT means the golden is missing; reuse `existing` for the compare path. + let existing: string | undefined; + try { + existing = fs.readFileSync(GOLDEN_FILE, 'utf8'); + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err; + } + + const action = resolveGoldenAction({ + update: UPDATE, + exists: existing !== undefined, + isCI: !!process.env.CI, // truthy check: fires on any CI runner, not just CI==='true' + }); + + if (action === 'fail') { + throw new Error( + `[ruby-captures-golden] golden file missing at ${GOLDEN_FILE} in CI. A missing golden must ` + + `not self-heal in CI — regenerate it locally with UPDATE_GOLDEN=1 and commit it.`, + ); + } + + if (action === 'regenerate') { + fs.mkdirSync(GOLDEN_DIR, { recursive: true }); + fs.writeFileSync(GOLDEN_FILE, formatGolden(snapshot), 'utf8'); + console.log( + `[ruby-captures-golden] ${UPDATE ? 'Regenerated' : 'Created'} golden at ${GOLDEN_FILE}`, + ); + return; + } + + const expected: Snapshot = JSON.parse(existing!); + expect( + snapshot, + 'emitRubyScopeCaptures output drifted from the committed golden. If this drift is intentional ' + + '(or the digest scheme changed), regenerate with ' + + 'UPDATE_GOLDEN=1 npx vitest run test/unit/scope-resolution/ruby/ruby-captures-golden.test.ts', + ).toEqual(expected); + }); + + // The fail-on-missing-in-CI rule, asserted purely (no filesystem mutation). + it.each([ + { update: true, exists: false, isCI: true, expected: 'regenerate' }, + { update: false, exists: false, isCI: true, expected: 'fail' }, + { update: false, exists: false, isCI: false, expected: 'regenerate' }, + { update: false, exists: true, isCI: true, expected: 'compare' }, + { update: false, exists: true, isCI: false, expected: 'compare' }, + ])( + 'resolveGoldenAction($update,$exists,$isCI) -> $expected', + ({ update, exists, isCI, expected }) => { + expect(resolveGoldenAction({ update, exists, isCI })).toBe(expected); + }, + ); + + it('produces a deterministic digest across repeated runs', () => { + const src = generateDao(8); + expect(digestCaptures(emitRubyScopeCaptures(src, 'a.rb'))).toBe( + digestCaptures(emitRubyScopeCaptures(src, 'a.rb')), + ); + }); + + it('digest is sensitive to capture-match emission order', () => { + const matches = emitRubyScopeCaptures(generateDao(6), 'a.rb'); + expect(matches.length).toBeGreaterThan(1); + const reversed = [...matches].reverse(); + // Reordering the emission changes the digest — the true byte-identical guard. + expect(digestCaptures(reversed)).not.toBe(digestCaptures(matches)); + }); + + it('records a capture-group count for every fixture and the DAO shape', () => { + const snapshot = buildSnapshot(); + const fixtureKeys = collectRubyFixtures().map((f) => f.key); + // Every collected fixture is present in the snapshot. + for (const k of fixtureKeys) expect(snapshot[k]).toBeDefined(); + // The DAO shape (which has symbols) yields a non-empty capture set. + expect(snapshot['synthetic:dao-20']!.captureGroups).toBeGreaterThan(0); + }); + + // PR #1918 P3 dedup regression: the constructor-return inference dedup is now + // computed from a snapshot of the YARD-pass return keys, not a LIVE `out.some` + // that also saw bindings this very loop pushed. The old code could + // cross-suppress the 2nd of two same-named methods one source row apart whose + // bodies both end in `Const.new`. Pin that BOTH bindings are emitted now. + it('emits both constructor-return bindings for two same-named methods (no cross-suppression)', () => { + const src = + 'class A\n def foo\n X.new\n end\nend\n' + 'class B\n def foo\n Y.new\n end\nend\n'; + const matches = emitRubyScopeCaptures(src, 'dedup.rb'); + + const returnBindings = matches.filter( + (m) => + m['@type-binding.return'] !== undefined && + m['@type-binding.name'] !== undefined && + m['@type-binding.name']!.text === 'foo', + ); + // Both foo->X and foo->Y must survive; the old live-some dedup dropped one. + expect(returnBindings.length).toBe(2); + const boundTypes = returnBindings.map((m) => m['@type-binding.type']!.text).sort(); + expect(boundTypes).toEqual(['X', 'Y']); + }); +}); diff --git a/gitnexus/test/unit/scope-resolution/rust/rust-captures-golden.test.ts b/gitnexus/test/unit/scope-resolution/rust/rust-captures-golden.test.ts new file mode 100644 index 000000000..0df536f12 --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/rust/rust-captures-golden.test.ts @@ -0,0 +1,233 @@ +/** + * Golden capture-parity test for `emitRustScopeCaptures` (PR #1918 follow-up). + * + * Pins the exact capture output of `emitRustScopeCaptures` across the whole + * `test/fixtures/lang-resolution/rust-*` corpus plus a synthetic generated-DAO + * source, so any future drift in the Rust scope-capture path fails CI rather + * than only being caught by a coarse perf tripwire or the CI-job fingerprint. + * + * This is a FORWARD-DRIFT guard: it locks in the current (post-#1918, verified) + * output as the baseline. It does not independently re-prove the original + * pre-fix parity — that was established during PR #1918. + * + * Regenerate the golden intentionally with `UPDATE_GOLDEN=1` in the environment. + * + * Per fixture the snapshot stores `{ captureGroups, digest }`: + * - captureGroups: number of capture matches (makes a count change legible) + * - digest: sha256 of a match-grouped, order-sensitive (emission-order) + * canonicalization (see canonicalize* below). Order-sensitivity is safe + * because emitRustScopeCaptures output is deterministic, and it makes the + * digest a true byte-identical guard (a reordering refactor is real drift). + * Nothing path/time/id-dependent leaks in. + * + * Pattern: mirrors test/unit/scope-resolution/go/go-captures-golden.test.ts. + */ +import { describe, it, expect } from 'vitest'; +import path from 'path'; +import fs from 'fs'; +import crypto from 'crypto'; +import { emitRustScopeCaptures } from '../../../../src/core/ingestion/languages/rust/index.js'; +import type { CaptureMatch } from 'gitnexus-shared'; + +// This test lives at test/unit/scope-resolution/rust/, so fixtures are THREE +// levels up (mirrors go-captures-golden.test.ts). +const FIXTURE_ROOT = path.resolve(__dirname, '..', '..', '..', 'fixtures', 'lang-resolution'); +const GOLDEN_DIR = path.resolve(__dirname, '..', '..', '..', 'fixtures', 'rust-captures-golden'); +const GOLDEN_FILE = path.join(GOLDEN_DIR, 'expected-captures.json'); + +const UPDATE = process.env.UPDATE_GOLDEN === '1'; + +interface FixtureSnapshot { + captureGroups: number; + digest: string; +} +type Snapshot = Record; + +/** + * Canonicalize ONE match. A CaptureMatch is a Record (multiple + * captures per match), so we group by match to preserve match identity: + * build one `tag|text|startLine:startCol-endLine:endCol` string per capture, + * sort them within the match, and join. We deliberately do NOT flatten every + * capture into one global list — that would lose match boundaries. + */ +function canonicalizeMatch(match: CaptureMatch): string { + const parts: string[] = []; + 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(';'); +} + +/** Order-sensitive (emission-order) digest of a full capture result (match-grouped). */ +function digestCaptures(matches: readonly CaptureMatch[]): string { + // No cross-match sort: the digest reflects emission order so a reordering + // refactor surfaces as drift. Within-match key order IS normalized + // (canonicalizeMatch sorts), since a CaptureMatch is an unordered Record. + const matchStrings = matches.map(canonicalizeMatch); + return crypto.createHash('sha256').update(matchStrings.join('\n')).digest('hex'); +} + +function snapshotOf(src: string, filePath: string): FixtureSnapshot { + const matches = emitRustScopeCaptures(src, filePath); + return { captureGroups: matches.length, digest: digestCaptures(matches) }; +} + +/** All `.rs` files under `lang-resolution/rust-*`, as sorted repo-relative-ish keys. */ +function collectRustFixtures(): { key: string; absPath: string }[] { + const out: { key: string; absPath: string }[] = []; + for (const entry of fs.readdirSync(FIXTURE_ROOT, { withFileTypes: true })) { + if (!entry.isDirectory() || !entry.name.startsWith('rust-')) 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('.rs')) { + 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; +} + +/** + * Small deterministic generated-DAO source — the #1848-shape at correctness + * scale. Mirrors the rust `unit` shape in bench/scope-capture/measure.mjs. + * The `fn get_id(&self) -> i64` impl method ensures the @type-binding.return + * impl-hoist path is exercised by the golden. + */ +function generateDao(entityCount: number): string { + let src = ''; + for (let i = 0; i < entityCount; i++) { + src += + `struct Entity${i} {\n id: i64,\n name: String,\n}\n\n` + + `impl Entity${i} {\n` + + ` fn get_id(&self) -> i64 { self.id }\n` + + ` fn set_name(&mut self, v: String) { self.name = v; }\n}\n\n`; + } + return src; +} + +function buildSnapshot(): Snapshot { + const snap: Snapshot = {}; + for (const { key, absPath } of collectRustFixtures()) { + snap[key] = snapshotOf(fs.readFileSync(absPath, 'utf8'), absPath); + } + snap['synthetic:dao-20'] = snapshotOf(generateDao(20), 'zz_generated_dao.rs'); + // Stable key order for deterministic JSON serialization. + return Object.fromEntries( + Object.keys(snap) + .sort() + .map((k) => [k, snap[k]!]), + ); +} + +function formatGolden(snap: Snapshot): string { + return JSON.stringify(snap, null, 2) + '\n'; +} + +/** + * Pure decision for what the golden test should do — extracted so the + * fail-on-missing-in-CI rule is unit-testable without touching the filesystem + * (and can never corrupt the committed golden). A missing golden must NOT + * self-heal in CI; locally it regenerates as a first-run convenience. + */ +type GoldenAction = 'regenerate' | 'compare' | 'fail'; +function resolveGoldenAction(opts: { + update: boolean; + exists: boolean; + isCI: boolean; +}): GoldenAction { + if (opts.update) return 'regenerate'; + if (!opts.exists) return opts.isCI ? 'fail' : 'regenerate'; + return 'compare'; +} + +describe('Rust scope captures — golden parity', () => { + it('matches the committed golden snapshot across all rust-* fixtures + DAO shape', () => { + const snapshot = buildSnapshot(); + + // Read the golden once (no existsSync-then-use, which is a TOCTOU race): + // ENOENT means the golden is missing; reuse `existing` for the compare path. + let existing: string | undefined; + try { + existing = fs.readFileSync(GOLDEN_FILE, 'utf8'); + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err; + } + + const action = resolveGoldenAction({ + update: UPDATE, + exists: existing !== undefined, + isCI: !!process.env.CI, // truthy check: fires on any CI runner, not just CI==='true' + }); + + if (action === 'fail') { + throw new Error( + `[rust-captures-golden] golden file missing at ${GOLDEN_FILE} in CI. A missing golden must ` + + `not self-heal in CI — regenerate it locally with UPDATE_GOLDEN=1 and commit it.`, + ); + } + + if (action === 'regenerate') { + fs.mkdirSync(GOLDEN_DIR, { recursive: true }); + fs.writeFileSync(GOLDEN_FILE, formatGolden(snapshot), 'utf8'); + console.log( + `[rust-captures-golden] ${UPDATE ? 'Regenerated' : 'Created'} golden at ${GOLDEN_FILE}`, + ); + return; + } + + const expected: Snapshot = JSON.parse(existing!); + expect( + snapshot, + 'emitRustScopeCaptures output drifted from the committed golden. If this drift is intentional ' + + '(or the digest scheme changed), regenerate with ' + + 'UPDATE_GOLDEN=1 npx vitest run test/unit/scope-resolution/rust/rust-captures-golden.test.ts', + ).toEqual(expected); + }); + + // The fail-on-missing-in-CI rule, asserted purely (no filesystem mutation). + it.each([ + { update: true, exists: false, isCI: true, expected: 'regenerate' }, + { update: false, exists: false, isCI: true, expected: 'fail' }, + { update: false, exists: false, isCI: false, expected: 'regenerate' }, + { update: false, exists: true, isCI: true, expected: 'compare' }, + { update: false, exists: true, isCI: false, expected: 'compare' }, + ])( + 'resolveGoldenAction($update,$exists,$isCI) -> $expected', + ({ update, exists, isCI, expected }) => { + expect(resolveGoldenAction({ update, exists, isCI })).toBe(expected); + }, + ); + + it('produces a deterministic digest across repeated runs', () => { + const src = generateDao(8); + expect(digestCaptures(emitRustScopeCaptures(src, 'a.rs'))).toBe( + digestCaptures(emitRustScopeCaptures(src, 'a.rs')), + ); + }); + + it('digest is sensitive to capture-match emission order', () => { + const matches = emitRustScopeCaptures(generateDao(6), 'a.rs'); + expect(matches.length).toBeGreaterThan(1); + const reversed = [...matches].reverse(); + // Reordering the emission changes the digest — the true byte-identical guard. + expect(digestCaptures(reversed)).not.toBe(digestCaptures(matches)); + }); + + it('records a capture-group count for every fixture and the DAO shape', () => { + const snapshot = buildSnapshot(); + const fixtureKeys = collectRustFixtures().map((f) => f.key); + // Every collected fixture is present in the snapshot. + for (const k of fixtureKeys) expect(snapshot[k]).toBeDefined(); + // The DAO shape (which has symbols) yields a non-empty capture set. + expect(snapshot['synthetic:dao-20']!.captureGroups).toBeGreaterThan(0); + }); +});