mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-28 01:31:23 +00:00
Merge branch 'main' into feat/multi-cli-devcontainer
This commit is contained in:
commit
48b71951cf
98 changed files with 10409 additions and 678 deletions
67
.github/workflows/ci-tests.yml
vendored
67
.github/workflows/ci-tests.yml
vendored
|
|
@ -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 emit<Lang>ScopeCaptures output is unchanged
|
||||
# (fingerprint) and stays linear (scaling < 1.5) for go/csharp/rust/php/
|
||||
# ruby/cobol. Catches an O(n^2) re-regression without the worker pool.
|
||||
run: node --import tsx bench/scope-capture/measure.mjs --check
|
||||
working-directory: gitnexus
|
||||
|
||||
- name: Cross-language pipeline benchmarks (GITNEXUS_BENCH, serial)
|
||||
env:
|
||||
GITNEXUS_BENCH: '1'
|
||||
run: >-
|
||||
npx vitest run --no-file-parallelism
|
||||
test/integration/cobol-pipeline-benchmark.test.ts
|
||||
test/integration/csharp-pipeline-benchmark.test.ts
|
||||
test/integration/rust-pipeline-benchmark.test.ts
|
||||
test/integration/php-pipeline-benchmark.test.ts
|
||||
test/integration/ruby-pipeline-benchmark.test.ts
|
||||
working-directory: gitnexus
|
||||
|
|
|
|||
|
|
@ -660,6 +660,14 @@ UPSTREAM (what depends on this):
|
|||
|
||||
Options: `maxDepth`, `minConfidence`, `relationTypes` (`CALLS`, `IMPORTS`, `EXTENDS`, `IMPLEMENTS`), `includeTests`, `limit` (max symbols per depth, default 100), `offset` (pagination start per depth), `summaryOnly` (counts and risk only, omits symbol list)
|
||||
|
||||
**Disambiguation** — when several symbols share the target name, `impact` returns a ranked `ambiguous` candidate list instead of guessing. Narrow it with `target_uid` (exact, zero-ambiguity), `file_path`, or `kind` (`Function`, `Class`, `Method`, …). From the CLI these are `--uid`, `--file`, and `--kind`, matching `gitnexus context`:
|
||||
|
||||
```bash
|
||||
gitnexus impact get_embeddings # → ambiguous: lists ranked candidates
|
||||
gitnexus impact get_embeddings --file src/embed.py # → resolves to the one in that file
|
||||
gitnexus impact get_embeddings --uid "Function:src/embed.py:get_embeddings" # exact
|
||||
```
|
||||
|
||||
### Process-Grouped Search
|
||||
|
||||
```
|
||||
|
|
|
|||
|
|
@ -170,6 +170,13 @@ gitnexus clean --all --force # Delete all indexes
|
|||
gitnexus wiki [path] # Generate LLM-powered docs from knowledge graph
|
||||
gitnexus wiki --model <model> # Wiki with custom LLM model (default: gpt-4o-mini)
|
||||
|
||||
# Direct graph queries — the same tools the MCP server exposes, no MCP daemon needed
|
||||
gitnexus query "<concept>" # Process-grouped hybrid search
|
||||
gitnexus context <symbol> [--uid <uid> | --file <path>] # 360° symbol view; flags disambiguate a shared name
|
||||
gitnexus impact <symbol> [--uid <uid> | --file <path> | --kind <kind>] # Blast radius; flags disambiguate a shared name
|
||||
gitnexus detect-changes # Map the working-tree diff to affected symbols and execution flows
|
||||
gitnexus cypher "<query>" # Run a raw Cypher query against the knowledge graph
|
||||
|
||||
# Repository groups (multi-repo / monorepo service tracking)
|
||||
gitnexus group create <name> # Create a repository group
|
||||
gitnexus group add <group> <groupPath> <registryName> # Add a repo to a group. <groupPath> is a hierarchy path (e.g. hr/hiring/backend); <registryName> is the repo's name from the registry (see `gitnexus list`)
|
||||
|
|
|
|||
1
gitnexus/bench/python-scope/baseline-fingerprint.txt
Normal file
1
gitnexus/bench/python-scope/baseline-fingerprint.txt
Normal file
|
|
@ -0,0 +1 @@
|
|||
f2b4376f30dab76f3befc9cbd3d7cc2bf1afbd7329a5e953439083e005de4a7c
|
||||
|
|
@ -0,0 +1 @@
|
|||
d51ea9edd1902fc20dd888f3fc51907c8119a4692b92c32a427801ac7f41d451
|
||||
202
gitnexus/bench/python-scope/import-target-fingerprint.mjs
Normal file
202
gitnexus/bench/python-scope/import-target-fingerprint.mjs
Normal file
|
|
@ -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');
|
||||
}
|
||||
216
gitnexus/bench/python-scope/measure.mjs
Normal file
216
gitnexus/bench/python-scope/measure.mjs
Normal file
|
|
@ -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');
|
||||
}
|
||||
27
gitnexus/bench/scope-capture/baselines.json
Normal file
27
gitnexus/bench/scope-capture/baselines.json
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
{
|
||||
"_comment": "Per-language baselines for bench/scope-capture/measure.mjs --check. fingerprint = order-independent sha256 over the lang-resolution/<lang>-* fixture corpus + a 20-entity synthetic source (correctness gate; re-baseline intentionally on a legitimate capture change). scaling_budget = max allowed (t800/t250)/(800/250); ~1.0 is linear, ~3.2 is quadratic. All six languages now thread the tree-sitter captured node instead of re-deriving it with findNodeAtRange(tree.rootNode,...) per match, so all are linear (go #1915, python #1918, ruby/php/rust/csharp this PR).",
|
||||
"go": {
|
||||
"fingerprint": "faca3555c61ed6980d2b739bf6b1cac7f4ad4644968a27e4687532d9835cd4c7",
|
||||
"scaling_budget": 1.5
|
||||
},
|
||||
"cobol": {
|
||||
"fingerprint": "575016f329c0be29eb90db974f750d02a21b4a12515f7029bda312df713b27b0",
|
||||
"scaling_budget": 1.5
|
||||
},
|
||||
"csharp": {
|
||||
"fingerprint": "bdc7803046011876b2d21ae38e9cb8c97ca1e01769f93ca8affe9317585427bf",
|
||||
"scaling_budget": 1.5
|
||||
},
|
||||
"rust": {
|
||||
"fingerprint": "025f5b6d4cf1d8cc42033f1f6b592f8d5428e571939c7f61df4d34b4bbe14be3",
|
||||
"scaling_budget": 1.5
|
||||
},
|
||||
"php": {
|
||||
"fingerprint": "00fe6e83cebd67c5f346fedb4234ebedf192995f9f171a424551cb792a0b91a9",
|
||||
"scaling_budget": 1.5
|
||||
},
|
||||
"ruby": {
|
||||
"fingerprint": "0f44b0d153b4534866589db93c582928651238b319cb606f2c6396362770cc18",
|
||||
"scaling_budget": 1.5
|
||||
}
|
||||
}
|
||||
266
gitnexus/bench/scope-capture/measure.mjs
Normal file
266
gitnexus/bench/scope-capture/measure.mjs
Normal file
|
|
@ -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 `emit<Lang>ScopeCaptures` on a synthetic DAO-style source at two
|
||||
* sizes (250 / 800 top-level entities), reporting elapsed_ms + a scaling
|
||||
* ratio `(t_large/t_small)/(800/250)`: ~1.0 is linear, ~3.2 is quadratic
|
||||
* (the O(matches × rootChildren) shape #1848 hit in Go);
|
||||
* - computes an order-independent sha256 fingerprint over the whole
|
||||
* `lang-resolution/<lang>-*` fixture corpus + a fixed 20-entity synthetic
|
||||
* source, as the correctness gate.
|
||||
*
|
||||
* Build-free: imports the `.ts` hotpaths through tsx
|
||||
* (`node --import tsx bench/scope-capture/measure.mjs`). Static `.ts` imports
|
||||
* work; a top-level `await import()` breaks tsx's lexer.
|
||||
*
|
||||
* Without args: prints one JSON object per language.
|
||||
* With `--check`: asserts each language's fingerprint == its committed baseline
|
||||
* (baselines.json) AND scaling_ratio < that language's recorded budget; exits
|
||||
* non-zero on any drift/regression.
|
||||
*/
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import crypto from 'node:crypto';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { emitGoScopeCaptures } from '../../src/core/ingestion/languages/go/index.ts';
|
||||
import { emitCsharpScopeCaptures } from '../../src/core/ingestion/languages/csharp/index.ts';
|
||||
import { emitRustScopeCaptures } from '../../src/core/ingestion/languages/rust/index.ts';
|
||||
import { emitPhpScopeCaptures } from '../../src/core/ingestion/languages/php/index.ts';
|
||||
import { emitRubyScopeCaptures } from '../../src/core/ingestion/languages/ruby/index.ts';
|
||||
import { emitCobolScopeCaptures } from '../../src/core/ingestion/languages/cobol/index.ts';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const FIXTURE_ROOT = path.resolve(__dirname, '..', '..', 'test', 'fixtures', 'lang-resolution');
|
||||
const BASELINE_PATH = path.resolve(__dirname, 'baselines.json');
|
||||
|
||||
// ---- correctness fingerprint (order-independent; mirrors python harness) ----
|
||||
|
||||
function canonicalizeMatch(match) {
|
||||
const parts = [];
|
||||
for (const tag of Object.keys(match)) {
|
||||
const cap = match[tag];
|
||||
if (cap === undefined || cap === null || cap.range === undefined) {
|
||||
parts.push(`${tag}|<no-range>`);
|
||||
continue;
|
||||
}
|
||||
const r = cap.range;
|
||||
parts.push(`${tag}|${cap.text}|${r.startLine}:${r.startCol}-${r.endLine}:${r.endCol}`);
|
||||
}
|
||||
parts.sort();
|
||||
return parts.join(';');
|
||||
}
|
||||
|
||||
function digestCaptures(matches) {
|
||||
return crypto
|
||||
.createHash('sha256')
|
||||
.update(matches.map(canonicalizeMatch).sort().join('\n'))
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
/** All fixture files for a language, sorted by repo-relative key. */
|
||||
function collectFixtures(prefix, exts) {
|
||||
const out = [];
|
||||
for (const entry of fs.readdirSync(FIXTURE_ROOT, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory() || !entry.name.startsWith(`${prefix}-`)) continue;
|
||||
const stack = [path.join(FIXTURE_ROOT, entry.name)];
|
||||
while (stack.length) {
|
||||
const dir = stack.pop();
|
||||
for (const c of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const p = path.join(dir, c.name);
|
||||
if (c.isDirectory()) stack.push(p);
|
||||
else if (exts.some((e) => c.name.endsWith(e))) {
|
||||
out.push({ key: path.relative(FIXTURE_ROOT, p).split(path.sep).join('/'), absPath: p });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out.sort((a, b) => a.key.localeCompare(b.key));
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---- per-language config: synthetic DAO generators + fixture globs ----
|
||||
|
||||
const LANGS = [
|
||||
{
|
||||
name: 'go',
|
||||
emit: emitGoScopeCaptures,
|
||||
fixturePrefix: 'go',
|
||||
exts: ['.go'],
|
||||
file: 'bench.go',
|
||||
header: 'package generated\n\n',
|
||||
unit: (n) =>
|
||||
`type Entity${n} struct {\n\tid int64\n\tname string\n}\n\n` +
|
||||
`func (e *Entity${n}) GetID() int64 { return e.id }\n` +
|
||||
`func (e *Entity${n}) SetName(v string) { e.name = v }\n\n`,
|
||||
},
|
||||
{
|
||||
name: 'csharp',
|
||||
emit: emitCsharpScopeCaptures,
|
||||
fixturePrefix: 'csharp',
|
||||
exts: ['.cs'],
|
||||
file: 'bench.cs',
|
||||
header: 'namespace Generated;\n\n',
|
||||
unit: (n) =>
|
||||
`public class Entity${n} {\n` +
|
||||
` public long Id;\n public string Name;\n` +
|
||||
` public long GetId() { return Id; }\n` +
|
||||
` public void SetName(string v) { Name = v; }\n}\n\n`,
|
||||
},
|
||||
{
|
||||
name: 'rust',
|
||||
emit: emitRustScopeCaptures,
|
||||
fixturePrefix: 'rust',
|
||||
exts: ['.rs'],
|
||||
file: 'bench.rs',
|
||||
header: '',
|
||||
unit: (n) =>
|
||||
`struct Entity${n} {\n id: i64,\n name: String,\n}\n\n` +
|
||||
`impl Entity${n} {\n` +
|
||||
` fn get_id(&self) -> i64 { self.id }\n` +
|
||||
` fn set_name(&mut self, v: String) { self.name = v; }\n}\n\n`,
|
||||
},
|
||||
{
|
||||
name: 'php',
|
||||
emit: emitPhpScopeCaptures,
|
||||
fixturePrefix: 'php',
|
||||
exts: ['.php'],
|
||||
file: 'bench.php',
|
||||
header: '<?php\n\n',
|
||||
unit: (n) =>
|
||||
`class Entity${n} {\n` +
|
||||
` public $id;\n public $name;\n` +
|
||||
` function getId() { return $this->id; }\n` +
|
||||
` function setName($v) { $this->name = $v; }\n}\n\n`,
|
||||
},
|
||||
{
|
||||
name: 'ruby',
|
||||
emit: emitRubyScopeCaptures,
|
||||
fixturePrefix: 'ruby',
|
||||
exts: ['.rb'],
|
||||
file: 'bench.rb',
|
||||
header: '',
|
||||
unit: (n) =>
|
||||
`class Entity${n}\n` +
|
||||
` def get_id\n @id\n end\n` +
|
||||
` def set_name(v)\n @name = v\n end\nend\n\n`,
|
||||
},
|
||||
{
|
||||
name: 'cobol',
|
||||
emit: emitCobolScopeCaptures,
|
||||
fixturePrefix: 'cobol',
|
||||
exts: ['.cbl', '.cpy'],
|
||||
file: 'bench.cbl',
|
||||
header:
|
||||
' IDENTIFICATION DIVISION.\n' +
|
||||
' PROGRAM-ID. BENCH.\n' +
|
||||
' PROCEDURE DIVISION.\n',
|
||||
unit: (n) => ` PARA-${String(n).padStart(5, '0')}.\n DISPLAY "P${n}".\n`,
|
||||
},
|
||||
];
|
||||
|
||||
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`);
|
||||
}
|
||||
7
gitnexus/package-lock.json
generated
7
gitnexus/package-lock.json
generated
|
|
@ -28,6 +28,7 @@
|
|||
"jsonc-parser": "^3.3.1",
|
||||
"lru-cache": "^11.0.0",
|
||||
"mnemonist": "^0.40.3",
|
||||
"node-addon-api": "8.8.0",
|
||||
"onnxruntime-node": "^1.24.0",
|
||||
"pandemonium": "^2.4.0",
|
||||
"pino": "^10.3.1",
|
||||
|
|
@ -3799,9 +3800,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/node-addon-api": {
|
||||
"version": "8.7.0",
|
||||
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.7.0.tgz",
|
||||
"integrity": "sha512-9MdFxmkKaOYVTV+XVRG8ArDwwQ77XIgIPyKASB1k3JPq3M8fGQQQE3YpMOrKm6g//Ktx8ivZr8xo1Qmtqub+GA==",
|
||||
"version": "8.8.0",
|
||||
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.8.0.tgz",
|
||||
"integrity": "sha512-c5Ko1fZJIJmzhFIkhRN76WTq+fC6tWnGy9CXA0fA+XygsWZmEwG8vmbkNqxMyoaa0Tin4djul49NzdVcJJcjeA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^18 || ^20 || >= 21"
|
||||
|
|
|
|||
|
|
@ -101,6 +101,9 @@ const OPTION_DESCRIPTION_KEYS = {
|
|||
'context|--content': 'help.option.content',
|
||||
'impact|-d, --direction <dir>': 'help.option.impact.direction',
|
||||
'impact|-r, --repo <name>': 'help.option.repo.target',
|
||||
'impact|-u, --uid <uid>': 'help.option.context.uid',
|
||||
'impact|-f, --file <path>': 'help.option.context.file',
|
||||
'impact|--kind <kind>': 'help.option.impact.kind',
|
||||
'impact|--depth <n>': 'help.option.impact.depth',
|
||||
'impact|--include-tests': 'help.option.impact.includeTests',
|
||||
'impact|--limit <n>': 'help.option.impact.limit',
|
||||
|
|
|
|||
|
|
@ -43,8 +43,11 @@ export const en = {
|
|||
'tool.noIndexed': 'GitNexus: No indexed repositories found. Run: gitnexus analyze',
|
||||
'tool.usage.query': 'Usage: gitnexus query <search_query>',
|
||||
'tool.usage.context': 'Usage: gitnexus context <symbol_name> [--uid <uid>] [--file <path>]',
|
||||
'tool.usage.impact': 'Usage: gitnexus impact <symbol_name> [--direction upstream|downstream]',
|
||||
'tool.usage.impact':
|
||||
'Usage: gitnexus impact <symbol_name> [--uid <uid>] [--file <path>] [--kind <kind>] [--direction upstream|downstream]',
|
||||
'tool.usage.cypher': 'Usage: gitnexus cypher <cypher_query>',
|
||||
'tool.warn.unknownKind':
|
||||
"--kind '{{kind}}' is not a known symbol kind (e.g. Function, Class, Method); it will not narrow the result.",
|
||||
'tool.detectChanges.noChanges': 'No changes detected.',
|
||||
'tool.detectChanges.changesSummary': 'Changes: {{files}} files, {{symbols}} symbols',
|
||||
'tool.detectChanges.affectedProcesses': 'Affected processes: {{count}}',
|
||||
|
|
@ -213,6 +216,8 @@ export const en = {
|
|||
'help.option.repo.target': 'Target repository',
|
||||
'help.option.context.uid': 'Direct symbol UID (zero-ambiguity lookup)',
|
||||
'help.option.context.file': 'File path to disambiguate common names',
|
||||
'help.option.impact.kind':
|
||||
'Kind filter to disambiguate common names (e.g. Function, Class, Method)',
|
||||
'help.option.impact.direction': 'upstream (dependants) or downstream (dependencies)',
|
||||
'help.option.impact.depth': 'Max relationship depth (default: 3)',
|
||||
'help.option.impact.includeTests': 'Include test files in results',
|
||||
|
|
|
|||
|
|
@ -47,8 +47,11 @@ export const zhCN = {
|
|||
'tool.noIndexed': 'GitNexus:未找到已索引仓库。请运行:gitnexus analyze',
|
||||
'tool.usage.query': '用法:gitnexus query <搜索词>',
|
||||
'tool.usage.context': '用法:gitnexus context <符号名> [--uid <uid>] [--file <路径>]',
|
||||
'tool.usage.impact': '用法:gitnexus impact <符号名> [--direction upstream|downstream]',
|
||||
'tool.usage.impact':
|
||||
'用法:gitnexus impact <符号名> [--uid <uid>] [--file <路径>] [--kind <类型>] [--direction upstream|downstream]',
|
||||
'tool.usage.cypher': '用法:gitnexus cypher <Cypher 查询>',
|
||||
'tool.warn.unknownKind':
|
||||
"--kind '{{kind}}' 不是已知的符号类型(如 Function、Class、Method),不会用于缩小结果范围。",
|
||||
'tool.detectChanges.noChanges': '未检测到变更。',
|
||||
'tool.detectChanges.changesSummary': '变更:{{files}} 个文件,{{symbols}} 个符号',
|
||||
'tool.detectChanges.affectedProcesses': '受影响流程:{{count}}',
|
||||
|
|
@ -199,6 +202,7 @@ export const zhCN = {
|
|||
'help.option.repo.target': '目标仓库',
|
||||
'help.option.context.uid': '直接符号 UID(零歧义查找)',
|
||||
'help.option.context.file': '用于消除常见名称歧义的文件路径',
|
||||
'help.option.impact.kind': '用于消除常见名称歧义的类型过滤(如 Function、Class、Method)',
|
||||
'help.option.impact.direction': 'upstream(依赖它的项)或 downstream(它依赖的项)',
|
||||
'help.option.impact.depth': '最大关系遍历深度(默认:3)',
|
||||
'help.option.impact.includeTests': '在结果中包含测试文件',
|
||||
|
|
|
|||
|
|
@ -219,10 +219,16 @@ program
|
|||
.action(createLbugLazyAction(() => import('./tool.js'), 'contextCommand'));
|
||||
|
||||
program
|
||||
.command('impact <target>')
|
||||
.command('impact [target]')
|
||||
.description('Blast radius analysis: what breaks if you change a symbol')
|
||||
.option('-d, --direction <dir>', 'upstream (dependants) or downstream (dependencies)', 'upstream')
|
||||
.option('-r, --repo <name>', 'Target repository')
|
||||
.option('-u, --uid <uid>', 'Direct symbol UID (zero-ambiguity lookup)')
|
||||
.option('-f, --file <path>', 'File path to disambiguate common names')
|
||||
.option(
|
||||
'--kind <kind>',
|
||||
'Kind filter to disambiguate common names (e.g. Function, Class, Method)',
|
||||
)
|
||||
.option('--depth <n>', 'Max relationship depth (default: 3)')
|
||||
.option('--include-tests', 'Include test files in results')
|
||||
.option('--limit <n>', 'Max symbols per depth level (default: 100)')
|
||||
|
|
|
|||
|
|
@ -16,8 +16,8 @@
|
|||
*/
|
||||
|
||||
import { writeSync } from 'node:fs';
|
||||
import { LocalBackend } from '../mcp/local/local-backend.js';
|
||||
import { cliErrorKey } from './cli-message.js';
|
||||
import { LocalBackend, VALID_NODE_LABELS } from '../mcp/local/local-backend.js';
|
||||
import { cliErrorKey, cliWarnKey } from './cli-message.js';
|
||||
import { formatDetectChangesResult } from './detect-changes-format.js';
|
||||
|
||||
let _backend: LocalBackend | null = null;
|
||||
|
|
@ -94,6 +94,11 @@ export async function contextCommand(
|
|||
content?: boolean;
|
||||
},
|
||||
): Promise<void> {
|
||||
// Reject a `--`-prefixed uid swallowed from a following flag (see impactCommand).
|
||||
if (options?.uid?.startsWith('--')) {
|
||||
cliErrorKey('tool.usage.context');
|
||||
process.exit(1);
|
||||
}
|
||||
if (!name?.trim() && !options?.uid) {
|
||||
cliErrorKey('tool.usage.context');
|
||||
process.exit(1);
|
||||
|
|
@ -111,10 +116,13 @@ export async function contextCommand(
|
|||
}
|
||||
|
||||
export async function impactCommand(
|
||||
target: string,
|
||||
target?: string,
|
||||
options?: {
|
||||
direction?: string;
|
||||
repo?: string;
|
||||
uid?: string;
|
||||
file?: string;
|
||||
kind?: string;
|
||||
depth?: string;
|
||||
includeTests?: boolean;
|
||||
limit?: string;
|
||||
|
|
@ -122,10 +130,25 @@ export async function impactCommand(
|
|||
summaryOnly?: boolean;
|
||||
},
|
||||
): Promise<void> {
|
||||
if (!target?.trim()) {
|
||||
// A `--`-prefixed uid means Commander swallowed a following flag as the uid
|
||||
// value (e.g. `impact --uid --file x` → uid === '--file'). Reject it rather
|
||||
// than forwarding a garbage uid that would silently resolve to not-found.
|
||||
if (options?.uid?.startsWith('--')) {
|
||||
cliErrorKey('tool.usage.impact');
|
||||
process.exit(1);
|
||||
}
|
||||
// Target is an optional positional: a uid alone is enough to resolve (parity
|
||||
// with `context [name]`). Only error when neither a target nor a uid is given.
|
||||
if (!target?.trim() && !options?.uid) {
|
||||
cliErrorKey('tool.usage.impact');
|
||||
process.exit(1);
|
||||
}
|
||||
// Soft-validate --kind: an unknown kind is a no-op hint (the backend scores
|
||||
// it but it matches nothing), so warn and proceed rather than rejecting —
|
||||
// parity with the lenient MCP surface and forward-compatible with new labels.
|
||||
if (options?.kind && !VALID_NODE_LABELS.has(options.kind)) {
|
||||
cliWarnKey('tool.warn.unknownKind', { kind: options.kind });
|
||||
}
|
||||
|
||||
try {
|
||||
const backend = await getBackend();
|
||||
|
|
@ -134,7 +157,10 @@ export async function impactCommand(
|
|||
const parsedLimit = Number.isFinite(rawLimit) ? rawLimit : undefined;
|
||||
const parsedOffset = Number.isFinite(rawOffset) ? rawOffset : undefined;
|
||||
const result = await backend.callTool('impact', {
|
||||
target,
|
||||
target: target || undefined,
|
||||
target_uid: options?.uid,
|
||||
file_path: options?.file,
|
||||
kind: options?.kind,
|
||||
direction: options?.direction || 'upstream',
|
||||
maxDepth: options?.depth ? parseInt(options.depth, 10) : undefined,
|
||||
includeTests: options?.includeTests ?? false,
|
||||
|
|
|
|||
|
|
@ -86,16 +86,33 @@ const NEW_QUALIFIED_CTOR_SPEC: PatternSpec<Record<string, never>> = {
|
|||
// proto loader). Matches either a bare call or an `obj.loadPackageDefinition(...)`
|
||||
// call. Plugin gates the qualified-constructor consumer on this —
|
||||
// structural check avoids materializing `tree.rootNode.text` for every file.
|
||||
const LOAD_PACKAGE_DEFINITION_SPEC: PatternSpec<Record<string, never>> = {
|
||||
meta: {},
|
||||
query: `
|
||||
(call_expression
|
||||
function: [
|
||||
(identifier) @fn (#eq? @fn "loadPackageDefinition")
|
||||
(member_expression property: (property_identifier) @fn (#eq? @fn "loadPackageDefinition"))
|
||||
])
|
||||
`,
|
||||
};
|
||||
//
|
||||
// These are TWO separate specs, NOT one `function: [ (identifier) ... (member_expression) ... ]`
|
||||
// alternation. Under the pinned tree-sitter@0.21.1 binding a top-level alternation
|
||||
// whose branches reuse the same capture name (`@fn`) collapses to one pattern with
|
||||
// a shared predicate bucket; the second branch's `@fn` is left unbound and its
|
||||
// `#eq?` is never enforced, so the member-expression branch would match EVERY
|
||||
// `obj.method(...)` call (e.g. `console.log(...)`) — turning this gate always-on
|
||||
// and emitting spurious qualified-constructor consumers. Two specs compile to two
|
||||
// queries with independent predicate buckets; `runCompiledPatterns` concatenates
|
||||
// their matches, so the `.length > 0` gate still means "either form is present".
|
||||
const LOAD_PACKAGE_DEFINITION_SPECS: PatternSpec<Record<string, never>>[] = [
|
||||
{
|
||||
meta: {},
|
||||
query: `
|
||||
(call_expression
|
||||
function: (identifier) @fn (#eq? @fn "loadPackageDefinition"))
|
||||
`,
|
||||
},
|
||||
{
|
||||
meta: {},
|
||||
query: `
|
||||
(call_expression
|
||||
function: (member_expression
|
||||
property: (property_identifier) @fn (#eq? @fn "loadPackageDefinition")))
|
||||
`,
|
||||
},
|
||||
];
|
||||
|
||||
interface NodeGrpcPatternBundle {
|
||||
grpcMethod: CompiledPatterns<Record<string, never>>;
|
||||
|
|
@ -107,11 +124,14 @@ interface NodeGrpcPatternBundle {
|
|||
}
|
||||
|
||||
function compileBundle(language: unknown, name: string): NodeGrpcPatternBundle {
|
||||
const mk = (spec: PatternSpec<Record<string, never>>, suffix: string) =>
|
||||
const mk = (
|
||||
spec: PatternSpec<Record<string, never>> | PatternSpec<Record<string, never>>[],
|
||||
suffix: string,
|
||||
) =>
|
||||
compilePatterns({
|
||||
name: `${name}-${suffix}`,
|
||||
language,
|
||||
patterns: [spec],
|
||||
patterns: Array.isArray(spec) ? spec : [spec],
|
||||
} satisfies LanguagePatterns<Record<string, never>>);
|
||||
return {
|
||||
grpcMethod: mk(GRPC_METHOD_SPEC, 'grpc-method'),
|
||||
|
|
@ -119,7 +139,7 @@ function compileBundle(language: unknown, name: string): NodeGrpcPatternBundle {
|
|||
getService: mk(GET_SERVICE_SPEC, 'get-service'),
|
||||
newSimpleCtor: mk(NEW_SIMPLE_CTOR_SPEC, 'new-simple-ctor'),
|
||||
newQualifiedCtor: mk(NEW_QUALIFIED_CTOR_SPEC, 'new-qualified-ctor'),
|
||||
loadPackageDefinition: mk(LOAD_PACKAGE_DEFINITION_SPEC, 'load-package-definition'),
|
||||
loadPackageDefinition: mk(LOAD_PACKAGE_DEFINITION_SPECS, 'load-package-definition'),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -19,14 +19,18 @@ import type {
|
|||
* - Spring `RestTemplate.getForObject/...`, `exchange(...)`
|
||||
* - Spring `WebClient.method(HttpMethod.X, ...)`, `WebClient.get().uri(...)`
|
||||
* - OkHttp `new Request.Builder().url("...")`
|
||||
* - OpenFeign interfaces with Spring MVC method annotations
|
||||
* - OpenFeign interfaces with Spring MVC method annotations or
|
||||
* native `@RequestLine("METHOD /path")` annotations
|
||||
* - Java / Apache HttpClient literal request construction
|
||||
*
|
||||
* The plugin runs two pattern bundles: one to collect class-level
|
||||
* `@RequestMapping` prefixes keyed by the enclosing class node, and a
|
||||
* second to match method-level annotations. The `scan` function walks
|
||||
* up from each matched annotation to find its enclosing class and
|
||||
* combines the prefix with the method path.
|
||||
* Every route-defining annotation (class/interface `@RequestMapping`
|
||||
* prefixes, `@FeignClient(path)` prefixes, `@(Get|...)Mapping` method
|
||||
* routes and native `@RequestLine`s) is matched by a single consolidated
|
||||
* query (`JAVA_ROUTE_ANNOTATION_PATTERNS`) in one pass via
|
||||
* `scanRouteAnnotations`. The `scan` function then walks up from each
|
||||
* matched method to its enclosing class/interface to combine the prefix
|
||||
* with the method path. Call-site consumers (RestTemplate, WebClient,
|
||||
* OkHttp, Java/Apache HttpClient) keep their own focused queries.
|
||||
*/
|
||||
|
||||
const METHOD_ANNOTATION_TO_HTTP: Record<string, string> = {
|
||||
|
|
@ -37,20 +41,17 @@ const METHOD_ANNOTATION_TO_HTTP: Record<string, string> = {
|
|||
PatchMapping: 'PATCH',
|
||||
};
|
||||
|
||||
// ─── Provider: Spring class-level @RequestMapping prefix ──────────────
|
||||
// Two patterns are needed because the AST shape differs depending on
|
||||
// whether the annotation uses a positional argument or a named one:
|
||||
// Each route-defining annotation has two AST shapes — a positional argument
|
||||
// and a named one — that must both be matched:
|
||||
// @RequestMapping("/api") → (annotation_argument_list (string_literal))
|
||||
// @RequestMapping(path = "/api") → (annotation_argument_list (element_value_pair key:(identifier) value:(string_literal)))
|
||||
// @RequestMapping(value = "/api") → same as above
|
||||
//
|
||||
// The named-argument pattern MUST constrain the `key` field to the route
|
||||
// member names (`path`/`value`); without it, the query also captures
|
||||
// non-route attributes such as `produces`, `consumes`, `headers`, `name`,
|
||||
// `params` (their right-hand string literals would be mis-extracted as
|
||||
// route prefixes — e.g. `produces = "application/json"` would corrupt
|
||||
// every method route under that controller). The sibling
|
||||
// `topic-patterns/java.ts` uses the same `key:` constraint approach.
|
||||
// For named arguments only the route member keys (`path`/`value`) carry a URL;
|
||||
// non-route attributes (`produces`, `consumes`, `headers`, `name`, `params`)
|
||||
// would otherwise be mis-extracted (e.g. `produces = "application/json"` would
|
||||
// corrupt every route). That key filtering is done in `isRouteMemberKey`, and
|
||||
// all of these annotations are matched by the one `JAVA_ROUTE_ANNOTATION_PATTERNS`
|
||||
// query below (see its header for why the filtering lives in JS, not the query).
|
||||
interface SpringRouteBinding {
|
||||
method: string;
|
||||
path: string;
|
||||
|
|
@ -71,9 +72,34 @@ interface SpringTypeInfo {
|
|||
methods: SpringMethodInfo[];
|
||||
}
|
||||
|
||||
// ─── Provider: Spring class/interface-level @RequestMapping prefix ───
|
||||
const SPRING_TYPE_PREFIX_PATTERNS = compilePatterns({
|
||||
name: 'java-spring-type-prefix',
|
||||
// ─── Route-defining annotations (one generic query, one pass) ─────────
|
||||
// Every Java route-mapper annotation shares one shape: an annotation carrying a
|
||||
// single string argument — positional `"..."` or named `key = "..."` — on a
|
||||
// class, interface, or method. This SINGLE query matches that shape generically;
|
||||
// `scanRouteAnnotations` then reads the annotation NAME (`@ann`) and declaration
|
||||
// kind (`@node.type`) in its for-loop to decide what each match means. Adding a
|
||||
// new framework annotation that follows this single-string-argument shape is a
|
||||
// change to that loop (and the lookup maps), not to this query. Annotations with
|
||||
// a different argument shape — e.g. an array value `@RequestMapping({"/a","/b"})`
|
||||
// — are out of scope here (as they were for the prior queries) and would need a
|
||||
// new branch.
|
||||
//
|
||||
// Captures (shared across all branches; intentionally framework-agnostic):
|
||||
// @ann → the annotation name identifier (RequestMapping, GetMapping, RequestLine, …)
|
||||
// @node → the enclosing declaration (class_declaration | interface_declaration | method_declaration)
|
||||
// @value → the string-literal argument
|
||||
// @key → the named-argument member key (absent for the positional shape)
|
||||
// @member → the method name (method_declaration branches only)
|
||||
//
|
||||
// The query carries NO `#eq?` / `#match?` predicates. Under the pinned
|
||||
// tree-sitter 0.21.x binding a top-level `[ ... ]` alternation compiles to one
|
||||
// pattern whose text predicates share a single bucket keyed by capture name, and
|
||||
// a `#match?` against a capture absent from the matched branch evaluates FALSE —
|
||||
// silently dropping sibling-branch matches. Keeping the query predicate-free
|
||||
// sidesteps that hazard entirely; all name/key discrimination lives in the
|
||||
// for-loop, where it reads as straight-line code.
|
||||
const JAVA_ROUTE_ANNOTATION_PATTERNS = compilePatterns({
|
||||
name: 'java-route-annotation',
|
||||
language: Java,
|
||||
patterns: [
|
||||
{
|
||||
|
|
@ -83,36 +109,44 @@ const SPRING_TYPE_PREFIX_PATTERNS = compilePatterns({
|
|||
(class_declaration
|
||||
(modifiers
|
||||
(annotation
|
||||
name: (identifier) @ann (#eq? @ann "RequestMapping")
|
||||
arguments: (annotation_argument_list (string_literal) @prefix)))) @type
|
||||
name: (identifier) @ann
|
||||
arguments: (annotation_argument_list (string_literal) @value)))) @node
|
||||
(interface_declaration
|
||||
(modifiers
|
||||
(annotation
|
||||
name: (identifier) @ann (#eq? @ann "RequestMapping")
|
||||
arguments: (annotation_argument_list (string_literal) @prefix)))) @type
|
||||
]
|
||||
`,
|
||||
},
|
||||
{
|
||||
meta: {},
|
||||
query: `
|
||||
[
|
||||
name: (identifier) @ann
|
||||
arguments: (annotation_argument_list (string_literal) @value)))) @node
|
||||
(class_declaration
|
||||
(modifiers
|
||||
(annotation
|
||||
name: (identifier) @ann (#eq? @ann "RequestMapping")
|
||||
name: (identifier) @ann
|
||||
arguments: (annotation_argument_list
|
||||
(element_value_pair
|
||||
key: (identifier) @key (#match? @key "^(path|value)$")
|
||||
value: (string_literal) @prefix))))) @type
|
||||
key: (identifier) @key
|
||||
value: (string_literal) @value))))) @node
|
||||
(interface_declaration
|
||||
(modifiers
|
||||
(annotation
|
||||
name: (identifier) @ann (#eq? @ann "RequestMapping")
|
||||
name: (identifier) @ann
|
||||
arguments: (annotation_argument_list
|
||||
(element_value_pair
|
||||
key: (identifier) @key (#match? @key "^(path|value)$")
|
||||
value: (string_literal) @prefix))))) @type
|
||||
key: (identifier) @key
|
||||
value: (string_literal) @value))))) @node
|
||||
(method_declaration
|
||||
(modifiers
|
||||
(annotation
|
||||
name: (identifier) @ann
|
||||
arguments: (annotation_argument_list (string_literal) @value)))
|
||||
name: (identifier) @member) @node
|
||||
(method_declaration
|
||||
(modifiers
|
||||
(annotation
|
||||
name: (identifier) @ann
|
||||
arguments: (annotation_argument_list
|
||||
(element_value_pair
|
||||
key: (identifier) @key
|
||||
value: (string_literal) @value))))
|
||||
name: (identifier) @member) @node
|
||||
]
|
||||
`,
|
||||
},
|
||||
|
|
@ -135,89 +169,41 @@ const SPRING_TYPE_DECLARATION_PATTERNS = compilePatterns({
|
|||
],
|
||||
} satisfies LanguagePatterns<Record<string, never>>);
|
||||
|
||||
// ─── Consumer: OpenFeign interface-level prefixes ───────────────────
|
||||
// Feign's `name`/`value` attributes identify a service, not an HTTP path,
|
||||
// so only `path` is used as a URL prefix. `@RequestMapping` on a Feign
|
||||
// interface is also common and does carry a path prefix.
|
||||
const FEIGN_INTERFACE_PREFIX_PATTERNS = compilePatterns({
|
||||
name: 'java-feign-interface-prefix',
|
||||
language: Java,
|
||||
patterns: [
|
||||
{
|
||||
meta: {},
|
||||
query: `
|
||||
(interface_declaration
|
||||
(modifiers
|
||||
(annotation
|
||||
name: (identifier) @ann (#eq? @ann "FeignClient")
|
||||
arguments: (annotation_argument_list
|
||||
(element_value_pair
|
||||
key: (identifier) @key (#eq? @key "path")
|
||||
value: (string_literal) @prefix))))) @interface
|
||||
`,
|
||||
},
|
||||
{
|
||||
meta: {},
|
||||
query: `
|
||||
(interface_declaration
|
||||
(modifiers
|
||||
(annotation
|
||||
name: (identifier) @ann (#eq? @ann "RequestMapping")
|
||||
arguments: (annotation_argument_list (string_literal) @prefix)))) @interface
|
||||
`,
|
||||
},
|
||||
{
|
||||
meta: {},
|
||||
query: `
|
||||
(interface_declaration
|
||||
(modifiers
|
||||
(annotation
|
||||
name: (identifier) @ann (#eq? @ann "RequestMapping")
|
||||
arguments: (annotation_argument_list
|
||||
(element_value_pair
|
||||
key: (identifier) @key (#match? @key "^(path|value)$")
|
||||
value: (string_literal) @prefix))))) @interface
|
||||
`,
|
||||
},
|
||||
],
|
||||
} satisfies LanguagePatterns<Record<string, never>>);
|
||||
// ─── Consumer: OpenFeign `@RequestLine("METHOD /path")` parsing ───────
|
||||
// OpenFeign's native annotation pairs an HTTP method and path in a single
|
||||
// string literal — see https://github.com/OpenFeign/feign#interface-annotations.
|
||||
// It is method-level only and is mutually exclusive with Spring MVC
|
||||
// `@GetMapping` / `@PostMapping` etc. on the same method (mixing them
|
||||
// requires a different Feign Contract — they are not combined). The match
|
||||
// itself comes from `JAVA_ROUTE_ANNOTATION_PATTERNS`; this regex splits the
|
||||
// verb from the path of the captured literal.
|
||||
//
|
||||
// Examples:
|
||||
// @RequestLine("GET /users/{id}")
|
||||
// @RequestLine("POST /users?status=active")
|
||||
const REQUEST_LINE_VERB_RE = /^\s*(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)\s+(\S.*?)\s*$/i;
|
||||
|
||||
// ─── Provider: Spring @(Get|Post|...)Mapping method annotations ───────
|
||||
// Same dual-pattern approach: positional vs named argument. The named
|
||||
// pattern restricts the annotation member name to `path`/`value` to
|
||||
// avoid capturing unrelated string-valued attributes
|
||||
// (`produces`, `consumes`, `headers`, `name`, `params`, ...).
|
||||
const SPRING_METHOD_ROUTE_PATTERNS = compilePatterns({
|
||||
name: 'java-spring-method-route',
|
||||
language: Java,
|
||||
patterns: [
|
||||
{
|
||||
meta: {},
|
||||
query: `
|
||||
(method_declaration
|
||||
(modifiers
|
||||
(annotation
|
||||
name: (identifier) @ann (#match? @ann "^(Get|Post|Put|Delete|Patch)Mapping$")
|
||||
arguments: (annotation_argument_list (string_literal) @path)))
|
||||
name: (identifier) @method_name) @method
|
||||
`,
|
||||
},
|
||||
{
|
||||
meta: {},
|
||||
query: `
|
||||
(method_declaration
|
||||
(modifiers
|
||||
(annotation
|
||||
name: (identifier) @ann (#match? @ann "^(Get|Post|Put|Delete|Patch)Mapping$")
|
||||
arguments: (annotation_argument_list
|
||||
(element_value_pair
|
||||
key: (identifier) @key (#match? @key "^(path|value)$")
|
||||
value: (string_literal) @path))))
|
||||
name: (identifier) @method_name) @method
|
||||
`,
|
||||
},
|
||||
],
|
||||
} satisfies LanguagePatterns<Record<string, never>>);
|
||||
/**
|
||||
* Parse a Feign `@RequestLine` value into a method + path pair.
|
||||
*
|
||||
* `@RequestLine("METHOD /path[?query]")` packs both fields in one string;
|
||||
* the query portion is dropped because contract IDs are method+path only
|
||||
* (consistent with how other consumers like RestTemplate/WebClient drop
|
||||
* query strings when their values are inline literals).
|
||||
*
|
||||
* Returns null if the value is not a recognized HTTP verb followed by a
|
||||
* path beginning with `/`.
|
||||
*/
|
||||
function parseRequestLine(raw: string): { method: string; path: string } | null {
|
||||
const match = REQUEST_LINE_VERB_RE.exec(raw);
|
||||
if (!match) return null;
|
||||
const [, verb, rest] = match;
|
||||
if (typeof verb !== 'string' || typeof rest !== 'string') return null;
|
||||
const queryIdx = rest.indexOf('?');
|
||||
const pathOnly = (queryIdx >= 0 ? rest.slice(0, queryIdx) : rest).trim();
|
||||
if (!pathOnly.startsWith('/')) return null;
|
||||
return { method: verb.toUpperCase(), path: pathOnly };
|
||||
}
|
||||
|
||||
// ─── Consumer: Spring RestTemplate (object-named + method-named) ──────
|
||||
// RestTemplate.getForObject / getForEntity → GET
|
||||
|
|
@ -433,34 +419,132 @@ function hasAnnotation(node: Parser.SyntaxNode, names: string | readonly string[
|
|||
return false;
|
||||
}
|
||||
|
||||
function collectTypePrefixes(tree: Parser.Tree): Map<number, string> {
|
||||
const prefixByTypeId = new Map<number, string>();
|
||||
for (const match of runCompiledPatterns(SPRING_TYPE_PREFIX_PATTERNS, tree)) {
|
||||
const prefixNode = match.captures.prefix;
|
||||
const typeNode = match.captures.type;
|
||||
if (!prefixNode || !typeNode) continue;
|
||||
const prefix = unquoteLiteral(prefixNode.text);
|
||||
if (prefix !== null) prefixByTypeId.set(typeNode.id, prefix);
|
||||
}
|
||||
return prefixByTypeId;
|
||||
/**
|
||||
* A named annotation argument contributes a route only when its member key is
|
||||
* `path` or `value`; a positional argument (no key node) always qualifies.
|
||||
* This is the JS-side replacement for the in-query `^(path|value)$` filter and
|
||||
* drops Spring's non-route string attributes (`produces`, `consumes`,
|
||||
* `headers`, `name`, `params`) that would otherwise be mis-read as routes.
|
||||
*/
|
||||
function isRouteMemberKey(keyNode: Parser.SyntaxNode | undefined): boolean {
|
||||
if (!keyNode) return true;
|
||||
return keyNode.text === 'path' || keyNode.text === 'value';
|
||||
}
|
||||
|
||||
function collectMethodRoutes(tree: Parser.Tree): Map<number, SpringRouteBinding[]> {
|
||||
const routesByMethodId = new Map<number, SpringRouteBinding[]>();
|
||||
for (const match of runCompiledPatterns(SPRING_METHOD_ROUTE_PATTERNS, tree)) {
|
||||
const annNode = match.captures.ann;
|
||||
const pathNode = match.captures.path;
|
||||
const methodNode = match.captures.method;
|
||||
if (!annNode || !pathNode || !methodNode) continue;
|
||||
const httpMethod = METHOD_ANNOTATION_TO_HTTP[annNode.text];
|
||||
if (!httpMethod) continue;
|
||||
const rawPath = unquoteLiteral(pathNode.text);
|
||||
if (rawPath === null) continue;
|
||||
const routes = routesByMethodId.get(methodNode.id) ?? [];
|
||||
routes.push({ method: httpMethod, path: rawPath });
|
||||
routesByMethodId.set(methodNode.id, routes);
|
||||
interface MethodRouteAnnotation {
|
||||
methodNode: Parser.SyntaxNode;
|
||||
methodName: string | null;
|
||||
httpMethod: string;
|
||||
rawPath: string;
|
||||
}
|
||||
|
||||
interface RequestLineAnnotation {
|
||||
methodNode: Parser.SyntaxNode;
|
||||
methodName: string | null;
|
||||
parsed: { method: string; path: string };
|
||||
}
|
||||
|
||||
interface RouteAnnotationScan {
|
||||
/** Spring `@RequestMapping` URL prefix per class/interface node id (last write wins). */
|
||||
prefixByTypeId: Map<number, string>;
|
||||
/** OpenFeign interface prefix per interface node id; `@FeignClient(path)` wins over `@RequestMapping`. */
|
||||
feignPrefixByInterfaceId: Map<number, string>;
|
||||
/** One entry per resolved Spring `@(Get|...)Mapping` route — a method with N mappings yields N entries. */
|
||||
methodRoutes: MethodRouteAnnotation[];
|
||||
/** One entry per OpenFeign `@RequestLine` whose value parses to a verb + path. */
|
||||
requestLines: RequestLineAnnotation[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve every Java route-defining annotation in a single tree-sitter pass.
|
||||
*
|
||||
* The generic `JAVA_ROUTE_ANNOTATION_PATTERNS` query yields one match per
|
||||
* annotation-carrying-a-string-argument on any class / interface / method. This
|
||||
* loop reads the annotation name and declaration kind to decide what each match
|
||||
* means, ignoring annotations it does not recognise. The HTTP verb map
|
||||
* (`METHOD_ANNOTATION_TO_HTTP`) and the `path`/`value` key filter
|
||||
* (`isRouteMemberKey`) live here rather than in the query (see its header).
|
||||
*/
|
||||
function scanRouteAnnotations(tree: Parser.Tree): RouteAnnotationScan {
|
||||
const matches = runCompiledPatterns(JAVA_ROUTE_ANNOTATION_PATTERNS, tree);
|
||||
|
||||
// The two prefix maps intentionally diverge for the same interface node:
|
||||
// `prefixByTypeId` feeds the Spring *provider* path (class prefix +
|
||||
// collectSpringTypes cross-file inheritance), while `feignPrefixByInterfaceId`
|
||||
// feeds the OpenFeign *consumer* path in scan(). An interface carrying both
|
||||
// `@RequestMapping` and `@FeignClient(path)` lands a different value in each.
|
||||
const prefixByTypeId = new Map<number, string>();
|
||||
const feignPrefixByInterfaceId = new Map<number, string>();
|
||||
const methodRoutes: MethodRouteAnnotation[] = [];
|
||||
const requestLines: RequestLineAnnotation[] = [];
|
||||
// Interface `@RequestMapping` prefixes rank below `@FeignClient(path)`;
|
||||
// collect them and apply only after the FeignClient pass below.
|
||||
const interfaceRequestMappingPrefixes: Array<{ id: number; prefix: string }> = [];
|
||||
|
||||
for (const { captures } of matches) {
|
||||
const annNode = captures.ann;
|
||||
const node = captures.node;
|
||||
const valueNode = captures.value;
|
||||
if (!annNode || !node || !valueNode) continue;
|
||||
const ann = annNode.text;
|
||||
const keyNode = captures.key; // undefined for the positional shape
|
||||
|
||||
if (node.type === 'method_declaration') {
|
||||
// Method-level: a Spring `@(Get|...)Mapping` route, or native `@RequestLine`.
|
||||
const httpMethod = METHOD_ANNOTATION_TO_HTTP[ann];
|
||||
if (httpMethod) {
|
||||
if (!isRouteMemberKey(keyNode)) continue;
|
||||
const rawPath = unquoteLiteral(valueNode.text);
|
||||
if (rawPath !== null) {
|
||||
methodRoutes.push({
|
||||
methodNode: node,
|
||||
methodName: captures.member?.text ?? null,
|
||||
httpMethod,
|
||||
rawPath,
|
||||
});
|
||||
}
|
||||
} else if (ann === 'RequestLine') {
|
||||
// Feign packs verb + path in one literal; its only named argument is `value`.
|
||||
if (keyNode && keyNode.text !== 'value') continue;
|
||||
const raw = unquoteLiteral(valueNode.text);
|
||||
const parsed = raw !== null ? parseRequestLine(raw) : null;
|
||||
if (parsed) {
|
||||
requestLines.push({
|
||||
methodNode: node,
|
||||
methodName: captures.member?.text ?? null,
|
||||
parsed,
|
||||
});
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Type-level (class or interface): a Spring `@RequestMapping` URL prefix, or
|
||||
// — on an interface — an OpenFeign `@FeignClient(path = "...")` prefix.
|
||||
if (ann === 'RequestMapping') {
|
||||
if (!isRouteMemberKey(keyNode)) continue;
|
||||
const prefix = unquoteLiteral(valueNode.text);
|
||||
if (prefix !== null) {
|
||||
prefixByTypeId.set(node.id, prefix);
|
||||
if (node.type === 'interface_declaration') {
|
||||
interfaceRequestMappingPrefixes.push({ id: node.id, prefix });
|
||||
}
|
||||
}
|
||||
} else if (ann === 'FeignClient' && node.type === 'interface_declaration') {
|
||||
// Feign's `name`/`value` identify a service, not a path — only `path` is a prefix.
|
||||
if (!keyNode || keyNode.text !== 'path') continue;
|
||||
const prefix = unquoteLiteral(valueNode.text);
|
||||
if (prefix !== null && !feignPrefixByInterfaceId.has(node.id)) {
|
||||
feignPrefixByInterfaceId.set(node.id, prefix);
|
||||
}
|
||||
}
|
||||
}
|
||||
return routesByMethodId;
|
||||
|
||||
for (const { id, prefix } of interfaceRequestMappingPrefixes) {
|
||||
if (!feignPrefixByInterfaceId.has(id)) feignPrefixByInterfaceId.set(id, prefix);
|
||||
}
|
||||
|
||||
return { prefixByTypeId, feignPrefixByInterfaceId, methodRoutes, requestLines };
|
||||
}
|
||||
|
||||
function collectDirectMethods(typeNode: Parser.SyntaxNode): Parser.SyntaxNode[] {
|
||||
|
|
@ -500,8 +584,13 @@ function collectImplementedInterfaces(typeNode: Parser.SyntaxNode): string[] {
|
|||
}
|
||||
|
||||
function collectSpringTypes(filePath: string, tree: Parser.Tree): SpringTypeInfo[] {
|
||||
const prefixByTypeId = collectTypePrefixes(tree);
|
||||
const routesByMethodId = collectMethodRoutes(tree);
|
||||
const { prefixByTypeId, methodRoutes } = scanRouteAnnotations(tree);
|
||||
const routesByMethodId = new Map<number, SpringRouteBinding[]>();
|
||||
for (const route of methodRoutes) {
|
||||
const routes = routesByMethodId.get(route.methodNode.id) ?? [];
|
||||
routes.push({ method: route.httpMethod, path: route.rawPath });
|
||||
routesByMethodId.set(route.methodNode.id, routes);
|
||||
}
|
||||
const out: SpringTypeInfo[] = [];
|
||||
|
||||
for (const match of runCompiledPatterns(SPRING_TYPE_DECLARATION_PATTERNS, tree)) {
|
||||
|
|
@ -593,57 +682,69 @@ export const JAVA_HTTP_PLUGIN: HttpLanguagePlugin = {
|
|||
scan(tree) {
|
||||
const out: HttpDetection[] = [];
|
||||
|
||||
// ─── Providers: Spring class prefix + method annotations ────────
|
||||
const prefixByTypeId = collectTypePrefixes(tree);
|
||||
// ─── Spring providers + OpenFeign consumers (one query pass) ────
|
||||
// `scanRouteAnnotations` resolves every route-defining annotation —
|
||||
// class/interface prefixes, method `@(Get|...)Mapping`s and native
|
||||
// `@RequestLine`s — from a single `matches()` pass over the tree.
|
||||
const { prefixByTypeId, feignPrefixByInterfaceId, methodRoutes, requestLines } =
|
||||
scanRouteAnnotations(tree);
|
||||
|
||||
const feignPrefixByInterfaceId = new Map<number, string>();
|
||||
for (const match of runCompiledPatterns(FEIGN_INTERFACE_PREFIX_PATTERNS, tree)) {
|
||||
const prefixNode = match.captures.prefix;
|
||||
const interfaceNode = match.captures.interface;
|
||||
if (!prefixNode || !interfaceNode) continue;
|
||||
const prefix = unquoteLiteral(prefixNode.text);
|
||||
if (prefix !== null && !feignPrefixByInterfaceId.has(interfaceNode.id))
|
||||
feignPrefixByInterfaceId.set(interfaceNode.id, prefix);
|
||||
}
|
||||
|
||||
for (const match of runCompiledPatterns(SPRING_METHOD_ROUTE_PATTERNS, tree)) {
|
||||
const annNode = match.captures.ann;
|
||||
const pathNode = match.captures.path;
|
||||
const nameNode = match.captures.method_name;
|
||||
const methodNode = match.captures.method;
|
||||
if (!annNode || !pathNode || !methodNode) continue;
|
||||
const httpMethod = METHOD_ANNOTATION_TO_HTTP[annNode.text];
|
||||
if (!httpMethod) continue;
|
||||
const rawPath = unquoteLiteral(pathNode.text);
|
||||
if (rawPath === null) continue;
|
||||
const enclosingInterface = findEnclosingInterface(methodNode);
|
||||
// A `@(Get|...)Mapping` inside a `@FeignClient` interface is an OpenFeign
|
||||
// *consumer* (it describes a remote call); the same annotation inside a
|
||||
// class is a Spring *provider*. A mapping on a non-Feign interface has no
|
||||
// enclosing class and is dropped here — interface→controller inheritance is
|
||||
// handled by `scanProject`.
|
||||
for (const route of methodRoutes) {
|
||||
const enclosingInterface = findEnclosingInterface(route.methodNode);
|
||||
if (enclosingInterface && hasAnnotation(enclosingInterface, 'FeignClient')) {
|
||||
const prefix = feignPrefixByInterfaceId.get(enclosingInterface.id) ?? '';
|
||||
const fullPath = joinPath(prefix, rawPath);
|
||||
out.push({
|
||||
role: 'consumer',
|
||||
framework: 'openfeign',
|
||||
method: httpMethod,
|
||||
path: fullPath,
|
||||
name: nameNode?.text ?? null,
|
||||
method: route.httpMethod,
|
||||
path: joinPath(prefix, route.rawPath),
|
||||
name: route.methodName,
|
||||
confidence: 0.7,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const enclosingClass = findEnclosingClass(methodNode);
|
||||
const enclosingClass = findEnclosingClass(route.methodNode);
|
||||
if (!enclosingClass) continue;
|
||||
const prefix = prefixByTypeId.get(enclosingClass.id) ?? '';
|
||||
const fullPath = joinPath(prefix, rawPath);
|
||||
out.push({
|
||||
role: 'provider',
|
||||
framework: 'spring',
|
||||
method: httpMethod,
|
||||
path: fullPath,
|
||||
name: nameNode?.text ?? null,
|
||||
method: route.httpMethod,
|
||||
path: joinPath(prefix, route.rawPath),
|
||||
name: route.methodName,
|
||||
confidence: 0.8,
|
||||
});
|
||||
}
|
||||
|
||||
// Native OpenFeign `@RequestLine("METHOD /path")`. Method-level only and
|
||||
// always declared on an interface (Feign builds a proxy from the interface).
|
||||
// We do NOT require an enclosing `@FeignClient`: `@RequestLine` is a core
|
||||
// `feign.*` annotation used with `Feign.builder()`, whereas `@FeignClient`
|
||||
// is the Spring Cloud variant that uses Spring MVC annotations instead — the
|
||||
// two are effectively mutually exclusive, so requiring `@FeignClient` here
|
||||
// would miss the annotation's primary use. The `RequestLine` name is itself
|
||||
// a strong, framework-specific signal, so a structural interface check is
|
||||
// enough to keep false positives away. A `@FeignClient(path=...)` prefix is
|
||||
// still applied when present (rare, but harmless).
|
||||
for (const requestLine of requestLines) {
|
||||
const enclosingInterface = findEnclosingInterface(requestLine.methodNode);
|
||||
if (!enclosingInterface) continue;
|
||||
const prefix = feignPrefixByInterfaceId.get(enclosingInterface.id) ?? '';
|
||||
out.push({
|
||||
role: 'consumer',
|
||||
framework: 'openfeign',
|
||||
method: requestLine.parsed.method,
|
||||
path: joinPath(prefix, requestLine.parsed.path),
|
||||
name: requestLine.methodName,
|
||||
confidence: 0.75,
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Consumers: RestTemplate ────────────────────────────────────
|
||||
for (const match of runCompiledPatterns(REST_TEMPLATE_PATTERNS, tree)) {
|
||||
const methodNode = match.captures.method;
|
||||
|
|
|
|||
|
|
@ -622,8 +622,9 @@ interface PythonRepoContext {
|
|||
|
||||
/** Strip `.py` and return the bare basename (e.g. `api/users.py` → `users`). */
|
||||
function fileShortKey(rel: string): string {
|
||||
const slash = rel.lastIndexOf('/');
|
||||
const file = slash >= 0 ? rel.slice(slash + 1) : rel;
|
||||
const normalized = rel.replace(/\\/g, '/');
|
||||
const slash = normalized.lastIndexOf('/');
|
||||
const file = slash >= 0 ? normalized.slice(slash + 1) : normalized;
|
||||
return file.endsWith('.py') ? file.slice(0, -3) : file;
|
||||
}
|
||||
|
||||
|
|
@ -633,7 +634,8 @@ function fileShortKey(rel: string): string {
|
|||
* case callers should fall back to the short key.
|
||||
*/
|
||||
function fileLongKey(rel: string): string {
|
||||
const noExt = rel.endsWith('.py') ? rel.slice(0, -3) : rel;
|
||||
const normalized = rel.replace(/\\/g, '/');
|
||||
const noExt = normalized.endsWith('.py') ? normalized.slice(0, -3) : normalized;
|
||||
const lastSlash = noExt.lastIndexOf('/');
|
||||
if (lastSlash < 0) return '';
|
||||
const beforeLast = noExt.slice(0, lastSlash);
|
||||
|
|
|
|||
154
gitnexus/src/core/ingestion/csharp-namespace-gate.ts
Normal file
154
gitnexus/src/core/ingestion/csharp-namespace-gate.ts
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
/**
|
||||
* Pure predicates gating C# `using` suffix-fallback resolution so BCL usings
|
||||
* (e.g. `System.Threading.Tasks`) can't match a coincidentally-named local
|
||||
* file (#1881).
|
||||
*
|
||||
* Lives in the shared `ingestion/` layer — NOT under `languages/csharp/` — so
|
||||
* BOTH the registry-primary scope resolver (`languages/csharp/import-target.ts`)
|
||||
* and the legacy DAG resolver (`import-resolvers/csharp.ts`) can import it
|
||||
* without an `import-resolvers/ -> languages/` dependency inversion (#5).
|
||||
*/
|
||||
|
||||
import type { CSharpNamespaceEvidence } from './language-config.js';
|
||||
|
||||
/**
|
||||
* Top-level namespace segments that clearly belong to the BCL / runtime / a
|
||||
* ubiquitous third-party package — i.e. roots a normal repo does NOT declare.
|
||||
* These stay gated even when the namespace scan is truncated, so a single
|
||||
* unreadable file / capped subtree can't silently re-enable BCL→local suffix
|
||||
* matches repo-wide (#1881). A repo that legitimately declares one of these
|
||||
* roots is still allowed via the alignment escape hatch below.
|
||||
*/
|
||||
const CSHARP_EXTERNAL_ROOTS: ReadonlySet<string> = new Set([
|
||||
// .NET BCL / runtime
|
||||
'System',
|
||||
'Microsoft',
|
||||
'Windows',
|
||||
'Mono',
|
||||
// ubiquitous third-party NuGet roots
|
||||
'Newtonsoft',
|
||||
'Serilog',
|
||||
'AutoMapper',
|
||||
'MediatR',
|
||||
'Polly',
|
||||
'FluentValidation',
|
||||
'Grpc',
|
||||
'Google',
|
||||
'Azure',
|
||||
'Amazon',
|
||||
'AWSSDK',
|
||||
// common test frameworks
|
||||
'Xunit',
|
||||
'NUnit',
|
||||
'Moq',
|
||||
'FluentAssertions',
|
||||
'NSubstitute',
|
||||
'Shouldly',
|
||||
]);
|
||||
|
||||
/** Whether `targetRaw`'s top-level segment is a clearly-external root. */
|
||||
function isExternalRoot(targetRaw: string): boolean {
|
||||
const dot = targetRaw.indexOf('.');
|
||||
const top = dot === -1 ? targetRaw : targetRaw.slice(0, dot);
|
||||
return CSHARP_EXTERNAL_ROOTS.has(top);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the unanchored suffix fallback may run for `targetRaw`.
|
||||
*
|
||||
* Fails OPEN when the namespace scan was truncated (large repos must not
|
||||
* silently lose legitimate edges, #1881 #11) and when no evidence was
|
||||
* threaded at all (preserves legacy permissive behavior). The truncation
|
||||
* fail-open is carved out for clearly-external roots (BCL / well-known
|
||||
* packages) that the repo does not declare, so one incomplete scan can't
|
||||
* re-open the #1881 hole repo-wide. Otherwise defers to
|
||||
* {@link importAlignsWithDeclaredNamespaces}.
|
||||
*/
|
||||
export function csharpSuffixFallbackAllowed(
|
||||
targetRaw: string,
|
||||
evidence: CSharpNamespaceEvidence | undefined,
|
||||
): boolean {
|
||||
if (evidence === undefined) return true;
|
||||
if (evidence.truncated) {
|
||||
// Keep clearly-external roots blocked through truncation UNLESS the repo
|
||||
// actually declares an aligning namespace (the alignment check is the
|
||||
// escape hatch — a repo that declares `namespace System;` still resolves).
|
||||
if (
|
||||
isExternalRoot(targetRaw) &&
|
||||
!importAlignsWithDeclaredNamespaces(
|
||||
targetRaw,
|
||||
evidence.declaredNamespaces,
|
||||
evidence.rootNamespaces,
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return importAlignsWithDeclaredNamespaces(
|
||||
targetRaw,
|
||||
evidence.declaredNamespaces,
|
||||
evidence.rootNamespaces,
|
||||
);
|
||||
}
|
||||
|
||||
/** True when `targetRaw` plausibly refers to a namespace declared in-repo. */
|
||||
export function importAlignsWithDeclaredNamespaces(
|
||||
targetRaw: string,
|
||||
declaredNamespaces: ReadonlySet<string> | undefined,
|
||||
rootNamespaces?: ReadonlySet<string>,
|
||||
): boolean {
|
||||
if (declaredNamespaces === undefined || declaredNamespaces.size === 0) return false;
|
||||
|
||||
// Exact: the import IS a declared in-repo namespace.
|
||||
if (declaredNamespaces.has(targetRaw)) return true;
|
||||
|
||||
// Child-of: the import's IMMEDIATE parent namespace is declared in-repo.
|
||||
// Anchoring on the direct parent — not "any declared prefix" — is what stops
|
||||
// a declared BCL prefix from green-lighting an unrelated BCL using: a repo
|
||||
// that declares `namespace System;` must NOT make `using
|
||||
// System.Threading.Tasks;` resolve to a coincidental local `Tasks.cs`,
|
||||
// because the import's parent `System.Threading` is not itself declared
|
||||
// (#1881). The case this still allows is a type / `using static` import under
|
||||
// a declared namespace laid out without its full path on disk, e.g.
|
||||
// `using static MyApp.Utils.Logger;` when `MyApp.Utils` is declared.
|
||||
const lastDot = targetRaw.lastIndexOf('.');
|
||||
if (lastDot > 0 && declaredNamespaces.has(targetRaw.slice(0, lastDot))) return true;
|
||||
|
||||
// Ancestor-of: the import is a strict prefix of some declared namespace
|
||||
// (e.g. `using MyApp;` when `MyApp.Models` is declared). Only honored when
|
||||
// the import also sits at or above an in-repo root namespace, so a BCL prefix
|
||||
// can't qualify merely because a file declares something deeper under it
|
||||
// (e.g. `System.Threading.Tasks.Extensions`) (#1881).
|
||||
const childPrefix = targetRaw + '.';
|
||||
for (const ns of declaredNamespaces) {
|
||||
if (ns.startsWith(childPrefix)) {
|
||||
return isAtOrAboveInRepoRoot(targetRaw, declaredNamespaces, rootNamespaces);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function isAtOrAboveInRepoRoot(
|
||||
targetRaw: string,
|
||||
declaredNamespaces: ReadonlySet<string>,
|
||||
rootNamespaces: ReadonlySet<string> | undefined,
|
||||
): boolean {
|
||||
const descendantPrefix = targetRaw + '.';
|
||||
if (rootNamespaces !== undefined && rootNamespaces.size > 0) {
|
||||
for (const root of rootNamespaces) {
|
||||
// targetRaw equals a root, or is an ancestor of one (e.g. `using MyApp;`
|
||||
// for csproj RootNamespace `MyApp.Core`).
|
||||
if (root === targetRaw || root.startsWith(descendantPrefix)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
// No explicit roots (e.g. no csproj): treat the top-level segment of each
|
||||
// declared namespace as the implied root.
|
||||
for (const ns of declaredNamespaces) {
|
||||
const dot = ns.indexOf('.');
|
||||
const top = dot === -1 ? ns : ns.slice(0, dot);
|
||||
if (top === targetRaw) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
|
@ -7,27 +7,45 @@ import { SupportedLanguages } from 'gitnexus-shared';
|
|||
import type { ImportResolutionConfig, ImportResolverStrategy } from '../types.js';
|
||||
import { createStandardStrategy } from '../standard.js';
|
||||
import { resolveCSharpImportInternal, resolveCSharpNamespaceDir } from '../csharp.js';
|
||||
import { csharpSuffixFallbackAllowed } from '../../csharp-namespace-gate.js';
|
||||
|
||||
/** C# namespace-based resolution strategy via .csproj configs. */
|
||||
export const csharpNamespaceStrategy: ImportResolverStrategy = (rawImportPath, _filePath, ctx) => {
|
||||
const csharpConfigs = ctx.configs.csharpConfigs;
|
||||
if (csharpConfigs.length > 0) {
|
||||
const resolvedFiles = resolveCSharpImportInternal(
|
||||
rawImportPath,
|
||||
csharpConfigs,
|
||||
ctx.normalizedFileList,
|
||||
ctx.allFileList,
|
||||
ctx.index,
|
||||
);
|
||||
if (resolvedFiles.length > 1) {
|
||||
const dirSuffix = resolveCSharpNamespaceDir(rawImportPath, csharpConfigs);
|
||||
if (dirSuffix) {
|
||||
return { kind: 'package', files: resolvedFiles, dirSuffix };
|
||||
}
|
||||
const evidence = ctx.configs.csharpNamespaces;
|
||||
if (csharpConfigs.length === 0) {
|
||||
// No csproj → there's no namespace→directory mapping to apply, so the
|
||||
// generic strategy would normally take over. But that generic suffix match
|
||||
// is UNGATED: it re-introduces the BCL→local spurious match the #1881 gate
|
||||
// exists to stop. Mirror the registry leg's no-csproj path — defer to the
|
||||
// generic strategy ONLY for imports that align with an in-repo declared
|
||||
// namespace; for everything else (BCL usings) return an authoritative empty
|
||||
// result that STOPS the chain (#2 parity). With no evidence threaded the
|
||||
// gate fails open, so behavior is unchanged when the scan didn't run.
|
||||
if (!csharpSuffixFallbackAllowed(rawImportPath, evidence)) {
|
||||
return { kind: 'files', files: [] };
|
||||
}
|
||||
if (resolvedFiles.length > 0) return { kind: 'files', files: resolvedFiles };
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
|
||||
const resolvedFiles = resolveCSharpImportInternal(
|
||||
rawImportPath,
|
||||
csharpConfigs,
|
||||
ctx.normalizedFileList,
|
||||
ctx.allFileList,
|
||||
ctx.index,
|
||||
evidence,
|
||||
);
|
||||
if (resolvedFiles.length > 1) {
|
||||
const dirSuffix = resolveCSharpNamespaceDir(rawImportPath, csharpConfigs);
|
||||
if (dirSuffix) {
|
||||
return { kind: 'package', files: resolvedFiles, dirSuffix };
|
||||
}
|
||||
}
|
||||
// Authoritative once csproj configs exist: return even an empty result to
|
||||
// STOP the chain, so the generic suffix fallback can't re-introduce the
|
||||
// gated BCL→local match this resolver just suppressed (#1881).
|
||||
return { kind: 'files', files: resolvedFiles };
|
||||
};
|
||||
|
||||
export const csharpImportConfig: ImportResolutionConfig = {
|
||||
|
|
|
|||
|
|
@ -7,11 +7,16 @@
|
|||
|
||||
import type { SuffixIndex } from './utils.js';
|
||||
import { suffixResolve } from './utils.js';
|
||||
import type { CSharpProjectConfig } from '../language-config.js';
|
||||
import type { CSharpProjectConfig, CSharpNamespaceEvidence } from '../language-config.js';
|
||||
import { csharpSuffixFallbackAllowed } from '../csharp-namespace-gate.js';
|
||||
|
||||
/**
|
||||
* Resolve a C# using-directive import path to matching .cs files (low-level helper).
|
||||
* Tries single-file match first, then directory match for namespace imports.
|
||||
*
|
||||
* The final unanchored suffix fallback is gated on `evidence` so BCL usings
|
||||
* (e.g. `System.Threading.Tasks`) can't match a coincidentally-named local
|
||||
* file (#1881). When `evidence` is omitted the fallback stays permissive.
|
||||
*/
|
||||
export function resolveCSharpImportInternal(
|
||||
importPath: string,
|
||||
|
|
@ -19,6 +24,7 @@ export function resolveCSharpImportInternal(
|
|||
normalizedFileList: string[],
|
||||
allFileList: string[],
|
||||
index?: SuffixIndex,
|
||||
evidence?: CSharpNamespaceEvidence,
|
||||
): string[] {
|
||||
const namespacePath = importPath.replace(/\./g, '/');
|
||||
const results: string[] = [];
|
||||
|
|
@ -86,7 +92,11 @@ export function resolveCSharpImportInternal(
|
|||
}
|
||||
}
|
||||
|
||||
// Fallback: suffix matching without namespace stripping (single file)
|
||||
// Fallback: suffix matching without namespace stripping (single file).
|
||||
// Gated on in-repo declared-namespace evidence (#1881).
|
||||
if (!csharpSuffixFallbackAllowed(importPath, evidence)) {
|
||||
return [];
|
||||
}
|
||||
const pathParts = namespacePath.split('/').filter(Boolean);
|
||||
const fallback = suffixResolve(pathParts, normalizedFileList, allFileList, index);
|
||||
return fallback ? [fallback] : [];
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ import { tryResolveWithExtensions } from './utils.js';
|
|||
export function resolvePythonImportInternal(
|
||||
currentFile: string,
|
||||
importPath: string,
|
||||
allFiles: Set<string>,
|
||||
allFiles: ReadonlySet<string>,
|
||||
): string | null {
|
||||
// Relative import — PEP 328 (https://peps.python.org/pep-0328/)
|
||||
if (importPath.startsWith('.')) {
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import type {
|
|||
TsconfigPaths,
|
||||
GoModuleConfig,
|
||||
CSharpProjectConfig,
|
||||
CSharpNamespaceEvidence,
|
||||
ComposerConfig,
|
||||
} from '../language-config.js';
|
||||
import type { SwiftPackageConfig } from '../language-config.js';
|
||||
|
|
@ -32,6 +33,8 @@ export interface ImportConfigs {
|
|||
composerConfig: ComposerConfig | null;
|
||||
swiftPackageConfig: SwiftPackageConfig | null;
|
||||
csharpConfigs: CSharpProjectConfig[];
|
||||
/** In-repo namespace evidence gating C# suffix-fallback resolution (#1881). */
|
||||
csharpNamespaces?: CSharpNamespaceEvidence;
|
||||
}
|
||||
|
||||
/** Pre-built lookup structures for import resolution. Build once, reuse across chunks. */
|
||||
|
|
|
|||
|
|
@ -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>): string | null {
|
||||
export function tryResolveWithExtensions(
|
||||
basePath: string,
|
||||
allFiles: ReadonlySet<string>,
|
||||
): string | null {
|
||||
for (const ext of EXTENSIONS) {
|
||||
const candidate = basePath + ext;
|
||||
if (allFiles.has(candidate)) return candidate;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
import fs from 'fs/promises';
|
||||
import { createReadStream } from 'fs';
|
||||
import { createInterface } from 'readline';
|
||||
import path from 'path';
|
||||
import type { ImportConfigs } from './import-resolvers/types.js';
|
||||
import type { CsharpStructureLineScanner } from './languages/csharp/namespace-siblings.js';
|
||||
|
||||
import { isDev } from './utils/env.js';
|
||||
|
||||
|
|
@ -40,6 +43,44 @@ export interface CSharpProjectConfig {
|
|||
projectDir: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Declared-namespace evidence used to gate C# suffix-fallback resolution so
|
||||
* BCL usings (e.g. `System.Threading.Tasks`) can't match a coincidentally-
|
||||
* named local file (#1881).
|
||||
*/
|
||||
export interface CSharpNamespaceEvidence {
|
||||
/** Every `namespace X.Y` declared in-repo (scan may be capped — see `truncated`). */
|
||||
readonly declaredNamespaces?: ReadonlySet<string>;
|
||||
/** csproj RootNamespace values plus the top-level segment of each declared
|
||||
* namespace — the anchor set for the parent-namespace gate direction. */
|
||||
readonly rootNamespaces?: ReadonlySet<string>;
|
||||
/** True when the BFS hit its dir/depth cap, so the namespace set may be
|
||||
* incomplete; the gate fails open (allows) in that case. */
|
||||
readonly truncated?: boolean;
|
||||
}
|
||||
|
||||
/** Result of a single BFS over a repo collecting both csproj configs and
|
||||
* declared `.cs` namespaces (one disk traversal — see `scanCSharpProject`). */
|
||||
export interface CSharpProjectScan {
|
||||
readonly configs: CSharpProjectConfig[];
|
||||
readonly declaredNamespaces: ReadonlySet<string>;
|
||||
readonly rootNamespaces: ReadonlySet<string>;
|
||||
readonly truncated: boolean;
|
||||
}
|
||||
|
||||
/** Project the one-pass {@link CSharpProjectScan} into the
|
||||
* {@link CSharpNamespaceEvidence} both import-resolution legs thread to the
|
||||
* #1881 gate — one shape, two carriers (`ImportConfigs.csharpNamespaces` for
|
||||
* the legacy DAG, `CsharpResolutionConfig.namespaces` for the scope resolver).
|
||||
* Keeps the field mapping in one place so the two carriers can't drift. */
|
||||
export function csharpScanToEvidence(scan: CSharpProjectScan): CSharpNamespaceEvidence {
|
||||
return {
|
||||
declaredNamespaces: scan.declaredNamespaces,
|
||||
rootNamespaces: scan.rootNamespaces,
|
||||
truncated: scan.truncated,
|
||||
};
|
||||
}
|
||||
|
||||
/** Swift Package Manager module config */
|
||||
export interface SwiftPackageConfig {
|
||||
/** Map of target name -> source directory path (e.g., "SiuperModel" -> "Package/Sources/SiuperModel") */
|
||||
|
|
@ -141,58 +182,258 @@ export async function loadComposerConfig(repoRoot: string): Promise<ComposerConf
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse .csproj files to extract RootNamespace.
|
||||
* Scans the repo root for .csproj files and returns configs for each.
|
||||
*/
|
||||
export async function loadCSharpProjectConfig(repoRoot: string): Promise<CSharpProjectConfig[]> {
|
||||
const configs: CSharpProjectConfig[] = [];
|
||||
// BFS scan for .csproj files up to 5 levels deep, cap at 100 dirs to avoid runaway scanning
|
||||
const scanQueue: { dir: string; depth: number }[] = [{ dir: repoRoot, depth: 0 }];
|
||||
const maxDepth = 5;
|
||||
const maxDirs = 100;
|
||||
let dirsScanned = 0;
|
||||
// BFS bounds shared by the C# project/namespace scan. Sized to comfortably
|
||||
// exceed normal C# repos so `truncated` stays the rare exception it was meant
|
||||
// to be: a too-low cap trips `truncated=true` on ordinary repos, which makes
|
||||
// `csharpSuffixFallbackAllowed` fail OPEN for every import and silently
|
||||
// disables the #1881 gate. Truncation remains the safety valve for genuinely
|
||||
// pathological trees (deep generated output, huge monorepos).
|
||||
const CSHARP_SCAN_MAX_DEPTH = 24;
|
||||
const CSHARP_SCAN_MAX_DIRS = 20000;
|
||||
// Bound on in-flight file reads per directory so a directory with thousands of
|
||||
// `.cs` files can't exhaust file descriptors / spike memory. Mirrors the
|
||||
// Phase-1 walker's `READ_CONCURRENCY` (see `filesystem-walker.ts`).
|
||||
const CSHARP_SCAN_READ_CONCURRENCY = 32;
|
||||
const CSHARP_SCAN_SKIP_DIRS = new Set(['node_modules', '.git', 'bin', 'obj']);
|
||||
const CSHARP_ROOT_NAMESPACE_RE = /<RootNamespace>\s*([^<]+)\s*<\/RootNamespace>/;
|
||||
|
||||
while (scanQueue.length > 0 && dirsScanned < maxDirs) {
|
||||
// Declared `namespace` names are extracted with the comment/string-aware
|
||||
// scanner shared with the scope-resolution namespace-siblings pass
|
||||
// (`extractCsharpStructureViaScanner`), not a bare regex: a regex matches
|
||||
// `namespace` inside comments and string literals, seeding the #1881 gate
|
||||
// with phantom namespaces. Imported lazily (and memoized) so the always-on
|
||||
// `loadImportConfigs` path — every repo, every language — doesn't eagerly
|
||||
// pull tree-sitter-c-sharp in via `namespace-siblings.ts` → `query.ts`.
|
||||
let csharpScannerFactoryPromise: Promise<() => CsharpStructureLineScanner> | undefined;
|
||||
function getCsharpStructureScannerFactory(): Promise<() => CsharpStructureLineScanner> {
|
||||
if (csharpScannerFactoryPromise === undefined) {
|
||||
csharpScannerFactoryPromise = import('./languages/csharp/namespace-siblings.js').then(
|
||||
(mod) => mod.createCsharpStructureScanner,
|
||||
);
|
||||
}
|
||||
return csharpScannerFactoryPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Single BFS over a repo that collects BOTH .csproj configs and the set of
|
||||
* `namespace` declarations from `.cs` files.
|
||||
*
|
||||
* The csproj walk is cheap (a handful of project files); the namespace scan
|
||||
* is NOT — it opens and reads every `.cs` file in the repo to collect its
|
||||
* `namespace` declarations. That `.cs` read cost is the price of the #1881
|
||||
* gate, not a saving: collapsing the csproj and namespace walks into one BFS
|
||||
* avoids a second directory traversal, but the per-file `.cs` reads are new
|
||||
* work this scan introduces. Reads within a directory are issued in bounded
|
||||
* windows (see below); directories are still visited breadth-first.
|
||||
*/
|
||||
export async function scanCSharpProject(repoRoot: string): Promise<CSharpProjectScan> {
|
||||
const configs: CSharpProjectConfig[] = [];
|
||||
const declaredNamespaces = new Set<string>();
|
||||
const rootNamespaces = new Set<string>();
|
||||
const scanQueue: { dir: string; depth: number }[] = [{ dir: repoRoot, depth: 0 }];
|
||||
let dirsScanned = 0;
|
||||
let truncated = false;
|
||||
|
||||
while (scanQueue.length > 0) {
|
||||
if (dirsScanned >= CSHARP_SCAN_MAX_DIRS) {
|
||||
truncated = true;
|
||||
break;
|
||||
}
|
||||
const { dir, depth } = scanQueue.shift()!;
|
||||
dirsScanned++;
|
||||
let entries: import('fs').Dirent[];
|
||||
try {
|
||||
const entries = await fs.readdir(dir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory() && depth < maxDepth) {
|
||||
// Skip common non-project directories
|
||||
if (
|
||||
entry.name === 'node_modules' ||
|
||||
entry.name === '.git' ||
|
||||
entry.name === 'bin' ||
|
||||
entry.name === 'obj'
|
||||
)
|
||||
continue;
|
||||
entries = await fs.readdir(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
// Unreadable directory → its `.cs` namespaces are missed, so the scan is
|
||||
// incomplete. Mark truncated so the #1881 gate fails OPEN (allows the
|
||||
// suffix fallback) rather than wrongly blocking an import whose declaring
|
||||
// namespace lived in the unread subtree (#5).
|
||||
truncated = true;
|
||||
continue;
|
||||
}
|
||||
// Collect read targets, then issue them in bounded windows (rather than all
|
||||
// at once) so a directory with thousands of `.cs` files can't exhaust file
|
||||
// descriptors / spike memory. csproj reads keep entry order (config
|
||||
// precedence matters); `.cs` namespace results land in shared Sets where
|
||||
// order is irrelevant.
|
||||
const csprojNames: string[] = [];
|
||||
const csNames: string[] = [];
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory()) {
|
||||
if (CSHARP_SCAN_SKIP_DIRS.has(entry.name)) continue;
|
||||
if (depth < CSHARP_SCAN_MAX_DEPTH) {
|
||||
scanQueue.push({ dir: path.join(dir, entry.name), depth: depth + 1 });
|
||||
} else {
|
||||
truncated = true; // a real subtree was pruned at the depth cap
|
||||
}
|
||||
if (entry.isFile() && entry.name.endsWith('.csproj')) {
|
||||
try {
|
||||
const csprojPath = path.join(dir, entry.name);
|
||||
const content = await fs.readFile(csprojPath, 'utf-8');
|
||||
const nsMatch = content.match(/<RootNamespace>\s*([^<]+)\s*<\/RootNamespace>/);
|
||||
const rootNamespace = nsMatch ? nsMatch[1].trim() : entry.name.replace(/\.csproj$/, '');
|
||||
const projectDir = path.relative(repoRoot, dir).replace(/\\/g, '/');
|
||||
configs.push({ rootNamespace, projectDir });
|
||||
if (isDev) {
|
||||
logger.info(
|
||||
`📦 Loaded C# project: ${entry.name} (namespace: ${rootNamespace}, dir: ${projectDir})`,
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// Can't read .csproj
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (!entry.isFile()) continue;
|
||||
if (entry.name.endsWith('.csproj')) {
|
||||
csprojNames.push(entry.name);
|
||||
} else if (entry.name.endsWith('.cs')) {
|
||||
csNames.push(entry.name);
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < csprojNames.length; i += CSHARP_SCAN_READ_CONCURRENCY) {
|
||||
const batch = csprojNames.slice(i, i + CSHARP_SCAN_READ_CONCURRENCY);
|
||||
const settled = await Promise.allSettled(
|
||||
batch.map((name) => readCsprojConfig(path.join(dir, name), name, repoRoot, dir)),
|
||||
);
|
||||
for (const r of settled) {
|
||||
const config = r.status === 'fulfilled' ? r.value : null;
|
||||
if (config) {
|
||||
configs.push(config);
|
||||
rootNamespaces.add(config.rootNamespace);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Can't read directory
|
||||
}
|
||||
for (let i = 0; i < csNames.length; i += CSHARP_SCAN_READ_CONCURRENCY) {
|
||||
const batch = csNames.slice(i, i + CSHARP_SCAN_READ_CONCURRENCY);
|
||||
const settled = await Promise.allSettled(
|
||||
batch.map((name) =>
|
||||
collectDeclaredNamespaces(path.join(dir, name), declaredNamespaces, rootNamespaces),
|
||||
),
|
||||
);
|
||||
// A `.cs` that was unreadable (or whose read/scan unexpectedly rejected)
|
||||
// leaves its namespaces uncollected → mark truncated to fail the #1881
|
||||
// gate OPEN rather than wrongly suppress an import. The scan streams each
|
||||
// file, so file size no longer trips truncation.
|
||||
for (const r of settled) {
|
||||
if (r.status !== 'fulfilled' || r.value === 'truncated') truncated = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return configs;
|
||||
|
||||
if (truncated) {
|
||||
// Surface the fail-open so an incomplete scan (dir/depth cap, or an
|
||||
// unreadable directory or `.cs` file) silently disabling the #1881 gate
|
||||
// repo-wide is observable (#4) rather than a mystery edge regression.
|
||||
logger.warn(
|
||||
`[csharp] namespace scan of ${repoRoot} truncated (dir cap ${CSHARP_SCAN_MAX_DIRS}, depth cap ${CSHARP_SCAN_MAX_DEPTH}, an unreadable directory, or an unreadable .cs file); the #1881 suffix-fallback gate fails open for unmatched usings`,
|
||||
);
|
||||
}
|
||||
return { configs, declaredNamespaces, rootNamespaces, truncated };
|
||||
}
|
||||
|
||||
// Generous soft budget for locating `<RootNamespace>`: a real .csproj declares
|
||||
// it in the first PropertyGroup near the top, so this is only reached by a
|
||||
// pathological project file with a huge leading ItemGroup and no early
|
||||
// RootNamespace. On hit we OMIT the config rather than guess a root (Codex F4).
|
||||
const CSPROJ_ROOT_SCAN_MAX_BYTES = 4 * 1024 * 1024;
|
||||
// Overlap kept across stream chunks so a `<RootNamespace>` tag straddling a
|
||||
// chunk boundary is still matched (the tag + a short namespace value fit well
|
||||
// within this window).
|
||||
const CSPROJ_TAG_OVERLAP = 512;
|
||||
|
||||
/**
|
||||
* Stream a `.csproj` just far enough to find `<RootNamespace>`, in constant
|
||||
* memory and without a stat-then-read filesystem race. Returns the namespace
|
||||
* when found; otherwise `rootNamespace: null` with `capHit` distinguishing a
|
||||
* genuine read-to-EOF absence (`false`) from "not found within the soft budget"
|
||||
* (`true`) — so the caller never synthesizes a wrong filename root for a late
|
||||
* tag (Codex F4).
|
||||
*/
|
||||
async function findCsprojRootNamespace(
|
||||
csprojPath: string,
|
||||
): Promise<{ rootNamespace: string | null; capHit: boolean }> {
|
||||
const stream = createReadStream(csprojPath, { encoding: 'utf-8' });
|
||||
let window = '';
|
||||
let bytesRead = 0;
|
||||
try {
|
||||
for await (const chunk of stream) {
|
||||
const text = chunk as string;
|
||||
bytesRead += text.length;
|
||||
window =
|
||||
(window.length > CSPROJ_TAG_OVERLAP ? window.slice(-CSPROJ_TAG_OVERLAP) : window) + text;
|
||||
const match = window.match(CSHARP_ROOT_NAMESPACE_RE);
|
||||
if (match) {
|
||||
stream.destroy();
|
||||
return { rootNamespace: match[1]!.trim(), capHit: false };
|
||||
}
|
||||
if (bytesRead >= CSPROJ_ROOT_SCAN_MAX_BYTES) {
|
||||
stream.destroy();
|
||||
return { rootNamespace: null, capHit: true };
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Unreadable .csproj: don't guess a filename root either — omit the config.
|
||||
return { rootNamespace: null, capHit: true };
|
||||
}
|
||||
return { rootNamespace: null, capHit: false }; // read to EOF, tag genuinely absent
|
||||
}
|
||||
|
||||
async function readCsprojConfig(
|
||||
csprojPath: string,
|
||||
fileName: string,
|
||||
repoRoot: string,
|
||||
dir: string,
|
||||
): Promise<CSharpProjectConfig | null> {
|
||||
const { rootNamespace: found, capHit } = await findCsprojRootNamespace(csprojPath);
|
||||
// A late `<RootNamespace>` we couldn't reach (capHit) or an unreadable file
|
||||
// must NOT synthesize a filename root — a wrong authoritative root would make
|
||||
// imports under the real root resolve to nothing and suppress the fallback
|
||||
// (Codex F4). Omit the config so the no-csproj fallback stays available. Only
|
||||
// fall back to the filename on a genuine read-to-EOF absence of the tag.
|
||||
if (capHit) return null;
|
||||
const rootNamespace = found ?? fileName.replace(/\.csproj$/, '');
|
||||
const projectDir = path.relative(repoRoot, dir).replace(/\\/g, '/');
|
||||
if (isDev) {
|
||||
logger.info(
|
||||
`📦 Loaded C# project: ${fileName} (namespace: ${rootNamespace}, dir: ${projectDir})`,
|
||||
);
|
||||
}
|
||||
return { rootNamespace, projectDir };
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream one `.cs` file line-by-line and collect its declared `namespace` names
|
||||
* into the shared Sets.
|
||||
*
|
||||
* Streaming (rather than reading the whole file into a string) keeps memory
|
||||
* constant regardless of file size, so a large generated `.cs` (`*.g.cs`, EF /
|
||||
* gRPC output) is fully scanned instead of skipped by a per-file size cap —
|
||||
* which would otherwise trip `truncated` and disable the #1881 gate repo-wide.
|
||||
* Only the cheap line scan streams here; the tree-sitter PARSE path keeps its
|
||||
* own size cap.
|
||||
*
|
||||
* Returns `'truncated'` when the file could not be read, so the caller marks the
|
||||
* scan truncated and the #1881 gate fails OPEN rather than wrongly suppress an
|
||||
* import declared in the unread file. Returns `'ok'` on a complete read.
|
||||
*/
|
||||
async function collectDeclaredNamespaces(
|
||||
filePath: string,
|
||||
declaredNamespaces: Set<string>,
|
||||
rootNamespaces: Set<string>,
|
||||
): Promise<'ok' | 'truncated'> {
|
||||
const createScanner = await getCsharpStructureScannerFactory();
|
||||
const scanner = createScanner();
|
||||
try {
|
||||
// `crlfDelay: Infinity` treats every `\r\n` as a single break; the line
|
||||
// scanner is terminator-agnostic, so a streamed scan yields the same
|
||||
// namespaces as scanning the whole file content at once.
|
||||
const lines = createInterface({
|
||||
input: createReadStream(filePath, { encoding: 'utf-8' }),
|
||||
crlfDelay: Infinity,
|
||||
});
|
||||
for await (const line of lines) {
|
||||
scanner.pushLine(line);
|
||||
}
|
||||
} catch {
|
||||
return 'truncated'; // unreadable source → signal truncation (fail open)
|
||||
}
|
||||
const structure = scanner.result();
|
||||
for (const ns of structure.namespaces) {
|
||||
declaredNamespaces.add(ns);
|
||||
const dot = ns.indexOf('.');
|
||||
rootNamespaces.add(dot === -1 ? ns : ns.slice(0, dot));
|
||||
}
|
||||
// A declaration the scanner could not fully capture (Codex F3) means the
|
||||
// collected namespaces are an incomplete picture of this file — treat it like
|
||||
// a truncated read so the #1881 gate fails OPEN rather than over-block an
|
||||
// import whose namespace was dropped.
|
||||
return structure.incomplete ? 'truncated' : 'ok';
|
||||
}
|
||||
|
||||
export async function loadSwiftPackageConfig(repoRoot: string): Promise<SwiftPackageConfig | null> {
|
||||
|
|
@ -231,11 +472,13 @@ export async function loadSwiftPackageConfig(repoRoot: string): Promise<SwiftPac
|
|||
|
||||
/** Load all language-specific configs once for an ingestion run. */
|
||||
export async function loadImportConfigs(repoRoot: string): Promise<ImportConfigs> {
|
||||
const csharpScan = await scanCSharpProject(repoRoot);
|
||||
return {
|
||||
tsconfigPaths: await loadTsconfigPaths(repoRoot),
|
||||
goModule: await loadGoModulePath(repoRoot),
|
||||
composerConfig: await loadComposerConfig(repoRoot),
|
||||
swiftPackageConfig: await loadSwiftPackageConfig(repoRoot),
|
||||
csharpConfigs: await loadCSharpProjectConfig(repoRoot),
|
||||
csharpConfigs: csharpScan.configs,
|
||||
csharpNamespaces: csharpScanToEvidence(csharpScan),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<T>` / `outer::v1::Base<T>`
|
||||
// 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<string> {
|
|||
return names;
|
||||
}
|
||||
|
||||
function markClassWithPackExpandedBase(filePath: string, classNode: SyntaxNode): void {
|
||||
const className = getTypeIdentifierName(classNode);
|
||||
if (className !== '') markCppDependentPackBase(filePath, className);
|
||||
}
|
||||
|
||||
interface CppBaseClassEntry {
|
||||
readonly node: SyntaxNode;
|
||||
readonly isPackExpansion: boolean;
|
||||
}
|
||||
|
||||
/** Yield each base-class entry from a `base_class_clause`. */
|
||||
function* iterBaseClasses(baseClause: SyntaxNode): IterableIterator<SyntaxNode> {
|
||||
function* iterBaseClasses(baseClause: SyntaxNode): IterableIterator<CppBaseClassEntry> {
|
||||
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<SyntaxNode>
|
|||
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
|
||||
|
|
|
|||
|
|
@ -46,6 +46,13 @@ import { findEnclosingClassDef } from '../../scope-resolution/scope/walkers.js';
|
|||
*/
|
||||
const dependentBasesByFile = new Map<string, Map<string, Map<string, Set<string>>>>();
|
||||
|
||||
/**
|
||||
* Class templates with pack-expanded bases (`struct Mix : Bases...`) have
|
||||
* an unknown set of base classes. Unqualified member lookup inside the class
|
||||
* cannot safely bind to class-owned methods outside the current class.
|
||||
*/
|
||||
const dependentPackBaseClassesByFile = new Map<string, Set<string>>();
|
||||
|
||||
/**
|
||||
* Post-`populateOwners` resolution: per-class-nodeId, the set of
|
||||
* dependent-base-class nodeIds. Built by `populateCppDependentBases`
|
||||
|
|
@ -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<T>`), 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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string, Capture> = {};
|
||||
// Parallel tag -> captured SyntaxNode map: the query hands us each matched
|
||||
// node as c.node, so anchors resolve via a type-guarded lookup (nodeIfType)
|
||||
// instead of re-deriving them with findNodeAtRange(tree.rootNode, ...) per
|
||||
// match — the O(matches x rootChildren) root-walk fixed for go #1915 /
|
||||
// python #1918, mirrored here.
|
||||
const nodeMap: Record<string, SyntaxNode> = {};
|
||||
for (const c of m.captures) {
|
||||
const tag = '@' + c.name;
|
||||
grouped[tag] = nodeToCapture(tag, c.node);
|
||||
nodeMap[tag] = c.node;
|
||||
}
|
||||
if (Object.keys(grouped).length === 0) continue;
|
||||
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,30 +9,112 @@
|
|||
* match. Cross-file partial-class aggregation runs at graph-bridge
|
||||
* time (Unit 6) via `populateOwners`.
|
||||
*
|
||||
* The legacy csproj-based `resolveCSharpImportInternal` needs config
|
||||
* objects the scope-resolver doesn't carry; the Unit 7 parity gate
|
||||
* will surface cases where the suffix-match diverges from the
|
||||
* namespace-based resolver and we'll adjust the contract if needed.
|
||||
* When `.csproj` configs are available, consults the legacy
|
||||
* namespace-directory resolver first. Both that resolver's suffix
|
||||
* fallback and the progressive prefix stripping below are gated on
|
||||
* declared in-repo namespaces so BCL usings like `System.Threading.Tasks`
|
||||
* cannot spuriously match a local `Tasks.cs` (#1881).
|
||||
*
|
||||
* Returning `null` lets the finalize algorithm mark the edge as
|
||||
* `linkStatus: 'unresolved'`.
|
||||
*/
|
||||
|
||||
import type { ParsedImport, WorkspaceIndex } from 'gitnexus-shared';
|
||||
import type { CSharpProjectConfig, CSharpNamespaceEvidence } from '../../language-config.js';
|
||||
import { resolveCSharpImportInternal } from '../../import-resolvers/csharp.js';
|
||||
import { buildSuffixIndex, type SuffixIndex } from '../../import-resolvers/utils.js';
|
||||
import { csharpSuffixFallbackAllowed } from '../../csharp-namespace-gate.js';
|
||||
|
||||
export interface CsharpResolveContext {
|
||||
readonly fromFile: string;
|
||||
readonly allFilePaths: ReadonlySet<string>;
|
||||
readonly csharpConfigs?: readonly CSharpProjectConfig[];
|
||||
readonly namespaces?: CSharpNamespaceEvidence;
|
||||
}
|
||||
|
||||
/** Normalized file list + suffix index, built once per workspace `allFilePaths`. */
|
||||
interface WorkspaceFileIndex {
|
||||
readonly normalized: string[];
|
||||
readonly all: string[];
|
||||
readonly index: SuffixIndex;
|
||||
}
|
||||
|
||||
// Memoize on Set identity: the orchestrator passes the SAME `allFilePaths`
|
||||
// Set through every `resolveImportTarget` call in a pass, so this rebuilds
|
||||
// the normalized list + suffix index once instead of once per import (#1881 #2).
|
||||
const workspaceFileIndexCache = new WeakMap<ReadonlySet<string>, WorkspaceFileIndex>();
|
||||
|
||||
function getWorkspaceFileIndex(allFilePaths: ReadonlySet<string>): WorkspaceFileIndex {
|
||||
const cached = workspaceFileIndexCache.get(allFilePaths);
|
||||
if (cached) return cached;
|
||||
const all = [...allFilePaths];
|
||||
const normalized = all.map((f) => f.replace(/\\/g, '/'));
|
||||
const built: WorkspaceFileIndex = { normalized, all, index: buildSuffixIndex(normalized, all) };
|
||||
workspaceFileIndexCache.set(allFilePaths, built);
|
||||
return built;
|
||||
}
|
||||
|
||||
export function resolveCsharpImportTarget(
|
||||
parsedImport: ParsedImport,
|
||||
workspaceIndex: WorkspaceIndex,
|
||||
): string | null {
|
||||
// WorkspaceIndex is `unknown` in the shared contract (Ring 1
|
||||
// placeholder). The scope-resolution orchestrator hands us a
|
||||
// CsharpResolveContext-shaped object; narrow structurally rather
|
||||
// than via a cast chain so unexpected shapes return null cleanly.
|
||||
const ctx = narrowContext(workspaceIndex);
|
||||
if (ctx === null) return null;
|
||||
if (parsedImport.kind === 'dynamic-unresolved') return null;
|
||||
if (parsedImport.targetRaw === null || parsedImport.targetRaw === '') return null;
|
||||
const targetRaw = parsedImport.targetRaw;
|
||||
const evidence = ctx.namespaces;
|
||||
|
||||
const csharpConfigs = ctx.csharpConfigs ?? [];
|
||||
if (csharpConfigs.length > 0) {
|
||||
const { normalized, all, index } = getWorkspaceFileIndex(ctx.allFilePaths);
|
||||
const fromCsproj = resolveCSharpImportInternal(
|
||||
targetRaw,
|
||||
[...csharpConfigs],
|
||||
normalized,
|
||||
all,
|
||||
index,
|
||||
evidence,
|
||||
);
|
||||
if (fromCsproj.length > 0) return fromCsproj[0]!;
|
||||
// csproj configs are authoritative: mirror legacy `configs/csharp.ts`,
|
||||
// which returns an empty result to STOP the chain. Falling through to the
|
||||
// ungated `resolveDirectMatch` would re-introduce the BCL→local match the
|
||||
// internal resolver's gate just suppressed (#1881 parity, #2).
|
||||
return null;
|
||||
}
|
||||
|
||||
// Namespace path: `System.Collections.Generic` → `System/Collections/Generic`.
|
||||
const pathLike = targetRaw.replace(/\./g, '/');
|
||||
|
||||
// Gate the WHOLE no-csproj path on declared in-repo namespaces — the direct
|
||||
// path/suffix match INCLUDED — so a BCL using can't resolve to a
|
||||
// coincidentally path-aligned local file (e.g. `Legacy/System/Threading/
|
||||
// Tasks.cs` satisfying `using System.Threading.Tasks;`). Running the gate
|
||||
// before `resolveDirectMatch` mirrors the legacy leg's gate-first ordering
|
||||
// (`import-resolvers/configs/csharp.ts`), so the two legs are equivalent
|
||||
// (#1881 parity, Codex F2). The gate keeps its fail-open for
|
||||
// undefined/truncated evidence, so legitimate edges in unscanned repos are
|
||||
// unaffected.
|
||||
if (!csharpSuffixFallbackAllowed(targetRaw, evidence)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Exact file / nested-suffix / namespace-dir direct-child match.
|
||||
const direct = resolveDirectMatch(ctx.allFilePaths, pathLike);
|
||||
if (direct !== null) return direct;
|
||||
|
||||
// Progressive prefix stripping — mirrors csproj's root-namespace mapping
|
||||
// without the csproj.
|
||||
return resolveByProgressiveStripping(ctx.allFilePaths, pathLike);
|
||||
}
|
||||
|
||||
/**
|
||||
* `WorkspaceIndex` is an opaque `unknown` placeholder in the shared contract;
|
||||
* the orchestrator hands us a `CsharpResolveContext`-shaped object. Narrow
|
||||
* structurally rather than via a cast chain so unexpected shapes fail cleanly.
|
||||
*/
|
||||
function narrowContext(workspaceIndex: WorkspaceIndex): CsharpResolveContext | null {
|
||||
const ctx = workspaceIndex as CsharpResolveContext | undefined;
|
||||
if (
|
||||
ctx === undefined ||
|
||||
|
|
@ -41,90 +123,78 @@ export function resolveCsharpImportTarget(
|
|||
) {
|
||||
return null;
|
||||
}
|
||||
if (parsedImport.kind === 'dynamic-unresolved') return null;
|
||||
if (parsedImport.targetRaw === null || parsedImport.targetRaw === '') return null;
|
||||
return ctx;
|
||||
}
|
||||
|
||||
// Namespace path: `System.Collections.Generic` → `System/Collections/Generic`.
|
||||
const pathLike = parsedImport.targetRaw.replace(/\./g, '/');
|
||||
const suffix = `/${pathLike}`;
|
||||
|
||||
// Exact file match: `System/Collections/Generic.cs` (rare but legal).
|
||||
// Suffix match for nested layouts: `src/lib/System/Collections/Generic.cs`.
|
||||
// Directory match: first `.cs` file directly inside the namespace dir
|
||||
// (e.g. `System/Collections/Generic/List.cs` matches namespace Generic).
|
||||
let exactFile: string | null = null;
|
||||
/**
|
||||
* First-pass resolution against the full namespace path:
|
||||
* exact whole-path file > nested suffix file > first `.cs` directly inside
|
||||
* the namespace directory.
|
||||
*/
|
||||
function resolveDirectMatch(allFilePaths: ReadonlySet<string>, pathLike: string): string | null {
|
||||
const exactName = `${pathLike}.cs`;
|
||||
const nestedSuffix = `/${exactName}`;
|
||||
let suffixFile: string | null = null;
|
||||
let directoryChild: string | null = null;
|
||||
const dirPrefix = `${pathLike}/`;
|
||||
const suffixDirPrefix = `/${dirPrefix}`;
|
||||
|
||||
for (const raw of ctx.allFilePaths) {
|
||||
for (const raw of allFilePaths) {
|
||||
const f = raw.replace(/\\/g, '/');
|
||||
if (!f.endsWith('.cs')) continue;
|
||||
if (f === `${pathLike}.cs`) {
|
||||
exactFile = raw;
|
||||
break;
|
||||
}
|
||||
if (suffixFile === null && f.endsWith(`${suffix}.cs`)) {
|
||||
suffixFile = raw;
|
||||
}
|
||||
if (directoryChild === null) {
|
||||
// Namespace-to-directory match: pick the first `.cs` directly in
|
||||
// the namespace dir (not nested deeper). Legacy resolver emits
|
||||
// all of them; we take one so the scope-resolver contract stays
|
||||
// single-target.
|
||||
const atRoot = f.startsWith(dirPrefix);
|
||||
const atNested = f.includes(suffixDirPrefix);
|
||||
if (atRoot || atNested) {
|
||||
const idx = atRoot ? 0 : f.indexOf(suffixDirPrefix) + 1;
|
||||
const after = f.slice(idx + dirPrefix.length);
|
||||
if (after.length > 0 && !after.includes('/')) {
|
||||
directoryChild = raw;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (f === exactName) return raw; // exact whole-path match wins
|
||||
if (suffixFile === null && f.endsWith(nestedSuffix)) suffixFile = raw;
|
||||
}
|
||||
|
||||
if (exactFile !== null) return exactFile;
|
||||
if (suffixFile !== null) return suffixFile;
|
||||
if (directoryChild !== null) return directoryChild;
|
||||
return findDirectChild(allFilePaths, pathLike);
|
||||
}
|
||||
|
||||
// Progressive prefix stripping — mirrors csproj's root-namespace
|
||||
// mapping without the csproj. `using CrossFile.Models;` in a repo
|
||||
// laid out `Models/User.cs` (no `CrossFile/` prefix) works because
|
||||
// the legacy resolver consults csproj; the scope-resolver layer
|
||||
// doesn't have csproj, so we try each suffix of the namespace path
|
||||
// against `.cs` files and directories.
|
||||
//
|
||||
// Also handles `using static CrossFile.Models.UserFactory;` —
|
||||
// strip the leading segment, try `Models/UserFactory.cs`; strip
|
||||
// two, try `UserFactory.cs`.
|
||||
/**
|
||||
* First `.cs` file that lives directly inside the namespace directory
|
||||
* `dirSegment` (at repo root or nested under a project prefix), not deeper.
|
||||
* The legacy resolver emits all of them; the scope-resolver contract is
|
||||
* single-target so we take one.
|
||||
*/
|
||||
function findDirectChild(allFilePaths: ReadonlySet<string>, dirSegment: string): string | null {
|
||||
const dirPrefix = `${dirSegment}/`;
|
||||
const nestedDirPrefix = `/${dirPrefix}`;
|
||||
for (const raw of allFilePaths) {
|
||||
const f = raw.replace(/\\/g, '/');
|
||||
if (!f.endsWith('.cs')) continue;
|
||||
const atRoot = f.startsWith(dirPrefix);
|
||||
const atNested = f.includes(nestedDirPrefix);
|
||||
if (!atRoot && !atNested) continue;
|
||||
const idx = atRoot ? 0 : f.indexOf(nestedDirPrefix) + 1;
|
||||
const after = f.slice(idx + dirPrefix.length);
|
||||
if (after.length > 0 && !after.includes('/')) return raw;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Try each suffix of the namespace path against `.cs` files and directories,
|
||||
* stripping leading segments one at a time. Models `using CrossFile.Models;`
|
||||
* resolving to `Models/User.cs` in a repo laid out without the `CrossFile/`
|
||||
* prefix (the scope-resolver layer has no csproj to consult).
|
||||
*/
|
||||
function resolveByProgressiveStripping(
|
||||
allFilePaths: ReadonlySet<string>,
|
||||
pathLike: string,
|
||||
): string | null {
|
||||
const segments = pathLike.split('/').filter(Boolean);
|
||||
for (let skip = 1; skip < segments.length; skip++) {
|
||||
const tail = segments.slice(skip).join('/');
|
||||
if (tail === '') continue;
|
||||
const tailFile = `${tail}.cs`;
|
||||
const tailSuffix = `/${tailFile}`;
|
||||
const tailDir = `${tail}/`;
|
||||
const tailSuffixDir = `/${tailDir}`;
|
||||
let tailDirectChild: string | null = null;
|
||||
for (const raw of ctx.allFilePaths) {
|
||||
let tailFileMatch: string | null = null;
|
||||
for (const raw of allFilePaths) {
|
||||
const f = raw.replace(/\\/g, '/');
|
||||
if (!f.endsWith('.cs')) continue;
|
||||
if (f === tailFile) return raw;
|
||||
if (f.endsWith(tailSuffix)) return raw;
|
||||
if (tailDirectChild === null) {
|
||||
const atRoot = f.startsWith(tailDir);
|
||||
const atNested = f.includes(tailSuffixDir);
|
||||
if (atRoot || atNested) {
|
||||
const idx = atRoot ? 0 : f.indexOf(tailSuffixDir) + 1;
|
||||
const after = f.slice(idx + tailDir.length);
|
||||
if (after.length > 0 && !after.includes('/')) tailDirectChild = raw;
|
||||
}
|
||||
if (f === tailFile || f.endsWith(tailSuffix)) {
|
||||
tailFileMatch = raw;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (tailDirectChild !== null) return tailDirectChild;
|
||||
if (tailFileMatch !== null) return tailFileMatch;
|
||||
const child = findDirectChild(allFilePaths, tail);
|
||||
if (child !== null) return child;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -48,19 +48,62 @@ export interface CsharpFileStructure {
|
|||
/** Dotted paths from `using static X.Y.Z;` (including
|
||||
* `global using static` and aliased `using static A = X.Y.Z;`). */
|
||||
readonly usingStaticPaths: readonly string[];
|
||||
/** True when the scanner saw a `namespace` / `using static` declaration it
|
||||
* could not fully capture (keyword not at line start, split across lines, or
|
||||
* an unparseable identifier form). Callers feeding the #1881 gate must treat
|
||||
* this like a truncated scan and fail OPEN, since a dropped namespace would
|
||||
* otherwise over-block a legitimate import (Codex F3). Absent/false on a
|
||||
* cleanly-scanned file. */
|
||||
readonly incomplete?: boolean;
|
||||
}
|
||||
|
||||
// A dotted C# namespace identifier: each segment is an optional verbatim `@`
|
||||
// followed by a Unicode letter/`_` and Unicode letters/digits/`_`. The `u` flag
|
||||
// makes the classes Unicode-aware so `namespace Café.Models;` is captured (the
|
||||
// old ASCII `[A-Za-z…]` truncated it). The `@` markers are stripped from the
|
||||
// capture so it matches the tree-sitter AST's `name` text.
|
||||
const CS_NS_IDENT = String.raw`@?[\p{L}_][\p{L}\p{N}_]*(?:\.@?[\p{L}_][\p{L}\p{N}_]*)*`;
|
||||
|
||||
// Line-anchored matchers for the worker-path fallback (see
|
||||
// `extractCsharpStructureViaScanner`). Anchored at line start (after
|
||||
// indentation); the scanner additionally tracks block-comment / string
|
||||
// state across lines so a keyword at the start of a line inside one of
|
||||
// those regions is skipped.
|
||||
const CS_NAMESPACE_RE = /^[ \t]*namespace[ \t]+([A-Za-z_@][A-Za-z0-9_.]*)/;
|
||||
const CS_NAMESPACE_RE = new RegExp(String.raw`^[ \t]*namespace[ \t]+(${CS_NS_IDENT})`, 'u');
|
||||
// `global using static`, plain `using static`, and the aliased
|
||||
// `using static Alias = NS.Type;` form (the AST keeps the RHS path, so
|
||||
// the optional `Alias =` is skipped and only the dotted path captured).
|
||||
const CS_USING_STATIC_RE =
|
||||
/^[ \t]*(?:global[ \t]+)?using[ \t]+static[ \t]+(?:[A-Za-z_@][A-Za-z0-9_]*[ \t]*=[ \t]*)?([A-Za-z_@][A-Za-z0-9_.]*)/;
|
||||
const CS_USING_STATIC_RE = new RegExp(
|
||||
String.raw`^[ \t]*(?:global[ \t]+)?using[ \t]+static[ \t]+(?:@?[\p{L}_][\p{L}\p{N}_]*[ \t]*=[ \t]*)?(${CS_NS_IDENT})`,
|
||||
'u',
|
||||
);
|
||||
|
||||
// Incompleteness detectors — used ONLY when the precise matchers above failed,
|
||||
// to flag a declaration the scanner could not capture (so the file fails the
|
||||
// #1881 gate OPEN instead of silently dropping the namespace). Kept
|
||||
// high-precision so ordinary files never trip them (which would wrongly disable
|
||||
// the gate repo-wide):
|
||||
// - `…_BARE`: the keyword alone on a line (the name is on the next line).
|
||||
// - `…_AT_START`: a line-start declaration the precise matcher couldn't parse.
|
||||
// - `CS_NAMESPACE_AFTER_CODE`: a `namespace` keyword right after a `}`/`;`/`{`/`]`
|
||||
// (real code, NOT a `//` comment), i.e. not at line start.
|
||||
const CS_NAMESPACE_BARE = /^[ \t]*namespace[ \t]*\r?$/;
|
||||
const CS_USING_STATIC_BARE = /^[ \t]*(?:global[ \t]+)?using[ \t]+static[ \t]*\r?$/;
|
||||
const CS_NAMESPACE_AT_START = /^[ \t]*namespace[ \t]+\S/;
|
||||
const CS_USING_STATIC_AT_START = /^[ \t]*(?:global[ \t]+)?using[ \t]+static[ \t]+\S/;
|
||||
const CS_NAMESPACE_AFTER_CODE = /[}\];{][ \t]*namespace[ \t]+@?[\p{L}_]/u;
|
||||
|
||||
/** Whether a `code`-state line declares a namespace / using-static the precise
|
||||
* matchers could not capture — see the detectors above. */
|
||||
function looksLikeUncapturedDeclaration(line: string): boolean {
|
||||
return (
|
||||
CS_NAMESPACE_BARE.test(line) ||
|
||||
CS_USING_STATIC_BARE.test(line) ||
|
||||
CS_NAMESPACE_AT_START.test(line) ||
|
||||
CS_USING_STATIC_AT_START.test(line) ||
|
||||
CS_NAMESPACE_AFTER_CODE.test(line)
|
||||
);
|
||||
}
|
||||
|
||||
/** Multi-line lexical state carried line-to-line by the scanner. */
|
||||
type CsScanState = 'code' | 'block' | 'verbatim' | 'raw';
|
||||
|
|
@ -182,26 +225,60 @@ function advanceCsScanState(
|
|||
* AST is a declaration whose keyword is not at the start of a code line
|
||||
* (split across lines, or sharing a line with a comment/string closer).
|
||||
* Mirrors PHP's `extractNamespaceViaScanner` (issue #1741). */
|
||||
export function extractCsharpStructureViaScanner(content: string): CsharpFileStructure {
|
||||
/** Incremental form of {@link extractCsharpStructureViaScanner}: feed lines one
|
||||
* at a time via `pushLine` (in source order), then read the accumulated
|
||||
* structure with `result()`. Lets a caller stream a file off disk
|
||||
* (`createReadStream` + `readline`) and scan it for `namespace` / `using
|
||||
* static` declarations in CONSTANT memory rather than buffering the whole file
|
||||
* into a string — the line splitting and per-line matching are identical, so a
|
||||
* streamed scan yields the same result as scanning the full content. The line
|
||||
* terminator must be stripped (as `readline` does, or `String.split('\n')`); a
|
||||
* trailing `\r` on a CRLF line is inert to both the matchers and the lexer. */
|
||||
export interface CsharpStructureLineScanner {
|
||||
pushLine(line: string): void;
|
||||
result(): CsharpFileStructure;
|
||||
}
|
||||
|
||||
/** Create a fresh stateful line scanner — see {@link CsharpStructureLineScanner}. */
|
||||
export function createCsharpStructureScanner(): CsharpStructureLineScanner {
|
||||
const namespaces: string[] = [];
|
||||
const usingStaticPaths: string[] = [];
|
||||
let incomplete = false;
|
||||
let state: CsScanState = 'code';
|
||||
let rawFence = 0;
|
||||
for (const line of content.split('\n')) {
|
||||
// Only match when the line START is real code — keywords reached while
|
||||
// inside a block comment / multi-line string are skipped.
|
||||
if (state === 'code') {
|
||||
const ns = CS_NAMESPACE_RE.exec(line);
|
||||
if (ns !== null) {
|
||||
namespaces.push(ns[1]!);
|
||||
} else {
|
||||
const us = CS_USING_STATIC_RE.exec(line);
|
||||
if (us !== null) usingStaticPaths.push(us[1]!);
|
||||
return {
|
||||
pushLine(line: string): void {
|
||||
// Only match when the line START is real code — keywords reached while
|
||||
// inside a block comment / multi-line string are skipped.
|
||||
if (state === 'code') {
|
||||
const ns = CS_NAMESPACE_RE.exec(line);
|
||||
if (ns !== null) {
|
||||
namespaces.push(ns[1]!.replace(/@/g, ''));
|
||||
} else {
|
||||
const us = CS_USING_STATIC_RE.exec(line);
|
||||
if (us !== null) {
|
||||
usingStaticPaths.push(us[1]!.replace(/@/g, ''));
|
||||
} else if (looksLikeUncapturedDeclaration(line)) {
|
||||
// A declaration the precise matchers couldn't capture → mark the
|
||||
// file incomplete so the #1881 gate fails OPEN (Codex F3).
|
||||
incomplete = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
[state, rawFence] = advanceCsScanState(line, state, rawFence);
|
||||
}
|
||||
return { namespaces, usingStaticPaths };
|
||||
[state, rawFence] = advanceCsScanState(line, state, rawFence);
|
||||
},
|
||||
result(): CsharpFileStructure {
|
||||
return incomplete
|
||||
? { namespaces, usingStaticPaths, incomplete }
|
||||
: { namespaces, usingStaticPaths };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function extractCsharpStructureViaScanner(content: string): CsharpFileStructure {
|
||||
const scanner = createCsharpStructureScanner();
|
||||
for (const line of content.split('\n')) scanner.pushLine(line);
|
||||
return scanner.result();
|
||||
}
|
||||
|
||||
/** Build a structural view of a C# file. Prefers `cachedTree` (handed in
|
||||
|
|
|
|||
|
|
@ -0,0 +1,30 @@
|
|||
/**
|
||||
* Per-workspace config for C# scope-resolution import targeting.
|
||||
*
|
||||
* Loaded once per analyze pass via `csharpScopeResolver.loadResolutionConfig`
|
||||
* and threaded into `resolveCsharpImportTarget`. The pure gate predicates live
|
||||
* in `../../csharp-namespace-gate.ts` (shared with the legacy DAG resolver).
|
||||
*/
|
||||
|
||||
import {
|
||||
scanCSharpProject,
|
||||
csharpScanToEvidence,
|
||||
type CSharpProjectConfig,
|
||||
type CSharpNamespaceEvidence,
|
||||
} from '../../language-config.js';
|
||||
|
||||
export interface CsharpResolutionConfig {
|
||||
readonly csharpConfigs: readonly CSharpProjectConfig[];
|
||||
/** In-repo declared-namespace evidence gating suffix-fallback resolution (#1881). */
|
||||
readonly namespaces?: CSharpNamespaceEvidence;
|
||||
}
|
||||
|
||||
export async function loadCsharpResolutionConfig(
|
||||
repoRoot: string,
|
||||
): Promise<CsharpResolutionConfig> {
|
||||
const scan = await scanCSharpProject(repoRoot);
|
||||
return {
|
||||
csharpConfigs: scan.configs,
|
||||
namespaces: csharpScanToEvidence(scan),
|
||||
};
|
||||
}
|
||||
|
|
@ -19,6 +19,7 @@ import {
|
|||
type CsharpResolveContext,
|
||||
} from './index.js';
|
||||
import { populateCsharpNamespaceSiblings } from './namespace-siblings.js';
|
||||
import { loadCsharpResolutionConfig, type CsharpResolutionConfig } from './resolution-config.js';
|
||||
import { unwrapCsharpCollectionAccessor } from './accessor-unwrap.js';
|
||||
|
||||
const csharpScopeResolver: ScopeResolver = {
|
||||
|
|
@ -26,8 +27,16 @@ const csharpScopeResolver: ScopeResolver = {
|
|||
languageProvider: csharpProvider,
|
||||
importEdgeReason: 'csharp-scope: using',
|
||||
|
||||
resolveImportTarget: (targetRaw, fromFile, allFilePaths) => {
|
||||
const ws: CsharpResolveContext = { fromFile, allFilePaths };
|
||||
loadResolutionConfig: (repoPath) => loadCsharpResolutionConfig(repoPath),
|
||||
|
||||
resolveImportTarget: (targetRaw, fromFile, allFilePaths, resolutionConfig) => {
|
||||
const config = resolutionConfig as CsharpResolutionConfig | undefined;
|
||||
const ws: CsharpResolveContext = {
|
||||
fromFile,
|
||||
allFilePaths,
|
||||
csharpConfigs: config?.csharpConfigs,
|
||||
namespaces: config?.namespaces,
|
||||
};
|
||||
// `WorkspaceIndex` is an opaque `unknown` placeholder in the
|
||||
// shared contract, so `ws` passes structurally without a cast.
|
||||
return resolveCsharpImportTarget(
|
||||
|
|
|
|||
|
|
@ -1,10 +1,5 @@
|
|||
import type { Capture, CaptureMatch } from 'gitnexus-shared';
|
||||
import {
|
||||
findNodeAtRange,
|
||||
nodeToCapture,
|
||||
syntheticCapture,
|
||||
type SyntaxNode,
|
||||
} from '../../utils/ast-helpers.js';
|
||||
import { nodeToCapture, syntheticCapture, type SyntaxNode } from '../../utils/ast-helpers.js';
|
||||
import { getGoParser, getGoScopeQuery } from './query.js';
|
||||
import { recordGoCacheHit, recordGoCacheMiss } from './cache-stats.js';
|
||||
import { computeGoCallArity, computeGoDeclarationArity } from './arity-metadata.js';
|
||||
|
|
@ -34,18 +29,29 @@ export function emitGoScopeCaptures(
|
|||
|
||||
for (const m of rawMatches) {
|
||||
const grouped: Record<string, Capture> = {};
|
||||
// Parallel tag -> captured SyntaxNode map. The tree-sitter query already
|
||||
// hands us the matched node as `c.node`; keeping it here lets us derive the
|
||||
// anchor/relative node by walking LOCALLY (parent chain / own subtree)
|
||||
// instead of re-walking from tree.rootNode (the O(matches x rootChildren)
|
||||
// hotpath that made #1848's 250-struct DAO file take ~10s). The captured
|
||||
// node either IS the node the old findNodeAtRange re-derived, or is a close
|
||||
// relative reachable by a bounded local walk.
|
||||
const nodeMap: Record<string, SyntaxNode> = {};
|
||||
for (const c of m.captures) {
|
||||
const tag = '@' + c.name;
|
||||
if (tag.startsWith('@_')) continue; // skip anonymous captures
|
||||
grouped[tag] = nodeToCapture(tag, c.node);
|
||||
nodeMap[tag] = c.node;
|
||||
}
|
||||
if (Object.keys(grouped).length === 0) continue;
|
||||
|
||||
if (grouped['@import.statement'] !== undefined) {
|
||||
const anchor = grouped['@import.statement']!;
|
||||
const importNode =
|
||||
findNodeAtRange(tree.rootNode, anchor.range, 'import_declaration') ??
|
||||
findNodeAtRange(tree.rootNode, anchor.range, 'import_spec');
|
||||
// The captured node is the `import_spec`; the original code preferred its
|
||||
// enclosing `import_declaration` ONLY when that ancestor shares the exact
|
||||
// same range (which never happens — the declaration always includes the
|
||||
// `import` keyword prefix — so it falls back to the import_spec itself).
|
||||
// Replicate that exactly via a local ancestor walk, never from root.
|
||||
const importNode = resolveImportNode(nodeMap['@import.statement']!);
|
||||
if (importNode !== null) {
|
||||
out.push(...splitGoImportStatement(importNode));
|
||||
continue;
|
||||
|
|
@ -53,23 +59,33 @@ export function emitGoScopeCaptures(
|
|||
}
|
||||
|
||||
if (grouped['@scope.function'] !== undefined) {
|
||||
const scopeCap = grouped['@scope.function']!;
|
||||
// @scope.function captures function_declaration | method_declaration |
|
||||
// func_literal. The original looked for a function_declaration or
|
||||
// method_declaration at the captured range; the captured node IS that
|
||||
// node for the first two, and a func_literal never coincides in range
|
||||
// with either, so the lookup yields null for func_literal.
|
||||
const scopeNode = nodeMap['@scope.function']!;
|
||||
const fnNode =
|
||||
findNodeAtRange(tree.rootNode, scopeCap.range, 'function_declaration') ??
|
||||
findNodeAtRange(tree.rootNode, scopeCap.range, 'method_declaration');
|
||||
scopeNode.type === 'function_declaration' || scopeNode.type === 'method_declaration'
|
||||
? scopeNode
|
||||
: null;
|
||||
if (fnNode !== null) {
|
||||
const receiver = synthesizeGoReceiverBinding(fnNode);
|
||||
if (receiver !== null) out.push(receiver);
|
||||
}
|
||||
}
|
||||
|
||||
if (isRawMultiAssignTypeBinding(tree.rootNode, grouped)) continue;
|
||||
if (isRawMultiAssignTypeBinding(nodeMap)) continue;
|
||||
|
||||
const declAnchor = grouped['@declaration.function'] ?? grouped['@declaration.method'];
|
||||
if (declAnchor !== undefined) {
|
||||
const declAnchorNode = nodeMap['@declaration.function'] ?? nodeMap['@declaration.method'];
|
||||
if (declAnchorNode !== undefined) {
|
||||
// @declaration.function / @declaration.method are captured directly on
|
||||
// the function_declaration / method_declaration node.
|
||||
const fnNode =
|
||||
findNodeAtRange(tree.rootNode, declAnchor.range, 'function_declaration') ??
|
||||
findNodeAtRange(tree.rootNode, declAnchor.range, 'method_declaration');
|
||||
declAnchorNode.type === 'function_declaration' ||
|
||||
declAnchorNode.type === 'method_declaration'
|
||||
? declAnchorNode
|
||||
: null;
|
||||
if (fnNode !== null) {
|
||||
const arity = computeGoDeclarationArity(fnNode);
|
||||
if (arity.parameterCount !== undefined) {
|
||||
|
|
@ -98,15 +114,15 @@ export function emitGoScopeCaptures(
|
|||
continue;
|
||||
}
|
||||
|
||||
const callAnchor =
|
||||
grouped['@reference.call.free'] ??
|
||||
grouped['@reference.call.member'] ??
|
||||
grouped['@reference.call.constructor'];
|
||||
if (callAnchor !== undefined && grouped['@reference.arity'] === undefined) {
|
||||
const callNode =
|
||||
findNodeAtRange(tree.rootNode, callAnchor.range, 'call_expression') ??
|
||||
findNodeAtRange(tree.rootNode, callAnchor.range, 'composite_literal');
|
||||
if (callNode !== null) {
|
||||
// @reference.call.free / .member are captured on the call_expression;
|
||||
// @reference.call.constructor on the composite_literal. The captured node
|
||||
// IS the node the old findNodeAtRange re-derived for each, so use it.
|
||||
const callNode =
|
||||
nodeMap['@reference.call.free'] ??
|
||||
nodeMap['@reference.call.member'] ??
|
||||
nodeMap['@reference.call.constructor'];
|
||||
if (callNode !== undefined && grouped['@reference.arity'] === undefined) {
|
||||
if (callNode.type === 'call_expression' || callNode.type === 'composite_literal') {
|
||||
grouped['@reference.arity'] = syntheticCapture(
|
||||
'@reference.arity',
|
||||
callNode,
|
||||
|
|
@ -146,18 +162,56 @@ export function emitGoScopeCaptures(
|
|||
return out;
|
||||
}
|
||||
|
||||
function isRawMultiAssignTypeBinding(
|
||||
rootNode: SyntaxNode,
|
||||
grouped: Record<string, Capture>,
|
||||
): boolean {
|
||||
/**
|
||||
* Resolve the node passed to `splitGoImportStatement` for an @import.statement
|
||||
* match. The capture is on the `import_spec`; the original preferred an
|
||||
* `import_declaration` at the SAME range, else the import_spec. An
|
||||
* import_declaration always includes the `import` keyword and so never shares
|
||||
* the spec's exact range — the only candidate is an ancestor, and it can only
|
||||
* match when ranges coincide. Walk the parent chain (bounded, local) for an
|
||||
* import_declaration whose range equals the spec's; otherwise return the spec.
|
||||
*/
|
||||
function resolveImportNode(importSpec: SyntaxNode): SyntaxNode {
|
||||
let current: SyntaxNode | null = importSpec.parent;
|
||||
while (current !== null) {
|
||||
if (current.type === 'import_declaration') {
|
||||
if (nodeRangeEquals(current, importSpec)) return current;
|
||||
break;
|
||||
}
|
||||
// import_spec is nested at most under import_declaration ->
|
||||
// import_spec_list -> import_spec; stop once we leave the import subtree.
|
||||
if (current.type !== 'import_spec_list') break;
|
||||
current = current.parent;
|
||||
}
|
||||
return importSpec;
|
||||
}
|
||||
|
||||
/** True iff two nodes occupy the exact same source range. */
|
||||
function nodeRangeEquals(a: SyntaxNode, b: SyntaxNode): boolean {
|
||||
return (
|
||||
a.startPosition.row === b.startPosition.row &&
|
||||
a.startPosition.column === b.startPosition.column &&
|
||||
a.endPosition.row === b.endPosition.row &&
|
||||
a.endPosition.column === b.endPosition.column
|
||||
);
|
||||
}
|
||||
|
||||
function isRawMultiAssignTypeBinding(nodeMap: Record<string, SyntaxNode>): boolean {
|
||||
const anchor =
|
||||
grouped['@type-binding.constructor'] ??
|
||||
grouped['@type-binding.call-return'] ??
|
||||
grouped['@type-binding.assertion'];
|
||||
nodeMap['@type-binding.constructor'] ??
|
||||
nodeMap['@type-binding.call-return'] ??
|
||||
nodeMap['@type-binding.assertion'];
|
||||
if (anchor === undefined) return false;
|
||||
|
||||
const node = findNodeAtRange(rootNode, anchor.range, 'short_var_declaration');
|
||||
if (node === null) return false;
|
||||
// These tags are captured directly ON the short_var_declaration, so the
|
||||
// captured node IS what the original findNodeAtRange(root, range,
|
||||
// 'short_var_declaration') re-derived. The var_declaration (var-form)
|
||||
// variants — @type-binding.assertion (`var x = e.(T)`) and
|
||||
// @type-binding.call-return (`var x = Func()`) — anchor on a var_declaration
|
||||
// instead; the old range+type lookup found no short_var_declaration at that
|
||||
// range and returned null -> false, which this type guard reproduces exactly.
|
||||
if (anchor.type !== 'short_var_declaration') return false;
|
||||
const node = anchor;
|
||||
const lhs = node.childForFieldName('left');
|
||||
const rhs = node.childForFieldName('right');
|
||||
if (lhs === null) return false;
|
||||
|
|
|
|||
|
|
@ -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<string, Capture> = {};
|
||||
// Parallel tag -> captured SyntaxNode map: the query hands us each matched
|
||||
// node as c.node, so anchors resolve via a type-guarded lookup (nodeIfType)
|
||||
// instead of re-deriving them with findNodeAtRange(tree.rootNode, ...) per
|
||||
// match — the O(matches x rootChildren) root-walk fixed for go #1915 /
|
||||
// python #1918, mirrored here.
|
||||
const nodeMap: Record<string, SyntaxNode> = {};
|
||||
for (const c of m.captures) {
|
||||
const tag = '@' + c.name;
|
||||
grouped[tag] = nodeToCapture(tag, c.node);
|
||||
nodeMap[tag] = c.node;
|
||||
}
|
||||
if (Object.keys(grouped).length === 0) continue;
|
||||
|
||||
|
|
@ -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 ──────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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<string, Capture> = {};
|
||||
// Parallel tag -> captured SyntaxNode map. The tree-sitter query already
|
||||
// hands us each matched node as `c.node`, so anchor nodes can be used
|
||||
// directly (or via a bounded LOCAL walk) instead of re-deriving them with
|
||||
// `findNodeAtRange(tree.rootNode, ...)`, which scanned all of root's named
|
||||
// children on every match -> O(matches x rootChildren). That was the #1848
|
||||
// hotpath in Go (fixed in eaf0a305); the same shape lived here in Python.
|
||||
const nodeMap: Record<string, SyntaxNode> = {};
|
||||
for (const c of m.captures) {
|
||||
const tag = '@' + c.name;
|
||||
grouped[tag] = nodeToCapture(tag, c.node);
|
||||
nodeMap[tag] = c.node;
|
||||
}
|
||||
if (Object.keys(grouped).length === 0) continue;
|
||||
|
||||
if (grouped['@import.statement'] !== undefined) {
|
||||
// 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'];
|
||||
|
|
|
|||
|
|
@ -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<string>`. Callers that
|
||||
* only hold a `ReadonlySet` should copy via `new Set(...)` at the
|
||||
* adapter boundary. */
|
||||
readonly allFilePaths: Set<string>;
|
||||
/** `ReadonlySet` so the orchestrator's stable run-level set flows straight
|
||||
* through to `getPythonFileIndex`'s `WeakMap` key (built once per run, not
|
||||
* copied per import). The whole resolver chain only reads the set. */
|
||||
readonly allFilePaths: ReadonlySet<string>;
|
||||
}
|
||||
|
||||
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<string>` and the chain only ever calls `.has()` + iterates, so
|
||||
// any set-like is valid. An `instanceof Set` check would reject a legitimate
|
||||
// non-`Set` `ReadonlySet` implementation and silently return null for every
|
||||
// import (PR #1918 tri-review P2).
|
||||
const allFilePaths = (ctx as { allFilePaths?: unknown } | undefined)?.allFilePaths;
|
||||
if (
|
||||
ctx === undefined ||
|
||||
typeof (ctx as { fromFile?: unknown }).fromFile !== 'string' ||
|
||||
!((ctx as { allFilePaths?: unknown }).allFilePaths instanceof Set)
|
||||
typeof (allFilePaths as { has?: unknown } | undefined)?.has !== 'function' ||
|
||||
typeof (allFilePaths as Iterable<string> | undefined)?.[Symbol.iterator] !== 'function'
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
|
@ -103,7 +110,7 @@ export function resolvePythonImportTarget(
|
|||
*/
|
||||
function resolveAbsoluteFromFiles(
|
||||
pathLike: string,
|
||||
allFilePaths: Set<string>,
|
||||
allFilePaths: ReadonlySet<string>,
|
||||
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 `…/<pathLike>.py` has basename
|
||||
// `<lastSeg>.py`; one matching `…/<pathLike>/__init__.py` has basename
|
||||
// `__init__.py`. Look up only those basename buckets and confirm the full
|
||||
// suffix, instead of scanning every file (the O(imports x files) hotpath).
|
||||
// The candidate SET is identical to the old full scan, and the tie-break
|
||||
// sort below fully determines the result, so output is unchanged. The
|
||||
// shared buildSuffixIndex is deliberately NOT used: it keeps only one
|
||||
// path per suffix (longest wins) and so cannot reproduce this exact
|
||||
// fewest-segments-then-lexicographic tie-break across all candidates.
|
||||
const index = getPythonFileIndex(allFilePaths);
|
||||
const lastSeg = pathLike.slice(pathLike.lastIndexOf('/') + 1);
|
||||
const matches: { raw: string; norm: string }[] = [];
|
||||
for (const 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 `<lastSeg>`
|
||||
// can match `…/<lastSeg>/__init__.py` — look them up by parent key (P2b) and
|
||||
// confirm the full suffix. Same final candidate set as the old `__init__.py`
|
||||
// scan, just without iterating unrelated packages.
|
||||
for (const cand of index.byInitParent.get(`${lastSeg}/__init__.py`) ?? []) {
|
||||
if (cand.norm.endsWith(suffixPkg)) matches.push(cand);
|
||||
}
|
||||
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<string>,
|
||||
allFilePaths: ReadonlySet<string>,
|
||||
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
|
||||
* (`<parentDir>/__init__.py`). The package suffix lookup (`pkg.sub` ->
|
||||
* `…/sub/__init__.py`) targets only same-named package dirs via this map
|
||||
* instead of scanning every `__init__.py` in the repo — the common
|
||||
* multi-segment import path no longer scales with package count
|
||||
* (PR #1918 review P2b). `__init__.py` files stay in `byBasename` too, for
|
||||
* the rarer explicit `pkg.__init__` import that resolves via the module
|
||||
* (`…<lastSeg>.py`) lookup.
|
||||
* - `dirPrefixes`: every directory prefix of a `.py` file, trailing-slashed
|
||||
* (`a/b/c.py` -> `a/`, `a/b/`), for "is there a .py file under `<dir>/`".
|
||||
*/
|
||||
interface PythonFileIndex {
|
||||
readonly normSet: Set<string>;
|
||||
readonly byBasename: Map<string, { raw: string; norm: string }[]>;
|
||||
readonly byInitParent: Map<string, { raw: string; norm: string }[]>;
|
||||
readonly dirPrefixes: Set<string>;
|
||||
}
|
||||
|
||||
const PYTHON_FILE_INDEX_CACHE = new WeakMap<ReadonlySet<string>, PythonFileIndex>();
|
||||
|
||||
function getPythonFileIndex(allFilePaths: ReadonlySet<string>): PythonFileIndex {
|
||||
const cached = PYTHON_FILE_INDEX_CACHE.get(allFilePaths);
|
||||
if (cached !== undefined) return cached;
|
||||
// Cache miss: materialize a fresh index. Counted so a test can assert this
|
||||
// happens once per run, not once per import (PR #1918 review P1 guard).
|
||||
recordPythonFileIndexBuild();
|
||||
|
||||
const normSet = new Set<string>();
|
||||
const byBasename = new Map<string, { raw: string; norm: string }[]>();
|
||||
const byInitParent = new Map<string, { raw: string; norm: string }[]>();
|
||||
const dirPrefixes = new Set<string>();
|
||||
|
||||
for (const raw of allFilePaths) {
|
||||
const norm = raw.replace(/\\/g, '/');
|
||||
// Python import resolution only ever queries `.py` paths: module `<seg>.py`
|
||||
// and package `<seg>/__init__.py` membership (normSet), `<lastSeg>.py` /
|
||||
// `__init__.py` basename buckets (byBasename), and `.py` directory prefixes
|
||||
// (dirPrefixes). Non-`.py` files can never match any of those, so skip them
|
||||
// — they were dead weight in every structure on polyglot monorepos
|
||||
// (PR #1918 review P3b; dirPrefixes was already `.py`-gated).
|
||||
if (!norm.endsWith('.py')) continue;
|
||||
normSet.add(norm);
|
||||
|
||||
const lastSlash = norm.lastIndexOf('/');
|
||||
const base = lastSlash >= 0 ? norm.slice(lastSlash + 1) : norm;
|
||||
let bucket = byBasename.get(base);
|
||||
if (bucket === undefined) {
|
||||
bucket = [];
|
||||
byBasename.set(base, bucket);
|
||||
}
|
||||
bucket.push({ raw, norm });
|
||||
|
||||
// Package files also get a parent-keyed bucket so a `pkg.sub` lookup hits
|
||||
// only `…/sub/__init__.py` candidates, not every `__init__.py` (P2b).
|
||||
if (base === '__init__.py' && lastSlash >= 0) {
|
||||
const dir = norm.slice(0, lastSlash);
|
||||
const parentSlash = dir.lastIndexOf('/');
|
||||
const parentName = parentSlash >= 0 ? dir.slice(parentSlash + 1) : dir;
|
||||
if (parentName) {
|
||||
const initKey = `${parentName}/__init__.py`;
|
||||
let ib = byInitParent.get(initKey);
|
||||
if (ib === undefined) {
|
||||
ib = [];
|
||||
byInitParent.set(initKey, ib);
|
||||
}
|
||||
ib.push({ raw, norm });
|
||||
}
|
||||
}
|
||||
|
||||
// Directory prefixes: every slash-terminated prefix of the path (every
|
||||
// index just past a '/', up to and including the file's own directory).
|
||||
// Scanning the FULL normalized path — including any leading '/' for
|
||||
// absolute paths — makes `dirPrefixes.has(X)` match exactly when the old
|
||||
// gate's `f.startsWith(X)` (X always ends in '/') matched. The previous
|
||||
// split+`filter(Boolean)` dropped the leading empty component, so an
|
||||
// absolute file `/repo/svc/x.py` yielded `repo/svc/` (no leading slash) and
|
||||
// gate-passed where `"/repo/svc/x.py".startsWith("repo/svc/")` is false
|
||||
// (PR #1918 review P3a). For relative paths the set is identical.
|
||||
for (let i = 0; i <= lastSlash; i++) {
|
||||
if (norm[i] === '/') dirPrefixes.add(norm.slice(0, i + 1));
|
||||
}
|
||||
}
|
||||
|
||||
const index: PythonFileIndex = { normSet, byBasename, byInitParent, dirPrefixes };
|
||||
PYTHON_FILE_INDEX_CACHE.set(allFilePaths, index);
|
||||
return index;
|
||||
}
|
||||
|
|
|
|||
29
gitnexus/src/core/ingestion/languages/python/index-stats.ts
Normal file
29
gitnexus/src/core/ingestion/languages/python/index-stats.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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<string>`. The copy is O(N) but called
|
||||
// once per import — trivial compared to the parser work.
|
||||
const ws: PythonResolveContext = { fromFile, allFilePaths: new Set(allFilePaths) };
|
||||
// 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(
|
||||
|
|
|
|||
|
|
@ -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<string, Capture> = {};
|
||||
// Parallel tag -> captured SyntaxNode map. The query already hands us each
|
||||
// matched node as c.node, so anchors are used directly (via nodeIfType)
|
||||
// instead of re-deriving them with findNodeAtRange(tree.rootNode, ...) per
|
||||
// match — the O(matches x rootChildren) root-walk fixed for go #1915 /
|
||||
// python #1918, mirrored here.
|
||||
const nodeMap: Record<string, SyntaxNode> = {};
|
||||
for (const c of m.captures) {
|
||||
const tag = '@' + c.name;
|
||||
if (tag.startsWith('@_')) continue;
|
||||
grouped[tag] = nodeToCapture(tag, c.node);
|
||||
nodeMap[tag] = c.node;
|
||||
}
|
||||
if (Object.keys(grouped).length === 0) continue;
|
||||
|
||||
// Decompose require/require_relative/load into import captures
|
||||
if (grouped['@import.statement'] !== undefined) {
|
||||
const anchor = grouped['@import.statement']!;
|
||||
const callNode = 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 = `<name>:<return-binding startLine>`, matching the old AND condition.
|
||||
//
|
||||
// Snapshot-vs-live note (PR #1918 tri-review P3): the old `out.some` was
|
||||
// evaluated LIVE, so it also saw constructor-return bindings this very loop
|
||||
// pushed in earlier iterations. That made the old code suppress the 2nd of
|
||||
// two same-named methods one source row apart whose bodies both end in
|
||||
// `Const.new` (the 1st's pushed binding startLine == the 2nd's row via the
|
||||
// 1-based/0-based offset below). The snapshot is built from the YARD pass
|
||||
// only, so it no longer cross-suppresses — both bindings are emitted, which
|
||||
// is the intended behavior (the cross-suppression was unintended). This
|
||||
// corner is absent from fixtures, so the capture fingerprint is unchanged;
|
||||
// ruby-captures-golden.test.ts pins it explicitly.
|
||||
const yardReturnKeys = new Set<string>();
|
||||
for (const m of out) {
|
||||
const ret = m['@type-binding.return'];
|
||||
const name = m['@type-binding.name'];
|
||||
if (ret !== undefined && name !== undefined) {
|
||||
yardReturnKeys.add(`${name.text}:${ret.range.startLine}`);
|
||||
}
|
||||
}
|
||||
for (const methodNode of [
|
||||
...tree.rootNode.descendantsOfType('method'),
|
||||
...tree.rootNode.descendantsOfType('singleton_method'),
|
||||
]) {
|
||||
const methodName = methodNode.childForFieldName('name')?.text;
|
||||
if (methodName === undefined) continue;
|
||||
// Skip if a YARD @return already created a return binding for this method
|
||||
if (
|
||||
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(
|
||||
|
|
|
|||
|
|
@ -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<string, Capture> = {};
|
||||
// Parallel tag -> captured SyntaxNode map: the query hands us each matched
|
||||
// node as c.node, so anchors resolve via a type-guarded lookup (nodeIfType)
|
||||
// instead of re-deriving them with findNodeAtRange(tree.rootNode, ...) per
|
||||
// match — the O(matches x rootChildren) root-walk fixed for go #1915 /
|
||||
// python #1918, mirrored here.
|
||||
const nodeMap: Record<string, SyntaxNode> = {};
|
||||
for (const c of m.captures) {
|
||||
const tag = '@' + c.name;
|
||||
if (tag.startsWith('@_')) continue;
|
||||
grouped[tag] = nodeToCapture(tag, c.node);
|
||||
nodeMap[tag] = c.node;
|
||||
}
|
||||
if (Object.keys(grouped).length === 0) continue;
|
||||
|
||||
// Decompose use declarations into individual import captures
|
||||
if (grouped['@import.statement'] !== undefined) {
|
||||
const 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));
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import type {
|
|||
MethodVisibility,
|
||||
} from '../../method-types.js';
|
||||
import { hasKeyword } from '../../field-extractors/configs/helpers.js';
|
||||
import { classifyCppParameterType } from '../../languages/cpp/arity-metadata.js';
|
||||
import { extractSimpleTypeName } from '../../type-extractors/shared.js';
|
||||
import type { SyntaxNode } from '../../utils/ast-helpers.js';
|
||||
|
||||
|
|
@ -149,6 +150,11 @@ function extractCppParameters(node: SyntaxNode): ParameterInfo[] {
|
|||
? (extractSimpleTypeName(typeNode) ?? typeNode.text?.trim() ?? null)
|
||||
: null,
|
||||
rawType: typeNode?.text?.trim() ?? null,
|
||||
typeClass: classifyCppParameterType(
|
||||
typeNode?.text?.trim() ?? 'unknown',
|
||||
declNode?.text,
|
||||
param.text,
|
||||
),
|
||||
isOptional: false,
|
||||
isVariadic: false,
|
||||
});
|
||||
|
|
@ -164,6 +170,11 @@ function extractCppParameters(node: SyntaxNode): ParameterInfo[] {
|
|||
? (extractSimpleTypeName(typeNode) ?? typeNode.text?.trim() ?? null)
|
||||
: null,
|
||||
rawType: typeNode?.text?.trim() ?? null,
|
||||
typeClass: classifyCppParameterType(
|
||||
typeNode?.text?.trim() ?? 'unknown',
|
||||
declNode?.text,
|
||||
param.text,
|
||||
),
|
||||
isOptional: true,
|
||||
isVariadic: false,
|
||||
});
|
||||
|
|
@ -180,6 +191,11 @@ function extractCppParameters(node: SyntaxNode): ParameterInfo[] {
|
|||
? (extractSimpleTypeName(typeNode) ?? typeNode.text?.trim() ?? null)
|
||||
: null,
|
||||
rawType: typeNode?.text?.trim() ?? null,
|
||||
typeClass: classifyCppParameterType(
|
||||
typeNode?.text?.trim() ?? 'unknown',
|
||||
declNode?.text,
|
||||
param.text,
|
||||
),
|
||||
isOptional: false,
|
||||
isVariadic: true,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
// gitnexus/src/core/ingestion/method-types.ts
|
||||
|
||||
import type { SupportedLanguages } from 'gitnexus-shared';
|
||||
import type { ParameterTypeClass, SupportedLanguages } from 'gitnexus-shared';
|
||||
import type { FieldVisibility } from './field-types.js';
|
||||
import type { SyntaxNode } from './utils/ast-helpers.js';
|
||||
|
||||
|
|
@ -14,6 +14,7 @@ export interface ParameterInfo {
|
|||
* Used by typeTagForId for overload disambiguation where generic args matter.
|
||||
* Falls back to `type` when not set. */
|
||||
rawType?: string | null;
|
||||
typeClass?: ParameterTypeClass;
|
||||
isOptional: boolean;
|
||||
isVariadic: boolean;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ import {
|
|||
typeTagForId,
|
||||
constTagForId,
|
||||
buildCollisionGroups,
|
||||
parameterShapeIdTag,
|
||||
} from './utils/method-props.js';
|
||||
import {
|
||||
extractTemplateArguments,
|
||||
|
|
@ -665,6 +666,13 @@ const processParsingSequential = async (
|
|||
cached.groups,
|
||||
);
|
||||
}
|
||||
const parameterShapeTag =
|
||||
nodeLabel === 'Function' || nodeLabel === 'Method'
|
||||
? parameterShapeIdTag(
|
||||
methodProps.parameterTypes as string[] | undefined,
|
||||
methodProps.parameterTypeClasses as ParameterTypeClass[] | undefined,
|
||||
)
|
||||
: '';
|
||||
const classTemplateArguments =
|
||||
extractedClassSymbol?.templateArguments ??
|
||||
provider.classExtractor?.extractTemplateArgumentsFromCapture?.({
|
||||
|
|
@ -717,7 +725,7 @@ const processParsingSequential = async (
|
|||
}
|
||||
const nodeId = generateId(
|
||||
nodeLabel,
|
||||
`${file.path}:${qualifiedName}${classTemplateTag}${arityTag}${constraintsTag}`,
|
||||
`${file.path}:${qualifiedName}${classTemplateTag}${arityTag}${constraintsTag}${parameterShapeTag}`,
|
||||
);
|
||||
const classNodeForSymbol = definitionNodeForRange || definitionNode || nameNode;
|
||||
const qualifiedTypeName =
|
||||
|
|
|
|||
|
|
@ -17,11 +17,12 @@
|
|||
* migrate.
|
||||
*/
|
||||
|
||||
import type { NodeLabel, ScopeId, SymbolDefinition } from 'gitnexus-shared';
|
||||
import type { NodeLabel, ParameterTypeClass, ScopeId, SymbolDefinition } from 'gitnexus-shared';
|
||||
import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js';
|
||||
import { generateId } from '../../../../lib/utils.js';
|
||||
import { qualifiedKey, simpleKey, type GraphNodeLookup } from '../graph-bridge/node-lookup.js';
|
||||
import { templateConstraintsIdTag } from '../../utils/template-arguments.js';
|
||||
import { parameterShapeIdTag } from '../../utils/method-props.js';
|
||||
/**
|
||||
* Labels that may legitimately ANCHOR a CALLS/ACCESSES edge as the
|
||||
* source ("caller"). A Variable / Property can be the TARGET of an
|
||||
|
|
@ -76,6 +77,7 @@ export function resolveDefGraphId(
|
|||
qualifiedName?: string;
|
||||
type?: NodeLabel;
|
||||
parameterTypes?: readonly string[];
|
||||
parameterTypeClasses?: readonly ParameterTypeClass[];
|
||||
templateArguments?: readonly string[];
|
||||
templateConstraints?: unknown;
|
||||
},
|
||||
|
|
@ -102,11 +104,23 @@ export function resolveDefGraphId(
|
|||
const cHit = nodeLookup.get(cKey);
|
||||
if (cHit !== undefined) return cHit;
|
||||
}
|
||||
if (
|
||||
(def.type === 'Function' || def.type === 'Method') &&
|
||||
def.parameterTypes !== undefined &&
|
||||
def.parameterTypeClasses !== undefined
|
||||
) {
|
||||
const shapeTag = parameterShapeIdTag(def.parameterTypes, def.parameterTypeClasses);
|
||||
if (shapeTag !== '') {
|
||||
const shapeKey = qualifiedKey(filePath, def.type, `${qn}${shapeTag}`);
|
||||
const shapeHit = nodeLookup.get(shapeKey);
|
||||
if (shapeHit !== undefined) return shapeHit;
|
||||
}
|
||||
}
|
||||
// Overload disambiguation: when the def carries parameter types,
|
||||
// try the parameter-typed key first so same-name same-arity
|
||||
// overloads route to their distinct graph nodes.
|
||||
if (
|
||||
def.type === 'Method' &&
|
||||
(def.type === 'Function' || def.type === 'Method') &&
|
||||
def.parameterTypes !== undefined &&
|
||||
def.parameterTypes.length > 0
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -18,9 +18,10 @@
|
|||
* format that downstream consumers (queries, edges, MCP) expect.
|
||||
*/
|
||||
|
||||
import type { NodeLabel } from 'gitnexus-shared';
|
||||
import type { NodeLabel, ParameterTypeClass } from 'gitnexus-shared';
|
||||
import type { KnowledgeGraph } from '../../../graph/types.js';
|
||||
import { templateConstraintsIdTag } from '../../utils/template-arguments.js';
|
||||
import { parameterShapeIdTag } from '../../utils/method-props.js';
|
||||
|
||||
export type GraphNodeLookup = ReadonlyMap<string, string>;
|
||||
|
||||
|
|
@ -42,6 +43,10 @@ function parseQualifiedFromId(id: string, label: NodeLabel, filePath: string): s
|
|||
return hash === -1 ? suffix : suffix.slice(0, hash);
|
||||
}
|
||||
|
||||
function stripCallableDisambiguatorTags(qualifiedName: string): string {
|
||||
return qualifiedName.replace(/~shape:.*$/, '').replace(/~c:[a-z0-9]+$/, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a qualified-key string in a separate keyspace from simple-key
|
||||
* strings. Prefix `<q>` can't appear in a valid filePath on any OS, so
|
||||
|
|
@ -84,7 +89,8 @@ export function buildGraphNodeLookup(graph: KnowledgeGraph): GraphNodeLookup {
|
|||
const qualified =
|
||||
props.qualifiedName ?? parseQualifiedFromId(node.id, node.label, props.filePath);
|
||||
if (qualified !== undefined && qualified.length > 0) {
|
||||
const qKey = qualifiedKey(props.filePath, node.label, qualified);
|
||||
const keyQualified = stripCallableDisambiguatorTags(qualified);
|
||||
const qKey = qualifiedKey(props.filePath, node.label, keyQualified);
|
||||
if (!lookup.has(qKey)) lookup.set(qKey, node.id);
|
||||
// Overload-disambiguating key: include parameter types so two
|
||||
// same-arity overloads (e.g. `Lookup(int)` vs `Lookup(string)`)
|
||||
|
|
@ -93,10 +99,25 @@ export function buildGraphNodeLookup(graph: KnowledgeGraph): GraphNodeLookup {
|
|||
// a parameter-types-suffixed key so resolveDefGraphId can find
|
||||
// the right overload by matching its def's parameterTypes.
|
||||
const pTypes = (props as { parameterTypes?: readonly string[] }).parameterTypes;
|
||||
if (pTypes !== undefined && pTypes.length > 0 && node.label === 'Method') {
|
||||
const pKey = qualifiedKey(props.filePath, node.label, `${qualified}~${pTypes.join(',')}`);
|
||||
if (
|
||||
pTypes !== undefined &&
|
||||
pTypes.length > 0 &&
|
||||
(node.label === 'Function' || node.label === 'Method')
|
||||
) {
|
||||
const pKey = qualifiedKey(
|
||||
props.filePath,
|
||||
node.label,
|
||||
`${keyQualified}~${pTypes.join(',')}`,
|
||||
);
|
||||
// Each overload is unique — set unconditionally.
|
||||
lookup.set(pKey, node.id);
|
||||
if (!lookup.has(pKey)) lookup.set(pKey, node.id);
|
||||
}
|
||||
const pClasses = (props as { parameterTypeClasses?: readonly ParameterTypeClass[] })
|
||||
.parameterTypeClasses;
|
||||
const shapeTag = parameterShapeIdTag(pTypes, pClasses);
|
||||
if (shapeTag !== '' && (node.label === 'Function' || node.label === 'Method')) {
|
||||
const shapeKey = qualifiedKey(props.filePath, node.label, `${keyQualified}${shapeTag}`);
|
||||
if (!lookup.has(shapeKey)) lookup.set(shapeKey, node.id);
|
||||
}
|
||||
// SFINAE / `requires`-clause disambiguation (issue #1579) — register
|
||||
// a constraint-fingerprinted key so resolveDefGraphId can locate the
|
||||
|
|
@ -109,7 +130,7 @@ export function buildGraphNodeLookup(graph: KnowledgeGraph): GraphNodeLookup {
|
|||
const cKey = qualifiedKey(
|
||||
props.filePath,
|
||||
node.label,
|
||||
`${qualified}${templateConstraintsIdTag(tConstraints)}`,
|
||||
`${keyQualified}${templateConstraintsIdTag(tConstraints)}`,
|
||||
);
|
||||
lookup.set(cKey, node.id);
|
||||
}
|
||||
|
|
@ -125,7 +146,7 @@ export function buildGraphNodeLookup(graph: KnowledgeGraph): GraphNodeLookup {
|
|||
const tKey = qualifiedKey(
|
||||
props.filePath,
|
||||
node.label,
|
||||
`${qualified}~${props.templateArguments.join(',')}`,
|
||||
`${keyQualified}~${props.templateArguments.join(',')}`,
|
||||
);
|
||||
if (!lookup.has(tKey)) lookup.set(tKey, node.id);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -35,6 +35,11 @@
|
|||
* candidates whose template constraints provably fail at the
|
||||
* call site. Three-valued; `'unknown'` keeps the candidate
|
||||
* (monotonicity).
|
||||
* 4d. Conservative C++ template partial-order approximation. When
|
||||
* template-placeholder overloads remain tied, prefer a candidate
|
||||
* whose parameter shape is more specialized for the observed
|
||||
* argument shape (`T*` over `T`, `const T&` over `T`). Unknown or
|
||||
* incomparable shapes are left ambiguous.
|
||||
* 5. Empty input returns empty output.
|
||||
*/
|
||||
|
||||
|
|
@ -205,6 +210,15 @@ export function narrowOverloadCandidates(
|
|||
});
|
||||
}
|
||||
|
||||
if (result.length > 1 && argTypes !== undefined && argTypes.length > 0) {
|
||||
const partiallyOrdered = rankByTemplatePartialOrdering(
|
||||
result,
|
||||
argTypes,
|
||||
hookCtx?.argumentTypeClasses,
|
||||
);
|
||||
if (partiallyOrdered !== undefined) result = partiallyOrdered;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
|
|
@ -330,6 +344,109 @@ function pairwiseCompare(a: readonly number[], b: readonly number[]): -1 | 0 | 1
|
|||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Closed-table approximation of C++ function-template partial ordering.
|
||||
*
|
||||
* Full `[temp.func.order]` requires template argument deduction. GitNexus
|
||||
* keeps this graph-safe by recognizing only syntactic placeholder shapes
|
||||
* that the C++ parameter sidecar already preserves:
|
||||
* - `T*` is more specialized than `T` for pointer arguments.
|
||||
*
|
||||
* Anything with unknown argument shape, non-template parameter spelling, or
|
||||
* incomparable specialized shapes stays ambiguous so callers suppress. The
|
||||
* placeholder detector is intentionally narrow: lowercase template parameters
|
||||
* are left ambiguous rather than guessed.
|
||||
*/
|
||||
function rankByTemplatePartialOrdering(
|
||||
candidates: readonly SymbolDefinition[],
|
||||
argTypes: readonly string[],
|
||||
argTypeClasses?: readonly ParameterTypeClass[],
|
||||
): readonly SymbolDefinition[] | undefined {
|
||||
if (argTypeClasses === undefined) return undefined;
|
||||
|
||||
const viable: Array<{ def: SymbolDefinition; ranks: number[] }> = [];
|
||||
for (const def of candidates) {
|
||||
const params = def.parameterTypes;
|
||||
const paramClasses = def.parameterTypeClasses;
|
||||
if (params === undefined || paramClasses === undefined) continue;
|
||||
|
||||
const ranks: number[] = [];
|
||||
let sawTemplateSlot = false;
|
||||
let ok = true;
|
||||
for (let i = 0; i < argTypes.length; i++) {
|
||||
const paramType = parameterTypeAt(params, i);
|
||||
const paramClass = parameterTypeClassAt(paramClasses, i);
|
||||
const argClass = argTypeClasses[i];
|
||||
if (paramType === undefined || paramClass === undefined || argClass === undefined) {
|
||||
ok = false;
|
||||
break;
|
||||
}
|
||||
|
||||
const rank = templatePartialOrderSlotRank(paramType, paramClass, argClass);
|
||||
if (rank === undefined) {
|
||||
ok = false;
|
||||
break;
|
||||
}
|
||||
sawTemplateSlot ||= isTemplatePlaceholder(paramType);
|
||||
ranks.push(rank);
|
||||
}
|
||||
if (ok && sawTemplateSlot) viable.push({ def, ranks });
|
||||
}
|
||||
if (viable.length === 0) return undefined;
|
||||
if (viable.length !== candidates.length) return [];
|
||||
if (viable.length <= 1) return viable.map((v) => v.def);
|
||||
|
||||
const dominated = new Set<number>();
|
||||
for (let i = 0; i < viable.length; i++) {
|
||||
if (dominated.has(i)) continue;
|
||||
for (let j = i + 1; j < viable.length; j++) {
|
||||
if (dominated.has(j)) continue;
|
||||
const cmp = compareSpecializationRanks(viable[i].ranks, viable[j].ranks);
|
||||
if (cmp < 0) dominated.add(j);
|
||||
else if (cmp > 0) dominated.add(i);
|
||||
}
|
||||
}
|
||||
return viable.filter((_, idx) => !dominated.has(idx)).map((v) => v.def);
|
||||
}
|
||||
|
||||
function templatePartialOrderSlotRank(
|
||||
paramType: string,
|
||||
paramClass: ParameterTypeClass,
|
||||
argClass: ParameterTypeClass,
|
||||
): number | undefined {
|
||||
if (!isTemplatePlaceholder(paramType)) return undefined;
|
||||
if (argClass.indirection === 'unknown' || paramClass.indirection === 'unknown') {
|
||||
return undefined;
|
||||
}
|
||||
if (isPointerShape(paramClass)) {
|
||||
return isPointerShape(argClass) ? 3 : undefined;
|
||||
}
|
||||
if (paramClass.indirection === 'value') return 1;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function isTemplatePlaceholder(typeName: string): boolean {
|
||||
return /^[A-Z]\w*$/.test(typeName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Higher specialization rank is better. Returns -1 when `a` dominates `b`,
|
||||
* +1 when `b` dominates `a`, and 0 for ties / incomparable vectors.
|
||||
*/
|
||||
function compareSpecializationRanks(a: readonly number[], b: readonly number[]): -1 | 0 | 1 {
|
||||
let aBetter = false;
|
||||
let bBetter = false;
|
||||
const len = Math.min(a.length, b.length);
|
||||
for (let i = 0; i < len; i++) {
|
||||
if (a[i] > b[i]) aBetter = true;
|
||||
else if (b[i] > a[i]) bBetter = true;
|
||||
if (aBetter && bBetter) return 0;
|
||||
}
|
||||
if (aBetter && !bBetter) return -1;
|
||||
if (bBetter && !aBetter) return 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect when >1 candidate share identical `parameterTypes` after the
|
||||
* per-language normalizer has collapsed distinct underlying types. This
|
||||
|
|
|
|||
|
|
@ -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<T extends SyntaxNode>(
|
||||
node: T | undefined,
|
||||
...types: readonly string[]
|
||||
): T | null {
|
||||
return node !== undefined && types.includes(node.type) ? node : null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import type { MethodInfo } from '../method-types.js';
|
||||
import { SupportedLanguages } from 'gitnexus-shared';
|
||||
import { SupportedLanguages, type ParameterTypeClass } from 'gitnexus-shared';
|
||||
|
||||
/** Languages where class overload signatures are declaration-only contracts
|
||||
* that should collapse to the implementation body's node ID. */
|
||||
|
|
@ -139,13 +139,58 @@ export function constTagForId(
|
|||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Disambiguate function-template overloads whose normalized parameter types
|
||||
* intentionally collapse to the same placeholder token (`T`, `U`, ...), but
|
||||
* whose C++ sidecar shape is semantically different (`T` vs `T*` / `T&`).
|
||||
*
|
||||
* Kept intentionally narrow: concrete types already use the existing raw-type
|
||||
* overload tag, and non-template languages should not acquire sidecar-shaped
|
||||
* IDs.
|
||||
*/
|
||||
export function parameterShapeIdTag(
|
||||
parameterTypes?: readonly string[],
|
||||
parameterTypeClasses?: readonly ParameterTypeClass[],
|
||||
): string {
|
||||
if (
|
||||
parameterTypes === undefined ||
|
||||
parameterTypeClasses === undefined ||
|
||||
parameterTypes.length === 0
|
||||
) {
|
||||
return '';
|
||||
}
|
||||
let hasTemplatePlaceholder = false;
|
||||
let hasDisambiguatingShape = false;
|
||||
const parts: string[] = [];
|
||||
for (let i = 0; i < parameterTypes.length; i++) {
|
||||
const type = parameterTypes[i];
|
||||
const typeClass = parameterTypeClasses[i];
|
||||
if (typeClass === undefined) return '';
|
||||
if (/^[A-Z]\w*$/.test(type)) hasTemplatePlaceholder = true;
|
||||
if (
|
||||
typeClass.indirection !== 'value' ||
|
||||
typeClass.pointerDepth > 0 ||
|
||||
(typeClass.cv !== 'none' && typeClass.cv !== 'unknown')
|
||||
) {
|
||||
hasDisambiguatingShape = true;
|
||||
}
|
||||
parts.push(
|
||||
`${type}:${typeClass.cv}:${typeClass.indirection}:${typeClass.pointerDepth.toString()}`,
|
||||
);
|
||||
}
|
||||
if (!hasTemplatePlaceholder || !hasDisambiguatingShape) return '';
|
||||
return `~shape:${parts.join('|')}`;
|
||||
}
|
||||
|
||||
/** Convert MethodInfo from methodExtractor into flat properties for a graph node. */
|
||||
export function buildMethodProps(info: MethodInfo): Record<string, unknown> {
|
||||
const types: string[] = [];
|
||||
const typeClasses: ParameterTypeClass[] = [];
|
||||
let optionalCount = 0;
|
||||
let hasVariadic = false;
|
||||
for (const p of info.parameters) {
|
||||
if (p.type !== null) types.push(p.type);
|
||||
if (p.typeClass !== undefined) typeClasses.push(p.typeClass);
|
||||
if (p.isOptional) optionalCount++;
|
||||
if (p.isVariadic) hasVariadic = true;
|
||||
}
|
||||
|
|
@ -155,6 +200,9 @@ export function buildMethodProps(info: MethodInfo): Record<string, unknown> {
|
|||
? { requiredParameterCount: info.parameters.length - optionalCount }
|
||||
: {}),
|
||||
...(types.length > 0 ? { parameterTypes: types } : {}),
|
||||
...(typeClasses.length === info.parameters.length && typeClasses.length > 0
|
||||
? { parameterTypeClasses: typeClasses }
|
||||
: {}),
|
||||
returnType: info.returnType ?? undefined,
|
||||
visibility: info.visibility,
|
||||
isStatic: info.isStatic,
|
||||
|
|
|
|||
|
|
@ -85,6 +85,7 @@ import {
|
|||
typeTagForId,
|
||||
constTagForId,
|
||||
buildCollisionGroups,
|
||||
parameterShapeIdTag,
|
||||
} from '../utils/method-props.js';
|
||||
import { extractTemplateArguments, templateArgumentsIdTag } from '../utils/template-arguments.js';
|
||||
import type { LanguageProvider } from '../language-provider.js';
|
||||
|
|
@ -1814,6 +1815,13 @@ const processFileGroup = (
|
|||
);
|
||||
arityTag += constTagForId(defMethodMap, nodeName, arityForId, defMethodInfo, groups);
|
||||
}
|
||||
const parameterShapeTag =
|
||||
nodeLabel === 'Function' || nodeLabel === 'Method'
|
||||
? parameterShapeIdTag(
|
||||
methodProps.parameterTypes as string[] | undefined,
|
||||
methodProps.parameterTypeClasses as ParameterTypeClass[] | undefined,
|
||||
)
|
||||
: '';
|
||||
const classTemplateArguments =
|
||||
extractedClassSymbol?.templateArguments ??
|
||||
provider.classExtractor?.extractTemplateArgumentsFromCapture?.({
|
||||
|
|
@ -1837,7 +1845,7 @@ const processFileGroup = (
|
|||
: '';
|
||||
const nodeId = generateId(
|
||||
nodeLabel,
|
||||
`${file.path}:${qualifiedName}${classTemplateTag}${arityTag}`,
|
||||
`${file.path}:${qualifiedName}${classTemplateTag}${arityTag}${parameterShapeTag}`,
|
||||
);
|
||||
const classNodeForSymbol = definitionNode || nameNode;
|
||||
const qualifiedTypeName =
|
||||
|
|
|
|||
|
|
@ -2920,8 +2920,14 @@ export class LocalBackend {
|
|||
typeof opts.offset === 'number' && Number.isFinite(opts.offset) ? opts.offset : 0;
|
||||
const paginationOffset = Math.max(0, Math.trunc(rawOffset));
|
||||
const summaryOnly = opts.summaryOnly ?? false;
|
||||
const relTypeFilter = relationTypes.map((t) => `'${t}'`).join(', ');
|
||||
const confidenceFilter = minConfidence > 0 ? ` AND r.confidence >= ${minConfidence}` : '';
|
||||
// Bind the BFS frontier query's filters as parameters (#1907 review F5):
|
||||
// node ids and relation types as bound lists, the confidence floor as a
|
||||
// bound number — no string interpolation reaches the query text. Preserve
|
||||
// the original "no confidence clause when minConfidence <= 0" behavior: an
|
||||
// unconditional `>= 0` would wrongly exclude NULL-confidence edges that the
|
||||
// unfiltered query includes.
|
||||
const safeMinConfidence = Number.isFinite(minConfidence) ? minConfidence : 0;
|
||||
const confidenceFilter = safeMinConfidence > 0 ? ' AND r.confidence >= $minConfidence' : '';
|
||||
|
||||
const symId = sym.id || sym[0];
|
||||
|
||||
|
|
@ -3009,15 +3015,19 @@ export class LocalBackend {
|
|||
for (let depth = 1; depth <= maxDepth && frontier.length > 0; depth++) {
|
||||
const nextFrontier: string[] = [];
|
||||
|
||||
// Batch frontier nodes into a single Cypher query per depth level
|
||||
const idList = frontier.map((id) => `'${id.replace(/'/g, "''")}'`).join(', ');
|
||||
// Batch frontier nodes into a single Cypher query per depth level.
|
||||
// ids/types/confidence are bound parameters (see above) — no interpolation.
|
||||
const query =
|
||||
direction === 'upstream'
|
||||
? `MATCH (caller)-[r:CodeRelation]->(n) WHERE n.id IN [${idList}] AND r.type IN [${relTypeFilter}]${confidenceFilter} RETURN n.id AS sourceId, caller.id AS id, caller.name AS name, labels(caller)[0] AS type, caller.filePath AS filePath, r.type AS relType, r.confidence AS confidence`
|
||||
: `MATCH (n)-[r:CodeRelation]->(callee) WHERE n.id IN [${idList}] AND r.type IN [${relTypeFilter}]${confidenceFilter} RETURN n.id AS sourceId, callee.id AS id, callee.name AS name, labels(callee)[0] AS type, callee.filePath AS filePath, r.type AS relType, r.confidence AS confidence`;
|
||||
? `MATCH (caller)-[r:CodeRelation]->(n) WHERE n.id IN $frontierIds AND r.type IN $relTypes${confidenceFilter} RETURN n.id AS sourceId, caller.id AS id, caller.name AS name, labels(caller)[0] AS type, caller.filePath AS filePath, r.type AS relType, r.confidence AS confidence`
|
||||
: `MATCH (n)-[r:CodeRelation]->(callee) WHERE n.id IN $frontierIds AND r.type IN $relTypes${confidenceFilter} RETURN n.id AS sourceId, callee.id AS id, callee.name AS name, labels(callee)[0] AS type, callee.filePath AS filePath, r.type AS relType, r.confidence AS confidence`;
|
||||
|
||||
try {
|
||||
const related = await executeQuery(repo.id, query);
|
||||
const related = await executeParameterized(repo.id, query, {
|
||||
frontierIds: frontier,
|
||||
relTypes: relationTypes,
|
||||
...(safeMinConfidence > 0 ? { minConfidence: safeMinConfidence } : {}),
|
||||
});
|
||||
|
||||
for (const rel of related) {
|
||||
const relId = rel.id || rel[1];
|
||||
|
|
|
|||
634
gitnexus/test/fixtures/csharp-captures-golden/expected-captures.json
vendored
Normal file
634
gitnexus/test/fixtures/csharp-captures-golden/expected-captures.json
vendored
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
362
gitnexus/test/fixtures/go-captures-golden/expected-captures.json
vendored
Normal file
362
gitnexus/test/fixtures/go-captures-golden/expected-captures.json
vendored
Normal file
|
|
@ -0,0 +1,362 @@
|
|||
{
|
||||
"go-aliased-package-import/internal/util/log.go": {
|
||||
"captureGroups": 4,
|
||||
"digest": "77be0bd9a9df464f42a6cefd0c16064d4ef97e79f5f61faa8df6069989cec9f6"
|
||||
},
|
||||
"go-aliased-package-import/main.go": {
|
||||
"captureGroups": 7,
|
||||
"digest": "3eb2e6d441dadede554b271b7a87b7b9ca557ab418bfb9e8524e6ace4c8fc547"
|
||||
},
|
||||
"go-ambiguous/internal/models/handler.go": {
|
||||
"captureGroups": 9,
|
||||
"digest": "619c516a5791095bc6380de62fa861364b3f9480f2ec87d4499c2c998f228713"
|
||||
},
|
||||
"go-ambiguous/internal/other/handler.go": {
|
||||
"captureGroups": 9,
|
||||
"digest": "08a61721581c4f17741ef0c4ee1c8945235ee7f2aca7d88d2fe122071cd62e6f"
|
||||
},
|
||||
"go-ambiguous/internal/services/user.go": {
|
||||
"captureGroups": 8,
|
||||
"digest": "802b81a07c64c01f2381cf33d41cdb1a58f535c0007b5ae151c38cda87f28321"
|
||||
},
|
||||
"go-assignment-chain/cmd/main.go": {
|
||||
"captureGroups": 50,
|
||||
"digest": "47ba5fd2ea96ee202b3a3de5c0db75ea18d0889064ed2138d62c67594f7d22e9"
|
||||
},
|
||||
"go-assignment-chain/models/repo.go": {
|
||||
"captureGroups": 8,
|
||||
"digest": "1b3acbf48751105d056253488f34159266bdb0b229248c85065e927b72bf4801"
|
||||
},
|
||||
"go-assignment-chain/models/user.go": {
|
||||
"captureGroups": 8,
|
||||
"digest": "457c76ebf1c86efac7a7d9f384a8e49acd5649c006e6cc8aa29a59c36f0b102a"
|
||||
},
|
||||
"go-call-result-binding/cmd/main.go": {
|
||||
"captureGroups": 16,
|
||||
"digest": "83d611dcee826ec848a0b3fd5a18a98103896d17c353d8d63cd15551039818e9"
|
||||
},
|
||||
"go-call-result-binding/models/user.go": {
|
||||
"captureGroups": 10,
|
||||
"digest": "5c31199e148340ee32dd48e32a00003e094be963b1287b9b40c6b4effca12175"
|
||||
},
|
||||
"go-calls/cmd/main.go": {
|
||||
"captureGroups": 6,
|
||||
"digest": "deaea55087c2652aa2d39fefa30048d5393cec40f7af1ab13921331b035030d6"
|
||||
},
|
||||
"go-calls/internal/onearg/log.go": {
|
||||
"captureGroups": 6,
|
||||
"digest": "a101eafe9f08396bb176cf3cadd3960d752802048e47c0ac9e9ace07244a46fb"
|
||||
},
|
||||
"go-calls/internal/zeroarg/log.go": {
|
||||
"captureGroups": 5,
|
||||
"digest": "7b322767a38298de8c6ce99fa6b1d69dda8bbb79704053644bfaa7d2e4473fdb"
|
||||
},
|
||||
"go-chain-call/cmd/main.go": {
|
||||
"captureGroups": 20,
|
||||
"digest": "5a8d7de8ae87887902d16f28cb70d31014c96ab5d2eb63cbd6c8be27650fa9a1"
|
||||
},
|
||||
"go-chain-call/models/repo.go": {
|
||||
"captureGroups": 10,
|
||||
"digest": "ac4799aae638d528c5c7c01c8b9734fc61e995596a12b12e790dcffab97b4029"
|
||||
},
|
||||
"go-chain-call/models/user.go": {
|
||||
"captureGroups": 10,
|
||||
"digest": "5c31199e148340ee32dd48e32a00003e094be963b1287b9b40c6b4effca12175"
|
||||
},
|
||||
"go-child-extends-parent/models/child.go": {
|
||||
"captureGroups": 3,
|
||||
"digest": "6fd9fe7b82066f82a93bf5e5024ddf89382091ec04e55648845c8295f13bd412"
|
||||
},
|
||||
"go-child-extends-parent/models/parent.go": {
|
||||
"captureGroups": 8,
|
||||
"digest": "454a724f571a5aede89d7657f76ed8e3c9b19bfc18d524141d1b646005f98e49"
|
||||
},
|
||||
"go-child-extends-parent/services/app.go": {
|
||||
"captureGroups": 10,
|
||||
"digest": "05f5df0369c90bc6e0da5a8a76e0661be36cddbe913ab2f83da2ee3733a46ec2"
|
||||
},
|
||||
"go-cmd-helper/cmd/server/internal/config/config.go": {
|
||||
"captureGroups": 5,
|
||||
"digest": "b10874198d380b0a186fb1e1ee8cedac644eb3b6b624398110a660183e59a0b5"
|
||||
},
|
||||
"go-cmd-helper/cmd/server/main.go": {
|
||||
"captureGroups": 7,
|
||||
"digest": "a1f9453bd71926d60e3f148f43b9af813cbd1cccc11b323896a55bdb443f8931"
|
||||
},
|
||||
"go-constructor-type-inference/cmd/main.go": {
|
||||
"captureGroups": 15,
|
||||
"digest": "4c496bf5ebaebae8c7480b1826b3285752a79ce50562756ded3b7fe0c6d6b325"
|
||||
},
|
||||
"go-constructor-type-inference/models/repo.go": {
|
||||
"captureGroups": 8,
|
||||
"digest": "1b3acbf48751105d056253488f34159266bdb0b229248c85065e927b72bf4801"
|
||||
},
|
||||
"go-constructor-type-inference/models/user.go": {
|
||||
"captureGroups": 8,
|
||||
"digest": "457c76ebf1c86efac7a7d9f384a8e49acd5649c006e6cc8aa29a59c36f0b102a"
|
||||
},
|
||||
"go-deep-field-chain/cmd/main.go": {
|
||||
"captureGroups": 13,
|
||||
"digest": "ca9cc7ae0f75928b1ea338f42e58cf02502e0c93ce4ea8867258c90543f79d16"
|
||||
},
|
||||
"go-deep-field-chain/models/models.go": {
|
||||
"captureGroups": 33,
|
||||
"digest": "0ad1df946f58e446a8dbd8ef13041b6e0177f53293946b955acf3c46684e4295"
|
||||
},
|
||||
"go-field-types/cmd/main.go": {
|
||||
"captureGroups": 9,
|
||||
"digest": "3bc98cf5640568cdff3202f2594fb39d563ee595560cb28d1adb31f264670ceb"
|
||||
},
|
||||
"go-field-types/models/models.go": {
|
||||
"captureGroups": 22,
|
||||
"digest": "fbdbf74d927c0ed07f4820c190fc6f2039b74ddead8abb45fc238812dfa4d4ef"
|
||||
},
|
||||
"go-for-call-expr/cmd/main.go": {
|
||||
"captureGroups": 27,
|
||||
"digest": "95217f85260d85baeb57638fff470c0a358be92cc49a5f918b084d809f47ba2a"
|
||||
},
|
||||
"go-for-call-expr/models/repo.go": {
|
||||
"captureGroups": 14,
|
||||
"digest": "312e59c1402cb83a4ce51c27866fde6c597f7121098b57ff1d3bd4dc6363834a"
|
||||
},
|
||||
"go-for-call-expr/models/user.go": {
|
||||
"captureGroups": 14,
|
||||
"digest": "c442a26c4051c2b7426850a381137507a147c21d631d6535d71ef1035b1506fb"
|
||||
},
|
||||
"go-inc-dec-write-access/main.go": {
|
||||
"captureGroups": 21,
|
||||
"digest": "0414398239624e44b1589f6a68f2c19636bf4b3ce74990cee1bd7cbeeb0591e6"
|
||||
},
|
||||
"go-local-shadow/cmd/main.go": {
|
||||
"captureGroups": 12,
|
||||
"digest": "1cda9982d9b4e4878208894523f0a9c7a806c6f08a5582d4771205716a1b2733"
|
||||
},
|
||||
"go-local-shadow/internal/utils/utils.go": {
|
||||
"captureGroups": 6,
|
||||
"digest": "51e941fa4c7765efc6e472d15a9d7ea31f59b67a6b606be868f4af47e5f54cb1"
|
||||
},
|
||||
"go-make-builtin/main.go": {
|
||||
"captureGroups": 18,
|
||||
"digest": "7c56328d8416338ae0075ae7dd9669b16f2035aa353fac7ee1e46109a58e80a6"
|
||||
},
|
||||
"go-make-builtin/models.go": {
|
||||
"captureGroups": 15,
|
||||
"digest": "4d5628f66471f1ad1180a47b797195506d190ddc84f61f6c57f2e22afd754c1e"
|
||||
},
|
||||
"go-map-range/main.go": {
|
||||
"captureGroups": 10,
|
||||
"digest": "7f3580a0e7858e6eb3176e0b5e1bec4867a2d5d07f2b216a04a3de79ff96170b"
|
||||
},
|
||||
"go-map-range/models/repo.go": {
|
||||
"captureGroups": 9,
|
||||
"digest": "6cbc4422fb287007735b4c58b5e9c84bbd6b8f6082e3b4d5fe48c306e6acc75b"
|
||||
},
|
||||
"go-map-range/models/user.go": {
|
||||
"captureGroups": 9,
|
||||
"digest": "7e4dbc05ad1de859cd3103c27cddb60566d95a8a8354c752e8c75f84d7ca055c"
|
||||
},
|
||||
"go-member-calls/cmd/main.go": {
|
||||
"captureGroups": 11,
|
||||
"digest": "e46f6d1dff39943f8f89c05e0d28f61f8471cdc729c91f01ca693a38a244b8ba"
|
||||
},
|
||||
"go-member-calls/models/user.go": {
|
||||
"captureGroups": 8,
|
||||
"digest": "457c76ebf1c86efac7a7d9f384a8e49acd5649c006e6cc8aa29a59c36f0b102a"
|
||||
},
|
||||
"go-method-chain-binding/cmd/main.go": {
|
||||
"captureGroups": 21,
|
||||
"digest": "0fff0df5dd77e13e0b9e62dd7f38efc038d33ffd5d891f684289eccbc46ff583"
|
||||
},
|
||||
"go-method-chain-binding/models/user.go": {
|
||||
"captureGroups": 24,
|
||||
"digest": "504ea598176dd8e01d759cc54e012735c746f14ec3a660af2c362fc356326f65"
|
||||
},
|
||||
"go-method-enrichment/animal.go": {
|
||||
"captureGroups": 15,
|
||||
"digest": "ac0933f59d4a88a25629d02f308c66847fd34ceba5831660fc8bff07109b7708"
|
||||
},
|
||||
"go-method-enrichment/app.go": {
|
||||
"captureGroups": 15,
|
||||
"digest": "ac0bdc2e6daf7d4e28fd255fe2edd143e8fab7ff316f4e32e2ca04a3b33f71c2"
|
||||
},
|
||||
"go-mixed-chain/cmd/main.go": {
|
||||
"captureGroups": 20,
|
||||
"digest": "3662b803da4f4fc1ac0552a45d3d262bd0db57f97b056614caacea8ad856250e"
|
||||
},
|
||||
"go-mixed-chain/models/models.go": {
|
||||
"captureGroups": 41,
|
||||
"digest": "dc2cedaefcd73faf13a9f704a4be8f0bd4140c608cd86480e1f4400fec49dbd1"
|
||||
},
|
||||
"go-multi-assign/app.go": {
|
||||
"captureGroups": 16,
|
||||
"digest": "66958a3e86caa54aedd795227dac274cfb3f4b39ab98964e8a7bcbdfc8a08ca6"
|
||||
},
|
||||
"go-multi-assign/models.go": {
|
||||
"captureGroups": 19,
|
||||
"digest": "a4d43cf2cd2f7bdbc750a7ee1f5e9c7611e5d1e25f1a46386d0eeb9f73bb7846"
|
||||
},
|
||||
"go-multi-return-inference/cmd/main.go": {
|
||||
"captureGroups": 36,
|
||||
"digest": "6229b69cfd15bf1465d770d97486522faac5a91cf8828c424c8c8c989b310224"
|
||||
},
|
||||
"go-multi-return-inference/models/repo.go": {
|
||||
"captureGroups": 10,
|
||||
"digest": "ac4799aae638d528c5c7c01c8b9734fc61e995596a12b12e790dcffab97b4029"
|
||||
},
|
||||
"go-multi-return-inference/models/user.go": {
|
||||
"captureGroups": 10,
|
||||
"digest": "5c31199e148340ee32dd48e32a00003e094be963b1287b9b40c6b4effca12175"
|
||||
},
|
||||
"go-new-builtin/main.go": {
|
||||
"captureGroups": 12,
|
||||
"digest": "1177b99217a42a28b0d768d29e1df4198f0d5ca5f1c2b584d528f5f02354b38f"
|
||||
},
|
||||
"go-new-builtin/models.go": {
|
||||
"captureGroups": 17,
|
||||
"digest": "40c9f89942406caf2610f0d1954b66e905c39dd5d617d7359e572445d586c15d"
|
||||
},
|
||||
"go-nullable-receiver/cmd/main.go": {
|
||||
"captureGroups": 27,
|
||||
"digest": "7a93b339f8c68ed3882802da21343c1d6b5231ae5cd8dd13eb4c05e2e7716320"
|
||||
},
|
||||
"go-nullable-receiver/models/repo.go": {
|
||||
"captureGroups": 8,
|
||||
"digest": "1b3acbf48751105d056253488f34159266bdb0b229248c85065e927b72bf4801"
|
||||
},
|
||||
"go-nullable-receiver/models/user.go": {
|
||||
"captureGroups": 8,
|
||||
"digest": "457c76ebf1c86efac7a7d9f384a8e49acd5649c006e6cc8aa29a59c36f0b102a"
|
||||
},
|
||||
"go-parent-resolution/models/base.go": {
|
||||
"captureGroups": 8,
|
||||
"digest": "2afaeb50d544a55fe437ef20e2c0de92152d2ba62f2693c329255787bb3d0a02"
|
||||
},
|
||||
"go-parent-resolution/models/user.go": {
|
||||
"captureGroups": 8,
|
||||
"digest": "c76ba16343dd94024fdaac10fa7640536966e530d675c0084434f42edb5b5f15"
|
||||
},
|
||||
"go-pkg/cmd/main.go": {
|
||||
"captureGroups": 14,
|
||||
"digest": "a7781d23802e876b9ca2951b7ebb317c5df5adf390ae9a57e8e2906af34f3255"
|
||||
},
|
||||
"go-pkg/internal/auth/service.go": {
|
||||
"captureGroups": 17,
|
||||
"digest": "2204643b50f486423ee7a5877b2bab7d6334cbe62b14b4435fe4ba8a6465ce92"
|
||||
},
|
||||
"go-pkg/internal/models/admin.go": {
|
||||
"captureGroups": 13,
|
||||
"digest": "1a5ec9fd5e752adcfec91cd03b3c2a67c124852228a527002237ec51cd4b39b7"
|
||||
},
|
||||
"go-pkg/internal/models/repository.go": {
|
||||
"captureGroups": 3,
|
||||
"digest": "7de6e11a3cf9c89afa9d89fe37dab85b208699f20105fbade223e4f78a63b15c"
|
||||
},
|
||||
"go-pkg/internal/models/user.go": {
|
||||
"captureGroups": 13,
|
||||
"digest": "e56fcea1c473866ed06fc0262702e54c72016a9556cc6337330c03cc1f638fe1"
|
||||
},
|
||||
"go-pointer-constructor-inference/cmd/main.go": {
|
||||
"captureGroups": 15,
|
||||
"digest": "39f9030e909a37f0e13724f50673dd4a72ba240fb6ac263192ee4feadbc88adf"
|
||||
},
|
||||
"go-pointer-constructor-inference/models/repo.go": {
|
||||
"captureGroups": 10,
|
||||
"digest": "ac4799aae638d528c5c7c01c8b9734fc61e995596a12b12e790dcffab97b4029"
|
||||
},
|
||||
"go-pointer-constructor-inference/models/user.go": {
|
||||
"captureGroups": 10,
|
||||
"digest": "5c31199e148340ee32dd48e32a00003e094be963b1287b9b40c6b4effca12175"
|
||||
},
|
||||
"go-receiver-method-free-call/example.go": {
|
||||
"captureGroups": 8,
|
||||
"digest": "2a3c26672d3b997bdc39644361c550f8cf0749489f0945d210e2fb7f3bca9383"
|
||||
},
|
||||
"go-receiver-method-free-call/util.go": {
|
||||
"captureGroups": 4,
|
||||
"digest": "0ac9740c13c851ca101e074bd16422f9da45b8c27db4befbc0c5e7479c2ccdda"
|
||||
},
|
||||
"go-receiver-resolution/cmd/main.go": {
|
||||
"captureGroups": 13,
|
||||
"digest": "e92c59312a46972a6083ba489888b550cdd024b809acbbf367ad025efb844a5c"
|
||||
},
|
||||
"go-receiver-resolution/models/repo.go": {
|
||||
"captureGroups": 8,
|
||||
"digest": "1b3acbf48751105d056253488f34159266bdb0b229248c85065e927b72bf4801"
|
||||
},
|
||||
"go-receiver-resolution/models/user.go": {
|
||||
"captureGroups": 8,
|
||||
"digest": "457c76ebf1c86efac7a7d9f384a8e49acd5649c006e6cc8aa29a59c36f0b102a"
|
||||
},
|
||||
"go-return-type-inference/cmd/main.go": {
|
||||
"captureGroups": 39,
|
||||
"digest": "ac8ca3dc1fb7fb4d7a1f947a77e93db890f04d328135dc514f36ba5cd04bc3e1"
|
||||
},
|
||||
"go-return-type-inference/models/repo.go": {
|
||||
"captureGroups": 16,
|
||||
"digest": "8c500db82093b4622acbca734f3040e6176000a9d9a4843e2a81e3f2b3daee7c"
|
||||
},
|
||||
"go-return-type-inference/models/user.go": {
|
||||
"captureGroups": 16,
|
||||
"digest": "a70b19b9c46a02003f26d0b70fa5368983a791583ec3776e983c733a9283eefa"
|
||||
},
|
||||
"go-same-package-factory/main.go": {
|
||||
"captureGroups": 14,
|
||||
"digest": "505d4d279615f3c99457d8cb0f29fdf946b547c6b1fda6d43aa6d4302dd61809"
|
||||
},
|
||||
"go-same-package-factory/repo.go": {
|
||||
"captureGroups": 8,
|
||||
"digest": "f8bb213588f517e166b421f19d72cde981153464cc3f3168499769e778c4a22e"
|
||||
},
|
||||
"go-same-package-factory/user.go": {
|
||||
"captureGroups": 8,
|
||||
"digest": "4daaad60f35d4519a24cd067baf4cd4bdc421e3dd80b9110a8dfaa8a9e75d9eb"
|
||||
},
|
||||
"go-split-method-owner/main.go": {
|
||||
"captureGroups": 9,
|
||||
"digest": "e9c105208ad6ef2f087f758972475aa403294e886751d75113b4ca45fa4e0b8a"
|
||||
},
|
||||
"go-split-method-owner/repo.go": {
|
||||
"captureGroups": 8,
|
||||
"digest": "f8bb213588f517e166b421f19d72cde981153464cc3f3168499769e778c4a22e"
|
||||
},
|
||||
"go-split-method-owner/save.go": {
|
||||
"captureGroups": 6,
|
||||
"digest": "6e0e1b5521a2fad1cd00252e771926d5caa7930440278968f7f8607821ad339c"
|
||||
},
|
||||
"go-split-method-owner/user.go": {
|
||||
"captureGroups": 3,
|
||||
"digest": "827dc0208b47776976313a4560fbd250ab7fa521ae46581213a79fc15fd52ce8"
|
||||
},
|
||||
"go-struct-literals/app.go": {
|
||||
"captureGroups": 10,
|
||||
"digest": "97ec29ec0e0dc1f23a804ff6808023a32eafdeb00f194d3bea9b50ed54c7f258"
|
||||
},
|
||||
"go-struct-literals/user.go": {
|
||||
"captureGroups": 10,
|
||||
"digest": "89b791a9d150fe341924d54d3173d9f35f72be899d0e7ac03a35d32119b94f8f"
|
||||
},
|
||||
"go-type-assertion/main.go": {
|
||||
"captureGroups": 11,
|
||||
"digest": "b8bd327d3965531802a93bc01e3c24969a027540397a1635119ef0b7da46c348"
|
||||
},
|
||||
"go-type-assertion/models.go": {
|
||||
"captureGroups": 17,
|
||||
"digest": "3780f8f7c145a15f849ae6958e492db0044e825b81aa8dfd21b9873759fad65e"
|
||||
},
|
||||
"go-variadic-resolution/cmd/main.go": {
|
||||
"captureGroups": 6,
|
||||
"digest": "443f9736b9c67df30fe97d8353ddc2584de4c88ba698e62878683d5d77362d89"
|
||||
},
|
||||
"go-variadic-resolution/internal/logger/logger.go": {
|
||||
"captureGroups": 4,
|
||||
"digest": "83b987f527f95e360966793148d07e569fa02fdc5e4febc09daa51cfce16b114"
|
||||
},
|
||||
"go-write-access/main.go": {
|
||||
"captureGroups": 20,
|
||||
"digest": "92fdad46b6933fd78dcbb19677a720f037306a4f03fedcca9156b54c1c9bf994"
|
||||
},
|
||||
"synthetic:dao-20": {
|
||||
"captureGroups": 481,
|
||||
"digest": "1698b5dd78c8094f251b10ab8cacebfbf453f38eddf7233fbc7964e32f04ceeb"
|
||||
}
|
||||
}
|
||||
13
gitnexus/test/fixtures/lang-resolution/cpp-template-partial-order-pointer/main.cpp
vendored
Normal file
13
gitnexus/test/fixtures/lang-resolution/cpp-template-partial-order-pointer/main.cpp
vendored
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
template<class T>
|
||||
void pick(T value) {
|
||||
}
|
||||
|
||||
template<class T>
|
||||
void pick(T* value) {
|
||||
}
|
||||
|
||||
void run() {
|
||||
int n = 0;
|
||||
int* p = &n;
|
||||
pick(p);
|
||||
}
|
||||
13
gitnexus/test/fixtures/lang-resolution/cpp-template-partial-order-tied/main.cpp
vendored
Normal file
13
gitnexus/test/fixtures/lang-resolution/cpp-template-partial-order-tied/main.cpp
vendored
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
template<class T>
|
||||
void pick(T* lhs, T rhs) {
|
||||
}
|
||||
|
||||
template<class T>
|
||||
void pick(T lhs, T* rhs) {
|
||||
}
|
||||
|
||||
void run() {
|
||||
int n = 0;
|
||||
int* p = &n;
|
||||
pick(p, p);
|
||||
}
|
||||
55
gitnexus/test/fixtures/lang-resolution/cpp-variadic-dependent-resolution/main.cpp
vendored
Normal file
55
gitnexus/test/fixtures/lang-resolution/cpp-variadic-dependent-resolution/main.cpp
vendored
Normal file
|
|
@ -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 <class... Ts>
|
||||
void logMany(int, Ts... xs) {
|
||||
(sink(xs), ...);
|
||||
}
|
||||
|
||||
template <class... Ts>
|
||||
void foldAmbiguous(Ts... xs) {
|
||||
(ambiguous(xs), ...);
|
||||
}
|
||||
|
||||
template <class... B>
|
||||
struct Mix : B... {
|
||||
void run() {
|
||||
inherited();
|
||||
helper();
|
||||
namespaceHelper();
|
||||
}
|
||||
};
|
||||
|
||||
template <class T>
|
||||
struct Current {
|
||||
void own();
|
||||
|
||||
void run() {
|
||||
own();
|
||||
}
|
||||
};
|
||||
|
||||
template <class T>
|
||||
struct UnknownSpecialization {
|
||||
typename T::value_type value;
|
||||
|
||||
void run() {
|
||||
value.use();
|
||||
}
|
||||
};
|
||||
|
||||
void callVariadic() {
|
||||
logMany(1, 2, 3);
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
// On-disk path (Legacy/System/Threading/Tasks.cs) path-aligns with
|
||||
// `using System.Threading.Tasks;` but declares an UNRELATED in-repo namespace,
|
||||
// so the only way an IMPORTS edge forms is the coincidental path — which the
|
||||
// gate must block in the no-csproj path on BOTH legs (#1881, Codex F2).
|
||||
namespace MyApp.Legacy;
|
||||
|
||||
public class Tasks
|
||||
{
|
||||
public void Run() { }
|
||||
}
|
||||
6
gitnexus/test/fixtures/lang-resolution/csharp-spurious-edges-no-csproj/Models/User.cs
vendored
Normal file
6
gitnexus/test/fixtures/lang-resolution/csharp-spurious-edges-no-csproj/Models/User.cs
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
namespace MyApp.Models;
|
||||
|
||||
public class User
|
||||
{
|
||||
public string Name { get; set; } = "";
|
||||
}
|
||||
13
gitnexus/test/fixtures/lang-resolution/csharp-spurious-edges-no-csproj/Services/OrderService.cs
vendored
Normal file
13
gitnexus/test/fixtures/lang-resolution/csharp-spurious-edges-no-csproj/Services/OrderService.cs
vendored
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
using System.Threading.Tasks;
|
||||
using MyApp.Models;
|
||||
|
||||
namespace MyApp.Services;
|
||||
|
||||
public class OrderService
|
||||
{
|
||||
public Task ProcessAsync()
|
||||
{
|
||||
var user = new User();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
6
gitnexus/test/fixtures/lang-resolution/csharp-spurious-edges/Legacy/Tasks.cs
vendored
Normal file
6
gitnexus/test/fixtures/lang-resolution/csharp-spurious-edges/Legacy/Tasks.cs
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
namespace MyApp.Legacy;
|
||||
|
||||
public class Tasks
|
||||
{
|
||||
public void Run() { }
|
||||
}
|
||||
6
gitnexus/test/fixtures/lang-resolution/csharp-spurious-edges/Models/User.cs
vendored
Normal file
6
gitnexus/test/fixtures/lang-resolution/csharp-spurious-edges/Models/User.cs
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
namespace MyApp.Models;
|
||||
|
||||
public class User
|
||||
{
|
||||
public string Name { get; set; } = "";
|
||||
}
|
||||
13
gitnexus/test/fixtures/lang-resolution/csharp-spurious-edges/Services/OrderService.cs
vendored
Normal file
13
gitnexus/test/fixtures/lang-resolution/csharp-spurious-edges/Services/OrderService.cs
vendored
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
using System.Threading.Tasks;
|
||||
using MyApp.Models;
|
||||
|
||||
namespace MyApp.Services;
|
||||
|
||||
public class OrderService
|
||||
{
|
||||
public Task ProcessAsync()
|
||||
{
|
||||
var user = new User();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
6
gitnexus/test/fixtures/lang-resolution/csharp-spurious-edges/Spurious.csproj
vendored
Normal file
6
gitnexus/test/fixtures/lang-resolution/csharp-spurious-edges/Spurious.csproj
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<RootNamespace>MyApp</RootNamespace>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
554
gitnexus/test/fixtures/php-captures-golden/expected-captures.json
vendored
Normal file
554
gitnexus/test/fixtures/php-captures-golden/expected-captures.json
vendored
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
758
gitnexus/test/fixtures/python-captures-golden/expected-captures.json
vendored
Normal file
758
gitnexus/test/fixtures/python-captures-golden/expected-captures.json
vendored
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
314
gitnexus/test/fixtures/ruby-captures-golden/expected-captures.json
vendored
Normal file
314
gitnexus/test/fixtures/ruby-captures-golden/expected-captures.json
vendored
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
454
gitnexus/test/fixtures/rust-captures-golden/expected-captures.json
vendored
Normal file
454
gitnexus/test/fixtures/rust-captures-golden/expected-captures.json
vendored
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -1534,3 +1534,82 @@ describe('CLI end-to-end', () => {
|
|||
}, 35000);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── impact disambiguation flags reach the backend at runtime (#1907 U2) ──
|
||||
// The mocked unit test proves the CLI option → callTool param mapping; this
|
||||
// proves the flags survive the real Commander → lazy-action → impactCommand →
|
||||
// callTool chain by spawning the actual CLI. The F2 gap is *flag-forwarding*,
|
||||
// so a uniquely-named fixture symbol is enough — no ambiguous fixture needed.
|
||||
// Tests self-skip when the environment cannot index the fixture (e.g. a
|
||||
// worktree without the built parse-worker); CI validates the real path.
|
||||
describe('impact disambiguation flags reach the backend (e2e, #1907)', () => {
|
||||
const SYMBOL = 'formatResponse'; // uniquely named, in mini-repo/src/formatter.ts
|
||||
let uid: string | undefined;
|
||||
let symbolFile: string | undefined;
|
||||
|
||||
beforeAll(() => {
|
||||
// Idempotent: the earlier analyze test may already have indexed mini-repo.
|
||||
runCli('analyze', MINI_REPO, 60000);
|
||||
// Derive the real uid + filePath from context so the test is robust to the
|
||||
// exact uid format rather than hard-coding `Function:<path>:<name>`.
|
||||
const ctx = runCliRaw(['context', SYMBOL, '--repo', 'mini-repo'], MINI_REPO, 30000);
|
||||
if (ctx.status === 0) {
|
||||
try {
|
||||
const parsed = JSON.parse(ctx.stdout.trim());
|
||||
uid = parsed?.symbol?.uid;
|
||||
symbolFile = parsed?.symbol?.filePath;
|
||||
} catch {
|
||||
/* leave undefined → tests self-skip below */
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('forwards --uid alone with no positional target (U1 + --uid end-to-end)', () => {
|
||||
if (!uid) return; // environment could not index — validated in CI
|
||||
const res = runCliRaw(['impact', '--uid', uid, '--repo', 'mini-repo'], MINI_REPO, 30000);
|
||||
if (res.status === null) return;
|
||||
expect(res.status).toBe(0);
|
||||
const out = JSON.parse(res.stdout.trim());
|
||||
expect(out).not.toHaveProperty('error');
|
||||
expect(out.target?.id).toBe(uid);
|
||||
});
|
||||
|
||||
it('forwards --file: the correct file resolves, a wrong file does not (negative control)', () => {
|
||||
if (!uid || !symbolFile) return;
|
||||
|
||||
const ok = runCliRaw(
|
||||
['impact', SYMBOL, '--file', symbolFile, '--repo', 'mini-repo'],
|
||||
MINI_REPO,
|
||||
30000,
|
||||
);
|
||||
if (ok.status === null) return;
|
||||
expect(ok.status).toBe(0);
|
||||
const okOut = JSON.parse(ok.stdout.trim());
|
||||
expect(okOut.status).not.toBe('ambiguous');
|
||||
expect(okOut.target?.filePath).toBe(symbolFile);
|
||||
|
||||
// Wrong --file hint → CONTAINS matches nothing → must NOT resolve to the
|
||||
// formatter.ts symbol. Proves the --file value reached the resolver.
|
||||
const wrong = runCliRaw(
|
||||
['impact', SYMBOL, '--file', 'does/not/exist/nowhere.ts', '--repo', 'mini-repo'],
|
||||
MINI_REPO,
|
||||
30000,
|
||||
);
|
||||
if (wrong.status === null) return;
|
||||
const wrongOut = JSON.parse(wrong.stdout.trim());
|
||||
expect(wrongOut.error !== undefined || wrongOut.target?.filePath !== symbolFile).toBe(true);
|
||||
});
|
||||
|
||||
it('forwards --kind: exit 0 with the kind hint applied', () => {
|
||||
if (!uid) return;
|
||||
const res = runCliRaw(
|
||||
['impact', SYMBOL, '--kind', 'Function', '--repo', 'mini-repo'],
|
||||
MINI_REPO,
|
||||
30000,
|
||||
);
|
||||
if (res.status === null) return;
|
||||
expect(res.status).toBe(0);
|
||||
const out = JSON.parse(res.stdout.trim());
|
||||
expect(out).not.toHaveProperty('error');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
456
gitnexus/test/integration/go-pipeline-benchmark.test.ts
Normal file
456
gitnexus/test/integration/go-pipeline-benchmark.test.ts
Normal file
|
|
@ -0,0 +1,456 @@
|
|||
/**
|
||||
* Go ingestion pipeline benchmark.
|
||||
*
|
||||
* Generates synthetic Go codebases at increasing scales and measures
|
||||
* wall-clock time and peak heap through the full pipeline — parsing,
|
||||
* Go scope capture (emitGoScopeCaptures), package/import resolution, and
|
||||
* call resolution.
|
||||
*
|
||||
* Run: GITNEXUS_BENCH=1 npx vitest run test/integration/go-pipeline-benchmark.test.ts
|
||||
*
|
||||
* The first suite ("scales with file count") generates many small files —
|
||||
* each a struct plus getter/setter/compute methods, the DAO-style shape that
|
||||
* stresses the Go scope-capture path. Run under vitest it falls back to the
|
||||
* sequential path (no compiled worker), so it measures parse + scope-capture
|
||||
* scaling without the worker pool.
|
||||
*
|
||||
* The second suite ("worker pool — issue #1848") reproduces the actual bug:
|
||||
* it points the pool at the COMPILED `dist/.../parse-worker.js` (real
|
||||
* worker_threads), generates one ~400 KiB generated-DAO file plus padding so
|
||||
* the 15-file worker threshold trips, and runs with a short sub-batch idle
|
||||
* timeout. Under the buggy code the worker looks idle while inside
|
||||
* emitGoScopeCaptures and the file is quarantined; the fix emits progress so
|
||||
* the file survives. Requires a build first:
|
||||
*
|
||||
* (cd gitnexus && npm run build)
|
||||
* GITNEXUS_BENCH=1 npx vitest run test/integration/go-pipeline-benchmark.test.ts
|
||||
*
|
||||
* The worker suite auto-skips if the compiled worker is absent.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { runPipelineFromRepo } from '../../src/core/ingestion/pipeline.js';
|
||||
import { emitGoScopeCaptures } from '../../src/core/ingestion/languages/go/index.js';
|
||||
|
||||
const BENCH_ENABLED = process.env.GITNEXUS_BENCH === '1';
|
||||
|
||||
const MODULE_PATH = 'example.com/go-bench';
|
||||
|
||||
/**
|
||||
* The compiled worker the pool spawns. Under vitest, `import.meta.url`
|
||||
* resolves to `src/`, where no `.js` exists — so we point straight at the
|
||||
* `dist/` build, the same fallback parse-impl uses. `null` when unbuilt.
|
||||
*/
|
||||
const DIST_WORKER_URL = new URL(
|
||||
'../../dist/core/ingestion/workers/parse-worker.js',
|
||||
import.meta.url,
|
||||
);
|
||||
const DIST_WORKER_AVAILABLE = fs.existsSync(fileURLToPath(DIST_WORKER_URL));
|
||||
|
||||
interface BenchResult {
|
||||
fileCount: number;
|
||||
structCount: number;
|
||||
packageCount: number;
|
||||
elapsedMs: number;
|
||||
peakHeapMB: number;
|
||||
nodeCount: number;
|
||||
edgeCount: number;
|
||||
}
|
||||
|
||||
function generateGoFixture(
|
||||
fileCount: number,
|
||||
packageCount: number,
|
||||
): { dir: string; structCount: number; packageCount: number } {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), `go-bench-${fileCount}-`));
|
||||
fs.writeFileSync(path.join(dir, 'go.mod'), `module ${MODULE_PATH}\n\ngo 1.22\n`);
|
||||
|
||||
const packages: string[] = [];
|
||||
for (let i = 0; i < packageCount; i++) {
|
||||
packages.push(`pkg${i}`);
|
||||
}
|
||||
|
||||
const structCount = fileCount;
|
||||
const createdPackages = new Set<string>();
|
||||
|
||||
for (let f = 0; f < fileCount; f++) {
|
||||
const pkg = packages[f % packages.length];
|
||||
const pkgDir = path.join(dir, pkg);
|
||||
if (!createdPackages.has(pkg)) {
|
||||
fs.mkdirSync(pkgDir, { recursive: true });
|
||||
createdPackages.add(pkg);
|
||||
}
|
||||
|
||||
const structName = `Item${f}`;
|
||||
|
||||
const siblingIdx = (f + 1) % fileCount;
|
||||
const siblingStruct = `Item${siblingIdx}`;
|
||||
const siblingPkg = packages[siblingIdx % packages.length];
|
||||
|
||||
const crossIdx = (f + Math.floor(fileCount / 3)) % fileCount;
|
||||
const crossStruct = `Item${crossIdx}`;
|
||||
const crossPkg = packages[crossIdx % packages.length];
|
||||
|
||||
// Only import packages we actually reference, and never our own.
|
||||
const imports = new Set<string>();
|
||||
if (siblingPkg !== pkg) imports.add(siblingPkg);
|
||||
if (crossPkg !== pkg) imports.add(crossPkg);
|
||||
|
||||
const importBlock =
|
||||
imports.size > 0
|
||||
? ['import (', ...[...imports].map((p) => `\t"${MODULE_PATH}/${p}"`), ')', '']
|
||||
: [];
|
||||
|
||||
const qualify = (otherPkg: string, name: string) =>
|
||||
otherPkg === pkg ? name : `${otherPkg}.${name}`;
|
||||
|
||||
const siblingRef = qualify(siblingPkg, siblingStruct);
|
||||
const siblingCtor = qualify(siblingPkg, `New${siblingStruct}`);
|
||||
const crossRef = qualify(crossPkg, crossStruct);
|
||||
const crossCtor = qualify(crossPkg, `New${crossStruct}`);
|
||||
|
||||
const content = [
|
||||
`package ${pkg}`,
|
||||
'',
|
||||
...importBlock,
|
||||
`type ${structName} struct {`,
|
||||
`\tID int64`,
|
||||
`\tName string`,
|
||||
`\tEmail string`,
|
||||
`\tValue float64`,
|
||||
`}`,
|
||||
'',
|
||||
`func New${structName}(id int64, name string) *${structName} {`,
|
||||
`\treturn &${structName}{ID: id, Name: name}`,
|
||||
`}`,
|
||||
'',
|
||||
`func (i *${structName}) GetID() int64 {`,
|
||||
`\treturn i.ID`,
|
||||
`}`,
|
||||
'',
|
||||
`func (i *${structName}) SetID(id int64) {`,
|
||||
`\ti.ID = id`,
|
||||
`}`,
|
||||
'',
|
||||
`func (i *${structName}) GetName() string {`,
|
||||
`\treturn i.Name`,
|
||||
`}`,
|
||||
'',
|
||||
`func (i *${structName}) SetValue(v float64) {`,
|
||||
`\ti.Value = v`,
|
||||
`}`,
|
||||
'',
|
||||
`func (i *${structName}) Compute() float64 {`,
|
||||
`\treturn i.Value * float64(i.ID)`,
|
||||
`}`,
|
||||
'',
|
||||
`func (i *${structName}) Process() *${siblingRef} {`,
|
||||
`\tsibling := ${siblingCtor}(i.ID, i.Name)`,
|
||||
`\tsibling.SetValue(i.Compute())`,
|
||||
`\treturn sibling`,
|
||||
`}`,
|
||||
'',
|
||||
`func (i *${structName}) CrossCall() *${crossRef} {`,
|
||||
`\tcross := ${crossCtor}(i.ID, i.Name)`,
|
||||
`\t_ = cross.GetID()`,
|
||||
`\treturn cross`,
|
||||
`}`,
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
fs.writeFileSync(path.join(pkgDir, `item${f}.go`), content);
|
||||
}
|
||||
|
||||
return { dir, structCount, packageCount: createdPackages.size };
|
||||
}
|
||||
|
||||
async function runBenchmark(
|
||||
fileCount: number,
|
||||
packageCount: number,
|
||||
budgetMs: number,
|
||||
): Promise<BenchResult> {
|
||||
const {
|
||||
dir,
|
||||
structCount,
|
||||
packageCount: actualPackages,
|
||||
} = generateGoFixture(fileCount, packageCount);
|
||||
|
||||
let peakHeapMB = 0;
|
||||
const heapSampler = setInterval(() => {
|
||||
const heap = process.memoryUsage().heapUsed / 1024 / 1024;
|
||||
if (heap > peakHeapMB) peakHeapMB = heap;
|
||||
}, 50);
|
||||
|
||||
let timeoutHandle: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
const start = Date.now();
|
||||
const result = await Promise.race([
|
||||
runPipelineFromRepo(dir, () => {}, { skipGraphPhases: true }),
|
||||
new Promise<never>((_, reject) => {
|
||||
// Hold the handle so the winning (pipeline) path can cancel it in the
|
||||
// finally — otherwise the timer lingers for up to budgetMs and its
|
||||
// late rejection surfaces as an unhandled promise rejection.
|
||||
timeoutHandle = setTimeout(
|
||||
() => reject(new Error(`Pipeline exceeded ${budgetMs}ms at ${fileCount} files`)),
|
||||
budgetMs,
|
||||
);
|
||||
}),
|
||||
]);
|
||||
const elapsedMs = Date.now() - start;
|
||||
|
||||
return {
|
||||
fileCount,
|
||||
structCount,
|
||||
packageCount: actualPackages,
|
||||
elapsedMs,
|
||||
peakHeapMB: Math.round(peakHeapMB),
|
||||
nodeCount: result.graph.nodeCount,
|
||||
edgeCount: result.graph.relationshipCount,
|
||||
};
|
||||
} finally {
|
||||
if (timeoutHandle !== undefined) clearTimeout(timeoutHandle);
|
||||
clearInterval(heapSampler);
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function printResults(label: string, results: BenchResult[]) {
|
||||
console.log(`\n${label}`);
|
||||
console.log('┌──────────┬─────────┬──────────┬───────────┬──────────┬───────┬───────┐');
|
||||
console.log('│ Files │ Structs │ Packages │ Time (ms) │ Heap MB │ Nodes │ Edges │');
|
||||
console.log('├──────────┼─────────┼──────────┼───────────┼──────────┼───────┼───────┤');
|
||||
for (const r of results) {
|
||||
console.log(
|
||||
`│ ${String(r.fileCount).padStart(8)} │ ${String(r.structCount).padStart(7)} │ ${String(r.packageCount).padStart(8)} │ ${String(r.elapsedMs).padStart(9)} │ ${String(r.peakHeapMB).padStart(8)} │ ${String(r.nodeCount).padStart(5)} │ ${String(r.edgeCount).padStart(5)} │`,
|
||||
);
|
||||
}
|
||||
console.log('└──────────┴─────────┴──────────┴───────────┴──────────┴───────┴───────┘');
|
||||
|
||||
if (results.length >= 2) {
|
||||
console.log('\nScaling ratios (time_ratio / file_ratio):');
|
||||
for (let i = 1; i < results.length; i++) {
|
||||
const fileRatio = results[i].fileCount / results[i - 1].fileCount;
|
||||
const timeRatio = results[i].elapsedMs / results[i - 1].elapsedMs;
|
||||
const scaling = timeRatio / fileRatio;
|
||||
console.log(
|
||||
` ${results[i - 1].fileCount} → ${results[i].fileCount}: ${scaling.toFixed(2)}x (${scaling < 1.5 ? 'linear' : scaling < 3 ? 'superlinear' : 'WARNING: quadratic'})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirrors the issue #1848 reproduction fixture: one large generated-DAO Go
|
||||
* file (`package generated`, struct + 7 methods per entity) plus `padCount`
|
||||
* trivial files so parse-impl crosses the 15-file worker threshold and the
|
||||
* real worker pool engages.
|
||||
*/
|
||||
function generateGoQuarantineFixture(
|
||||
entityCount: number,
|
||||
padCount: number,
|
||||
): { dir: string; bigFileBytes: number; fileCount: number } {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), `go-bench-1848-${entityCount}-`));
|
||||
|
||||
const lines = [
|
||||
'package generated',
|
||||
'',
|
||||
'// Code generated for GitNexus issue #1848 repro. DO NOT EDIT.',
|
||||
'',
|
||||
];
|
||||
for (let i = 0; i < entityCount; i++) {
|
||||
const n = String(i).padStart(4, '0');
|
||||
lines.push(`type DefUserDao${n} struct {`);
|
||||
lines.push('\tid int64');
|
||||
lines.push('\tname string');
|
||||
lines.push('\temail string');
|
||||
lines.push('\tcreatedAt int64');
|
||||
lines.push('}', '');
|
||||
lines.push(`func (d *DefUserDao${n}) GetID() int64 { return d.id }`);
|
||||
lines.push(`func (d *DefUserDao${n}) SetID(id int64) { d.id = id }`);
|
||||
lines.push(`func (d *DefUserDao${n}) GetName() string { return d.name }`);
|
||||
lines.push(`func (d *DefUserDao${n}) SetName(name string) { d.name = name }`);
|
||||
lines.push(`func (d *DefUserDao${n}) GetEmail() string { return d.email }`);
|
||||
lines.push(`func (d *DefUserDao${n}) SetEmail(email string) { d.email = email }`);
|
||||
lines.push(`func (d *DefUserDao${n}) Validate() error { return nil }`);
|
||||
lines.push('');
|
||||
}
|
||||
const bigContent = lines.join('\n');
|
||||
fs.writeFileSync(path.join(dir, 'zz_generated.def_userdao.go'), bigContent);
|
||||
|
||||
for (let i = 0; i < padCount; i++) {
|
||||
const idx = String(i).padStart(2, '0');
|
||||
fs.writeFileSync(
|
||||
path.join(dir, `pad_${idx}.go`),
|
||||
`package pad${i}\n\nfunc Ping${i}() int { return ${i} }\n`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
dir,
|
||||
bigFileBytes: Buffer.byteLength(bigContent),
|
||||
fileCount: 1 + padCount,
|
||||
};
|
||||
}
|
||||
|
||||
describe.skipIf(!BENCH_ENABLED)('Go pipeline benchmark', () => {
|
||||
it('scales with file count (workers enabled)', async () => {
|
||||
const scales = [100, 250, 500];
|
||||
const results: BenchResult[] = [];
|
||||
|
||||
for (const fileCount of scales) {
|
||||
const packageCount = Math.max(4, Math.ceil(Math.sqrt(fileCount)));
|
||||
const result = await runBenchmark(fileCount, packageCount, 180_000);
|
||||
results.push(result);
|
||||
console.log(
|
||||
` ${fileCount} files: ${result.elapsedMs}ms, ${result.peakHeapMB}MB heap, ${result.nodeCount} nodes, ${result.edgeCount} edges`,
|
||||
);
|
||||
}
|
||||
|
||||
printResults('Go Pipeline — Workers Enabled', results);
|
||||
|
||||
for (let i = 1; i < results.length; i++) {
|
||||
const fileRatio = results[i].fileCount / results[i - 1].fileCount;
|
||||
const timeRatio = results[i].elapsedMs / results[i - 1].elapsedMs;
|
||||
// The scale steps are 2.5x (100->250) and 2x (250->500). A quadratic
|
||||
// regression makes timeRatio ~= fileRatio^2, i.e. timeRatio/fileRatio ~=
|
||||
// fileRatio (2.5 and 2.0) — which a `< 3` bound would wave through. The
|
||||
// O(n) path keeps this ratio ~1 (measured 0.44 and 0.75 post-fix), so a
|
||||
// `< 1.5` bound (the printResults "linear" boundary) actually fails on a
|
||||
// re-regression to O(n^2) while leaving comfortable headroom for linear.
|
||||
expect(timeRatio / fileRatio).toBeLessThan(1.5);
|
||||
}
|
||||
}, 300_000);
|
||||
});
|
||||
|
||||
describe.skipIf(!BENCH_ENABLED || !DIST_WORKER_AVAILABLE)(
|
||||
'Go pipeline benchmark — worker pool (issue #1848)',
|
||||
() => {
|
||||
if (BENCH_ENABLED && !DIST_WORKER_AVAILABLE) {
|
||||
// Surfaced once when the bench is requested but the worker is unbuilt.
|
||||
console.warn(
|
||||
`\n[go-bench] Skipping worker-pool suite: compiled worker not found at\n ${fileURLToPath(DIST_WORKER_URL)}\n Build first: (cd gitnexus && npm run build)\n`,
|
||||
);
|
||||
}
|
||||
|
||||
// Tunables mirror run-analyze-repro.sh. 800 entities ≈ 406 KiB — under the
|
||||
// 512 KiB GITNEXUS_MAX_FILE_SIZE ceiling so the file is parsed, not skipped.
|
||||
const entityCount = Number(process.env.REPRO_GO_ENTITIES ?? 800);
|
||||
// 30 s reproduces on the report author's machine; raising it (e.g. 120000)
|
||||
// is the documented workaround. Kept overridable so the same test can both
|
||||
// reproduce the bug and confirm the fix.
|
||||
const subBatchTimeoutMs = Number(process.env.GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS ?? 30_000);
|
||||
|
||||
it('does not quarantine the large generated Go file on sub-batch idle timeout', async () => {
|
||||
const { dir, bigFileBytes, fileCount } = generateGoQuarantineFixture(entityCount, 14);
|
||||
|
||||
// Sub-batch knobs that force fine chunking onto the worker (env-only —
|
||||
// there is no PipelineOptions field for these two). Capture the prior
|
||||
// values before the try; set them as the first statements INSIDE it so
|
||||
// the finally's restore covers every path that mutated the process env.
|
||||
const prevMaxBytes = process.env.GITNEXUS_WORKER_SUB_BATCH_MAX_BYTES;
|
||||
const prevTimeout = process.env.GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS;
|
||||
|
||||
let peakHeapMB = 0;
|
||||
const heapSampler = setInterval(() => {
|
||||
const heap = process.memoryUsage().heapUsed / 1024 / 1024;
|
||||
if (heap > peakHeapMB) peakHeapMB = heap;
|
||||
}, 50);
|
||||
|
||||
try {
|
||||
process.env.GITNEXUS_WORKER_SUB_BATCH_MAX_BYTES = '262144';
|
||||
process.env.GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS = String(subBatchTimeoutMs);
|
||||
const start = Date.now();
|
||||
const result = await runPipelineFromRepo(dir, () => {}, {
|
||||
skipGraphPhases: true,
|
||||
// Real worker_threads against the compiled worker — the surface the
|
||||
// bug actually lives on.
|
||||
workerUrlForTest: DIST_WORKER_URL,
|
||||
// Match the repro's chunking so the byte budget mirrors the issue.
|
||||
chunkByteBudget: 262144,
|
||||
parseChunkConcurrency: 1,
|
||||
});
|
||||
const elapsedMs = Date.now() - start;
|
||||
|
||||
// The big file alone emits ≥ entityCount*5 nodes (1 struct + 7 methods
|
||||
// each). If the worker is quarantined on the idle timeout, those
|
||||
// vanish — so this threshold is the regression guard.
|
||||
const survivalFloor = entityCount * 5;
|
||||
const survived = result.graph.nodeCount >= survivalFloor;
|
||||
|
||||
console.log(
|
||||
`\nGo Pipeline — Worker Pool (issue #1848)` +
|
||||
`\n files: ${fileCount} (1 big @ ${Math.round(bigFileBytes / 1024)} KiB + 14 pad)` +
|
||||
`\n entities: ${entityCount}, sub-batch idle timeout: ${subBatchTimeoutMs}ms` +
|
||||
`\n elapsed: ${elapsedMs}ms, peak heap: ${Math.round(peakHeapMB)}MB` +
|
||||
`\n usedWorkerPool: ${result.usedWorkerPool}` +
|
||||
`\n nodes: ${result.graph.nodeCount} (survival floor ${survivalFloor}) → ${survived ? 'SURVIVED' : 'QUARANTINED (bug reproduced)'}`,
|
||||
);
|
||||
|
||||
// Sanity: the worker path must have actually engaged, else the test
|
||||
// proves nothing about the worker bug.
|
||||
expect(result.usedWorkerPool).toBe(true);
|
||||
// The fix: the generated file is fully parsed despite the idle timeout.
|
||||
expect(result.graph.nodeCount).toBeGreaterThanOrEqual(survivalFloor);
|
||||
} finally {
|
||||
clearInterval(heapSampler);
|
||||
if (prevMaxBytes === undefined) delete process.env.GITNEXUS_WORKER_SUB_BATCH_MAX_BYTES;
|
||||
else process.env.GITNEXUS_WORKER_SUB_BATCH_MAX_BYTES = prevMaxBytes;
|
||||
if (prevTimeout === undefined) delete process.env.GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS;
|
||||
else process.env.GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS = prevTimeout;
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}, 360_000);
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Unlike the two suites above, this one is NOT gated behind GITNEXUS_BENCH and
|
||||
* needs no compiled worker — so it runs in normal CI and is the actual guard
|
||||
* against an O(n^2) re-regression of emitGoScopeCaptures (issue #1848). It calls
|
||||
* the hotpath directly on a ~400-struct generated source. The O(n) path does
|
||||
* this in a few hundred ms; the old findNodeAtRange-from-root behaviour took
|
||||
* ~25s+ at this size. The budget is a coarse tripwire (huge margin over the
|
||||
* fixed path, far below a quadratic regression), not a microbenchmark — keep it
|
||||
* generous so it never flakes on a loaded CI runner.
|
||||
*/
|
||||
describe('Go scope-capture O(n^2) regression tripwire', () => {
|
||||
function generateGoStructSource(structCount: number): string {
|
||||
const lines = ['package generated', ''];
|
||||
for (let i = 0; i < structCount; i++) {
|
||||
const n = String(i).padStart(4, '0');
|
||||
lines.push(
|
||||
`type Item${n} struct {`,
|
||||
'\tid int64',
|
||||
'\tname string',
|
||||
'}',
|
||||
'',
|
||||
`func (d *Item${n}) GetID() int64 { return d.id }`,
|
||||
`func (d *Item${n}) SetID(id int64) { d.id = id }`,
|
||||
`func (d *Item${n}) GetName() string { return d.name }`,
|
||||
`func (d *Item${n}) Validate() error { return nil }`,
|
||||
'',
|
||||
);
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
it('parses a 400-struct file in well under the O(n^2) tripwire budget', () => {
|
||||
const STRUCT_COUNT = 400;
|
||||
const BUDGET_MS = 5_000; // coarse: ~20x the fixed path (~250ms), trips a ~20x regression; a quadratic regression at 400 structs is ~25s
|
||||
const src = generateGoStructSource(STRUCT_COUNT);
|
||||
|
||||
emitGoScopeCaptures(src, 'tripwire-warmup.go'); // warm up the parser/query JIT
|
||||
|
||||
const start = Date.now();
|
||||
const matches = emitGoScopeCaptures(src, 'tripwire.go');
|
||||
const elapsedMs = Date.now() - start;
|
||||
|
||||
// Sanity: the captures are actually produced (each struct + 4 methods emits
|
||||
// far more than 10 capture groups), so a fast-but-empty result can't pass.
|
||||
expect(matches.length).toBeGreaterThan(STRUCT_COUNT * 10);
|
||||
// The actual regression guard: a re-regression to O(n^2) blows this budget.
|
||||
expect(elapsedMs).toBeLessThan(BUDGET_MS);
|
||||
}, 30_000);
|
||||
});
|
||||
|
|
@ -302,6 +302,116 @@ withTestLbugDB(
|
|||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── impact disambiguation + label-scoped resolution (#1907) ─────────
|
||||
// Covers the disambiguation surface the CLI --uid/--file/--kind flags
|
||||
// wire through to, and guards the label-scoped resolver against the
|
||||
// binder failure that motivated the fix.
|
||||
describe('impact disambiguation (#1907)', () => {
|
||||
let backend: LocalBackend;
|
||||
|
||||
beforeAll(async () => {
|
||||
const ext = handle as typeof handle & { _backend?: LocalBackend };
|
||||
if (!ext._backend) {
|
||||
throw new Error(
|
||||
'LocalBackend not initialized — afterSetup did not attach _backend to handle',
|
||||
);
|
||||
}
|
||||
backend = ext._backend;
|
||||
});
|
||||
|
||||
it('reports an ambiguous target with disambiguation guidance', async () => {
|
||||
// Two Methods named 'authenticate' (AuthService + BaseService).
|
||||
const result = await backend.callTool('impact', {
|
||||
target: 'authenticate',
|
||||
direction: 'upstream',
|
||||
});
|
||||
expect(result).not.toHaveProperty('error');
|
||||
expect(result.status).toBe('ambiguous');
|
||||
expect(result.message).toMatch(/disambiguate/i);
|
||||
const uids = (result.candidates ?? []).map((c: any) => c.uid);
|
||||
expect(uids).toContain('method:AuthService.authenticate');
|
||||
expect(uids).toContain('method:BaseService.authenticate');
|
||||
});
|
||||
|
||||
it('resolves the ambiguous target via target_uid (the --uid flag path)', async () => {
|
||||
const result = await backend.callTool('impact', {
|
||||
target: 'authenticate',
|
||||
target_uid: 'method:BaseService.authenticate',
|
||||
direction: 'upstream',
|
||||
});
|
||||
expect(result).not.toHaveProperty('error');
|
||||
expect(result.status).not.toBe('ambiguous');
|
||||
// target_uid selects the exact symbol, bypassing the name ranker.
|
||||
expect(result.target?.id).toBe('method:BaseService.authenticate');
|
||||
expect(result.target?.filePath).toBe('src/base.ts');
|
||||
});
|
||||
|
||||
it('resolves the ambiguous target via file_path (the --file flag path)', async () => {
|
||||
const result = await backend.callTool('impact', {
|
||||
target: 'authenticate',
|
||||
file_path: 'src/base.ts',
|
||||
direction: 'upstream',
|
||||
});
|
||||
expect(result).not.toHaveProperty('error');
|
||||
expect(result.status).not.toBe('ambiguous');
|
||||
expect(result.target?.id).toBe('method:BaseService.authenticate');
|
||||
});
|
||||
|
||||
it('does not crash when a name collides across symbol and non-symbol labels', async () => {
|
||||
// 'alpha' exists as both a Function and a Tool sharing src/tools.py.
|
||||
// The Tool node table has no startLine/endLine columns, so the
|
||||
// resolver's `RETURN n.startLine` projection only binds because the
|
||||
// candidate set also contains a label that *does* have those columns
|
||||
// (lenient multi-table binding). This guards that the disambiguation
|
||||
// path keeps tolerating non-symbol node types — and would catch a
|
||||
// future naive label-scoping that reintroduces the #1907 binder error
|
||||
// ("Cannot find property … for n") by matching property-poor tables
|
||||
// in isolation.
|
||||
const result = await backend.callTool('impact', {
|
||||
target: 'alpha',
|
||||
direction: 'upstream',
|
||||
});
|
||||
expect(result).not.toHaveProperty('error');
|
||||
expect(result.status).toBe('ambiguous');
|
||||
const uids = (result.candidates ?? []).map((c: any) => c.uid);
|
||||
expect(uids).toContain('func:alpha');
|
||||
expect(uids).toContain('Tool:alpha');
|
||||
});
|
||||
|
||||
it('context resolves the same cross-label collision without crashing', async () => {
|
||||
const result = await backend.callTool('context', { name: 'alpha' });
|
||||
expect(result).not.toHaveProperty('error');
|
||||
expect(result.status).toBe('ambiguous');
|
||||
const uids = (result.candidates ?? []).map((c: any) => c.uid);
|
||||
expect(uids).toContain('func:alpha');
|
||||
// Assert the non-symbol Tool node stays in the candidate set, not just
|
||||
// that nothing crashed — a regression that silently dropped Tool from
|
||||
// the lenient-binding match would otherwise pass the non-crash check.
|
||||
expect(uids).toContain('Tool:alpha');
|
||||
});
|
||||
|
||||
it('ranks the kind-matching candidate first when kind is supplied (the --kind flag path)', async () => {
|
||||
// 'alpha' is both a Function (func:alpha) and a Tool (Tool:alpha).
|
||||
// kind only adds +0.20 in scoreCandidate, so 0.50 + 0.20 = 0.70 stays
|
||||
// below the 0.95 confident-resolution threshold — the response is still
|
||||
// ambiguous. What kind buys is ranking: the Function is promoted above
|
||||
// the non-matching Tool. This exercises the scoreCandidate kind branch
|
||||
// against a real DB rather than only through the mocked CLI unit test.
|
||||
const result = await backend.callTool('impact', {
|
||||
target: 'alpha',
|
||||
kind: 'Function',
|
||||
direction: 'upstream',
|
||||
});
|
||||
expect(result).not.toHaveProperty('error');
|
||||
expect(result.status).toBe('ambiguous');
|
||||
const candidates = result.candidates ?? [];
|
||||
expect(candidates[0]?.uid).toBe('func:alpha');
|
||||
expect(candidates[0]?.kind).toBe('Function');
|
||||
const tool = candidates.find((c: any) => c.uid === 'Tool:alpha');
|
||||
expect(candidates[0]?.score).toBeGreaterThan(tool?.score);
|
||||
});
|
||||
});
|
||||
},
|
||||
{
|
||||
seed: LOCAL_BACKEND_SEED_DATA,
|
||||
|
|
@ -327,3 +437,68 @@ withTestLbugDB(
|
|||
},
|
||||
},
|
||||
);
|
||||
|
||||
// ─── impact BFS bound parameters (#1907 review F5) ───────────────────────
|
||||
// Isolated DB (not the shared seed) with a frontier node whose id contains a
|
||||
// single quote. Under the old string-interpolated query this id had to be
|
||||
// hand-escaped; the parameterized query (executeParameterized with bound
|
||||
// $frontierIds/$relTypes) carries it as data. Guards that a quote-bearing id
|
||||
// traverses without a Prepare/parser error, and that a no-caller symbol
|
||||
// returns an empty result rather than erroring.
|
||||
withTestLbugDB(
|
||||
'local-backend-impact-param',
|
||||
(handle) => {
|
||||
describe('impact BFS bound parameters (#1907 F5)', () => {
|
||||
let backend: LocalBackend;
|
||||
|
||||
beforeAll(() => {
|
||||
const ext = handle as typeof handle & { _backend?: LocalBackend };
|
||||
if (!ext._backend) {
|
||||
throw new Error('LocalBackend not initialized — afterSetup did not attach _backend');
|
||||
}
|
||||
backend = ext._backend;
|
||||
});
|
||||
|
||||
it('traverses a caller whose id contains a single quote without a query error', async () => {
|
||||
const result = await backend.callTool('impact', { target: 'sink', direction: 'upstream' });
|
||||
expect(result).not.toHaveProperty('error');
|
||||
const d1 = result.byDepth?.[1] || result.byDepth?.['1'] || [];
|
||||
const callerIds = d1.map((d: any) => d.uid ?? d.id);
|
||||
expect(callerIds).toContain("func:o'd");
|
||||
});
|
||||
|
||||
it('returns an empty result (not an error) for a symbol with no callers', async () => {
|
||||
const result = await backend.callTool('impact', {
|
||||
target: 'sink',
|
||||
direction: 'downstream',
|
||||
});
|
||||
expect(result).not.toHaveProperty('error');
|
||||
expect(result.impactedCount).toBe(0);
|
||||
});
|
||||
});
|
||||
},
|
||||
{
|
||||
seed: [
|
||||
`CREATE (a:Function {id: "func:o'd", name: 'odd', filePath: 'src/q.ts', startLine: 1, endLine: 3, isExported: true, content: 'function odd() {}', description: 'caller with a quote in its id'})`,
|
||||
`CREATE (b:Function {id: 'func:sink', name: 'sink', filePath: 'src/q.ts', startLine: 5, endLine: 8, isExported: true, content: 'function sink() {}', description: 'callee'})`,
|
||||
`MATCH (a:Function), (b:Function) WHERE a.id = "func:o'd" AND b.id = 'func:sink'
|
||||
CREATE (a)-[:CodeRelation {type: 'CALLS', confidence: 1.0, reason: 'direct', step: 0}]->(b)`,
|
||||
],
|
||||
poolAdapter: true,
|
||||
afterSetup: async (handle) => {
|
||||
vi.mocked(listRegisteredRepos).mockResolvedValue([
|
||||
{
|
||||
name: 'param-repo',
|
||||
path: '/param/repo',
|
||||
storagePath: handle.tmpHandle.dbPath,
|
||||
indexedAt: new Date().toISOString(),
|
||||
lastCommit: 'abc123',
|
||||
stats: { files: 1, nodes: 2, communities: 0, processes: 0 },
|
||||
},
|
||||
]);
|
||||
const backend = new LocalBackend();
|
||||
await backend.init();
|
||||
(handle as any)._backend = backend;
|
||||
},
|
||||
},
|
||||
);
|
||||
|
|
|
|||
54
gitnexus/test/integration/php-scope-capture-tripwire.test.ts
Normal file
54
gitnexus/test/integration/php-scope-capture-tripwire.test.ts
Normal file
|
|
@ -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 `<?php` header + N classes, each with fields and
|
||||
* methods. Maximizes top-level children AND member matches, which is exactly
|
||||
* the O(matches x rootChildren) shape the fix removed. Reuses the synthetic
|
||||
* unit shape from bench/scope-capture/measure.mjs.
|
||||
*/
|
||||
function generatePhpDaoSource(entityCount: number): string {
|
||||
let src = '<?php\n\n';
|
||||
for (let i = 0; i < entityCount; i++) {
|
||||
src +=
|
||||
`class Entity${i} {\n` +
|
||||
` public $id;\n public $name;\n` +
|
||||
` function getId() { return $this->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);
|
||||
});
|
||||
87
gitnexus/test/integration/python-import-index-reuse.test.ts
Normal file
87
gitnexus/test/integration/python-import-index-reuse.test.ts
Normal file
|
|
@ -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<string> {
|
||||
const files = new Set<string>();
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
|
|
@ -350,6 +350,89 @@ describe('C++ variadic call resolution', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('C++ variadic packs and dependent-name resolution (#1894)', () => {
|
||||
let result: PipelineResult;
|
||||
|
||||
beforeAll(async () => {
|
||||
result = await runPipelineFromRepo(
|
||||
path.join(FIXTURES, 'cpp-variadic-dependent-resolution'),
|
||||
() => {},
|
||||
);
|
||||
}, 60000);
|
||||
|
||||
it('keeps parameter-pack functions viable when call arity exceeds the fixed prefix', () => {
|
||||
const calls = getRelationships(result, 'CALLS').filter(
|
||||
(c) => c.source === 'callVariadic' && c.target === 'logMany',
|
||||
);
|
||||
|
||||
expect(calls).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('emits one fold-expression edge when the folded callee is unambiguous', () => {
|
||||
const calls = getRelationships(result, 'CALLS').filter(
|
||||
(c) => c.source === 'logMany' && c.target === 'sink',
|
||||
);
|
||||
|
||||
expect(calls).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('emits zero fold-expression edges when overload resolution remains ambiguous', () => {
|
||||
const calls = getRelationships(result, 'CALLS').filter(
|
||||
(c) => c.source === 'foldAmbiguous' && c.target === 'ambiguous',
|
||||
);
|
||||
|
||||
expect(calls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('does not emit a concrete EXTENDS edge for a pack-expanded base', () => {
|
||||
const extendsEdges = getRelationships(result, 'EXTENDS').filter(
|
||||
(e) => e.source === 'Mix' && e.target === 'B',
|
||||
);
|
||||
|
||||
expect(extendsEdges).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('does not bind unqualified member lookup through a pack-expanded dependent base', () => {
|
||||
const calls = getRelationships(result, 'CALLS').filter(
|
||||
(c) => c.source === 'run' && c.target === 'inherited',
|
||||
);
|
||||
|
||||
expect(calls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('preserves free helper calls inside a class with a pack-expanded dependent base', () => {
|
||||
const calls = getRelationships(result, 'CALLS').filter(
|
||||
(c) => c.source === 'run' && c.target === 'helper',
|
||||
);
|
||||
|
||||
expect(calls).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('preserves using-declaration namespace helper calls inside a pack-base class', () => {
|
||||
const calls = getRelationships(result, 'CALLS').filter(
|
||||
(c) => c.source === 'run' && c.target === 'namespaceHelper',
|
||||
);
|
||||
|
||||
expect(calls).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('resolves current-instantiation unqualified member calls', () => {
|
||||
const calls = getRelationships(result, 'CALLS').filter(
|
||||
(c) => c.source === 'run' && c.target === 'own',
|
||||
);
|
||||
|
||||
expect(calls).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('keeps unknown-specialization member types unresolved', () => {
|
||||
const calls = getRelationships(result, 'CALLS').filter(
|
||||
(c) => c.source === 'run' && c.target === 'use',
|
||||
);
|
||||
|
||||
expect(calls).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Local shadow: same-file definition takes priority over imported name
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -1473,6 +1556,34 @@ describe('C++ template overload disambiguation (vector<int> vs vector<string>)',
|
|||
});
|
||||
});
|
||||
|
||||
describe('C++ template partial ordering (#1635)', () => {
|
||||
it('pick(T*) wins over pick(T) for pointer arguments', async () => {
|
||||
const result = await runPipelineFromRepo(
|
||||
path.join(FIXTURES, 'cpp-template-partial-order-pointer'),
|
||||
() => {},
|
||||
);
|
||||
|
||||
const calls = getRelationships(result, 'CALLS').filter(
|
||||
(c) => c.source === 'run' && c.target === 'pick',
|
||||
);
|
||||
expect(calls.length).toBe(1);
|
||||
const target = result.graph.getNode(calls[0].rel.targetId);
|
||||
expect(target?.properties.startLine).toBe(5);
|
||||
});
|
||||
|
||||
it('pick(T*, T) vs pick(T, T*) emits zero CALLS edges when partial ordering is incomparable', async () => {
|
||||
const result = await runPipelineFromRepo(
|
||||
path.join(FIXTURES, 'cpp-template-partial-order-tied'),
|
||||
() => {},
|
||||
);
|
||||
|
||||
const calls = getRelationships(result, 'CALLS').filter(
|
||||
(c) => c.source === 'run' && c.target === 'pick',
|
||||
);
|
||||
expect(calls.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Phase P: C++ template overload cross-file + chain resolution ──────────
|
||||
|
||||
describe('C++ template overload cross-file and chain resolution', () => {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
/**
|
||||
* C#: heritage resolution via base_list + ambiguous namespace-import refusal
|
||||
*/
|
||||
import { describe, expect, beforeAll } from 'vitest';
|
||||
import { describe, expect, beforeAll, afterAll, vi } from 'vitest';
|
||||
import path from 'path';
|
||||
import {
|
||||
FIXTURES,
|
||||
|
|
@ -2603,3 +2603,158 @@ describe('C# namespace-as-root with no trailing newline (issue #1086)', () => {
|
|||
expect(edge!.rel.reason).toBe('csharp-scope: using');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Spurious IMPORTS: BCL usings must not match coincidentally-named local files
|
||||
// (#1881)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('C# spurious import edges (#1881)', () => {
|
||||
let result: PipelineResult;
|
||||
|
||||
beforeAll(async () => {
|
||||
result = await runPipelineFromRepo(path.join(FIXTURES, 'csharp-spurious-edges'), () => {});
|
||||
}, 60000);
|
||||
|
||||
it('does not emit IMPORTS from System.Threading.Tasks to a local Tasks.cs', () => {
|
||||
const imports = getRelationships(result, 'IMPORTS');
|
||||
const spurious = imports.find(
|
||||
(e) =>
|
||||
e.sourceFilePath === 'Services/OrderService.cs' && e.targetFilePath === 'Legacy/Tasks.cs',
|
||||
);
|
||||
expect(spurious).toBeUndefined();
|
||||
});
|
||||
|
||||
it('still emits the legitimate in-repo edge OrderService.cs -> Models/User.cs', () => {
|
||||
// Guards against the negative above passing vacuously: the fixture's
|
||||
// `using MyApp.Models;` must resolve to a real IMPORTS edge.
|
||||
const imports = getRelationships(result, 'IMPORTS');
|
||||
expect(imports.length).toBeGreaterThan(0);
|
||||
const legit = imports.find(
|
||||
(e) =>
|
||||
e.sourceFilePath === 'Services/OrderService.cs' && e.targetFilePath === 'Models/User.cs',
|
||||
);
|
||||
expect(legit).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// #1881 on the LEGACY DAG leg, forced in-process so it runs under `npm test`
|
||||
// (not only the CI parity matrix). `isRegistryPrimary` reads `process.env`
|
||||
// per call with no caching, so stubbing the flag before the pipeline run
|
||||
// routes C# import resolution through `csharpNamespaceStrategy` (#8).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('C# spurious import edges — legacy DAG leg (#1881, #8)', () => {
|
||||
let result: PipelineResult;
|
||||
|
||||
beforeAll(async () => {
|
||||
vi.stubEnv('REGISTRY_PRIMARY_CSHARP', '0');
|
||||
result = await runPipelineFromRepo(path.join(FIXTURES, 'csharp-spurious-edges'), () => {});
|
||||
}, 60000);
|
||||
|
||||
afterAll(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it('does not emit IMPORTS from System.Threading.Tasks to a local Tasks.cs', () => {
|
||||
const imports = getRelationships(result, 'IMPORTS');
|
||||
const spurious = imports.find(
|
||||
(e) =>
|
||||
e.sourceFilePath === 'Services/OrderService.cs' && e.targetFilePath === 'Legacy/Tasks.cs',
|
||||
);
|
||||
expect(spurious).toBeUndefined();
|
||||
});
|
||||
|
||||
it('still emits the legitimate in-repo edge OrderService.cs -> Models/User.cs', () => {
|
||||
const imports = getRelationships(result, 'IMPORTS');
|
||||
const legit = imports.find(
|
||||
(e) =>
|
||||
e.sourceFilePath === 'Services/OrderService.cs' && e.targetFilePath === 'Models/User.cs',
|
||||
);
|
||||
expect(legit).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// #1881 / Codex F2: in the NO-CSPROJ path the registry leg ran an ungated
|
||||
// direct-match before the gate, so a path-aligned `Legacy/System/Threading/
|
||||
// Tasks.cs` satisfied `using System.Threading.Tasks;`. Both legs must now block
|
||||
// it (gate-first), proving the legs are equivalent. Fixture ships NO .csproj.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('C# spurious import edges — no-csproj direct-match, registry leg (#1881, Codex F2)', () => {
|
||||
let result: PipelineResult;
|
||||
|
||||
beforeAll(async () => {
|
||||
// Pin to the registry leg: only progressive stripping resolves a no-csproj
|
||||
// namespace import, so the legit-edge assertion below is registry-specific.
|
||||
// Pinning also keeps this deterministic under the parity matrix's legacy run.
|
||||
vi.stubEnv('REGISTRY_PRIMARY_CSHARP', '1');
|
||||
result = await runPipelineFromRepo(
|
||||
path.join(FIXTURES, 'csharp-spurious-edges-no-csproj'),
|
||||
() => {},
|
||||
);
|
||||
}, 60000);
|
||||
|
||||
afterAll(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it('does not emit IMPORTS from System.Threading.Tasks to a path-aligned Legacy/System/Threading/Tasks.cs', () => {
|
||||
const imports = getRelationships(result, 'IMPORTS');
|
||||
const spurious = imports.find(
|
||||
(e) =>
|
||||
e.sourceFilePath === 'Services/OrderService.cs' &&
|
||||
e.targetFilePath === 'Legacy/System/Threading/Tasks.cs',
|
||||
);
|
||||
expect(spurious).toBeUndefined();
|
||||
});
|
||||
|
||||
it('still emits the legitimate in-repo edge OrderService.cs -> Models/User.cs', () => {
|
||||
const imports = getRelationships(result, 'IMPORTS');
|
||||
expect(imports.length).toBeGreaterThan(0);
|
||||
const legit = imports.find(
|
||||
(e) =>
|
||||
e.sourceFilePath === 'Services/OrderService.cs' && e.targetFilePath === 'Models/User.cs',
|
||||
);
|
||||
expect(legit).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('C# spurious import edges — no-csproj direct-match, legacy DAG leg (#1881, Codex F2, #8)', () => {
|
||||
let result: PipelineResult;
|
||||
|
||||
beforeAll(async () => {
|
||||
vi.stubEnv('REGISTRY_PRIMARY_CSHARP', '0');
|
||||
result = await runPipelineFromRepo(
|
||||
path.join(FIXTURES, 'csharp-spurious-edges-no-csproj'),
|
||||
() => {},
|
||||
);
|
||||
}, 60000);
|
||||
|
||||
afterAll(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it('does not emit IMPORTS from System.Threading.Tasks to a path-aligned Legacy/System/Threading/Tasks.cs', () => {
|
||||
const imports = getRelationships(result, 'IMPORTS');
|
||||
const spurious = imports.find(
|
||||
(e) =>
|
||||
e.sourceFilePath === 'Services/OrderService.cs' &&
|
||||
e.targetFilePath === 'Legacy/System/Threading/Tasks.cs',
|
||||
);
|
||||
expect(spurious).toBeUndefined();
|
||||
});
|
||||
|
||||
it('ingested the fixture so the absence of the spurious edge is meaningful (anti-vacuity)', () => {
|
||||
// The legacy DAG leg cannot resolve a no-csproj namespace import to a file
|
||||
// (`using MyApp.Models;` targets a directory of types — only the registry
|
||||
// leg's progressive stripping resolves it without a csproj RootNamespace, a
|
||||
// known registry-superiority gap). So the anti-vacuity guard here asserts
|
||||
// the three fixture files were ingested as graph nodes, proving the spurious
|
||||
// edge is absent because the gate blocked it — not because nothing parsed.
|
||||
const files = getNodesByLabel(result, 'File');
|
||||
expect(files.length).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -313,6 +313,11 @@ const LEGACY_RESOLVER_PARITY_EXPECTED_FAILURES: Readonly<Record<string, Readonly
|
|||
'g(1, 2) resolves to fixed-arity g(int, int), not g(int, ...)',
|
||||
"h(1, 'a') resolves to h(int, double), not h(int, ...)",
|
||||
'k(1, 2, 3) keeps the ellipsis overload viable when it is the only match',
|
||||
// Pack-expanded dependent bases (`struct Mix : B...`) are suppressed
|
||||
// at C++ scope-capture time in the registry-primary path. The legacy
|
||||
// DAG still sees same-file class-owned methods by simple name and
|
||||
// over-emits `Mix::run -> B::inherited`.
|
||||
'does not bind unqualified member lookup through a pack-expanded dependent base',
|
||||
// User-defined conversion ranking (#1631) builds on the C++
|
||||
// conversion-rank hook and the registry-primary C++ owner sidecars.
|
||||
// Legacy DAG has no user-defined-conversion sidecar or ranking path.
|
||||
|
|
@ -384,6 +389,10 @@ const LEGACY_RESOLVER_PARITY_EXPECTED_FAILURES: Readonly<Record<string, Readonly
|
|||
// PR #1634: deep-nesting suppression. The scope-resolver enforces a
|
||||
// one-level cap on namespace walking. The legacy DAG picks arbitrarily.
|
||||
'Derived<T>::g() -> this->f() emits zero CALLS when Inner is two levels deep (ns.a.b) — one-level cap enforced',
|
||||
// Template partial ordering (#1635) relies on C++ parameter type-class
|
||||
// sidecars and scope-resolver overload narrowing. The legacy DAG does not
|
||||
// rank function-template shapes, so it leaves the call unresolved.
|
||||
'pick(T*) wins over pick(T) for pointer arguments',
|
||||
]),
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
|
|
@ -649,19 +649,26 @@ describe('LocalBackend.callTool', () => {
|
|||
|
||||
it('impact byDepth items include a processes field (default empty when no processes)', async () => {
|
||||
// Resolver returns target; BFS returns one frontier caller; no STEP_IN_PROCESS rows.
|
||||
(executeParameterized as any).mockResolvedValue([
|
||||
{ id: 'func:main', name: 'main', type: 'Function', filePath: 'src/index.ts' },
|
||||
]);
|
||||
(executeQuery as any).mockResolvedValue([
|
||||
{
|
||||
id: 'func:caller',
|
||||
name: 'caller',
|
||||
type: 'Function',
|
||||
filePath: 'src/uses-main.ts',
|
||||
relType: 'CALLS',
|
||||
confidence: 0.9,
|
||||
},
|
||||
]);
|
||||
(executeParameterized as any).mockImplementation((_repoId: string, cypher: string) => {
|
||||
// BFS frontier query is now parameterized (#1907 U3).
|
||||
if (cypher.includes('r.type IN') && !cypher.includes('STEP_IN_PROCESS')) {
|
||||
return Promise.resolve([
|
||||
{
|
||||
id: 'func:caller',
|
||||
name: 'caller',
|
||||
type: 'Function',
|
||||
filePath: 'src/uses-main.ts',
|
||||
relType: 'CALLS',
|
||||
confidence: 0.9,
|
||||
},
|
||||
]);
|
||||
}
|
||||
// Symbol resolution.
|
||||
return Promise.resolve([
|
||||
{ id: 'func:main', name: 'main', type: 'Function', filePath: 'src/index.ts' },
|
||||
]);
|
||||
});
|
||||
(executeQuery as any).mockResolvedValue([]);
|
||||
|
||||
const result = await backend.callTool('impact', { target: 'main', direction: 'upstream' });
|
||||
const d1 = result.byDepth?.[1] || result.byDepth?.['1'] || [];
|
||||
|
|
@ -674,6 +681,19 @@ describe('LocalBackend.callTool', () => {
|
|||
|
||||
it('impact populates byDepth processes when STEP_IN_PROCESS rows exist', async () => {
|
||||
(executeParameterized as any).mockImplementation((_repoId: string, cypher: string) => {
|
||||
// BFS frontier query is now parameterized (#1907 U3).
|
||||
if (cypher.includes('r.type IN') && !cypher.includes('STEP_IN_PROCESS')) {
|
||||
return Promise.resolve([
|
||||
{
|
||||
id: 'func:caller',
|
||||
name: 'caller',
|
||||
type: 'Function',
|
||||
filePath: 'src/uses-main.ts',
|
||||
relType: 'CALLS',
|
||||
confidence: 0.9,
|
||||
},
|
||||
]);
|
||||
}
|
||||
// Symbol resolver name-lookup
|
||||
if (cypher.includes('WHERE n.name =')) {
|
||||
return Promise.resolve([
|
||||
|
|
@ -739,6 +759,20 @@ describe('LocalBackend.callTool', () => {
|
|||
it('impact summaryOnly:true skips the per-symbol STEP_IN_PROCESS enrichment pass', async () => {
|
||||
// Resolver returns target; BFS returns one caller; aggregation returns one process row.
|
||||
(executeParameterized as any).mockImplementation((_repoId: string, cypher: string) => {
|
||||
// BFS frontier query is now parameterized (#1907 U3) — return a caller so
|
||||
// the per-symbol-skip assertion below is meaningful (not vacuous).
|
||||
if (cypher.includes('r.type IN') && !cypher.includes('STEP_IN_PROCESS')) {
|
||||
return Promise.resolve([
|
||||
{
|
||||
id: 'func:caller',
|
||||
name: 'caller',
|
||||
type: 'Function',
|
||||
filePath: 'src/a.ts',
|
||||
relType: 'CALLS',
|
||||
confidence: 0.9,
|
||||
},
|
||||
]);
|
||||
}
|
||||
if (cypher.includes('WHERE n.name =')) {
|
||||
return Promise.resolve([
|
||||
{ id: 'func:main', name: 'main', type: 'Function', filePath: 'src/index.ts' },
|
||||
|
|
@ -811,6 +845,19 @@ describe('LocalBackend.callTool', () => {
|
|||
await backend.init();
|
||||
|
||||
(executeParameterized as any).mockImplementation((_repoId: string, cypher: string) => {
|
||||
// BFS frontier query is now parameterized (#1907 U3).
|
||||
if (cypher.includes('r.type IN') && !cypher.includes('STEP_IN_PROCESS')) {
|
||||
return Promise.resolve([
|
||||
{
|
||||
id: 'func:caller',
|
||||
name: 'caller',
|
||||
type: 'Function',
|
||||
filePath: 'src/uses-main.ts',
|
||||
relType: 'CALLS',
|
||||
confidence: 0.9,
|
||||
},
|
||||
]);
|
||||
}
|
||||
// UID resolver
|
||||
if (cypher.includes('WHERE n.id = $uid')) {
|
||||
return Promise.resolve([
|
||||
|
|
|
|||
140
gitnexus/test/unit/cli-impact-disambiguation.test.ts
Normal file
140
gitnexus/test/unit/cli-impact-disambiguation.test.ts
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
/**
|
||||
* Unit Tests: CLI `impact` disambiguation flag wiring (#1907)
|
||||
*
|
||||
* The CLI `impact` command gained --uid / --file / --kind so that, when impact
|
||||
* reports an `ambiguous` target, users can follow the "disambiguate" guidance
|
||||
* straight from the terminal (previously only the MCP tool accepted these).
|
||||
* These tests pin that impactCommand forwards the flags to
|
||||
* callTool('impact', …) under the backend's parameter names
|
||||
* (target_uid / file_path / kind) — the same names the MCP impact tool uses.
|
||||
*
|
||||
* The LocalBackend is fully mocked: this isolates the CLI option → tool param
|
||||
* mapping from any graph/DB behaviour.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
const { callTool, init } = vi.hoisted(() => ({
|
||||
callTool: vi.fn(),
|
||||
init: vi.fn().mockResolvedValue(true),
|
||||
}));
|
||||
|
||||
vi.mock('../../src/mcp/local/local-backend.js', () => ({
|
||||
LocalBackend: class {
|
||||
init = init;
|
||||
callTool = callTool;
|
||||
},
|
||||
// U4: impactCommand imports VALID_NODE_LABELS to soft-validate --kind.
|
||||
VALID_NODE_LABELS: new Set(['Function', 'Class', 'Interface', 'Method', 'Constructor']),
|
||||
}));
|
||||
|
||||
// impactCommand prints its result via fs.writeSync(fd 1, …). Silence that so
|
||||
// the assertion-only test does not write JSON to the runner's stdout. tool.ts
|
||||
// uses only writeSync from node:fs, so a full mock is safe here (matches the
|
||||
// pattern in tool-direct-cli.test.ts).
|
||||
vi.mock('node:fs', () => ({
|
||||
writeSync: vi.fn(),
|
||||
}));
|
||||
|
||||
import { impactCommand } from '../../src/cli/tool.js';
|
||||
|
||||
describe('CLI impact disambiguation flags (#1907)', () => {
|
||||
beforeEach(() => {
|
||||
callTool.mockReset();
|
||||
callTool.mockResolvedValue({ status: 'found', impactedCount: 0 });
|
||||
});
|
||||
|
||||
it('forwards --uid/--file/--kind as target_uid/file_path/kind', async () => {
|
||||
await impactCommand('get_embeddings', {
|
||||
direction: 'upstream',
|
||||
uid: 'Function:isma/scripts/ingest_md_file.py:get_embeddings',
|
||||
file: 'isma/scripts/ingest_md_file.py',
|
||||
kind: 'Function',
|
||||
});
|
||||
|
||||
expect(callTool).toHaveBeenCalledTimes(1);
|
||||
expect(callTool).toHaveBeenCalledWith(
|
||||
'impact',
|
||||
expect.objectContaining({
|
||||
target: 'get_embeddings',
|
||||
target_uid: 'Function:isma/scripts/ingest_md_file.py:get_embeddings',
|
||||
file_path: 'isma/scripts/ingest_md_file.py',
|
||||
kind: 'Function',
|
||||
direction: 'upstream',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('leaves disambiguation params undefined when no flags are supplied', async () => {
|
||||
await impactCommand('AuthService', { direction: 'upstream' });
|
||||
|
||||
expect(callTool).toHaveBeenCalledTimes(1);
|
||||
const params = callTool.mock.calls[0][1] as Record<string, unknown>;
|
||||
expect(params.target).toBe('AuthService');
|
||||
expect(params.target_uid).toBeUndefined();
|
||||
expect(params.file_path).toBeUndefined();
|
||||
expect(params.kind).toBeUndefined();
|
||||
});
|
||||
|
||||
// U1 (#1914 review F1): impact's positional target is now optional, so a uid
|
||||
// alone resolves — parity with `context [name]`.
|
||||
it('resolves uid-only with no positional target (parity with context)', async () => {
|
||||
await impactCommand(undefined, {
|
||||
direction: 'upstream',
|
||||
uid: 'Function:src/auth.ts:login',
|
||||
});
|
||||
|
||||
expect(callTool).toHaveBeenCalledTimes(1);
|
||||
const params = callTool.mock.calls[0][1] as Record<string, unknown>;
|
||||
expect(params.target_uid).toBe('Function:src/auth.ts:login');
|
||||
expect(params.target).toBeUndefined();
|
||||
});
|
||||
|
||||
it('errors when neither a target nor a uid is provided', async () => {
|
||||
const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => {
|
||||
throw new Error('process.exit');
|
||||
}) as never);
|
||||
|
||||
await expect(impactCommand(undefined, {})).rejects.toThrow('process.exit');
|
||||
expect(exitSpy).toHaveBeenCalledWith(1);
|
||||
expect(callTool).not.toHaveBeenCalled();
|
||||
|
||||
exitSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('rejects a --prefixed uid value (a flag swallowed by Commander) without forwarding it', async () => {
|
||||
const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => {
|
||||
throw new Error('process.exit');
|
||||
}) as never);
|
||||
|
||||
await expect(impactCommand(undefined, { uid: '--file' })).rejects.toThrow('process.exit');
|
||||
expect(callTool).not.toHaveBeenCalled();
|
||||
|
||||
exitSpy.mockRestore();
|
||||
});
|
||||
|
||||
// U4 (#1914 review F3): an unknown --kind warns to stderr but still resolves.
|
||||
it('warns on an unknown --kind but still forwards the request', async () => {
|
||||
const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
|
||||
|
||||
await impactCommand('login', { kind: 'Funktion', direction: 'upstream' });
|
||||
|
||||
expect(callTool).toHaveBeenCalledTimes(1);
|
||||
const stderr = stderrSpy.mock.calls.map((c) => String(c[0])).join('');
|
||||
expect(stderr).toContain('Funktion');
|
||||
expect(stderr).toContain('not a known symbol kind');
|
||||
|
||||
stderrSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('does not warn for a known --kind', async () => {
|
||||
const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
|
||||
|
||||
await impactCommand('login', { kind: 'Function', direction: 'upstream' });
|
||||
|
||||
expect(callTool).toHaveBeenCalledTimes(1);
|
||||
const stderr = stderrSpy.mock.calls.map((c) => String(c[0])).join('');
|
||||
expect(stderr).not.toContain('not a known symbol kind');
|
||||
|
||||
stderrSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
|
@ -196,13 +196,18 @@ describe('CLI help surface', () => {
|
|||
expect(result.stdout).toContain('--file <path>');
|
||||
});
|
||||
|
||||
it('impact help keeps repo and include-tests flags', () => {
|
||||
it('impact help keeps repo, include-tests, and disambiguation flags', () => {
|
||||
const result = runHelp('impact');
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stdout).toContain('--depth <n>');
|
||||
expect(result.stdout).toContain('--include-tests');
|
||||
expect(result.stdout).toContain('--repo <name>');
|
||||
// Disambiguation flags (#1907) — mirror the context help test so a
|
||||
// missing-flag regression on impact is caught here too.
|
||||
expect(result.stdout).toContain('--uid <uid>');
|
||||
expect(result.stdout).toContain('--file <path>');
|
||||
expect(result.stdout).toContain('--kind <kind>');
|
||||
});
|
||||
|
||||
it('detect-changes help exposes compare scope and base-ref flags', () => {
|
||||
|
|
|
|||
|
|
@ -96,4 +96,84 @@ describe('extractCsharpStructureViaScanner', () => {
|
|||
const src = `/* header */ class C {}\nnamespace App.Real;`;
|
||||
expect(extractCsharpStructureViaScanner(src).namespaces).toEqual(['App.Real']);
|
||||
});
|
||||
|
||||
// --- Unicode / @-verbatim identifiers (Codex F3): these must be CAPTURED, not
|
||||
// truncated/dropped, so the #1881 gate doesn't over-block legitimate imports.
|
||||
it('captures a Unicode namespace identifier', () => {
|
||||
const out = extractCsharpStructureViaScanner('namespace Café.Modèles;');
|
||||
expect(out.namespaces).toEqual(['Café.Modèles']);
|
||||
expect(out.incomplete).toBeFalsy();
|
||||
});
|
||||
|
||||
it('captures a non-Latin (Greek) namespace identifier', () => {
|
||||
expect(extractCsharpStructureViaScanner('namespace Ωμέγα.Models;').namespaces).toEqual([
|
||||
'Ωμέγα.Models',
|
||||
]);
|
||||
});
|
||||
|
||||
it('strips a leading @ from a verbatim namespace identifier to match the AST', () => {
|
||||
expect(extractCsharpStructureViaScanner('namespace @namespace.Models;').namespaces).toEqual([
|
||||
'namespace.Models',
|
||||
]);
|
||||
});
|
||||
|
||||
it('strips a mid-path @ from a verbatim namespace segment', () => {
|
||||
expect(extractCsharpStructureViaScanner('namespace App.@class.Models;').namespaces).toEqual([
|
||||
'App.class.Models',
|
||||
]);
|
||||
});
|
||||
|
||||
// --- Forms the line scanner cannot capture must flag `incomplete` so the
|
||||
// caller fails the #1881 gate OPEN (Codex F3) instead of dropping the namespace.
|
||||
it('flags `incomplete` for a namespace declaration split across lines', () => {
|
||||
const out = extractCsharpStructureViaScanner('namespace\n App.Models;');
|
||||
expect(out.namespaces).toEqual([]);
|
||||
expect(out.incomplete).toBe(true);
|
||||
});
|
||||
|
||||
it('flags `incomplete` for a namespace keyword not at line start', () => {
|
||||
const out = extractCsharpStructureViaScanner('class C {} namespace App.Models;');
|
||||
expect(out.incomplete).toBe(true);
|
||||
});
|
||||
|
||||
it('flags `incomplete` for an attributed same-line namespace', () => {
|
||||
const out = extractCsharpStructureViaScanner('[Obsolete] namespace App.Legacy;');
|
||||
expect(out.incomplete).toBe(true);
|
||||
});
|
||||
|
||||
// --- Guards: ordinary / handled forms must NEVER set `incomplete`, or one
|
||||
// exotic line would wrongly disable the gate repo-wide.
|
||||
it('does NOT flag `incomplete` for ordinary handled forms', () => {
|
||||
for (const src of [
|
||||
'namespace App.Models;',
|
||||
'namespace App.Services\n{\n}',
|
||||
'namespace A.One {}\nnamespace A.Two {}',
|
||||
'using static System.Math;\nnamespace App;',
|
||||
'global using static App.Utils.Logger;',
|
||||
'using static M = App.Utils.MathUtils;',
|
||||
'using System.Collections.Generic;\nusing App.Models;',
|
||||
'\t\tnamespace App.Indented;',
|
||||
'public class Global {}',
|
||||
'',
|
||||
]) {
|
||||
expect(extractCsharpStructureViaScanner(src).incomplete).toBeFalsy();
|
||||
}
|
||||
});
|
||||
|
||||
it('does NOT flag `incomplete` for a `// namespace` line comment or a namespace mentioned after `//`', () => {
|
||||
expect(
|
||||
extractCsharpStructureViaScanner('// namespace Fake.Comment;\nnamespace App.Real;')
|
||||
.incomplete,
|
||||
).toBeFalsy();
|
||||
expect(
|
||||
extractCsharpStructureViaScanner('public class C {} // namespace Foo').incomplete,
|
||||
).toBeFalsy();
|
||||
});
|
||||
|
||||
it('does NOT flag `incomplete` for an identifier that merely starts with "namespace"', () => {
|
||||
// `namespaceManager` is an ordinary identifier, not the keyword.
|
||||
expect(
|
||||
extractCsharpStructureViaScanner('var namespaceManager = Get();').incomplete,
|
||||
).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1135,6 +1135,37 @@ export const authClient = new authProto.auth.v1.AuthService(
|
|||
expect(consumers[0].contractId).toBe('grpc::auth.v1.AuthService/*');
|
||||
});
|
||||
|
||||
it('test_extract_ts_qualified_ctor_without_loadPackageDefinition_is_ignored', async () => {
|
||||
// Regression: an unrelated `obj.method(...)` member call must not trip the
|
||||
// loadPackageDefinition gate. With no loadPackageDefinition call present, a
|
||||
// qualified `new pkg...Service(...)` constructor must NOT become a consumer.
|
||||
// Pre-fix, the gate's shared-capture `function: [...]` alternation matched
|
||||
// every member call, so this spuriously emitted an AuthService consumer.
|
||||
writeFile(
|
||||
'proto/auth.proto',
|
||||
`syntax = "proto3";
|
||||
package auth.v1;
|
||||
service AuthService {
|
||||
rpc Login (LoginRequest) returns (LoginResponse);
|
||||
}`,
|
||||
);
|
||||
writeFile(
|
||||
'src/auth.client.ts',
|
||||
`import * as grpc from '@grpc/grpc-js';
|
||||
|
||||
logger.info('starting up');
|
||||
export const authClient = new authProto.auth.v1.AuthService(
|
||||
'localhost:50051',
|
||||
grpc.credentials.createInsecure(),
|
||||
);`,
|
||||
);
|
||||
|
||||
const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir));
|
||||
const consumers = contracts.filter((c) => c.role === 'consumer');
|
||||
|
||||
expect(consumers).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('test_extract_ts_duplicate_consumer_patterns_in_one_file_dedupes_deterministically', async () => {
|
||||
writeFile(
|
||||
'proto/auth.proto',
|
||||
|
|
|
|||
|
|
@ -1774,6 +1774,336 @@ interface PrecedenceClient {
|
|||
expect(consumers.find((c) => c.contractId === 'http::GET::/rm-path/orders')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('extracts native @RequestLine consumers on @FeignClient interfaces', async () => {
|
||||
const dir = path.join(tmpDir, 'java-feign-request-line-basic');
|
||||
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'src', 'AiClient.java'),
|
||||
`
|
||||
import org.springframework.cloud.openfeign.FeignClient;
|
||||
import feign.RequestLine;
|
||||
|
||||
@FeignClient(name = "ai-backend")
|
||||
interface AiClient {
|
||||
@RequestLine("POST /ai/summarize")
|
||||
String summarize();
|
||||
|
||||
@RequestLine("GET /ai/health")
|
||||
String health();
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
const contracts = await extractor.extract(null, dir, makeRepo(dir));
|
||||
const consumers = contracts.filter((c) => c.role === 'consumer');
|
||||
|
||||
expect(
|
||||
consumers.find(
|
||||
(c) =>
|
||||
c.contractId === 'http::POST::/ai/summarize' &&
|
||||
c.meta.framework === 'openfeign' &&
|
||||
c.confidence === 0.75,
|
||||
),
|
||||
).toBeDefined();
|
||||
expect(
|
||||
consumers.find(
|
||||
(c) =>
|
||||
c.contractId === 'http::GET::/ai/health' &&
|
||||
c.meta.framework === 'openfeign' &&
|
||||
c.confidence === 0.75,
|
||||
),
|
||||
).toBeDefined();
|
||||
});
|
||||
|
||||
it('joins @FeignClient(path=...) prefix with @RequestLine paths', async () => {
|
||||
const dir = path.join(tmpDir, 'java-feign-request-line-prefix');
|
||||
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'src', 'OrderClient.java'),
|
||||
`
|
||||
import org.springframework.cloud.openfeign.FeignClient;
|
||||
import feign.RequestLine;
|
||||
|
||||
@FeignClient(name = "order-service", path = "/api")
|
||||
interface OrderClient {
|
||||
@RequestLine("GET /orders/{id}")
|
||||
OrderDto get(Long id);
|
||||
|
||||
@RequestLine("DELETE /orders/{id}")
|
||||
void delete(Long id);
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
const contracts = await extractor.extract(null, dir, makeRepo(dir));
|
||||
const consumers = contracts.filter((c) => c.role === 'consumer');
|
||||
|
||||
expect(
|
||||
consumers.find((c) => c.contractId === 'http::GET::/api/orders/{param}'),
|
||||
).toBeDefined();
|
||||
expect(
|
||||
consumers.find((c) => c.contractId === 'http::DELETE::/api/orders/{param}'),
|
||||
).toBeDefined();
|
||||
});
|
||||
|
||||
it('strips query strings from @RequestLine values when forming contract IDs', async () => {
|
||||
const dir = path.join(tmpDir, 'java-feign-request-line-query');
|
||||
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'src', 'SearchClient.java'),
|
||||
`
|
||||
import org.springframework.cloud.openfeign.FeignClient;
|
||||
import feign.RequestLine;
|
||||
|
||||
@FeignClient(name = "search-service")
|
||||
interface SearchClient {
|
||||
@RequestLine("GET /search?q={query}&limit={limit}")
|
||||
SearchResult search();
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
const contracts = await extractor.extract(null, dir, makeRepo(dir));
|
||||
const consumers = contracts.filter((c) => c.role === 'consumer');
|
||||
|
||||
// Query string is dropped — contract ID is method+path only.
|
||||
expect(consumers.find((c) => c.contractId === 'http::GET::/search')).toBeDefined();
|
||||
expect(
|
||||
consumers.find((c) => c.contractId.includes('?') || c.contractId.includes('limit')),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('extracts native @RequestLine on a plain interface without @FeignClient (Feign.builder())', async () => {
|
||||
// The canonical core-Feign usage: a plain interface with `@RequestLine`,
|
||||
// wired up via `Feign.builder()`. There is NO `@FeignClient` annotation
|
||||
// (that is the Spring Cloud variant, which uses Spring MVC annotations and
|
||||
// is mutually exclusive with `@RequestLine`). This is the shape used by
|
||||
// real client-jar consumers, so it must be recognized.
|
||||
const dir = path.join(tmpDir, 'java-request-line-no-feign');
|
||||
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'src', 'BigModelClient.java'),
|
||||
`
|
||||
import feign.Headers;
|
||||
import feign.RequestLine;
|
||||
import feign.Response;
|
||||
|
||||
public interface BigModelClient {
|
||||
@RequestLine("POST /ai/summarization")
|
||||
@Headers("Content-Type: application/json")
|
||||
Response summarize();
|
||||
|
||||
@RequestLine("GET /ai/concurrent")
|
||||
Response concurrent();
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
const contracts = await extractor.extract(null, dir, makeRepo(dir));
|
||||
const consumers = contracts.filter((c) => c.role === 'consumer');
|
||||
|
||||
expect(
|
||||
consumers.find(
|
||||
(c) =>
|
||||
c.contractId === 'http::POST::/ai/summarization' &&
|
||||
c.meta.framework === 'openfeign' &&
|
||||
c.confidence === 0.75,
|
||||
),
|
||||
).toBeDefined();
|
||||
expect(
|
||||
consumers.find(
|
||||
(c) => c.contractId === 'http::GET::/ai/concurrent' && c.meta.framework === 'openfeign',
|
||||
),
|
||||
).toBeDefined();
|
||||
});
|
||||
|
||||
it('mixes @RequestLine and @GetMapping methods on the same @FeignClient interface', async () => {
|
||||
const dir = path.join(tmpDir, 'java-feign-mixed-annotations');
|
||||
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'src', 'MixedClient.java'),
|
||||
`
|
||||
import org.springframework.cloud.openfeign.FeignClient;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import feign.RequestLine;
|
||||
|
||||
@FeignClient(name = "mixed-service", path = "/api")
|
||||
interface MixedClient {
|
||||
@GetMapping("/spring-style")
|
||||
String springStyle();
|
||||
|
||||
@RequestLine("GET /native-style")
|
||||
String nativeStyle();
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
const contracts = await extractor.extract(null, dir, makeRepo(dir));
|
||||
const consumers = contracts.filter((c) => c.role === 'consumer');
|
||||
|
||||
// Both annotation styles produce contracts — they don't conflict.
|
||||
expect(
|
||||
consumers.find(
|
||||
(c) =>
|
||||
c.contractId === 'http::GET::/api/spring-style' &&
|
||||
c.meta.framework === 'openfeign' &&
|
||||
c.confidence === 0.7,
|
||||
),
|
||||
).toBeDefined();
|
||||
expect(
|
||||
consumers.find(
|
||||
(c) =>
|
||||
c.contractId === 'http::GET::/api/native-style' &&
|
||||
c.meta.framework === 'openfeign' &&
|
||||
c.confidence === 0.75,
|
||||
),
|
||||
).toBeDefined();
|
||||
});
|
||||
|
||||
it('extracts @RequestLine values written with the named "value" argument', async () => {
|
||||
const dir = path.join(tmpDir, 'java-feign-request-line-named');
|
||||
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'src', 'NamedArgClient.java'),
|
||||
`
|
||||
import org.springframework.cloud.openfeign.FeignClient;
|
||||
import feign.RequestLine;
|
||||
|
||||
@FeignClient(name = "named-arg-service")
|
||||
interface NamedArgClient {
|
||||
@RequestLine(value = "POST /create")
|
||||
String create();
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
const contracts = await extractor.extract(null, dir, makeRepo(dir));
|
||||
const consumers = contracts.filter((c) => c.role === 'consumer');
|
||||
|
||||
expect(
|
||||
consumers.find(
|
||||
(c) => c.contractId === 'http::POST::/create' && c.meta.framework === 'openfeign',
|
||||
),
|
||||
).toBeDefined();
|
||||
});
|
||||
|
||||
it('ignores @RequestLine whose named argument is not "value"', async () => {
|
||||
// The consolidated query matches every named annotation argument; the
|
||||
// scanRouteAnnotations loop drops a @RequestLine whose key is not `value`.
|
||||
const dir = path.join(tmpDir, 'java-feign-request-line-wrong-key');
|
||||
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'src', 'WrongKeyClient.java'),
|
||||
`
|
||||
import org.springframework.cloud.openfeign.FeignClient;
|
||||
import feign.RequestLine;
|
||||
|
||||
@FeignClient(name = "wrong-key-service")
|
||||
interface WrongKeyClient {
|
||||
@RequestLine(name = "GET /should-not-extract")
|
||||
String shouldNotBeExtracted();
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
const contracts = await extractor.extract(null, dir, makeRepo(dir));
|
||||
const consumers = contracts.filter((c) => c.role === 'consumer');
|
||||
|
||||
expect(
|
||||
consumers.find((c) => c.contractId === 'http::GET::/should-not-extract'),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('ignores @RequestLine values that are not a "VERB /path" line', async () => {
|
||||
// `parseRequestLine` only accepts a recognized HTTP verb followed by a
|
||||
// path starting with `/`. Malformed values (no verb, no leading-slash
|
||||
// path, or unknown verb) must be dropped — this guards the relaxed
|
||||
// (no-@FeignClient) matcher from turning arbitrary `@RequestLine` string
|
||||
// literals into bogus contracts.
|
||||
const dir = path.join(tmpDir, 'java-request-line-malformed');
|
||||
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'src', 'MalformedClient.java'),
|
||||
`
|
||||
import feign.RequestLine;
|
||||
|
||||
interface MalformedClient {
|
||||
@RequestLine("not a request line at all")
|
||||
String noVerb();
|
||||
|
||||
@RequestLine("GET relative/no/leading/slash")
|
||||
String noLeadingSlash();
|
||||
|
||||
@RequestLine("FETCH /unknown-verb")
|
||||
String unknownVerb();
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
const contracts = await extractor.extract(null, dir, makeRepo(dir));
|
||||
const consumers = contracts.filter((c) => c.role === 'consumer');
|
||||
|
||||
// None of the three malformed values should yield a contract.
|
||||
expect(
|
||||
consumers.filter((c) => c.symbolRef.filePath.endsWith('MalformedClient.java')),
|
||||
).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('ignores @RequestLine on a class method (Feign proxies are interfaces only)', async () => {
|
||||
// The relaxed matcher still requires an enclosing interface: Feign builds
|
||||
// its proxy from an interface, so a `@RequestLine` on a concrete class
|
||||
// method is not a Feign call and must not be emitted as a consumer.
|
||||
const dir = path.join(tmpDir, 'java-request-line-on-class');
|
||||
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'src', 'NotAProxy.java'),
|
||||
`
|
||||
import feign.RequestLine;
|
||||
|
||||
class NotAProxy {
|
||||
@RequestLine("GET /should-not-extract")
|
||||
String call() { return null; }
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
const contracts = await extractor.extract(null, dir, makeRepo(dir));
|
||||
const consumers = contracts.filter((c) => c.role === 'consumer');
|
||||
|
||||
expect(
|
||||
consumers.find((c) => c.contractId === 'http::GET::/should-not-extract'),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('prefers @FeignClient(path=...) over @RequestMapping when @RequestMapping appears first', async () => {
|
||||
// Reverse-order companion to the precedence test above: @FeignClient(path)
|
||||
// must win even when @RequestMapping is the first annotation in source,
|
||||
// exercising the deferred interfaceRequestMappingPrefixes apply.
|
||||
const dir = path.join(tmpDir, 'java-openfeign-prefix-precedence-reversed');
|
||||
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'src', 'ReversedPrecedenceClient.java'),
|
||||
`
|
||||
import org.springframework.cloud.openfeign.FeignClient;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
|
||||
@RequestMapping("/rm-path")
|
||||
@FeignClient(name = "order-service", path = "/feign-path")
|
||||
interface ReversedPrecedenceClient {
|
||||
@GetMapping("/orders")
|
||||
OrderDto getOrders();
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
const contracts = await extractor.extract(null, dir, makeRepo(dir));
|
||||
const consumers = contracts.filter((c) => c.role === 'consumer');
|
||||
|
||||
expect(consumers.find((c) => c.contractId === 'http::GET::/feign-path/orders')).toBeDefined();
|
||||
expect(consumers.find((c) => c.contractId === 'http::GET::/rm-path/orders')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('extracts Java and Apache HttpClient literal request construction', async () => {
|
||||
const dir = path.join(tmpDir, 'java-http-client-consumer');
|
||||
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
|
||||
|
|
|
|||
|
|
@ -68,30 +68,9 @@ describe('impact: batching and grouping', () => {
|
|||
const chunkSizes: number[] = [];
|
||||
let chunkCallIndex = 0;
|
||||
|
||||
executeQueryMock.mockImplementation(async (...args: any[]) => {
|
||||
const query = typeof args[1] === 'string' ? args[1] : String(args[0] ?? '');
|
||||
// Depth traversal query (find related nodes) -- return 250 impacted ids
|
||||
if (query.includes('r.type IN') && !query.includes('STEP_IN_PROCESS')) {
|
||||
const res: any[] = [];
|
||||
for (let i = 0; i < 250; i++) {
|
||||
res.push({
|
||||
id: `node-${i}`,
|
||||
name: `n${i}`,
|
||||
filePath: `file-${i}.js`,
|
||||
relType: 'CALLS',
|
||||
confidence: null,
|
||||
});
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
// NOTE: process-chunk enrichment previously used executeQuery; our
|
||||
// implementation now calls executeParameterized for those chunks. We
|
||||
// still keep this branch to support any legacy calls, but primary
|
||||
// chunk tracking will be handled via executeParameterizedMock below.
|
||||
|
||||
return [];
|
||||
});
|
||||
// BFS frontier query is now parameterized (#1907 U3) — handled in
|
||||
// executeParameterizedMock below; executeQuery is unused by the impact path.
|
||||
executeQueryMock.mockImplementation(async () => []);
|
||||
|
||||
// Handle parameterized calls (including chunked STEP_IN_PROCESS queries)
|
||||
executeParameterizedMock.mockImplementation(async (...args: any[]) => {
|
||||
|
|
@ -117,6 +96,20 @@ describe('impact: batching and grouping', () => {
|
|||
},
|
||||
];
|
||||
}
|
||||
// BFS frontier query (parameterized #1907 U3): return the 250 impacted ids.
|
||||
if (query.includes('r.type IN') && !query.includes('STEP_IN_PROCESS')) {
|
||||
const res: any[] = [];
|
||||
for (let i = 0; i < 250; i++) {
|
||||
res.push({
|
||||
id: `node-${i}`,
|
||||
name: `n${i}`,
|
||||
filePath: `file-${i}.js`,
|
||||
relType: 'CALLS',
|
||||
confidence: null,
|
||||
});
|
||||
}
|
||||
return res;
|
||||
}
|
||||
// Default target resolution
|
||||
return [{ id: 'sym1', name: 'Target', filePath: 'f' }];
|
||||
});
|
||||
|
|
@ -151,6 +144,19 @@ describe('impact: batching and grouping', () => {
|
|||
|
||||
executeParameterizedMock.mockImplementation(async (...args: any[]) => {
|
||||
const query = typeof args[1] === 'string' ? args[1] : String(args[0] ?? '');
|
||||
// BFS frontier query (parameterized #1907 U3): return 6 impacted nodes.
|
||||
if (query.includes('r.type IN') && !query.includes('STEP_IN_PROCESS')) {
|
||||
const res: any[] = [];
|
||||
for (let i = 0; i < 6; i++)
|
||||
res.push({
|
||||
id: `node-${i}`,
|
||||
name: `n${i}`,
|
||||
filePath: `file-${i}.js`,
|
||||
relType: 'CALLS',
|
||||
confidence: null,
|
||||
});
|
||||
return res;
|
||||
}
|
||||
if (!query.includes('STEP_IN_PROCESS'))
|
||||
return [{ id: 'symA', name: 'TargetA', filePath: 'f' }];
|
||||
// For STEP_IN_PROCESS in this test, return grouping rows
|
||||
|
|
@ -190,25 +196,9 @@ describe('impact: batching and grouping', () => {
|
|||
];
|
||||
});
|
||||
|
||||
// Prepare impacted nodes: smaller set for clarity (6 nodes -> chunk size default 100 so single chunk)
|
||||
executeQueryMock.mockImplementation(async (...args: any[]) => {
|
||||
const query = typeof args[1] === 'string' ? args[1] : String(args[0] ?? '');
|
||||
if (query.includes('r.type IN') && !query.includes('STEP_IN_PROCESS')) {
|
||||
// return 6 nodes
|
||||
const res: any[] = [];
|
||||
for (let i = 0; i < 6; i++)
|
||||
res.push({
|
||||
id: `node-${i}`,
|
||||
name: `n${i}`,
|
||||
filePath: `file-${i}.js`,
|
||||
relType: 'CALLS',
|
||||
confidence: null,
|
||||
});
|
||||
return res;
|
||||
}
|
||||
|
||||
return [];
|
||||
});
|
||||
// BFS frontier query is now parameterized (#1907 U3) — handled in
|
||||
// executeParameterizedMock above; executeQuery is unused by the impact path.
|
||||
executeQueryMock.mockImplementation(async () => []);
|
||||
|
||||
const params = { target: 'TargetA', direction: 'downstream', maxDepth: 1 } as any;
|
||||
const res = await (backend as any)._impactImpl(repoHandle, params);
|
||||
|
|
@ -243,23 +233,9 @@ describe('impact: batching and grouping', () => {
|
|||
(backend as any).repos.set(repoHandle.id, repoHandle);
|
||||
(backend as any).ensureInitialized = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
// Depth traversal returns 500 impacted nodes
|
||||
executeQueryMock.mockImplementation(async (...args: any[]) => {
|
||||
const query = typeof args[1] === 'string' ? args[1] : String(args[0] ?? '');
|
||||
if (query.includes('r.type IN') && !query.includes('STEP_IN_PROCESS')) {
|
||||
const res: any[] = [];
|
||||
for (let i = 0; i < 500; i++)
|
||||
res.push({
|
||||
id: `node-${i}`,
|
||||
name: `n${i}`,
|
||||
filePath: `file-${i}.js`,
|
||||
relType: 'CALLS',
|
||||
confidence: null,
|
||||
});
|
||||
return res;
|
||||
}
|
||||
return [];
|
||||
});
|
||||
// BFS frontier query is now parameterized (#1907 U3) — handled in
|
||||
// executeParameterizedMock below; executeQuery is unused by the impact path.
|
||||
executeQueryMock.mockImplementation(async () => []);
|
||||
|
||||
const chunkSizes: number[] = [];
|
||||
|
||||
|
|
@ -294,6 +270,19 @@ describe('impact: batching and grouping', () => {
|
|||
return [{ name: 'ModuleA' }];
|
||||
}
|
||||
|
||||
// BFS frontier query (parameterized #1907 U3): return 500 impacted nodes.
|
||||
if (query.includes('r.type IN') && !query.includes('STEP_IN_PROCESS')) {
|
||||
const res: any[] = [];
|
||||
for (let i = 0; i < 500; i++)
|
||||
res.push({
|
||||
id: `node-${i}`,
|
||||
name: `n${i}`,
|
||||
filePath: `file-${i}.js`,
|
||||
relType: 'CALLS',
|
||||
confidence: null,
|
||||
});
|
||||
return res;
|
||||
}
|
||||
// Default: target resolution
|
||||
return [{ id: 'symX', name: 'TargetX', filePath: 'f' }];
|
||||
});
|
||||
|
|
|
|||
|
|
@ -46,30 +46,35 @@ function makeBackend() {
|
|||
return { backend, repoHandle };
|
||||
}
|
||||
|
||||
// The BFS frontier query is now parameterized (bound $frontierIds/$relTypes,
|
||||
// #1907 U3), so the caller rows come back through executeParameterizedMock
|
||||
// (matched on `r.type IN`) rather than executeQueryMock. Symbol resolution and
|
||||
// the label-enrichment UNION still fall through to the default symbol row.
|
||||
function setupMultiDepthHub(d1Count: number, d2Count: number) {
|
||||
let depth = 0;
|
||||
executeParameterizedMock.mockImplementation(async (...args: any[]) => {
|
||||
const query = typeof args[1] === 'string' ? args[1] : String(args[0] ?? '');
|
||||
if (query.includes('STEP_IN_PROCESS')) return [];
|
||||
if (query.includes('MEMBER_OF')) return [];
|
||||
if (query.includes('r.type IN')) {
|
||||
depth++;
|
||||
const count = depth === 1 ? d1Count : depth === 2 ? d2Count : 0;
|
||||
const res: any[] = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
res.push({
|
||||
id: `d${depth}-caller-${i}`,
|
||||
name: `d${depth}caller${i}`,
|
||||
filePath: `src/d${depth}-caller-${i}.ts`,
|
||||
relType: 'CALLS',
|
||||
confidence: null,
|
||||
});
|
||||
}
|
||||
return res;
|
||||
}
|
||||
return [{ id: 'hub1', name: 'HubSymbol', filePath: 'hub.ts' }];
|
||||
});
|
||||
|
||||
executeQueryMock.mockImplementation(async () => {
|
||||
depth++;
|
||||
const count = depth === 1 ? d1Count : depth === 2 ? d2Count : 0;
|
||||
const res: any[] = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
res.push({
|
||||
id: `d${depth}-caller-${i}`,
|
||||
name: `d${depth}caller${i}`,
|
||||
filePath: `src/d${depth}-caller-${i}.ts`,
|
||||
relType: 'CALLS',
|
||||
confidence: null,
|
||||
});
|
||||
}
|
||||
return res;
|
||||
});
|
||||
executeQueryMock.mockImplementation(async () => []);
|
||||
}
|
||||
|
||||
function setupHubSymbol(count: number) {
|
||||
|
|
@ -77,12 +82,7 @@ function setupHubSymbol(count: number) {
|
|||
const query = typeof args[1] === 'string' ? args[1] : String(args[0] ?? '');
|
||||
if (query.includes('STEP_IN_PROCESS')) return [];
|
||||
if (query.includes('MEMBER_OF')) return [];
|
||||
return [{ id: 'hub1', name: 'HubSymbol', filePath: 'hub.ts' }];
|
||||
});
|
||||
|
||||
executeQueryMock.mockImplementation(async (...args: any[]) => {
|
||||
const query = typeof args[1] === 'string' ? args[1] : String(args[0] ?? '');
|
||||
if (query.includes('r.type IN') && !query.includes('STEP_IN_PROCESS')) {
|
||||
if (query.includes('r.type IN')) {
|
||||
const res: any[] = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
res.push({
|
||||
|
|
@ -95,8 +95,10 @@ function setupHubSymbol(count: number) {
|
|||
}
|
||||
return res;
|
||||
}
|
||||
return [];
|
||||
return [{ id: 'hub1', name: 'HubSymbol', filePath: 'hub.ts' }];
|
||||
});
|
||||
|
||||
executeQueryMock.mockImplementation(async () => []);
|
||||
}
|
||||
|
||||
describe('impact: pagination and summaryOnly (#414)', () => {
|
||||
|
|
|
|||
|
|
@ -352,6 +352,65 @@ describe('csharpNamespaceStrategy', () => {
|
|||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('no csproj + non-aligned BCL import: stops the chain instead of the ungated standard strategy (#2)', () => {
|
||||
// Parity with the registry leg's no-csproj path. Without csproj configs the
|
||||
// generic strategy would suffix-match `System.Threading.Tasks` onto the
|
||||
// coincidental local `Legacy/Tasks.cs`. The gate sees the import aligns
|
||||
// with no declared namespace, so the strategy returns an absorbing sentinel
|
||||
// (`{ kind: 'files', files: [] }`) that STOPS the chain — the standard
|
||||
// strategy never runs and no spurious edge is emitted.
|
||||
const ctx = makeCtx(['Services/OrderService.cs', 'Legacy/Tasks.cs'], {
|
||||
csharpNamespaces: {
|
||||
declaredNamespaces: new Set(['MyApp.Services', 'MyApp.Legacy']),
|
||||
rootNamespaces: new Set(['MyApp']),
|
||||
truncated: false,
|
||||
},
|
||||
});
|
||||
const result = csharpNamespaceStrategy(
|
||||
'System.Threading.Tasks',
|
||||
'Services/OrderService.cs',
|
||||
ctx,
|
||||
);
|
||||
expect(result).toEqual({ kind: 'files', files: [] });
|
||||
});
|
||||
|
||||
it('no csproj + in-repo-aligned import: keeps delegating to the standard strategy (#2)', () => {
|
||||
// An import that DOES align with a declared namespace must keep returning
|
||||
// null so the generic strategy resolves it — legitimate no-csproj behavior
|
||||
// is unchanged; only non-aligned (BCL) imports are stopped.
|
||||
const ctx = makeCtx(['Services/OrderService.cs', 'Models/User.cs'], {
|
||||
csharpNamespaces: {
|
||||
declaredNamespaces: new Set(['MyApp.Models', 'MyApp.Services']),
|
||||
rootNamespaces: new Set(['MyApp']),
|
||||
truncated: false,
|
||||
},
|
||||
});
|
||||
const result = csharpNamespaceStrategy('MyApp.Models', 'Services/OrderService.cs', ctx);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('returns an empty files result (chain-stop) for a gated BCL import when csproj configs exist (#1881, #8)', () => {
|
||||
// Legacy DAG leg of #1881: with csproj configs present, a BCL using like
|
||||
// `System.Threading.Tasks` must NOT suffix-match the coincidental local
|
||||
// `Legacy/Tasks.cs`. The strategy returns `{ kind: 'files', files: [] }`
|
||||
// (absorbing sentinel) to STOP the chain, NOT null — null would let the
|
||||
// generic suffix fallback re-introduce the spurious edge.
|
||||
const ctx = makeCtx(['Services/OrderService.cs', 'Legacy/Tasks.cs'], {
|
||||
csharpConfigs: [{ rootNamespace: 'MyApp', projectDir: '' }],
|
||||
csharpNamespaces: {
|
||||
declaredNamespaces: new Set(['MyApp.Services', 'MyApp.Legacy']),
|
||||
rootNamespaces: new Set(['MyApp']),
|
||||
truncated: false,
|
||||
},
|
||||
});
|
||||
const result = csharpNamespaceStrategy(
|
||||
'System.Threading.Tasks',
|
||||
'Services/OrderService.cs',
|
||||
ctx,
|
||||
);
|
||||
expect(result).toEqual({ kind: 'files', files: [] });
|
||||
});
|
||||
|
||||
it('csharpImportConfig full chain produces package-kind (strategy-order guard)', () => {
|
||||
const files = ['src/Services/Auth/AuthService.cs', 'src/Services/Auth/TokenService.cs'];
|
||||
const ctx = makeCtx(files, {
|
||||
|
|
|
|||
|
|
@ -21,6 +21,13 @@ const pointer = (base: string): ParameterTypeClass => ({
|
|||
pointerDepth: 1,
|
||||
});
|
||||
|
||||
const constRef = (base: string): ParameterTypeClass => ({
|
||||
base,
|
||||
cv: 'const',
|
||||
indirection: 'lvalue-ref',
|
||||
pointerDepth: 0,
|
||||
});
|
||||
|
||||
const ellipsis = (): ParameterTypeClass => ({
|
||||
base: '...',
|
||||
cv: 'unknown',
|
||||
|
|
@ -143,3 +150,79 @@ describe('narrowOverloadCandidates with C++ pointer-rank sidecars (#1637)', () =
|
|||
expect(result.map((d) => d.nodeId)).toEqual(['log:ellipsis']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('narrowOverloadCandidates with C++ template partial ordering (#1635)', () => {
|
||||
it('selects T* over T for pointer arguments', () => {
|
||||
const byValue = mkDef('pick:T', ['T'], [value('T')]);
|
||||
const byPointer = mkDef('pick:T*', ['T'], [pointer('T')]);
|
||||
|
||||
const result = narrowOverloadCandidates([byValue, byPointer], 1, ['int'], {
|
||||
argumentTypeClasses: [pointer('int')],
|
||||
});
|
||||
|
||||
expect(result.map((d) => d.nodeId)).toEqual(['pick:T*']);
|
||||
});
|
||||
|
||||
it('keeps const T& versus T ambiguous for value arguments', () => {
|
||||
const byValue = mkDef('pick:T', ['T'], [value('T')]);
|
||||
const byReference = mkDef('pick:const-T-ref', ['T'], [constRef('T')]);
|
||||
|
||||
const result = narrowOverloadCandidates([byValue, byReference], 1, ['int'], {
|
||||
argumentTypeClasses: [value('int')],
|
||||
});
|
||||
|
||||
expect(result.map((d) => d.nodeId)).toEqual([]);
|
||||
});
|
||||
|
||||
it('suppresses when any surviving candidate cannot participate in ordering', () => {
|
||||
const concreteSlot = mkDef('pick:T-int', ['T', 'int'], [value('T'), value('int')]);
|
||||
const pointerSlot = mkDef('pick:T-T*', ['T', 'T'], [value('T'), pointer('T')]);
|
||||
|
||||
const result = narrowOverloadCandidates([concreteSlot, pointerSlot], 2, ['int', 'int'], {
|
||||
argumentTypeClasses: [pointer('int'), pointer('int')],
|
||||
});
|
||||
|
||||
expect(result.map((d) => d.nodeId)).toEqual([]);
|
||||
});
|
||||
|
||||
it('suppresses when a surviving candidate lacks parameter sidecars', () => {
|
||||
const withoutSidecar: SymbolDefinition = {
|
||||
...mkDef('pick:no-sidecar', ['T'], [value('T')]),
|
||||
parameterTypeClasses: undefined,
|
||||
};
|
||||
const byPointer = mkDef('pick:T*', ['T'], [pointer('T')]);
|
||||
|
||||
const result = narrowOverloadCandidates([withoutSidecar, byPointer], 1, ['int'], {
|
||||
argumentTypeClasses: [pointer('int')],
|
||||
});
|
||||
|
||||
expect(result.map((d) => d.nodeId)).toEqual([]);
|
||||
});
|
||||
|
||||
it('leaves lowercase template placeholders ambiguous rather than guessing', () => {
|
||||
const byValue = mkDef('pick:t', ['t'], [value('t')]);
|
||||
const byPointer = mkDef('pick:t*', ['t'], [pointer('t')]);
|
||||
|
||||
const result = narrowOverloadCandidates([byValue, byPointer], 1, ['int'], {
|
||||
argumentTypeClasses: [pointer('int')],
|
||||
});
|
||||
|
||||
expect(result.map((d) => d.nodeId)).toEqual(['pick:t', 'pick:t*']);
|
||||
});
|
||||
|
||||
it('keeps crossed template shapes ambiguous', () => {
|
||||
const pointerThenValue = mkDef('pick:T*-T', ['T', 'T'], [pointer('T'), value('T')]);
|
||||
const valueThenPointer = mkDef('pick:T-T*', ['T', 'T'], [value('T'), pointer('T')]);
|
||||
|
||||
const result = narrowOverloadCandidates(
|
||||
[pointerThenValue, valueThenPointer],
|
||||
2,
|
||||
['int', 'int'],
|
||||
{
|
||||
argumentTypeClasses: [pointer('int'), pointer('int')],
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.map((d) => d.nodeId)).toEqual(['pick:T*-T', 'pick:T-T*']);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<string, FixtureSnapshot>;
|
||||
|
||||
/**
|
||||
* Canonicalize ONE match. A CaptureMatch is a Record<tag, Capture> (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);
|
||||
});
|
||||
});
|
||||
|
|
@ -7,9 +7,20 @@
|
|||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { promises as fsp } from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import { emitCsharpScopeCaptures } from '../../../../src/core/ingestion/languages/csharp/captures.js';
|
||||
import { interpretCsharpImport } from '../../../../src/core/ingestion/languages/csharp/interpret.js';
|
||||
import { resolveCsharpImportTarget } from '../../../../src/core/ingestion/languages/csharp/import-target.js';
|
||||
import { loadCsharpResolutionConfig } from '../../../../src/core/ingestion/languages/csharp/resolution-config.js';
|
||||
import { getMaxFileSizeBytes } from '../../../../src/core/ingestion/utils/max-file-size.js';
|
||||
import {
|
||||
csharpSuffixFallbackAllowed,
|
||||
importAlignsWithDeclaredNamespaces,
|
||||
} from '../../../../src/core/ingestion/csharp-namespace-gate.js';
|
||||
import { csharpScopeResolver } from '../../../../src/core/ingestion/languages/csharp/scope-resolver.js';
|
||||
import type { CSharpProjectConfig } from '../../../../src/core/ingestion/language-config.js';
|
||||
import type { ParsedImport, WorkspaceIndex } from 'gitnexus-shared';
|
||||
|
||||
function importsFor(src: string): ParsedImport[] {
|
||||
|
|
@ -105,8 +116,32 @@ describe('interpretCsharpImport — using flavors', () => {
|
|||
});
|
||||
|
||||
describe('resolveCsharpImportTarget — suffix match against .cs files', () => {
|
||||
function ctx(fromFile: string, paths: string[]): WorkspaceIndex {
|
||||
return { fromFile, allFilePaths: new Set(paths) } as unknown as WorkspaceIndex;
|
||||
function ctx(
|
||||
fromFile: string,
|
||||
paths: string[],
|
||||
declaredNamespaces?: ReadonlySet<string>,
|
||||
extra?: {
|
||||
rootNamespaces?: ReadonlySet<string>;
|
||||
truncated?: boolean;
|
||||
csharpConfigs?: readonly CSharpProjectConfig[];
|
||||
},
|
||||
): WorkspaceIndex {
|
||||
const hasEvidence =
|
||||
declaredNamespaces !== undefined ||
|
||||
extra?.rootNamespaces !== undefined ||
|
||||
extra?.truncated !== undefined;
|
||||
return {
|
||||
fromFile,
|
||||
allFilePaths: new Set(paths),
|
||||
csharpConfigs: extra?.csharpConfigs,
|
||||
namespaces: hasEvidence
|
||||
? {
|
||||
declaredNamespaces,
|
||||
rootNamespaces: extra?.rootNamespaces,
|
||||
truncated: extra?.truncated,
|
||||
}
|
||||
: undefined,
|
||||
} as unknown as WorkspaceIndex;
|
||||
}
|
||||
|
||||
it('resolves `MyApp.Services` to `MyApp/Services/...cs` when a direct child exists', () => {
|
||||
|
|
@ -174,4 +209,582 @@ describe('resolveCsharpImportTarget — suffix match against .cs files', () => {
|
|||
} as unknown as WorkspaceIndex);
|
||||
expect(result).toBe(null);
|
||||
});
|
||||
|
||||
it('does not map BCL usings to coincidentally-named local files (#1881)', () => {
|
||||
const parsed: ParsedImport = {
|
||||
kind: 'namespace',
|
||||
localName: 'Tasks',
|
||||
importedName: 'System.Threading.Tasks',
|
||||
targetRaw: 'System.Threading.Tasks',
|
||||
};
|
||||
const result = resolveCsharpImportTarget(
|
||||
parsed,
|
||||
ctx(
|
||||
'Services/OrderService.cs',
|
||||
['Services/OrderService.cs', 'Tasks.cs', 'Events/OrderCreatedEvent.cs'],
|
||||
new Set(['MyApp.Services', 'MyApp.Events', 'MyApp.Legacy']),
|
||||
),
|
||||
);
|
||||
expect(result).toBe(null);
|
||||
});
|
||||
|
||||
it('does not map a BCL using to a coincidentally PATH-ALIGNED local file via direct-match (#1881, Codex F2)', () => {
|
||||
// The no-csproj direct-match must be gated too: `Legacy/System/Threading/
|
||||
// Tasks.cs` path-aligns with `using System.Threading.Tasks;` and would
|
||||
// satisfy resolveDirectMatch's nested-suffix match — but System.* is not a
|
||||
// declared in-repo namespace, so the gate (now run FIRST) blocks it.
|
||||
const parsed: ParsedImport = {
|
||||
kind: 'namespace',
|
||||
localName: 'Tasks',
|
||||
importedName: 'System.Threading.Tasks',
|
||||
targetRaw: 'System.Threading.Tasks',
|
||||
};
|
||||
const result = resolveCsharpImportTarget(
|
||||
parsed,
|
||||
ctx(
|
||||
'Services/OrderService.cs',
|
||||
['Services/OrderService.cs', 'Legacy/System/Threading/Tasks.cs', 'Models/User.cs'],
|
||||
new Set(['MyApp.Services', 'MyApp.Legacy', 'MyApp.Models']),
|
||||
),
|
||||
);
|
||||
expect(result).toBe(null);
|
||||
});
|
||||
|
||||
it('still resolves a legitimate in-repo using via direct-match when evidence is present (Codex F2 guard)', () => {
|
||||
// Gating the direct-match must NOT over-block a legitimate aligned import:
|
||||
// `using MyApp.Services;` aligns (exact declared) so the gate passes and the
|
||||
// namespace-dir direct-child match still resolves.
|
||||
const parsed: ParsedImport = {
|
||||
kind: 'namespace',
|
||||
localName: 'Services',
|
||||
importedName: 'MyApp.Services',
|
||||
targetRaw: 'MyApp.Services',
|
||||
};
|
||||
const result = resolveCsharpImportTarget(
|
||||
parsed,
|
||||
ctx(
|
||||
'MyApp/Program.cs',
|
||||
['MyApp/Program.cs', 'MyApp/Services/UserService.cs'],
|
||||
new Set(['MyApp.Services']),
|
||||
),
|
||||
);
|
||||
expect(result).toBe('MyApp/Services/UserService.cs');
|
||||
});
|
||||
|
||||
it('still resolves in-repo namespace imports via progressive stripping', () => {
|
||||
const parsed: ParsedImport = {
|
||||
kind: 'namespace',
|
||||
localName: 'Models',
|
||||
importedName: 'MyApp.Models',
|
||||
targetRaw: 'MyApp.Models',
|
||||
};
|
||||
const result = resolveCsharpImportTarget(
|
||||
parsed,
|
||||
ctx(
|
||||
'Services/UserService.cs',
|
||||
['Services/UserService.cs', 'Models/User.cs'],
|
||||
new Set(['MyApp.Models', 'MyApp.Services']),
|
||||
),
|
||||
);
|
||||
expect(result).toBe('Models/User.cs');
|
||||
});
|
||||
|
||||
it('drives the csproj-first branch: resolves via the internal resolver when configs exist (#7)', () => {
|
||||
const parsed: ParsedImport = {
|
||||
kind: 'namespace',
|
||||
localName: 'Models',
|
||||
importedName: 'MyApp.Models',
|
||||
targetRaw: 'MyApp.Models',
|
||||
};
|
||||
const result = resolveCsharpImportTarget(
|
||||
parsed,
|
||||
ctx(
|
||||
'Services/OrderService.cs',
|
||||
['Services/OrderService.cs', 'Models/User.cs'],
|
||||
new Set(['MyApp.Services', 'MyApp.Models']),
|
||||
{
|
||||
rootNamespaces: new Set(['MyApp']),
|
||||
csharpConfigs: [{ rootNamespace: 'MyApp', projectDir: '' }],
|
||||
},
|
||||
),
|
||||
);
|
||||
expect(result).toBe('Models/User.cs');
|
||||
});
|
||||
|
||||
it('mirrors legacy authority: csproj present + internal-resolver-empty returns null, no ungated direct match (#2)', () => {
|
||||
// `Foo/Bar.cs` is an exact whole-path match that the ungated
|
||||
// `resolveDirectMatch` would have returned. With csproj configs present
|
||||
// and `Foo.Bar` outside the declared namespaces, the legacy strategy
|
||||
// returns an empty result that STOPS the chain — the registry path must
|
||||
// now do the same (return null) instead of falling through.
|
||||
const parsed: ParsedImport = {
|
||||
kind: 'namespace',
|
||||
localName: 'Bar',
|
||||
importedName: 'Foo.Bar',
|
||||
targetRaw: 'Foo.Bar',
|
||||
};
|
||||
const result = resolveCsharpImportTarget(
|
||||
parsed,
|
||||
ctx(
|
||||
'Services/OrderService.cs',
|
||||
['Services/OrderService.cs', 'Foo/Bar.cs'],
|
||||
new Set(['MyApp.Models']),
|
||||
{
|
||||
rootNamespaces: new Set(['MyApp']),
|
||||
csharpConfigs: [{ rootNamespace: 'MyApp', projectDir: '' }],
|
||||
},
|
||||
),
|
||||
);
|
||||
expect(result).toBe(null);
|
||||
});
|
||||
|
||||
it('requires the rootNamespaces anchor end-to-end: parent-of import resolves only when anchored (#7)', () => {
|
||||
// `using MyApp.Core;` is an ancestor of declared `MyApp.Core.Models`.
|
||||
// The gate opens ONLY when `MyApp.Core` sits at/above an in-repo root, so
|
||||
// `Core/Thing.cs` resolves with roots {MyApp.Core} but not without them.
|
||||
const parsed: ParsedImport = {
|
||||
kind: 'namespace',
|
||||
localName: 'Core',
|
||||
importedName: 'MyApp.Core',
|
||||
targetRaw: 'MyApp.Core',
|
||||
};
|
||||
const anchored = resolveCsharpImportTarget(
|
||||
parsed,
|
||||
ctx(
|
||||
'Services/OrderService.cs',
|
||||
['Services/OrderService.cs', 'Core/Thing.cs'],
|
||||
new Set(['MyApp.Core.Models']),
|
||||
{
|
||||
rootNamespaces: new Set(['MyApp.Core']),
|
||||
},
|
||||
),
|
||||
);
|
||||
expect(anchored).toBe('Core/Thing.cs');
|
||||
|
||||
const unanchored = resolveCsharpImportTarget(
|
||||
parsed,
|
||||
ctx(
|
||||
'Services/OrderService.cs',
|
||||
['Services/OrderService.cs', 'Core/Thing.cs'],
|
||||
new Set(['MyApp.Core.Models']),
|
||||
),
|
||||
);
|
||||
expect(unanchored).toBe(null);
|
||||
});
|
||||
|
||||
it('a sibling import outside the declared namespaces does not resolve even with roots (#7)', () => {
|
||||
// `using MyApp.Other;` is neither a child nor an ancestor of the only
|
||||
// declared namespace `MyApp.Models`, so the gate stays closed and the
|
||||
// otherwise-matchable `Other/Thing.cs` is left unresolved.
|
||||
const parsed: ParsedImport = {
|
||||
kind: 'namespace',
|
||||
localName: 'Other',
|
||||
importedName: 'MyApp.Other',
|
||||
targetRaw: 'MyApp.Other',
|
||||
};
|
||||
const result = resolveCsharpImportTarget(
|
||||
parsed,
|
||||
ctx(
|
||||
'Services/OrderService.cs',
|
||||
['Services/OrderService.cs', 'Other/Thing.cs'],
|
||||
new Set(['MyApp.Models']),
|
||||
{
|
||||
rootNamespaces: new Set(['MyApp']),
|
||||
},
|
||||
),
|
||||
);
|
||||
expect(result).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('importAlignsWithDeclaredNamespaces — declared-namespace gate (#1881)', () => {
|
||||
it('matches an exactly-declared namespace', () => {
|
||||
expect(importAlignsWithDeclaredNamespaces('MyApp.Models', new Set(['MyApp.Models']))).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('child-of: import nested under a declared ancestor namespace', () => {
|
||||
// `using MyApp.Models.Detail;` when the repo declares `MyApp.Models`.
|
||||
expect(
|
||||
importAlignsWithDeclaredNamespaces('MyApp.Models.Detail', new Set(['MyApp.Models'])),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('child-of allows a using-static type under a declared namespace (#1)', () => {
|
||||
// `using static MyApp.Utils.Logger;` — the parent namespace `MyApp.Utils`
|
||||
// is declared, so the type import aligns even though `MyApp.Utils.Logger`
|
||||
// itself is not a declared namespace.
|
||||
expect(importAlignsWithDeclaredNamespaces('MyApp.Utils.Logger', new Set(['MyApp.Utils']))).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('child-of stays anchored: a declared BCL root does NOT qualify a BCL using (#1)', () => {
|
||||
// A repo that declares `namespace System;` (a shim) must not green-light
|
||||
// `using System.Threading.Tasks;` — the import's parent `System.Threading`
|
||||
// is NOT declared, so the only match would be a coincidental local
|
||||
// `Tasks.cs`. The old "any declared prefix" rule re-opened #1881 here.
|
||||
expect(
|
||||
importAlignsWithDeclaredNamespaces(
|
||||
'System.Threading.Tasks',
|
||||
new Set(['System', 'MyApp.Models']),
|
||||
new Set(['System', 'MyApp']),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('parent-of: parent-namespace import resolves against a declared child', () => {
|
||||
// `using MyApp;` when the repo declares `MyApp.Models` — must still open
|
||||
// the gate (anchored on the in-repo root namespace `MyApp`).
|
||||
expect(
|
||||
importAlignsWithDeclaredNamespaces('MyApp', new Set(['MyApp.Models']), new Set(['MyApp'])),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('parent-of works without explicit roots via the top-level declared segment', () => {
|
||||
expect(importAlignsWithDeclaredNamespaces('MyApp', new Set(['MyApp.Models']))).toBe(true);
|
||||
});
|
||||
|
||||
it('parent-of for a multi-segment csproj root (using MyApp; with RootNamespace MyApp.Core)', () => {
|
||||
expect(
|
||||
importAlignsWithDeclaredNamespaces(
|
||||
'MyApp',
|
||||
new Set(['MyApp.Core.Models']),
|
||||
new Set(['MyApp.Core', 'MyApp']),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('parent-of stays anchored: a BCL prefix does NOT qualify via a locally-declared sub-namespace (#5)', () => {
|
||||
// A file declaring `namespace System.Threading.Tasks.Extensions` must not
|
||||
// open the gate for `using System.Threading.Tasks;`.
|
||||
const declared = new Set(['System.Threading.Tasks.Extensions', 'MyApp.Models']);
|
||||
expect(
|
||||
importAlignsWithDeclaredNamespaces(
|
||||
'System.Threading.Tasks',
|
||||
declared,
|
||||
new Set(['MyApp', 'System']),
|
||||
),
|
||||
).toBe(false);
|
||||
// Same conclusion without explicit roots (top-level segment fallback).
|
||||
expect(importAlignsWithDeclaredNamespaces('System.Threading.Tasks', declared)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for an unrelated BCL namespace', () => {
|
||||
expect(
|
||||
importAlignsWithDeclaredNamespaces(
|
||||
'System.Linq',
|
||||
new Set(['MyApp.Services']),
|
||||
new Set(['MyApp']),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for an empty or undefined declared set', () => {
|
||||
expect(importAlignsWithDeclaredNamespaces('MyApp', new Set())).toBe(false);
|
||||
expect(importAlignsWithDeclaredNamespaces('MyApp', undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('csharpSuffixFallbackAllowed — fail-open safety valves (#1881)', () => {
|
||||
const declared = new Set(['MyApp.Models']);
|
||||
const roots = new Set(['MyApp']);
|
||||
|
||||
it('blocks a non-aligned import when evidence is present and complete', () => {
|
||||
// Baseline: with complete evidence, a BCL using that aligns with nothing
|
||||
// declared in-repo is blocked.
|
||||
expect(
|
||||
csharpSuffixFallbackAllowed('System.Threading.Tasks', {
|
||||
declaredNamespaces: declared,
|
||||
rootNamespaces: roots,
|
||||
truncated: false,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('fails OPEN (allows) when no evidence was threaded (#7)', () => {
|
||||
// The exact same import the complete-evidence case blocks must be ALLOWED
|
||||
// when evidence is undefined — preserving the pre-gate permissive behavior
|
||||
// for callers that never ran the scan.
|
||||
expect(csharpSuffixFallbackAllowed('System.Threading.Tasks', undefined)).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps a clearly-external BCL root BLOCKED even when the scan was truncated (#1881, Codex F1)', () => {
|
||||
// A single truncation must NOT silently re-enable BCL→local suffix matches
|
||||
// repo-wide: System.* stays gated through truncation when the repo does not
|
||||
// declare it. (This reverses the prior blanket-fail-open for external roots.)
|
||||
expect(
|
||||
csharpSuffixFallbackAllowed('System.Threading.Tasks', {
|
||||
declaredNamespaces: declared,
|
||||
rootNamespaces: roots,
|
||||
truncated: true,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('fails OPEN for a genuinely local-looking import when the scan was truncated (#6)', () => {
|
||||
// Non-external roots still fail open under truncation so an incomplete
|
||||
// (capped/unreadable) scan does not silently drop a legitimate in-repo edge.
|
||||
expect(
|
||||
csharpSuffixFallbackAllowed('MyApp.Internal.Widget', {
|
||||
declaredNamespaces: declared,
|
||||
rootNamespaces: roots,
|
||||
truncated: true,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('lets an external root fail OPEN through truncation when the repo declares it (escape hatch)', () => {
|
||||
// If the repo actually declares the (normally-external) root, the alignment
|
||||
// escape hatch allows the import even under truncation.
|
||||
expect(
|
||||
csharpSuffixFallbackAllowed('System.Threading.Tasks', {
|
||||
declaredNamespaces: new Set(['System.Threading']),
|
||||
rootNamespaces: new Set(['System']),
|
||||
truncated: true,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('csharpScopeResolver.resolveImportTarget — config→ctx adapter wiring (#9)', () => {
|
||||
it('threads resolutionConfig.namespaces into the gate so a BCL using is blocked', () => {
|
||||
// Exercises the adapter (NOT resolveCsharpImportTarget directly): the
|
||||
// resolutionConfig that loadResolutionConfig returns must reach the gate as
|
||||
// ctx.namespaces. With a coincidental local `Tasks.cs` present and
|
||||
// `System.Threading.Tasks` outside the declared namespaces, the wired
|
||||
// evidence blocks the spurious edge.
|
||||
const result = csharpScopeResolver.resolveImportTarget(
|
||||
'System.Threading.Tasks',
|
||||
'Services/OrderService.cs',
|
||||
new Set(['Services/OrderService.cs', 'Tasks.cs']),
|
||||
{
|
||||
csharpConfigs: [],
|
||||
namespaces: {
|
||||
declaredNamespaces: new Set(['MyApp.Services', 'MyApp.Legacy']),
|
||||
rootNamespaces: new Set(['MyApp']),
|
||||
truncated: false,
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(result).toBe(null);
|
||||
});
|
||||
|
||||
it('threads csharpConfigs so a csproj-mapped import resolves through the adapter', () => {
|
||||
// The other half of the wiring: csharpConfigs must reach ctx.csharpConfigs
|
||||
// so the csproj root-namespace mapping runs.
|
||||
const result = csharpScopeResolver.resolveImportTarget(
|
||||
'MyApp.Models',
|
||||
'Services/OrderService.cs',
|
||||
new Set(['Services/OrderService.cs', 'Models/User.cs']),
|
||||
{
|
||||
csharpConfigs: [{ rootNamespace: 'MyApp', projectDir: '' }],
|
||||
namespaces: {
|
||||
declaredNamespaces: new Set(['MyApp.Models', 'MyApp.Services']),
|
||||
rootNamespaces: new Set(['MyApp']),
|
||||
truncated: false,
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(result).toBe('Models/User.cs');
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadCsharpResolutionConfig — one-pass namespace scan (#1881)', () => {
|
||||
async function makeTempRepo(files: Record<string, string>): Promise<string> {
|
||||
const root = await fsp.mkdtemp(path.join(os.tmpdir(), 'csharp-scan-'));
|
||||
for (const [rel, content] of Object.entries(files)) {
|
||||
const full = path.join(root, rel);
|
||||
await fsp.mkdir(path.dirname(full), { recursive: true });
|
||||
await fsp.writeFile(full, content, 'utf-8');
|
||||
}
|
||||
return root;
|
||||
}
|
||||
|
||||
it('collects file-scoped, block, and multiple-per-file namespaces; skips bin/obj; reads csproj root', async () => {
|
||||
const root = await makeTempRepo({
|
||||
'App.csproj':
|
||||
'<Project><PropertyGroup><RootNamespace>MyApp</RootNamespace></PropertyGroup></Project>',
|
||||
'Scoped.cs': 'namespace Alpha.Scoped;\npublic class A {}',
|
||||
'Block.cs': 'namespace Beta.Block\n{\n public class B {}\n}',
|
||||
'Multi.cs': 'namespace Gamma.One { }\nnamespace Gamma.Two { }',
|
||||
'bin/Generated.cs': 'namespace Should.Skip;',
|
||||
'obj/Temp.cs': 'namespace Should.AlsoSkip;',
|
||||
});
|
||||
try {
|
||||
const config = await loadCsharpResolutionConfig(root);
|
||||
const ns = config.namespaces!;
|
||||
expect(ns.truncated).toBe(false);
|
||||
expect([...ns.declaredNamespaces!].sort()).toEqual([
|
||||
'Alpha.Scoped',
|
||||
'Beta.Block',
|
||||
'Gamma.One',
|
||||
'Gamma.Two',
|
||||
]);
|
||||
expect(ns.declaredNamespaces!.has('Should.Skip')).toBe(false);
|
||||
expect(ns.declaredNamespaces!.has('Should.AlsoSkip')).toBe(false);
|
||||
// csproj RootNamespace + top-level segment of each declared namespace.
|
||||
expect(ns.rootNamespaces!.has('MyApp')).toBe(true);
|
||||
expect([...ns.rootNamespaces!].sort()).toEqual(['Alpha', 'Beta', 'Gamma', 'MyApp']);
|
||||
expect(config.csharpConfigs).toHaveLength(1);
|
||||
expect(config.csharpConfigs[0]!.rootNamespace).toBe('MyApp');
|
||||
} finally {
|
||||
await fsp.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps truncated=false for a realistic-depth layout so the gate stays engaged (#1)', async () => {
|
||||
// A repo nested ~8 levels deep is well within the production cap
|
||||
// (CSHARP_SCAN_MAX_DEPTH=24). Were the cap as low as the old value (5),
|
||||
// this layout would trip `truncated` and disable the #1881 gate for the
|
||||
// whole repo. Proving truncated===false here pins the gate ON for repos
|
||||
// of normal depth.
|
||||
const root = await makeTempRepo({
|
||||
'App.csproj':
|
||||
'<Project><PropertyGroup><RootNamespace>MyApp</RootNamespace></PropertyGroup></Project>',
|
||||
'a/b/c/d/e/f/g/h/Deep.cs': 'namespace MyApp.Deep.Feature;',
|
||||
});
|
||||
try {
|
||||
const config = await loadCsharpResolutionConfig(root);
|
||||
const ns = config.namespaces!;
|
||||
expect(ns.truncated).toBe(false);
|
||||
expect(ns.declaredNamespaces!.has('MyApp.Deep.Feature')).toBe(true);
|
||||
} finally {
|
||||
await fsp.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('sets the truncation flag when the depth cap prunes a subtree (#11)', async () => {
|
||||
// repoRoot is depth 0; the chain below nests one level past the depth cap
|
||||
// (CSHARP_SCAN_MAX_DEPTH=24) so the deepest dir is pruned, its namespace
|
||||
// is missed, and the flag trips. Built relative to the real cap — do NOT
|
||||
// lower the production cap for the test.
|
||||
const deepChain = Array.from({ length: 25 }, (_, i) => `d${i}`).join('/');
|
||||
const root = await makeTempRepo({
|
||||
'Shallow.cs': 'namespace Shallow.Ns;',
|
||||
[`${deepChain}/Deep.cs`]: 'namespace Deep.Ns;',
|
||||
});
|
||||
try {
|
||||
const config = await loadCsharpResolutionConfig(root);
|
||||
const ns = config.namespaces!;
|
||||
expect(ns.truncated).toBe(true);
|
||||
expect(ns.declaredNamespaces!.has('Shallow.Ns')).toBe(true);
|
||||
expect(ns.declaredNamespaces!.has('Deep.Ns')).toBe(false);
|
||||
} finally {
|
||||
await fsp.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('streams a large .cs file end-to-end, collecting namespaces past the old size cap (#1881)', async () => {
|
||||
// The namespace scan streams each file, so a `.cs` far larger than the old
|
||||
// per-file size cap is read end-to-end in constant memory instead of being
|
||||
// skipped. A namespace at the START and one at the very END (well past the
|
||||
// old cap boundary) must BOTH be collected, and `truncated` must stay false
|
||||
// — a big generated file no longer disables the #1881 gate repo-wide.
|
||||
const cap = getMaxFileSizeBytes();
|
||||
const padLine = '// pad pad pad pad pad pad\n';
|
||||
const padding = padLine.repeat(Math.ceil((cap * 3) / padLine.length));
|
||||
const huge = `namespace Generated.Head;\n${padding}namespace Generated.Tail { }\n`;
|
||||
const root = await makeTempRepo({
|
||||
'Hand.cs': 'namespace Hand.Written;',
|
||||
'Generated.cs': huge,
|
||||
});
|
||||
try {
|
||||
const config = await loadCsharpResolutionConfig(root);
|
||||
const ns = config.namespaces!;
|
||||
expect(ns.truncated).toBe(false);
|
||||
expect(ns.declaredNamespaces!.has('Hand.Written')).toBe(true);
|
||||
expect(ns.declaredNamespaces!.has('Generated.Head')).toBe(true);
|
||||
expect(ns.declaredNamespaces!.has('Generated.Tail')).toBe(true);
|
||||
} finally {
|
||||
await fsp.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('collects a Unicode namespace through the streamed scan, not truncated (Codex F3)', async () => {
|
||||
// The scanner is now Unicode-aware, so a non-ASCII namespace is captured
|
||||
// end-to-end instead of being dropped (which would over-block its imports).
|
||||
const root = await makeTempRepo({
|
||||
'App.csproj':
|
||||
'<Project><PropertyGroup><RootNamespace>MyApp</RootNamespace></PropertyGroup></Project>',
|
||||
'Models/Café.cs': 'namespace Café.App;\npublic class Modèle {}',
|
||||
});
|
||||
try {
|
||||
const config = await loadCsharpResolutionConfig(root);
|
||||
const ns = config.namespaces!;
|
||||
expect(ns.truncated).toBe(false);
|
||||
expect(ns.declaredNamespaces!.has('Café.App')).toBe(true);
|
||||
} finally {
|
||||
await fsp.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('marks the scan truncated when a file has an uncaptured namespace form, failing the gate OPEN (Codex F3)', async () => {
|
||||
// A namespace split across lines is not captured by the line scanner; the
|
||||
// scan must flag truncated so the dropped namespace fails the #1881 gate
|
||||
// OPEN rather than over-block an import declared in that file.
|
||||
const root = await makeTempRepo({
|
||||
'App.csproj':
|
||||
'<Project><PropertyGroup><RootNamespace>MyApp</RootNamespace></PropertyGroup></Project>',
|
||||
'Weird.cs': 'namespace\n MyApp.Weird;\npublic class W {}',
|
||||
});
|
||||
try {
|
||||
const config = await loadCsharpResolutionConfig(root);
|
||||
const ns = config.namespaces!;
|
||||
expect(ns.truncated).toBe(true);
|
||||
// A local-looking import under the dropped namespace fails open (and U1's
|
||||
// external-root denylist still keeps BCL roots blocked under truncation).
|
||||
expect(csharpSuffixFallbackAllowed('MyApp.Weird.Thing', ns)).toBe(true);
|
||||
expect(csharpSuffixFallbackAllowed('System.Threading.Tasks', ns)).toBe(false);
|
||||
} finally {
|
||||
await fsp.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('recovers <RootNamespace> past the old read cap via streaming (Codex F4)', async () => {
|
||||
// A big leading <ItemGroup> pushes <RootNamespace> past the old 512KB read
|
||||
// cap; the streamed scan reads on until it finds the tag, so the correct
|
||||
// root is recovered (pre-fix the capped read synthesized the filename 'App').
|
||||
const cap = getMaxFileSizeBytes();
|
||||
const itemLine = ' <Compile Include="src/Generated/F.cs" />\n';
|
||||
const bigItemGroup =
|
||||
' <ItemGroup>\n' +
|
||||
itemLine.repeat(Math.ceil((cap * 2) / itemLine.length)) +
|
||||
' </ItemGroup>\n';
|
||||
const csproj =
|
||||
'<Project Sdk="Microsoft.NET.Sdk">\n' +
|
||||
bigItemGroup +
|
||||
' <PropertyGroup><RootNamespace>MyApp</RootNamespace></PropertyGroup>\n' +
|
||||
'</Project>\n';
|
||||
const root = await makeTempRepo({
|
||||
'App.csproj': csproj,
|
||||
'Models/User.cs': 'namespace MyApp.Models;\npublic class User {}',
|
||||
});
|
||||
try {
|
||||
const config = await loadCsharpResolutionConfig(root);
|
||||
expect(config.csharpConfigs).toHaveLength(1);
|
||||
expect(config.csharpConfigs[0]!.rootNamespace).toBe('MyApp');
|
||||
} finally {
|
||||
await fsp.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('falls back to the filename root only when <RootNamespace> is genuinely absent (Codex F4 control)', async () => {
|
||||
// A genuine read-to-EOF absence still synthesizes the filename root, so a
|
||||
// .csproj without RootNamespace is unchanged — the fix only avoids guessing
|
||||
// when the tag was unreachable.
|
||||
const root = await makeTempRepo({
|
||||
'App.csproj':
|
||||
'<Project Sdk="Microsoft.NET.Sdk"><PropertyGroup><TargetFramework>net8.0</TargetFramework></PropertyGroup></Project>',
|
||||
'Models/User.cs': 'namespace App.Models;\npublic class User {}',
|
||||
});
|
||||
try {
|
||||
const config = await loadCsharpResolutionConfig(root);
|
||||
expect(config.csharpConfigs).toHaveLength(1);
|
||||
expect(config.csharpConfigs[0]!.rootNamespace).toBe('App');
|
||||
} finally {
|
||||
await fsp.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,235 @@
|
|||
/**
|
||||
* Golden capture-parity test for `emitGoScopeCaptures` (issue #1848 follow-up).
|
||||
*
|
||||
* Pins the exact capture output of `emitGoScopeCaptures` across the whole
|
||||
* `test/fixtures/lang-resolution/go-*` corpus plus a synthetic generated-DAO
|
||||
* source, so any future drift in the Go scope-capture path fails CI rather than
|
||||
* only being caught by a coarse perf tripwire or pipeline-level resolver tests.
|
||||
*
|
||||
* This is a FORWARD-DRIFT guard: it locks in the current (post-#1915, verified)
|
||||
* output as the baseline. It does not independently re-prove the original
|
||||
* pre-fix parity — that was established during PR #1915.
|
||||
*
|
||||
* Regenerate the golden intentionally with `UPDATE_GOLDEN=1` in the environment.
|
||||
*
|
||||
* Per fixture the snapshot stores `{ captureGroups, digest }`:
|
||||
* - captureGroups: number of capture matches (makes a count change legible)
|
||||
* - digest: sha256 of a match-grouped, order-sensitive (emission-order)
|
||||
* canonicalization (see canonicalize* below). Order-sensitivity is safe
|
||||
* because emitGoScopeCaptures output is deterministic, and it makes the
|
||||
* digest a true byte-identical guard (a reordering refactor is real drift).
|
||||
* Nothing path/time/id-dependent leaks in.
|
||||
*
|
||||
* Pattern: mirrors test/integration/pipeline-graph-golden.test.ts.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import crypto from 'crypto';
|
||||
import { emitGoScopeCaptures } from '../../../../src/core/ingestion/languages/go/index.js';
|
||||
import type { CaptureMatch } from 'gitnexus-shared';
|
||||
|
||||
// This test lives at test/unit/scope-resolution/go/, so fixtures are THREE
|
||||
// levels up (unlike pipeline-graph-golden.test.ts at test/integration/).
|
||||
const FIXTURE_ROOT = path.resolve(__dirname, '..', '..', '..', 'fixtures', 'lang-resolution');
|
||||
const GOLDEN_DIR = path.resolve(__dirname, '..', '..', '..', 'fixtures', 'go-captures-golden');
|
||||
const GOLDEN_FILE = path.join(GOLDEN_DIR, 'expected-captures.json');
|
||||
|
||||
const UPDATE = process.env.UPDATE_GOLDEN === '1';
|
||||
|
||||
interface FixtureSnapshot {
|
||||
captureGroups: number;
|
||||
digest: string;
|
||||
}
|
||||
type Snapshot = Record<string, FixtureSnapshot>;
|
||||
|
||||
/**
|
||||
* Canonicalize ONE match. A CaptureMatch is a Record<tag, Capture> (multiple
|
||||
* captures per match), so we group by match to preserve match identity:
|
||||
* build one `tag|text|startLine:startCol-endLine:endCol` string per capture,
|
||||
* sort them within the match, and join. We deliberately do NOT flatten every
|
||||
* capture into one global list — that would lose match boundaries.
|
||||
*/
|
||||
function canonicalizeMatch(match: CaptureMatch): string {
|
||||
const parts: string[] = [];
|
||||
for (const tag of Object.keys(match)) {
|
||||
const cap = match[tag]!;
|
||||
const r = cap.range;
|
||||
parts.push(`${tag}|${cap.text}|${r.startLine}:${r.startCol}-${r.endLine}:${r.endCol}`);
|
||||
}
|
||||
parts.sort();
|
||||
return parts.join(';');
|
||||
}
|
||||
|
||||
/** Order-sensitive (emission-order) digest of a full capture result (match-grouped). */
|
||||
function digestCaptures(matches: readonly CaptureMatch[]): string {
|
||||
// No cross-match sort: the digest reflects emission order so a reordering
|
||||
// refactor surfaces as drift. Within-match key order IS normalized
|
||||
// (canonicalizeMatch sorts), since a CaptureMatch is an unordered Record.
|
||||
const matchStrings = matches.map(canonicalizeMatch);
|
||||
return crypto.createHash('sha256').update(matchStrings.join('\n')).digest('hex');
|
||||
}
|
||||
|
||||
function snapshotOf(src: string, filePath: string): FixtureSnapshot {
|
||||
const matches = emitGoScopeCaptures(src, filePath);
|
||||
return { captureGroups: matches.length, digest: digestCaptures(matches) };
|
||||
}
|
||||
|
||||
/** All `.go` files under `lang-resolution/go-*`, as sorted repo-relative-ish keys. */
|
||||
function collectGoFixtures(): { key: string; absPath: string }[] {
|
||||
const out: { key: string; absPath: string }[] = [];
|
||||
for (const entry of fs.readdirSync(FIXTURE_ROOT, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory() || !entry.name.startsWith('go-')) continue;
|
||||
const stack = [path.join(FIXTURE_ROOT, entry.name)];
|
||||
while (stack.length) {
|
||||
const dir = stack.pop()!;
|
||||
for (const c of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const p = path.join(dir, c.name);
|
||||
if (c.isDirectory()) stack.push(p);
|
||||
else if (c.name.endsWith('.go')) {
|
||||
out.push({ key: path.relative(FIXTURE_ROOT, p).split(path.sep).join('/'), absPath: p });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out.sort((a, b) => a.key.localeCompare(b.key));
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Small deterministic generated-DAO source — the #1848 shape at correctness scale. */
|
||||
function generateDao(entityCount: number): string {
|
||||
const lines = ['package generated', ''];
|
||||
for (let i = 0; i < entityCount; i++) {
|
||||
const n = String(i).padStart(4, '0');
|
||||
lines.push(
|
||||
`type DefUserDao${n} struct {`,
|
||||
'\tid int64',
|
||||
'\tname string',
|
||||
'}',
|
||||
'',
|
||||
`func (d *DefUserDao${n}) GetID() int64 { return d.id }`,
|
||||
`func (d *DefUserDao${n}) SetName(name string) { d.name = name }`,
|
||||
`func (d *DefUserDao${n}) Validate() error { return nil }`,
|
||||
'',
|
||||
);
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function buildSnapshot(): Snapshot {
|
||||
const snap: Snapshot = {};
|
||||
for (const { key, absPath } of collectGoFixtures()) {
|
||||
snap[key] = snapshotOf(fs.readFileSync(absPath, 'utf8'), absPath);
|
||||
}
|
||||
snap['synthetic:dao-20'] = snapshotOf(generateDao(20), 'zz_generated.def_userdao.go');
|
||||
// Stable key order for deterministic JSON serialization.
|
||||
return Object.fromEntries(
|
||||
Object.keys(snap)
|
||||
.sort()
|
||||
.map((k) => [k, snap[k]!]),
|
||||
);
|
||||
}
|
||||
|
||||
function formatGolden(snap: Snapshot): string {
|
||||
return JSON.stringify(snap, null, 2) + '\n';
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure decision for what the golden test should do — extracted so the
|
||||
* fail-on-missing-in-CI rule is unit-testable without touching the filesystem
|
||||
* (and can never corrupt the committed golden). A missing golden must NOT
|
||||
* self-heal in CI; locally it regenerates as a first-run convenience.
|
||||
*/
|
||||
type GoldenAction = 'regenerate' | 'compare' | 'fail';
|
||||
function resolveGoldenAction(opts: {
|
||||
update: boolean;
|
||||
exists: boolean;
|
||||
isCI: boolean;
|
||||
}): GoldenAction {
|
||||
if (opts.update) return 'regenerate';
|
||||
if (!opts.exists) return opts.isCI ? 'fail' : 'regenerate';
|
||||
return 'compare';
|
||||
}
|
||||
|
||||
describe('Go scope captures — golden parity', () => {
|
||||
it('matches the committed golden snapshot across all go-* fixtures + DAO shape', () => {
|
||||
const snapshot = buildSnapshot();
|
||||
|
||||
// Read the golden once (no existsSync-then-use, which is a TOCTOU race):
|
||||
// ENOENT means the golden is missing; reuse `existing` for the compare path.
|
||||
let existing: string | undefined;
|
||||
try {
|
||||
existing = fs.readFileSync(GOLDEN_FILE, 'utf8');
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
|
||||
}
|
||||
|
||||
const action = resolveGoldenAction({
|
||||
update: UPDATE,
|
||||
exists: existing !== undefined,
|
||||
isCI: !!process.env.CI, // truthy check: fires on any CI runner, not just CI==='true'
|
||||
});
|
||||
|
||||
if (action === 'fail') {
|
||||
throw new Error(
|
||||
`[go-captures-golden] golden file missing at ${GOLDEN_FILE} in CI. A missing golden must ` +
|
||||
`not self-heal in CI — regenerate it locally with UPDATE_GOLDEN=1 and commit it.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (action === 'regenerate') {
|
||||
fs.mkdirSync(GOLDEN_DIR, { recursive: true });
|
||||
fs.writeFileSync(GOLDEN_FILE, formatGolden(snapshot), 'utf8');
|
||||
console.log(
|
||||
`[go-captures-golden] ${UPDATE ? 'Regenerated' : 'Created'} golden at ${GOLDEN_FILE}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const expected: Snapshot = JSON.parse(existing!);
|
||||
expect(
|
||||
snapshot,
|
||||
'emitGoScopeCaptures output drifted from the committed golden. If this drift is intentional ' +
|
||||
'(or the digest scheme changed), regenerate with ' +
|
||||
'UPDATE_GOLDEN=1 npx vitest run test/unit/scope-resolution/go/go-captures-golden.test.ts',
|
||||
).toEqual(expected);
|
||||
});
|
||||
|
||||
// The fail-on-missing-in-CI rule, asserted purely (no filesystem mutation).
|
||||
it.each([
|
||||
{ update: true, exists: false, isCI: true, expected: 'regenerate' },
|
||||
{ update: false, exists: false, isCI: true, expected: 'fail' },
|
||||
{ update: false, exists: false, isCI: false, expected: 'regenerate' },
|
||||
{ update: false, exists: true, isCI: true, expected: 'compare' },
|
||||
{ update: false, exists: true, isCI: false, expected: 'compare' },
|
||||
])(
|
||||
'resolveGoldenAction($update,$exists,$isCI) -> $expected',
|
||||
({ update, exists, isCI, expected }) => {
|
||||
expect(resolveGoldenAction({ update, exists, isCI })).toBe(expected);
|
||||
},
|
||||
);
|
||||
|
||||
it('produces a deterministic digest across repeated runs', () => {
|
||||
const src = generateDao(8);
|
||||
expect(digestCaptures(emitGoScopeCaptures(src, 'a.go'))).toBe(
|
||||
digestCaptures(emitGoScopeCaptures(src, 'a.go')),
|
||||
);
|
||||
});
|
||||
|
||||
it('digest is sensitive to capture-match emission order', () => {
|
||||
const matches = emitGoScopeCaptures(generateDao(6), 'a.go');
|
||||
expect(matches.length).toBeGreaterThan(1);
|
||||
const reversed = [...matches].reverse();
|
||||
// Reordering the emission changes the digest — the true byte-identical guard.
|
||||
expect(digestCaptures(reversed)).not.toBe(digestCaptures(matches));
|
||||
});
|
||||
|
||||
it('records a capture-group count for every fixture and the DAO shape', () => {
|
||||
const snapshot = buildSnapshot();
|
||||
const fixtureKeys = collectGoFixtures().map((f) => f.key);
|
||||
// Every collected fixture is present in the snapshot.
|
||||
for (const k of fixtureKeys) expect(snapshot[k]).toBeDefined();
|
||||
// The DAO shape (which has symbols) yields a non-empty capture set.
|
||||
expect(snapshot['synthetic:dao-20']!.captureGroups).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
|
@ -62,4 +62,104 @@ func main() {
|
|||
expect(tags).toContain('@reference.read');
|
||||
expect(tags).toContain('@reference.write');
|
||||
});
|
||||
|
||||
// ── Edge shapes the #1915 captured-node refactor reasons about but no
|
||||
// lang-resolution fixture exercises (issue #1848 follow-up U2). ──
|
||||
|
||||
it('synthesizes a receiver for a method but not for a func_literal scope', () => {
|
||||
// Source has BOTH a real method and a closure. A weak "no @type-binding.self
|
||||
// anywhere" assertion would pass even if the method_declaration receiver
|
||||
// branch regressed (a closure-only fixture has none to lose); asserting the
|
||||
// method's receiver IS present catches that regression.
|
||||
const src = `
|
||||
package main
|
||||
|
||||
type User struct{ Name string }
|
||||
|
||||
func (u *User) Save() { _ = u.Name }
|
||||
|
||||
func main() {
|
||||
f := func() int { return 1 }
|
||||
_ = f()
|
||||
}
|
||||
`;
|
||||
const matches = emitGoScopeCaptures(src, 'main.go');
|
||||
// The closure is still captured as a @scope.function...
|
||||
expect(matches.some((m) => m['@scope.function']?.text.startsWith('func()'))).toBe(true);
|
||||
// ...and the method's receiver self-binding is synthesized (name + pointer-stripped type)...
|
||||
const selves = matches.filter((m) => m['@type-binding.self'] !== undefined);
|
||||
expect(selves).toHaveLength(1); // exactly one — from the method, not the closure
|
||||
expect(selves[0]!['@type-binding.name']?.text).toBe('u');
|
||||
expect(selves[0]!['@type-binding.type']?.text).toBe('User');
|
||||
});
|
||||
|
||||
it('does not drop a var-form type assertion binding', () => {
|
||||
// `var x int = e.(T)` anchors on a var_declaration, not a short_var_declaration,
|
||||
// so isRawMultiAssignTypeBinding must NOT filter it (old findNodeAtRange path
|
||||
// returned null -> false; the new anchor.type guard reproduces that).
|
||||
const src = `
|
||||
package main
|
||||
|
||||
func main() {
|
||||
var x int = any(1).(int)
|
||||
_ = x
|
||||
}
|
||||
`;
|
||||
const assertion = emitGoScopeCaptures(src, 'main.go').find(
|
||||
(m) => m['@type-binding.assertion'] !== undefined,
|
||||
);
|
||||
expect(assertion).toBeDefined();
|
||||
expect(assertion!['@type-binding.name']?.text).toBe('x');
|
||||
});
|
||||
|
||||
it('does not drop a var-form call-return binding', () => {
|
||||
const src = `
|
||||
package main
|
||||
|
||||
func NewThing() int { return 1 }
|
||||
|
||||
func main() {
|
||||
var y = NewThing()
|
||||
_ = y
|
||||
}
|
||||
`;
|
||||
const callReturn = emitGoScopeCaptures(src, 'main.go').find(
|
||||
(m) => m['@type-binding.call-return'] !== undefined,
|
||||
);
|
||||
expect(callReturn).toBeDefined();
|
||||
expect(callReturn!['@type-binding.name']?.text).toBe('y');
|
||||
});
|
||||
|
||||
it('resolves a single unparenthesized import the same as a grouped one', () => {
|
||||
// Exercises resolveImportNode's no-import_spec_list parent chain. NOTE: not
|
||||
// redundant with go-imports.test.ts, which calls splitGoImportStatement
|
||||
// directly and bypasses captures.ts / resolveImportNode.
|
||||
const single = emitGoScopeCaptures(
|
||||
`
|
||||
package main
|
||||
|
||||
import "fmt"
|
||||
|
||||
func main() { fmt.Println() }
|
||||
`,
|
||||
'main.go',
|
||||
);
|
||||
const sources = single
|
||||
.filter((m) => m['@import.source'] !== undefined)
|
||||
.map((m) => m['@import.source']!.text);
|
||||
expect(sources).toEqual(['fmt']);
|
||||
});
|
||||
|
||||
it('captures a generic function declaration', () => {
|
||||
const src = `
|
||||
package main
|
||||
|
||||
func Map[T any](x T) T { return x }
|
||||
`;
|
||||
const decl = emitGoScopeCaptures(src, 'main.go').find(
|
||||
(m) => m['@declaration.function'] !== undefined,
|
||||
);
|
||||
expect(decl).toBeDefined();
|
||||
expect(decl!['@declaration.name']?.text).toBe('Map');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<string, FixtureSnapshot>;
|
||||
|
||||
/**
|
||||
* Canonicalize ONE match. A CaptureMatch is a Record<tag, Capture> (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 = '<?php\n\n';
|
||||
for (let i = 0; i < entityCount; i++) {
|
||||
src +=
|
||||
`class Entity${i} {\n` +
|
||||
` public $id;\n public $name;\n` +
|
||||
` function getId() { return $this->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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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<string, FixtureSnapshot>;
|
||||
|
||||
/**
|
||||
* Canonicalize ONE match. A CaptureMatch is a Record<tag, Capture> (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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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 `<lastSeg>.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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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<string, FixtureSnapshot>;
|
||||
|
||||
/**
|
||||
* Canonicalize ONE match. A CaptureMatch is a Record<tag, Capture> (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']);
|
||||
});
|
||||
});
|
||||
|
|
@ -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<string, FixtureSnapshot>;
|
||||
|
||||
/**
|
||||
* Canonicalize ONE match. A CaptureMatch is a Record<tag, Capture> (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);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue