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/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/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/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/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/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/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/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/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/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/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/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/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 57fadebb1..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 // --------------------------------------------------------------------------- diff --git a/gitnexus/test/integration/resolvers/helpers.ts b/gitnexus/test/integration/resolvers/helpers.ts index d4722d2e7..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. 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/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/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); + }); +});