feat(go): index gin/echo routes and report them in impact (#3402) (#3417)

This commit is contained in:
Gergő Magyar 2026-10-01 10:22:45 +01:00 • committed by GitHub
parent acb65b95b6
commit e42122a0f6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
50 changed files with 3373 additions and 30 deletions

View file

@ -303,6 +303,8 @@ type ParsedImportSyntax =
*/
| {
readonly kind: 'namespace';
/** The source omitted a local alias; the imported module declares its binding name. */
readonly implicitLocalName?: boolean;
/** Scope-visible handle (e.g. `np` in `import numpy as np`; `numpy` when unaliased). */
readonly localName: string;
/** Module being aliased (e.g. `numpy` in `import numpy as np`). */

View file

@ -0,0 +1,294 @@
{
"_what": "Go route extraction, package-scoped symbolic resolution and import binding (#3417). Run from gitnexus: node --import tsx bench/go-route-resolution/measure.cjs --check",
"_method": "5 warmups, minimum of 15 samples per scale; each workload grows 4x. Parsing/model setup and identity checks are untimed. A fresh resolution context and its lazy indexes are timed in every sample. Ratio-only gates: linear_factor = (large_ms / small_ms) / 4; linear work is about 1, quadratic work about 4.",
"_arms": {
"spread": "One typed receiver and route per function.",
"concentrated": "One receiver declaration with growing same-type writes and registrations in one function.",
"lexical": "Captured writes, callable parameters, inner router shadowing and outer package handlers; exact identities include declines.",
"method_bucket": "Repeated type/function/method names across growing packages; type hits, absent methods, bare functions and constructors. Methods use sibling-file worker owner IDs.",
"import_bindings": "Implicit package clauses across eight files and explicit aliases; exact callback counts guard unnecessary target/source reads."
},
"_triage": "Verify identities and counts before considering a timing failure. Re-run without competing CPU load. Budgets retain headroom above five observed runs while rejecting the pre-optimization quadratic paths. Do not update identities or raise budgets to hide missing symbols or repeated scans. The pre-optimization implementation was also run with --check: identities passed and the concentrated/method_bucket scaling gates failed. Isolated mutations rejected null/false/0/empty-string/array baselines, missing fingerprint/count/budget fields, and an extractor returning no routes.",
"arms": {
"spread": {
"linear_scaling_budget": 1.8,
"small": {
"units": 96,
"routes": 96,
"named_handlers": 96,
"receiver_hints": 96,
"fingerprint": "9caeb2c583ef673e17d87e8f374c8dace551edcb8b9dd992b22da10d1ba93c8d"
},
"large": {
"units": 384,
"routes": 384,
"named_handlers": 384,
"receiver_hints": 384,
"fingerprint": "8cc6e9756f0603f1957eb397f8cf3027ab99fdec8b123a5e4ed1261149660469"
}
},
"concentrated": {
"linear_scaling_budget": 2.2,
"small": {
"units": 96,
"routes": 96,
"named_handlers": 96,
"receiver_hints": 96,
"fingerprint": "a479f138f41b6e01680d424412e44974feacde8b2981c6acf675efcd1ff245c7"
},
"large": {
"units": 384,
"routes": 384,
"named_handlers": 384,
"receiver_hints": 384,
"fingerprint": "655d462758f16beaa3ac12d5fcc64d714e431def943c3acf875473bb1576be37"
}
},
"lexical": {
"linear_scaling_budget": 1.8,
"small": {
"units": 96,
"routes": 288,
"named_handlers": 192,
"receiver_hints": 0,
"fingerprint": "ac23e05109a57a9eb4d8be1fa271af2bb76b0781f60701610d94e17a53b9828b"
},
"large": {
"units": 384,
"routes": 1152,
"named_handlers": 768,
"receiver_hints": 0,
"fingerprint": "3f5d49b2a00eecaa4244fa32203a9010a152ceb2f21c730c3b4cb086f4c38a89"
}
},
"method_bucket": {
"linear_scaling_budget": 1.8,
"small": {
"units": 256,
"sites": 1024,
"resolved": 768,
"declined": 256,
"fingerprint": "4b7f06062ed85991ade1412555ee3c23b3cd74aa83ab89e6218a61f94e6996c6"
},
"large": {
"units": 1024,
"sites": 4096,
"resolved": 3072,
"declined": 1024,
"fingerprint": "777eb3833387d15d36c029bbdbc0f3f011dfaa89377d088322d43986e2f2cace"
}
},
"import_bindings": {
"linear_scaling_budget": 1.8,
"small": {
"units": 512,
"imports": 1024,
"target_lookups": 512,
"source_reads": 4096,
"fingerprint": "aa4f723bda1bdd450cf5254f4e32bb819bac83d19c4bfa9efb546d9afa69d0c6"
},
"large": {
"units": 2048,
"imports": 4096,
"target_lookups": 2048,
"source_reads": 16384,
"fingerprint": "4da12f2bcf51eeb649feb1dae0a35259d196ff28011fd6bd399244601e321bdc"
}
}
},
"_measured": {
"date_utc": "2026-09-30",
"before_commit": "39c1c0fae318693bcce5254548a361a76f0da6b7",
"after_implementation": "The receiver memoization and per-pass package indexes in this change.",
"environment": {
"node": "v24.19.0",
"platform": "Linux x86_64",
"cpu": "AMD EPYC 9V74 80-Core Processor"
},
"comparison": "Same harness and correctness identities, serial runs on one machine; before is one run and after is five runs. Milliseconds are observations, not gates. Speedups use the median after large minimum.",
"results": {
"spread": {
"before": {
"small_min_ms": 21.189,
"large_min_ms": 95.47,
"linear_factor": 1.126
},
"after_runs": [
{
"small_min_ms": 18.894,
"large_min_ms": 83.253,
"linear_factor": 1.102
},
{
"small_min_ms": 19.741,
"large_min_ms": 80.441,
"linear_factor": 1.019
},
{
"small_min_ms": 19.006,
"large_min_ms": 81.562,
"linear_factor": 1.073
},
{
"small_min_ms": 19.699,
"large_min_ms": 88.138,
"linear_factor": 1.119
},
{
"small_min_ms": 20.602,
"large_min_ms": 86.424,
"linear_factor": 1.049
}
],
"large_speedup_vs_before": 1.15
},
"concentrated": {
"before": {
"small_min_ms": 173.134,
"large_min_ms": 2800.987,
"linear_factor": 4.045
},
"after_runs": [
{
"small_min_ms": 10.03,
"large_min_ms": 48.801,
"linear_factor": 1.216
},
{
"small_min_ms": 10.113,
"large_min_ms": 56.996,
"linear_factor": 1.409
},
{
"small_min_ms": 10.135,
"large_min_ms": 43.464,
"linear_factor": 1.072
},
{
"small_min_ms": 10.613,
"large_min_ms": 60.817,
"linear_factor": 1.433
},
{
"small_min_ms": 10.574,
"large_min_ms": 51.15,
"linear_factor": 1.209
}
],
"large_speedup_vs_before": 54.76
},
"lexical": {
"before": {
"small_min_ms": 47.881,
"large_min_ms": 228.503,
"linear_factor": 1.193
},
"after_runs": [
{
"small_min_ms": 52.334,
"large_min_ms": 224.091,
"linear_factor": 1.07
},
{
"small_min_ms": 53.196,
"large_min_ms": 226.357,
"linear_factor": 1.064
},
{
"small_min_ms": 51.727,
"large_min_ms": 235.301,
"linear_factor": 1.137
},
{
"small_min_ms": 58.227,
"large_min_ms": 232.055,
"linear_factor": 0.996
},
{
"small_min_ms": 53.33,
"large_min_ms": 233.07,
"linear_factor": 1.093
}
],
"large_speedup_vs_before": 0.98
},
"method_bucket": {
"before": {
"small_min_ms": 39.84,
"large_min_ms": 607.851,
"linear_factor": 3.814
},
"after_runs": [
{
"small_min_ms": 1.298,
"large_min_ms": 4.987,
"linear_factor": 0.961
},
{
"small_min_ms": 1.225,
"large_min_ms": 4.554,
"linear_factor": 0.929
},
{
"small_min_ms": 1.229,
"large_min_ms": 4.969,
"linear_factor": 1.011
},
{
"small_min_ms": 1.189,
"large_min_ms": 5.675,
"linear_factor": 1.194
},
{
"small_min_ms": 1.28,
"large_min_ms": 4.819,
"linear_factor": 0.941
}
],
"large_speedup_vs_before": 122.32
},
"import_bindings": {
"before": {
"small_min_ms": 0.789,
"large_min_ms": 3.257,
"linear_factor": 1.032
},
"after_runs": [
{
"small_min_ms": 0.763,
"large_min_ms": 2.983,
"linear_factor": 0.978
},
{
"small_min_ms": 0.774,
"large_min_ms": 3.249,
"linear_factor": 1.049
},
{
"small_min_ms": 0.785,
"large_min_ms": 3.31,
"linear_factor": 1.054
},
{
"small_min_ms": 0.811,
"large_min_ms": 3.182,
"linear_factor": 0.981
},
{
"small_min_ms": 0.757,
"large_min_ms": 3.514,
"linear_factor": 1.161
}
],
"large_speedup_vs_before": 1.0
}
},
"before_checkout_commit": "dbcf2921f7b46f1fdebf6ca231ebde3bd231d79f",
"before_tree": "8602ce55a5ecf170f7463691b74aadf657ca89ac"
},
"_verified": {
"performance_negative_control": "The same harness on the pre-optimization checkout passed every identity/count check and failed concentrated and method_bucket scaling gates.",
"empty_output_negative_control": "An isolated faithful current-tree snapshot with extraction returning [] failed route counts and fingerprints in all three extraction arms.",
"malformed_baselines": "null, false, 0, empty string, array, missing fingerprint, missing route count, missing units, missing budget and missing arm all failed before timing."
}
}

View file

@ -0,0 +1,322 @@
#!/usr/bin/env node
/**
* Build-free Go route extraction and symbolic-resolution benchmark (#3417).
*
* node --import tsx bench/go-route-resolution/measure.cjs
* node --import tsx bench/go-route-resolution/measure.cjs --check
*
* Parsing, model construction, fingerprints and counts are outside timing.
* Resolution creates a fresh pass context inside each timed sample, including
* lazy index construction rather than measuring only warmed lookups.
* Each arm grows 4x. Only normalized scaling ratios are timing gates; exact
* route/target identities and positive counts prevent fast empty answers.
* BENCH_SOURCE_ROOT selects another repository checkout for comparison.
*/
const assert = require('node:assert/strict');
const { existsSync, readFileSync } = require('node:fs');
const { resolve, join } = require('node:path');
const { pathToFileURL } = require('node:url');
const ROOT = resolve(__dirname, '../..');
const SOURCE_ROOT = process.env.BENCH_SOURCE_ROOT
? resolve(process.env.BENCH_SOURCE_ROOT, 'gitnexus')
: ROOT;
const BASELINE = join(__dirname, 'baseline.json');
const WARMUP = 5;
const REPS = 15;
const SCALES = { extraction: [96, 384], resolution: [256, 1024], imports: [512, 2048] };
const FILE = 'app/routes.go';
const ARM_FIELDS = {
spread: ['routes', 'named_handlers', 'receiver_hints'],
concentrated: ['routes', 'named_handlers', 'receiver_hints'],
lexical: ['routes', 'named_handlers', 'receiver_hints'],
method_bucket: ['sites', 'resolved', 'declined'],
import_bindings: ['imports', 'target_lookups', 'source_reads'],
};
const load = (file) => import(pathToFileURL(join(SOURCE_ROOT, 'src', file)).href);
function source(shape, count) {
const body = ['package app', 'import "github.com/gin-gonic/gin"'];
if (shape === 'concentrated') {
body.push('func Register(r *gin.Engine) {', 'h := &Handler{}');
for (let i = 0; i < count; i++) {
body.push('h = &Handler{}', `r.GET("/route-${i}", h.Do)`);
}
body.push('}');
} else {
for (let i = 0; i < count; i++) {
if (shape === 'spread') {
body.push(
`func Register${i}(r *gin.Engine) {`,
'h := &Handler{}',
`r.GET("/route-${i}", h.Do)`,
'}',
);
} else {
body.push(
`func Register${i}(r *gin.Engine, Handle func(*gin.Context)) {`,
'h := &A{}',
'func() { h = &B{} }()',
`r.GET("/capture-${i}", h.Do)`,
`r.GET("/bare-${i}", Handle)`,
`{ var r FakeRouter; r.GET("/fake-${i}", PackageHandler) }`,
`r.GET("/outer-${i}", PackageHandler)`,
'}',
);
}
}
}
return body.join('\n');
}
function routeIdentity(route) {
return JSON.stringify([
route.filePath,
route.httpMethod,
route.routePath,
route.handlerName ?? null,
route.handlerReceiver ?? null,
route.lineNumber,
]);
}
function pair(small, large) {
return { small, large, linear_factor: large.min_ms / small.min_ms / (large.units / small.units) };
}
async function main() {
const checkRequested = process.argv.includes('--check');
const baseline = checkRequested ? JSON.parse(readFileSync(BASELINE, 'utf8')) : undefined;
if (checkRequested) {
assert.ok(
baseline !== null && typeof baseline === 'object' && !Array.isArray(baseline),
'baseline must be a non-null object',
);
assert.deepEqual(
Object.keys(baseline.arms).sort(),
Object.keys(ARM_FIELDS).sort(),
'baseline must retain exactly all five arms',
);
for (const [shape, fields] of Object.entries(ARM_FIELDS)) {
const arm = baseline.arms[shape];
assert.ok(
Number.isFinite(arm.linear_scaling_budget) && arm.linear_scaling_budget > 0,
`${shape}: missing or invalid scaling budget`,
);
for (const scale of ['small', 'large']) {
const expected = arm[scale];
assert.deepEqual(
Object.keys(expected).sort(),
['units', ...fields, 'fingerprint'].sort(),
`${shape}.${scale}: missing or unexpected correctness fields`,
);
assert.match(expected.fingerprint, /^[a-f0-9]{64}$/);
for (const key of ['units', ...fields]) {
assert.ok(
Number.isSafeInteger(expected[key]) && expected[key] >= 0,
`${shape}.${scale}.${key}: invalid count`,
);
}
assert.ok(expected.units > 0, `${shape}.${scale}: empty workload`);
}
}
}
const [
{ default: Parser },
{ default: Go },
{ extractGoGinEchoRoutes },
{ resolveGoRouteHandler },
{ createSemanticModel },
{ minSample, fingerprintIds },
] = await Promise.all([
import('tree-sitter'),
import('tree-sitter-go'),
load('core/ingestion/route-extractors/go-gin-echo.ts'),
load('core/ingestion/languages/go/route-handler.ts'),
load('core/ingestion/model/semantic-model.ts'),
import('../lib/identity-guard.mjs'),
]);
const parser = new Parser();
parser.setLanguage(Go);
function extraction(shape, units) {
const text = source(shape, units);
const tree = parser.parse(text, null, { bufferSize: Buffer.byteLength(text) + 1 });
assert.equal(tree.rootNode.hasError, false, `${shape}: invalid Go syntax`);
const { last, ms } = minSample(() => extractGoGinEchoRoutes(tree, FILE), WARMUP, REPS);
return {
units,
min_ms: ms,
routes: last.length,
named_handlers: last.filter((r) => r.handlerName !== undefined).length,
receiver_hints: last.filter((r) => r.handlerReceiver !== undefined).length,
fingerprint: fingerprintIds(last.map(routeIdentity)),
};
}
function resolution(units) {
const model = createSemanticModel();
const routes = [];
for (let i = 0; i < units; i++) {
const dir = `app/pkg${i}`;
for (const name of ['Handler', 'Empty']) {
const file = `${dir}/types.go`;
model.symbols.add(file, name, `Struct:${file}:${name}`, 'Struct');
routes.push({
filePath: `${dir}/routes.go`,
routePath: `/${i}/${name}`,
httpMethod: 'GET',
decoratorName: 'GET',
lineNumber: i + 1,
source: 'gin-route',
handlerName: 'h.Do',
handlerReceiver: { kind: 'type', name },
});
}
// Worker ownership uses the method's file, deliberately different from
// the struct's file. A canonical-owner-only lookup would lose every hit.
const file = `${dir}/methods.go`;
model.symbols.add(file, 'Do', `Method:${file}:Handler.Do`, 'Method', {
ownerId: `Struct:${file}:Handler`,
});
for (const name of ['Handle', 'NewHandler']) {
model.symbols.add(file, name, `Function:${file}:${name}`, 'Function', {
returnType: name === 'NewHandler' ? '*Handler' : undefined,
});
routes.push({
filePath: `${dir}/routes.go`,
routePath: `/${i}/${name}`,
httpMethod: 'GET',
decoratorName: 'GET',
lineNumber: i + 1,
source: 'gin-route',
handlerName: name === 'Handle' ? name : 'h.Do',
...(name === 'NewHandler' ? { handlerReceiver: { kind: 'constructor', name } } : {}),
});
}
}
const run = () => {
const context = { model, importTargetsFor: () => [] };
return routes.map((r) => resolveGoRouteHandler(r, context));
};
const { last, ms } = minSample(run, WARMUP, REPS);
const resolved = last.filter((id) => id !== undefined).length;
assert.equal(resolved, units * 3, 'symbol resolution lost its positive control');
return {
units,
min_ms: ms,
sites: routes.length,
resolved,
declined: routes.length - resolved,
fingerprint: fingerprintIds(
last.map((id, i) => `${routes[i].routePath} -> ${id ?? '<none>'}`),
),
};
}
function imports(resolveGoImportBinding, units) {
const texts = new Map();
const inputs = [];
for (let i = 0; i < units; i++) {
const files = Array.from({ length: 8 }, (_, j) => `pkg${i}/file${j}.go`);
files.forEach((file) =>
texts.set(file, `/* package old is a comment */\npackage declared${i}\n`),
);
const targetRaw = `example.com/app/pkg${i}/v2`;
inputs.push({
parsed: {
kind: 'namespace',
targetRaw,
localName: `pkg${i}`,
importedName: `pkg${i}`,
implicitLocalName: true,
},
files,
});
inputs.push({
parsed: { kind: 'namespace', targetRaw, localName: `alias${i}`, importedName: `alias${i}` },
files,
});
}
let targetLookups = 0;
let sourceReads = 0;
const run = () => {
targetLookups = 0;
sourceReads = 0;
return inputs.map(({ parsed, files }) =>
resolveGoImportBinding(
parsed,
() => {
targetLookups++;
return files;
},
(file) => {
sourceReads++;
return texts.get(file);
},
),
);
};
const { last, ms } = minSample(run, WARMUP, REPS);
assert.equal(targetLookups, units, 'explicit aliases must not resolve targets again');
assert.equal(sourceReads, units * 8, 'implicit imports must read their package clauses');
return {
units,
min_ms: ms,
imports: last.length,
target_lookups: targetLookups,
source_reads: sourceReads,
fingerprint: fingerprintIds(last.map((item, i) => `${i}:${item.kind}:${item.localName}`)),
};
}
const report = { reps: REPS, warmup: WARMUP, source_root: SOURCE_ROOT };
for (const shape of ['spread', 'concentrated', 'lexical']) {
report[shape] = pair(...SCALES.extraction.map((n) => extraction(shape, n)));
}
report.method_bucket = pair(...SCALES.resolution.map(resolution));
const importPath = join(SOURCE_ROOT, 'src/core/ingestion/languages/go/import-binding.ts');
if (existsSync(importPath)) {
const { resolveGoImportBinding } = await import(pathToFileURL(importPath).href);
report.import_bindings = pair(...SCALES.imports.map((n) => imports(resolveGoImportBinding, n)));
}
if (checkRequested) {
const errors = [];
for (const [shape, expected] of Object.entries(baseline.arms)) {
const actual = report[shape];
if (!actual) {
errors.push(`${shape}: missing arm`);
continue;
}
for (const scale of ['small', 'large']) {
for (const [key, value] of Object.entries(expected[scale])) {
if (actual[scale][key] !== value)
errors.push(`${shape}.${scale}.${key}: ${actual[scale][key]} != ${value}`);
}
if (!Number.isFinite(actual[scale].min_ms) || actual[scale].min_ms <= 0) {
errors.push(`${shape}.${scale}: invalid timing`);
}
}
const budget = expected.linear_scaling_budget;
if (
!Number.isFinite(budget) ||
budget <= 0 ||
!Number.isFinite(actual.linear_factor) ||
actual.linear_factor > budget
) {
errors.push(`${shape}.linear_factor: ${actual.linear_factor} exceeds ${budget}`);
}
}
console.log(JSON.stringify({ ok: errors.length === 0, report, errors }, null, 2));
if (errors.length) process.exitCode = 1;
} else {
console.log(JSON.stringify(report, null, 2));
}
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});

View file

@ -1,7 +1,8 @@
{
"_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. The synthetic source is now HERITAGE-BEARING for every language (each Entity extends/implements/embeds/uses-trait/conforms-to a shared base) so the #1951 @reference.inherits synth is gated at scale, not just the base capture loop. All languages 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 #1951, java #1956).",
"go": {
"fingerprint": "2e3099db962f82f9d8641707a8b2bcf6875dafec1f54904f2dec7ac65ba22e9f",
"fingerprint": "fdc3a691e2ebbc5b0d6fe1da328864293587a75e296e106c705c7e31ee622a5b",
"_rebaselined_3417_versioned_import_name": "#3417: splitGoImportStatement now uses the conventional package qualifier before a /vN module suffix. Compared the pre-fix 2539b08 and HEAD emitters over the SAME HEAD corpus: exactly two @import.name texts changed from v2 to bar for github.com/foo/bar/v2, in go-extern-qualified-signatures/memory/memory.go and store/store.go; every other capture tag, text, range and match order is unchanged. The capture golden already records both changes. fixture_count 122 and capture_groups_fp 2579 are unchanged, as are synthetic counts 5508/17608; scaling 0.977 < the unchanged 1.5 budget. Prior 2e3099db962f82f9d8641707a8b2bcf6875dafec1f54904f2dec7ac65ba22e9f -> fdc3a691e2ebbc5b0d6fe1da328864293587a75e296e106c705c7e31ee622a5b.",
"_rebaselined_3190_ctor_qualified_name": "#3190: generic composite-literal constructors now carry @reference.qualified-name with the written pkg.Box[T] spelling so constructor fallback can tell a qualifier from a bare unique-name guess. DIGEST DRIFT ONLY: the tag is added to existing constructor matches (captureGroups unchanged on go-constructor-type-inference/cmd/main.go). Prior 990e4921a7ef0e6aa4d7da92b671de91fa2621cb27918adb27742740dc6ad902 -> 2e3099db962f82f9d8641707a8b2bcf6875dafec1f54904f2dec7ac65ba22e9f; capture_groups_fp 2579, fixture_count 122.",
"_rebaselined_3190": "Corrected go-method-enrichment/app.go to share package animal with its declarations; its capture count is now 16 rather than 15. No Go emitter change. Scaling budget unchanged.",
"scaling_budget": 1.5,

View file

@ -384,7 +384,7 @@ export const en = {
'help.option.impact.depth': 'Max relationship depth (default: 3)',
'help.option.impact.includeTests': 'Include test files in results',
'help.option.impact.limit':
'Max symbols per depth level and affected processes/modules to return (default: 100)',
'Max symbols per depth level (default: 100); explicit --limit also caps affected processes/modules/routes',
'help.option.impact.offset': 'Skip N symbols per depth level for pagination',
'help.option.impact.summaryOnly': 'Return counts and risk only, omit symbol list',
'help.option.trace.fromUid': 'Source symbol UID (zero-ambiguity lookup)',

View file

@ -348,7 +348,8 @@ export const zhCN = {
'help.option.impact.direction': 'upstream(依赖它的项)或 downstream(它依赖的项)',
'help.option.impact.depth': '最大关系遍历深度(默认:3)',
'help.option.impact.includeTests': '在结果中包含测试文件',
'help.option.impact.limit': '每层深度最大符号数及最多返回的受影响流程/模块数(默认:100)',
'help.option.impact.limit':
'每层深度最大符号数(默认:100);显式指定 --limit 时,也限制受影响流程/模块/路由数',
'help.option.impact.offset': '每层深度跳过 N 个符号(分页用)',
'help.option.impact.summaryOnly': '仅返回计数和风险等级,省略符号列表',
'help.option.trace.fromUid': '源符号 UID(零歧义查找)',

View file

@ -514,7 +514,7 @@ program
.option('--include-tests', 'Include test files in results')
.option(
'-l, --limit <n>',
'Max symbols per depth level and affected processes/modules to return (default: 100)',
'Max symbols per depth level (default: 100); explicit --limit also caps affected processes/modules/routes',
)
.option('--offset <n>', 'Skip N symbols per depth level for pagination')
.option('--summary-only', 'Return counts and risk only, omit symbol list')

View file

@ -282,6 +282,8 @@ export async function impactCommand(
result.affected_processes = result.affected_processes.slice(0, parsedLimit);
if (Array.isArray(result.affected_modules))
result.affected_modules = result.affected_modules.slice(0, parsedLimit);
if (Array.isArray(result.affected_routes))
result.affected_routes = result.affected_routes.slice(0, parsedLimit);
}
output(result);
} catch (err: unknown) {

View file

@ -22,6 +22,7 @@ import type { ParsedImport, SymbolDefinition } from 'gitnexus-shared';
import { yieldToEventLoop } from './utils/event-loop.js';
import type { ExtractedRoute, ExtractedFetchCall } from './workers/parse-worker.js';
import type { ExtractedDecoratorRoute } from './workers/parse-worker.js';
import type { LanguageProvider } from './language-provider.js';
import { normalizeFetchURL, routeMatches } from './route-extractors/nextjs.js';
import {
normalizeExtractedRoutePath,
@ -48,6 +49,13 @@ interface RouteResolutionFile {
interface RouteHandlerResolutionContext {
readonly files: readonly RouteResolutionFile[];
readonly resolveImportTarget: (parsedImport: ParsedImport, fromFile: string) => string | null;
/** Every file an import resolves to (an import may name a whole directory of files). */
readonly resolveImportTargets?: (
parsedImport: ParsedImport,
fromFile: string,
) => readonly string[];
/** The route file's `LanguageProvider.resolveRouteHandler`, when it defines one. */
readonly providerRouteHandler?: (filePath: string) => LanguageProvider['resolveRouteHandler'];
readonly isExportedSymbol: (nodeId: string) => boolean;
/** 0-based graph-node startLine for same-name tRPC handler disambiguation. */
readonly nodeStartLine?: (nodeId: string) => number | undefined;
@ -534,17 +542,29 @@ export function resolveRouteHandlerSymbols(
dataHandlersByIdentity.set(key, state);
}
// A language that resolves its own handlers sees only this seam: the
// model, and the workspace files an import local name resolves to.
const importTargetsFor = (fromFile: string, localName: string): readonly string[] => {
const parsedImport = uniqueImport(fromFile, localName);
if (parsedImport === undefined || routeContext?.resolveImportTargets === undefined) return [];
return routeContext.resolveImportTargets(parsedImport, fromFile);
};
const providerContext = { model, importTargetsFor };
const decoratorHandlerId = (dr: ExtractedDecoratorRoute): string | undefined => {
if (dr.source === DATA_ROUTE_TABLE_SOURCE) return dataHandlerByRoute.get(dr);
const providerHandler = routeContext?.providerRouteHandler?.(dr.filePath);
if (providerHandler) return providerHandler(dr, providerContext);
return dr.handlerName ? uniqueSymbolId(dr.filePath, dr.handlerName) : undefined;
};
// Decorator routes (Spring / FastAPI / generic) — the decorated handler in
// the route's own file. Data tables additionally suppress an identity when
// duplicate entries resolve to different handlers: recording either one
// would invent a single-winner dispatch that the loop does not prove.
// the route's own file, unless the route's language resolves its own
// handlers. Data tables additionally suppress an identity when duplicate
// entries resolve to different handlers: recording either one would invent a
// single-winner dispatch that the loop does not prove.
for (const dr of decoratorRoutes) {
const handlerId =
dr.source === DATA_ROUTE_TABLE_SOURCE
? dataHandlerByRoute.get(dr)
: dr.handlerName
? uniqueSymbolId(dr.filePath, dr.handlerName)
: undefined;
const handlerId = decoratorHandlerId(dr);
// An unproven data-table entry never becomes a Route node, so it must not
// reserve the identity and suppress a later, valid framework declaration.
if (dr.source === DATA_ROUTE_TABLE_SOURCE && handlerId === undefined) continue;

View file

@ -47,6 +47,19 @@ import type {
} from './route-extractors/constant-resolver.js';
import type Parser from 'tree-sitter';
import type { ExtractedDecoratorRoute } from './workers/parse-worker.js';
import type { SemanticModel } from './model/semantic-model.js';
/** What a provider's {@link LanguageProviderConfig.resolveRouteHandler} can see. */
export interface RouteHandlerResolutionHookContext {
/** The model after every file has been parsed; scope resolution has not run. */
readonly model: SemanticModel;
/**
* Every workspace file the import bound to `localName` in `fromFile` resolves
* to. `[]` when the name is not a unique import binding or the import does
* not resolve inside the workspace (stdlib, third-party).
*/
readonly importTargetsFor: (fromFile: string, localName: string) => readonly string[];
}
import type { SpringNonHttpHandlerFact } from './frameworks/spring/non-http-handlers.js';
import type { SpringMessageProducerFact } from './frameworks/spring/message-producers.js';
@ -544,7 +557,8 @@ interface LanguageProviderConfig {
* Decorators are the common case and the reason for the name, but not the only
* shape: JS/TS uses this hook for hand-rolled dispatch guards
* (`route-extractors/dispatch-guard.ts`), where a raw `node:http` server
* declares a route by comparing the request path to a literal. Anything that
* declares a route by comparing the request path to a literal, and Go uses it
* for gin/echo verb calls (`route-extractors/go-gin-echo.ts`). Anything that
* yields a `(path, verb, handler)` triple from one file's AST belongs here —
* set `ExtractedDecoratorRoute.source` when the provenance is not a decorator,
* so the `HANDLES_ROUTE` edge does not claim one.
@ -579,6 +593,24 @@ interface LanguageProviderConfig {
*/
readonly decoratorRouteHandlerName?: (decoratorNode: SyntaxNode) => string | undefined;
/**
* Resolve a route's `handlerName` to a symbol node id, for routes this
* language extracted. When defined, the routes phase asks this hook instead of
* looking the name up in the route's own file — for languages whose handlers
* routinely live in other files of the same package or module, and whose
* visibility rules only the language knows.
*
* Return a node id only when exactly one definition matches; `undefined`
* leaves the route without `handlerSymbolId` (fail-open, never a wrong
* handler).
*
* Default: undefined (same-file lookup by name).
*/
readonly resolveRouteHandler?: (
route: ExtractedDecoratorRoute,
context: RouteHandlerResolutionHookContext,
) => string | undefined;
/**
* Collect a project-wide, language-agnostic view of route-defining
* class/interface declarations (`SharedSpringType`) from a parsed file.

View file

@ -26,6 +26,8 @@ import { createMethodExtractor } from '../method-extractors/generic.js';
import { goMethodConfig } from '../method-extractors/configs/go.js';
import { createVariableExtractor } from '../variable-extractors/generic.js';
import { goVariableConfig } from '../variable-extractors/configs/go.js';
import { extractGoGinEchoRoutes } from '../route-extractors/go-gin-echo.js';
import { resolveGoRouteHandler } from './go/route-handler.js';
import { createCallExtractor } from '../call-extractors/generic.js';
import { goCallConfig } from '../call-extractors/configs/go.js';
import {
@ -155,6 +157,9 @@ export const goProvider = defineLanguage({
wrapperNodeTypes: ['type_declaration'],
}),
builtInNames: GO_BUILT_INS,
// gin / echo verb registrations with in-function Group prefixes (#3402).
extractDecoratorRoutes: extractGoGinEchoRoutes,
resolveRouteHandler: resolveGoRouteHandler,
// ── RFC #909 Ring 3: scope-based resolution hooks ──────────
emitScopeCaptures: emitGoScopeCaptures,

View file

@ -0,0 +1,21 @@
import type { ParsedImport } from 'gitnexus-shared';
import { inferGoPackageName } from './package-clause.js';
/** An omitted alias binds the imported package clause, never its path spelling. */
export function resolveGoImportBinding(
parsedImport: ParsedImport,
resolveTargetFiles: () => readonly string[],
sourceTextFor: (filePath: string) => string | undefined,
): ParsedImport {
if (parsedImport.kind !== 'namespace' || !parsedImport.implicitLocalName) return parsedImport;
const targetFiles = resolveTargetFiles();
if (targetFiles.length === 0) return parsedImport;
const names = new Set(targetFiles.map((file) => inferGoPackageName(sourceTextFor(file) ?? '')));
const name = names.size === 1 ? names.values().next().value : undefined;
if (!name) {
// Keep the dependency without inventing a scope-visible package qualifier.
return { kind: 'side-effect', targetRaw: parsedImport.targetRaw };
}
return { ...parsedImport, localName: name, importedName: name };
}

View file

@ -1,4 +1,5 @@
import type { CaptureMatch } from 'gitnexus-shared';
import { goImportPackageName } from './import-package-name.js';
import { syntheticCapture } from '../../utils/ast-helpers.js';
import type { SyntaxNode } from '../../utils/ast-helpers.js';
@ -25,7 +26,7 @@ export function splitGoImportStatement(node: SyntaxNode): CaptureMatch[] {
const rawPath = pathNode.text.replace(/^"|"$/g, '').replace(/^`|`$/g, '');
const nameNode = node.childForFieldName('name');
const alias = nameNode?.text;
const leaf = rawPath.split('/').filter(Boolean).pop() ?? rawPath;
const leaf = goImportPackageName(rawPath);
const kind =
alias === '.' ? 'dot' : alias === '_' ? 'blank' : alias === undefined ? 'namespace' : 'alias';

View file

@ -0,0 +1,7 @@
/** Conventional package qualifier; semantic-version suffixes name modules, not packages. */
export function goImportPackageName(importPath: string): string {
const segments = importPath.split('/').filter(Boolean);
const leaf = segments.at(-1) ?? importPath;
const name = /^v\d+$/.test(leaf) && segments.length > 1 ? segments[segments.length - 2] : leaf;
return name.replace(/\.v\d+$/, '');
}

View file

@ -14,7 +14,13 @@ export function interpretGoImport(captures: CaptureMatch): ParsedImport | null {
}
if (kind === 'namespace') {
if (name === undefined) return null;
return { kind: 'namespace', localName: name, importedName: name, targetRaw: source };
return {
kind: 'namespace',
localName: name,
importedName: name,
targetRaw: source,
implicitLocalName: true,
};
}
return null;
}

View file

@ -0,0 +1,212 @@
import type { SyntaxNode } from 'tree-sitter';
const FUNCTIONS = new Set(['function_declaration', 'method_declaration', 'func_literal']);
const SCOPES = new Set([
'block',
'if_statement',
'for_statement',
'expression_switch_statement',
'type_switch_statement',
'select_statement',
'expression_case',
'type_case',
'communication_case',
'default_case',
]);
const BINDING_NODES = new Set([
'short_var_declaration',
'var_spec',
'const_spec',
'assignment_statement',
'range_clause',
'receive_statement',
'type_switch_statement',
'type_spec',
'type_alias',
]);
/** A declaration, shared by every use and assignment that resolves to it. */
export interface GoRouteBinding {
readonly visibleFrom: number;
readonly ownerFunction: SyntaxNode;
readonly type: SyntaxNode | null;
readonly isInputParameter: boolean;
readonly values: (SyntaxNode | null)[];
capturedWrite: boolean;
}
function enclosingGoFunction(node: SyntaxNode): SyntaxNode | undefined {
for (let current: SyntaxNode | null = node; current; current = current.parent) {
if (FUNCTIONS.has(current.type)) return current;
}
return undefined;
}
function lexicalScope(node: SyntaxNode): SyntaxNode | undefined {
for (let current = node.parent; current; current = current.parent) {
if (SCOPES.has(current.type)) return current;
if (FUNCTIONS.has(current.type)) return current.childForFieldName('body') ?? undefined;
}
return undefined;
}
/**
* Route-local binding evidence. Declaration identity and visibility follow Go
* blocks; value inference deliberately remains conservative across all writes.
* Nested functions are visited so writes to captured locals cannot leave stale
* proofs. Their own declarations remain separate from the captured bindings.
*/
export class GoRouteBindings {
private readonly scopes = new Map<number, Map<string, GoRouteBinding>>();
constructor(root: SyntaxNode) {
const assignments: { name: SyntaxNode; value: SyntaxNode | null }[] = [];
const stack = [root];
while (stack.length > 0) {
const node = stack.pop();
if (!node) break;
if (FUNCTIONS.has(node.type)) this.parameters(node);
this.collectNode(node, assignments);
for (let i = node.namedChildCount - 1; i >= 0; i--) {
const child = node.namedChild(i);
if (child) stack.push(child);
}
}
// Resolve writes after collecting declarations, but at each write's source
// position. A short declaration's LHS can refer to its newly declared name;
// its initializer still sees the previous scope through ordinary lookup.
for (const { name, value } of assignments) {
const parent = name.parent?.type === 'expression_list' ? name.parent.parent : name.parent;
const declaration = parent?.type === 'short_var_declaration';
const scope = declaration && parent ? lexicalScope(parent) : undefined;
const binding = scope ? this.scopes.get(scope.id)?.get(name.text) : this.lookup(name);
if (!binding) continue;
binding.values.push(value);
if (enclosingGoFunction(name)?.id !== binding.ownerFunction.id) binding.capturedWrite = true;
}
}
private collectNode(
node: SyntaxNode,
assignments: { name: SyntaxNode; value: SyntaxNode | null }[],
): void {
if (!BINDING_NODES.has(node.type)) return;
const scope = lexicalScope(node);
if (!scope) return;
if (node.type === 'short_var_declaration') {
const names = node.childForFieldName('left')?.namedChildren ?? [];
const values = node.childForFieldName('right')?.namedChildren ?? [];
names.forEach((name, index) => {
if (name.type !== 'identifier' || name.text === '_') return;
const existing = this.scopes.get(scope.id)?.get(name.text);
if (!existing) this.declare(name, scope, node.endIndex);
assignments.push({ name, value: this.pairedValue(values, names.length, index) });
});
} else if (node.type === 'var_spec' || node.type === 'const_spec') {
const names = node.childrenForFieldName('name');
const values = node.childForFieldName('value')?.namedChildren ?? [];
for (const [index, name] of names.entries()) {
const binding = this.declare(name, scope, node.endIndex, node.childForFieldName('type'));
if (binding && values.length > 0) {
binding.values.push(this.pairedValue(values, names.length, index));
}
}
} else if (node.type === 'assignment_statement') {
const names = node.childForFieldName('left')?.namedChildren ?? [];
const values = node.childForFieldName('right')?.namedChildren ?? [];
names.forEach((name, index) => {
if (name.type === 'identifier') {
assignments.push({ name, value: this.pairedValue(values, names.length, index) });
}
});
} else if (node.type === 'range_clause' || node.type === 'receive_statement') {
const declares = node.children.some((child) => child.type === ':=');
for (const name of node.childForFieldName('left')?.namedChildren ?? []) {
if (name.type !== 'identifier') continue;
if (declares) this.declare(name, scope, node.endIndex)?.values.push(null);
else assignments.push({ name, value: null });
}
} else if (node.type === 'type_switch_statement') {
for (const name of node.childForFieldName('alias')?.namedChildren ?? []) {
this.declare(name, node, name.endIndex)?.values.push(null);
}
} else if (node.type === 'type_spec' || node.type === 'type_alias') {
const name = node.childForFieldName('name');
if (name) this.declare(name, scope, name.endIndex);
}
}
lookup(node: SyntaxNode): GoRouteBinding | undefined {
for (let current: SyntaxNode | null = node; current; current = current.parent) {
const binding = this.scopes.get(current.id)?.get(node.text);
if (binding && binding.visibleFrom <= node.startIndex) return binding;
}
return undefined;
}
private declare(
name: SyntaxNode,
scope: SyntaxNode,
visibleFrom: number,
type: SyntaxNode | null = null,
isInputParameter = false,
): GoRouteBinding | undefined {
if (name.text === '_') return undefined;
const ownerFunction = enclosingGoFunction(scope);
if (!ownerFunction) return undefined;
let names = this.scopes.get(scope.id);
if (!names) {
names = new Map();
this.scopes.set(scope.id, names);
}
const binding: GoRouteBinding = {
visibleFrom,
ownerFunction,
type,
isInputParameter,
values: [],
capturedWrite: false,
};
names.set(name.text, binding);
return binding;
}
private parameters(fn: SyntaxNode): void {
const body = fn.childForFieldName('body');
if (!body) return;
for (const parameter of fn.childForFieldName('type_parameters')?.namedChildren ?? []) {
for (const name of parameter.childrenForFieldName('name')) {
this.declare(name, fn, name.endIndex);
}
}
for (const field of ['receiver', 'parameters', 'result']) {
for (const parameter of fn.childForFieldName(field)?.namedChildren ?? []) {
if (
parameter.type !== 'parameter_declaration' &&
parameter.type !== 'variadic_parameter_declaration'
)
continue;
const fixed = parameter.type === 'parameter_declaration';
for (const name of parameter.childrenForFieldName('name')) {
this.declare(
name,
body,
body.startIndex,
fixed ? parameter.childForFieldName('type') : null,
fixed && field !== 'result',
);
}
}
}
}
private pairedValue(
values: readonly SyntaxNode[],
count: number,
index: number,
): SyntaxNode | null {
// A multi-result call proves only the first result's constructor hint.
return (values.length === count ? values[index] : index === 0 ? values[0] : null) ?? null;
}
}

View file

@ -0,0 +1,187 @@
import type { SymbolDefinition } from 'gitnexus-shared';
import { generateId } from '../../../../lib/utils.js';
import type { RouteHandlerResolutionHookContext } from '../../language-provider.js';
import type { SemanticModel } from '../../model/semantic-model.js';
import type { ExtractedDecoratorRoute } from '../../workers/parse-worker.js';
import { goPackageDir } from './package-clause.js';
/**
* Resolve a gin/echo handler designator (`Login`, `pkg.Login`, `h.Login`) to a
* symbol, looking across the files of a Go package — a package is a directory,
* so a handler registered in `router.go` is usually a method declared in a
* sibling file (#3402).
*
* Every step is unique-or-decline, and nothing falls back to a name-only match:
* with the router and the handlers in different packages, a same-named method
* in the router's own directory belongs to an unrelated type.
*
* Runs at the end of the parse phase, before scope resolution, so a Method's
* `ownerId` is still the worker's `Struct:<methodFile>:<Receiver>` id — keyed on
* the METHOD's file, which is not the struct's node id when the struct is
* declared in another file of the package. Ownership is therefore matched on
* either id.
*/
export function resolveGoRouteHandler(
route: ExtractedDecoratorRoute,
context: RouteHandlerResolutionHookContext,
): string | undefined {
const designator = route.handlerName;
if (!designator) return undefined;
const parts = designator.split('.');
const routeDir = goPackageDir(route.filePath);
if (parts.length === 1) return uniqueId(functionsIn(context, routeDir, designator));
if (parts.length !== 2) return undefined;
const member = parts[1];
// A receiver of unknown type (`h := deps.Users`, a package-level var) declines.
const hint = route.handlerReceiver;
if (hint === undefined) return undefined;
const dir = hint.qualifier === undefined ? routeDir : packageDir(context, route, hint.qualifier);
if (dir === undefined) return undefined;
if (hint.kind === 'module') return uniqueId(functionsIn(context, dir, member));
if (hint.name === undefined) return undefined;
if (hint.kind === 'type') return methodOfType(context, dir, hint.name, member);
const constructor = unique(functionsIn(context, dir, hint.name));
const owner = constructor && ownerTypeName(constructor.returnType);
return constructor && owner
? methodOfType(context, goPackageDir(constructor.filePath), owner, member)
: undefined;
}
type DefinitionBuckets = Map<string, SymbolDefinition[]>;
type PackageIndex = Map<string, DefinitionBuckets>;
const bucketKey = (dir: string, name: string): string => JSON.stringify([dir, name]);
function append(bucket: DefinitionBuckets, key: string, def: SymbolDefinition): void {
const values = bucket.get(key);
if (values) values.push(def);
else bucket.set(key, [def]);
}
function packageDefinitions(
cache: PackageIndex,
name: string,
definitions: () => readonly SymbolDefinition[],
): DefinitionBuckets {
let packages = cache.get(name);
if (!packages) {
packages = new Map();
for (const def of definitions()) {
if (!def.filePath.endsWith('_test.go')) append(packages, goPackageDir(def.filePath), def);
}
cache.set(name, packages);
}
return packages;
}
/** One immutable post-parse model per context; no cache survives a new pass. */
class GoRouteHandlerIndex {
private readonly functions = new Map<string, DefinitionBuckets>();
private readonly types = new Map<string, DefinitionBuckets>();
private readonly methods = new Map<string, DefinitionBuckets>();
constructor(private readonly model: SemanticModel) {}
functionsIn(dir: string, name: string): readonly SymbolDefinition[] {
return (
packageDefinitions(this.functions, name, () =>
this.model.symbols.lookupCallableByName(name).filter((def) => def.type === 'Function'),
).get(dir) ?? []
);
}
typesIn(dir: string, name: string): readonly SymbolDefinition[] {
return (
packageDefinitions(this.types, name, () =>
this.model.types.lookupClassByName(name).filter((def) => def.type === 'Struct'),
).get(dir) ?? []
);
}
methodsOf(
dir: string,
ownerId: string,
typeName: string,
member: string,
): readonly SymbolDefinition[] {
let index = this.methods.get(member);
if (!index) {
index = new Map();
for (const def of this.model.methods.lookupMethodByName(member)) {
if (def.filePath.endsWith('_test.go') || def.ownerId === undefined) continue;
const packageDir = goPackageDir(def.filePath);
const prefix = generateId('Struct', `${def.filePath}:`);
if (def.ownerId.startsWith(prefix)) {
append(index, bucketKey(packageDir, def.ownerId.slice(prefix.length)), def);
}
}
this.methods.set(member, index);
}
return [
...this.model.methods
.lookupAllByOwner(ownerId, member)
.filter((def) => !def.filePath.endsWith('_test.go') && goPackageDir(def.filePath) === dir),
...(index.get(bucketKey(dir, typeName)) ?? []),
];
}
}
const routeIndexes = new WeakMap<RouteHandlerResolutionHookContext, GoRouteHandlerIndex>();
function indexFor(context: RouteHandlerResolutionHookContext): GoRouteHandlerIndex {
let index = routeIndexes.get(context);
if (!index) {
index = new GoRouteHandlerIndex(context.model);
routeIndexes.set(context, index);
}
return index;
}
function unique(defs: readonly SymbolDefinition[]): SymbolDefinition | undefined {
const byId = new Map(defs.map((def) => [def.nodeId, def]));
return byId.size === 1 ? byId.values().next().value : undefined;
}
const uniqueId = (defs: readonly SymbolDefinition[]): string | undefined => unique(defs)?.nodeId;
function functionsIn(
context: RouteHandlerResolutionHookContext,
dir: string,
name: string,
): readonly SymbolDefinition[] {
return indexFor(context).functionsIn(dir, name);
}
/** The one package directory an import local name resolves to, if any. */
function packageDir(
context: RouteHandlerResolutionHookContext,
route: ExtractedDecoratorRoute,
localName: string,
): string | undefined {
const dirs = new Set(context.importTargetsFor(route.filePath, localName).map(goPackageDir));
return dirs.size === 1 ? dirs.values().next().value : undefined;
}
/**
* `*T`, `T`, `(*T, error)` → `T`. A qualified (`pkg.T`) or composite result is
* declined: the owner would live in another package this hint cannot name.
*/
function ownerTypeName(returnType: string | undefined): string | undefined {
if (returnType === undefined) return undefined;
const first = returnType.replace(/^\(/, '').split(',')[0]?.trim() ?? '';
const name = first.replace(/^\*/, '');
return /^[_\p{L}][_\p{L}\p{Nd}]*$/u.test(name) ? name : undefined;
}
function methodOfType(
context: RouteHandlerResolutionHookContext,
dir: string,
typeName: string,
member: string,
): string | undefined {
const index = indexFor(context);
const owner = unique(index.typesIn(dir, typeName));
if (owner === undefined) return undefined;
return uniqueId(index.methodsOf(dir, owner.nodeId, typeName, member));
}

View file

@ -17,6 +17,7 @@ import { populateGoRangeBindings } from './range-binding.js';
import { expandGoWildcardNames } from './expand-wildcards.js';
import { goMapValueType } from './interpret.js';
import { goIsGlobalNameFallbackPlausible } from './name-fallback-visibility.js';
import { resolveGoImportBinding } from './import-binding.js';
/** Slice `[]T` and array `[N]T` / `[...]T` → the element spelling. Hoisted —
* a literal inside the hook would mint a fresh RegExp per folded subscript. */
@ -32,6 +33,8 @@ export const goScopeResolver: ScopeResolver = {
resolveImportTarget: (targetRaw, fromFile, allFilePaths, resolutionConfig) =>
resolveGoImportTarget(targetRaw, fromFile, allFilePaths, resolutionConfig),
resolveImportBinding: resolveGoImportBinding,
expandsWildcardTo: (targetModuleScope, parsedFiles) =>
expandGoWildcardNames(targetModuleScope, parsedFiles),

View file

@ -1834,9 +1834,16 @@ export async function runChunkedParseAndResolve(
`exportedTypeMap=${exportedTypeMap.size} parsedFiles=${allParsedFiles.length} nodes=${graph.nodeCount}`,
);
const routeFilePaths = new Set(allPaths);
// Route files whose handlers resolve through imports: data route tables, and
// routes of a language that resolves its own handlers (`resolveRouteHandler`).
// Both need the file's parsed imports and its language's resolution config.
const dataRouteFilePaths = new Set(
allDecoratorRoutes
.filter((route) => route.source === DATA_ROUTE_TABLE_SOURCE)
.filter(
(route) =>
route.source === DATA_ROUTE_TABLE_SOURCE ||
getProviderForFile(route.filePath)?.resolveRouteHandler !== undefined,
)
.map((route) => route.filePath),
);
const routeResolutionConfigs = new Map<SupportedLanguages, unknown>();
@ -1852,12 +1859,12 @@ export async function runChunkedParseAndResolve(
);
}
let routeResolutionFiles = allParsedFiles;
const resolveRouteImportTarget = (
const resolveRouteImportTargets = (
parsedImport: ParsedImport,
fromFile: string,
): string | null => {
): readonly string[] => {
const language = getLanguageFromFilename(fromFile);
if (language === null) return null;
if (language === null) return [];
const target = SCOPE_RESOLVERS.get(language)?.resolveImportTarget(
parsedImport.targetRaw ?? '',
fromFile,
@ -1865,8 +1872,15 @@ export async function runChunkedParseAndResolve(
routeResolutionConfigs.get(language),
{ parsedFiles: routeResolutionFiles, parsedImport },
);
if (typeof target === 'string') return target;
return target?.length === 1 ? target[0] : null;
if (typeof target === 'string') return [target];
return target ?? [];
};
const resolveRouteImportTarget = (
parsedImport: ParsedImport,
fromFile: string,
): string | null => {
const targets = resolveRouteImportTargets(parsedImport, fromFile);
return targets.length === 1 ? targets[0] : null;
};
if (parsedFileStorePath !== undefined && dataRouteFilePaths.size > 0) {
const byPath = await loadParsedFilesForPaths(parsedFileStorePath, dataRouteFilePaths);
@ -1887,6 +1901,34 @@ export async function runChunkedParseAndResolve(
}
// Part 2 (#2138): resolve each route's handler to a real symbol UID now that
// the model is fully populated and decorator-route prefixes are finalized.
const routeSourceTexts = new Map<string, string | undefined>();
const routeSourceTextFor = (filePath: string): string | undefined => {
if (!routeSourceTexts.has(filePath)) {
try {
routeSourceTexts.set(filePath, fs.readFileSync(path.join(repoPath, filePath), 'utf-8'));
} catch {
routeSourceTexts.set(filePath, undefined);
}
}
return routeSourceTexts.get(filePath);
};
routeResolutionFiles = routeResolutionFiles.map((parsed) => {
if (!dataRouteFilePaths.has(parsed.filePath)) return parsed;
const language = getLanguageFromFilename(parsed.filePath);
const resolveBinding =
language === null ? undefined : SCOPE_RESOLVERS.get(language)?.resolveImportBinding;
if (!resolveBinding) return parsed;
return {
...parsed,
parsedImports: parsed.parsedImports.map((parsedImport) =>
resolveBinding(
parsedImport,
() => resolveRouteImportTargets(parsedImport, parsed.filePath),
routeSourceTextFor,
),
),
};
});
const routeHandlerSymbols = resolveRouteHandlerSymbols(
model,
allExtractedRoutes,
@ -1894,6 +1936,8 @@ export async function runChunkedParseAndResolve(
{
files: routeResolutionFiles,
resolveImportTarget: resolveRouteImportTarget,
resolveImportTargets: resolveRouteImportTargets,
providerRouteHandler: (filePath) => getProviderForFile(filePath)?.resolveRouteHandler,
isExportedSymbol: (nodeId: string) => graph.getNode(nodeId)?.properties.isExported === true,
nodeStartLine: (id) => {
const n = graph.getNode(id);

View file

@ -0,0 +1,392 @@
/**
* gin / echo routes for the indexer (#3402).
*
* A gin or echo endpoint is a verb call on a router value —
* `admin.POST("/rounds/:id", h.Finalize)` — whose full URL is the join of every
* `Group("/p")` the router value was derived from. Neither the verb call nor
* any one `Group` call is the route on its own, so this walks each function
* body, tracks which local names hold a router with a PROVEN prefix, and emits
* a route only for a verb call on such a name.
*
* Proven means: the name is an engine (`*gin.Engine` / `*echo.Echo` parameter,
* `gin.Default()`, `gin.New()`, `echo.New()`) or a `Group(<string literal>)` of
* a proven router, and every assignment to it in the function agrees. Anything
* else — a `*gin.RouterGroup` parameter, a computed group path, a struct field,
* a name assigned two different routers — is unknown, and a verb call on it is
* dropped. A route stored under the wrong URL is a false fact that `route_map`
* and FETCHES matching would repeat; a missing route is a documented gap
* (groups handed to another function are the known one).
*
* The handler travels as `handlerName` (the raw designator) plus a
* `handlerReceiver` hint read from this file's own syntax. Resolving it to a
* symbol needs the rest of the package, so that is the Go provider's
* `resolveRouteHandler` hook, not this file.
*/
import type Parser from 'tree-sitter';
import { goImportPackageName } from '../languages/go/import-package-name.js';
import { GoRouteBindings, type GoRouteBinding } from '../languages/go/route-bindings.js';
import { normalizeExtractedRoutePath } from './route-path.js';
import type { SyntaxNode } from 'tree-sitter';
import type { ExtractedDecoratorRoute, RouteHandlerReceiver } from '../workers/parse-worker.js';
export const GIN_ROUTE_SOURCE = 'gin-route';
export const ECHO_ROUTE_SOURCE = 'echo-route';
const VERBS: ReadonlySet<string> = new Set([
'GET',
'POST',
'PUT',
'PATCH',
'DELETE',
'HEAD',
'OPTIONS',
]);
interface Framework {
readonly source: string;
/** Import local name of the framework package. */
readonly alias: string;
/** Engine type name reached through the alias (`gin.Engine`, `echo.Echo`). */
readonly engineType: string;
/** Engine constructors reached through the alias. */
readonly constructors: ReadonlySet<string>;
/** gin takes the handler last (middleware first); echo takes it second. */
readonly handlerArg: 'last' | 'second';
}
const FUNCTION_TYPE_LIST = ['function_declaration', 'method_declaration', 'func_literal'];
const FUNCTION_TYPES: ReadonlySet<string> = new Set(FUNCTION_TYPE_LIST);
function stringLiteral(node: SyntaxNode | null | undefined): string | null {
if (!node || node.hasError) return null;
const body = node.text.slice(1, -1);
// Go discards carriage returns in raw strings, including CRLF source files.
if (node.type === 'raw_string_literal') return body.replace(/\r/g, '');
if (node.type !== 'interpreted_string_literal') return null;
if (!body.includes('\\')) return body;
const simple: Readonly<Record<string, string>> = {
a: '\x07',
b: '\b',
f: '\f',
n: '\n',
r: '\r',
t: '\t',
v: '\v',
'\\': '\\',
'"': '"',
};
const chunks: Buffer[] = [];
const tokens =
/\\(?:[abfnrtv\\"]|[0-7]{3}|x[\da-fA-F]{2}|u[\da-fA-F]{4}|U[\da-fA-F]{8})|[^\\"\n]+/g;
let consumed = 0;
for (const match of body.matchAll(tokens)) {
if (match.index !== consumed) return null;
const token = match[0];
consumed += token.length;
if (!token.startsWith('\\')) {
chunks.push(Buffer.from(token));
} else if (simple[token[1]] !== undefined) {
chunks.push(Buffer.from(simple[token[1]]));
} else {
const octal = /[0-7]/.test(token[1]);
const value = Number.parseInt(token.slice(octal ? 1 : 2), octal ? 8 : 16);
if (octal || token[1] === 'x') {
// Octal and hex escapes encode bytes, not Unicode code points.
if (value > 255) return null;
chunks.push(Buffer.from([value]));
} else {
if (value > 0x10ffff || (value >= 0xd800 && value <= 0xdfff)) return null;
chunks.push(Buffer.from(String.fromCodePoint(value)));
}
}
}
if (consumed !== body.length) return null;
try {
// Arbitrary non-UTF-8 Go byte strings cannot be represented losslessly in a URL.
return new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(Buffer.concat(chunks));
} catch {
return null;
}
}
/** The framework this file routes with, when exactly one is imported. */
function readImports(root: SyntaxNode): {
readonly framework: Framework | null;
} {
let gin: string | null = null;
let echo: string | null = null;
// Imports sit only at file scope; this runs on every Go file, so skip bodies.
const specs = root.namedChildren
.filter((node) => node.type === 'import_declaration')
.flatMap((decl) => decl.descendantsOfType('import_spec'));
for (const spec of specs) {
const importPath = stringLiteral(spec.childForFieldName('path'));
if (importPath === null) continue;
const explicit = spec.childForFieldName('name')?.text;
// `_` and `.` imports bind no qualifier this file can call through.
if (explicit === '_' || explicit === '.') continue;
const local = explicit ?? goImportPackageName(importPath);
if (!local) continue;
if (importPath === 'github.com/gin-gonic/gin') gin = local;
if (/^github\.com\/labstack\/echo(\/v\d+)?$/.test(importPath)) echo = local;
}
// Both, or neither: no way to tell which argument is the handler.
if (gin !== null && echo === null) {
return {
framework: {
source: GIN_ROUTE_SOURCE,
alias: gin,
engineType: 'Engine',
constructors: new Set(['Default', 'New']),
handlerArg: 'last',
},
};
}
if (echo !== null && gin === null) {
return {
framework: {
source: ECHO_ROUTE_SOURCE,
alias: echo,
engineType: 'Echo',
constructors: new Set(['New']),
handlerArg: 'second',
},
};
}
return { framework: null };
}
/** Named descendants of a function body, not descending into nested functions. */
function bodyNodes(body: SyntaxNode): SyntaxNode[] {
const out: SyntaxNode[] = [];
const stack: SyntaxNode[] = [...body.namedChildren].reverse();
while (stack.length > 0) {
const node = stack.pop() as SyntaxNode;
out.push(node);
if (FUNCTION_TYPES.has(node.type)) continue;
for (let i = node.namedChildCount - 1; i >= 0; i--) {
const child = node.namedChild(i);
if (child) stack.push(child);
}
}
return out;
}
/** `T`, `*T`, `pkg.T`, `*pkg.T` → type hint; anything else → undefined. */
function typeHint(
typeNode: SyntaxNode | null | undefined,
bindings: GoRouteBindings,
): RouteHandlerReceiver | undefined {
if (!typeNode) return undefined;
if (typeNode.type === 'pointer_type') return typeHint(typeNode.namedChild(0), bindings);
if (typeNode.type === 'type_identifier') {
return bindings.lookup(typeNode) ? undefined : { kind: 'type', name: typeNode.text };
}
if (typeNode.type === 'qualified_type') {
const packageNode = typeNode.childForFieldName('package');
if (packageNode && bindings.lookup(packageNode)) return undefined;
const pkg = packageNode?.text;
const name = typeNode.childForFieldName('name')?.text;
return pkg && name ? { kind: 'type', name, qualifier: pkg } : undefined;
}
return undefined;
}
/** Receiver hint for the value a name was assigned. */
function valueHint(value: SyntaxNode, bindings: GoRouteBindings): RouteHandlerReceiver | undefined {
if (value.type === 'unary_expression' && value.childForFieldName('operator')?.text === '&') {
const operand = value.childForFieldName('operand');
return operand ? valueHint(operand, bindings) : undefined;
}
if (value.type === 'composite_literal')
return typeHint(value.childForFieldName('type'), bindings);
if (value.type === 'call_expression') {
const fn = value.childForFieldName('function');
if (fn?.type === 'identifier') {
return bindings.lookup(fn) ? undefined : { kind: 'constructor', name: fn.text };
}
if (fn?.type === 'selector_expression') {
const operand = fn.childForFieldName('operand');
const field = fn.childForFieldName('field');
if (operand?.type === 'identifier' && field && !bindings.lookup(operand)) {
return { kind: 'constructor', name: field.text, qualifier: operand.text };
}
}
}
return undefined;
}
const sameHint = (a: RouteHandlerReceiver, b: RouteHandlerReceiver): boolean =>
a.kind === b.kind && a.name === b.name && a.qualifier === b.qualifier;
class RouterPrefixes {
private readonly memo = new Map<GoRouteBinding, string | null>();
private readonly visiting = new Set<GoRouteBinding>();
constructor(
private readonly bindings: GoRouteBindings,
private readonly fw: Framework,
private readonly fn: SyntaxNode,
) {}
/** Proven prefix of a router expression, or null when it cannot be proven. */
of(node: SyntaxNode): string | null {
if (node.type === 'parenthesized_expression') {
const inner = node.namedChild(0);
return inner ? this.of(inner) : null;
}
if (node.type === 'identifier') {
const binding = this.bindings.lookup(node);
return binding ? this.ofBinding(binding) : null;
}
if (node.type !== 'call_expression') return null;
const fn = node.childForFieldName('function');
if (fn?.type !== 'selector_expression') return null;
const operand = fn.childForFieldName('operand');
const field = fn.childForFieldName('field')?.text;
if (!operand || !field) return null;
if (
operand.type === 'identifier' &&
operand.text === this.fw.alias &&
!this.bindings.lookup(operand)
) {
return this.fw.constructors.has(field) ? '' : null;
}
if (field !== 'Group') return null;
const path = stringLiteral(node.childForFieldName('arguments')?.namedChild(0));
if (path === null) return null;
const base = this.of(operand);
return base === null ? null : normalizeExtractedRoutePath(path, base);
}
private ofBinding(binding: GoRouteBinding): string | null {
// Captured routers remain outside this extractor's supported route forms.
if (binding.ownerFunction.id !== this.fn.id || binding.capturedWrite) return null;
const cached = this.memo.get(binding);
if (cached !== undefined) return cached;
if (this.visiting.has(binding)) return null;
this.visiting.add(binding);
const hint = typeHint(binding.type, this.bindings);
const candidates: (string | null)[] = [];
if (
binding.isInputParameter &&
hint?.kind === 'type' &&
hint.qualifier === this.fw.alias &&
hint.name === this.fw.engineType
) {
candidates.push('');
} else if (binding.isInputParameter || binding.values.length === 0) {
candidates.push(null);
}
for (const value of binding.values) candidates.push(value === null ? null : this.of(value));
const first = candidates[0] ?? null;
const result = candidates.every((candidate) => candidate === first) ? first : null;
this.visiting.delete(binding);
this.memo.set(binding, result);
return result;
}
}
/** Hints refer only to visible declarations; unsupported local types decline. */
function receiverHint(
node: SyntaxNode,
bindings: GoRouteBindings,
memo: Map<GoRouteBinding, RouteHandlerReceiver | undefined>,
): RouteHandlerReceiver | undefined {
const binding = bindings.lookup(node);
// Workspace resolution checks the actual imported package's declared name.
if (!binding) return { kind: 'module', qualifier: node.text };
if (memo.has(binding)) return memo.get(binding);
if (binding.capturedWrite) return undefined;
const hints = binding.values.map((value) =>
value === null ? undefined : valueHint(value, bindings),
);
if (binding.isInputParameter || binding.values.length === 0) {
hints.push(typeHint(binding.type, bindings));
}
const first = hints[0];
const result =
first && hints.every((hint) => hint !== undefined && sameHint(hint, first)) ? first : undefined;
memo.set(binding, result);
return result;
}
interface VerbRegistration {
readonly call: SyntaxNode;
readonly verb: string;
readonly receiver: SyntaxNode;
readonly args: readonly SyntaxNode[];
readonly path: string;
}
/** `recv.VERB("<literal>", …handler)` — the shape, before its receiver is proven. */
function verbRegistration(call: SyntaxNode): VerbRegistration | null {
if (call.type !== 'call_expression') return null;
const callee = call.childForFieldName('function');
if (callee?.type !== 'selector_expression') return null;
const verb = callee.childForFieldName('field')?.text;
const receiver = callee.childForFieldName('operand');
if (!verb || !VERBS.has(verb) || !receiver) return null;
const args = call.childForFieldName('arguments')?.namedChildren ?? [];
if (args.length < 2) return null;
const path = stringLiteral(args[0]);
return path === null ? null : { call, verb, receiver, args, path };
}
export function extractGoGinEchoRoutes(
tree: Parser.Tree,
filePath: string,
lineOffset = 0,
): ExtractedDecoratorRoute[] {
const root = tree.rootNode;
const { framework } = readImports(root);
if (framework === null) return [];
const out: ExtractedDecoratorRoute[] = [];
const handlerHints = new Map<GoRouteBinding, RouteHandlerReceiver | undefined>();
let bindings: GoRouteBindings | undefined;
for (const fn of root.descendantsOfType(FUNCTION_TYPE_LIST)) {
const body = fn.childForFieldName('body');
if (!body) continue;
const nodes = bodyNodes(body);
const registrations = nodes.flatMap((node) => {
const registration = verbRegistration(node);
return registration === null ? [] : [registration];
});
// Most functions in a gin-importing file register nothing; skip their bindings.
if (registrations.length === 0) continue;
bindings ??= new GoRouteBindings(root);
const prefixes = new RouterPrefixes(bindings, framework, fn);
for (const { call, verb, receiver, args, path } of registrations) {
const prefix = prefixes.of(receiver);
if (prefix === null) continue;
const handler = framework.handlerArg === 'last' ? args[args.length - 1] : args[1];
const route: ExtractedDecoratorRoute = {
filePath,
routePath: normalizeExtractedRoutePath(path, prefix),
httpMethod: verb,
decoratorName: verb,
lineNumber: call.startPosition.row + 1 + lineOffset,
prefix: null,
source: framework.source,
};
if (handler.type === 'identifier' && !bindings.lookup(handler)) {
route.handlerName = handler.text;
} else if (handler.type === 'selector_expression') {
const operand = handler.childForFieldName('operand');
const field = handler.childForFieldName('field');
if (operand?.type === 'identifier' && field) {
route.handlerName = `${operand.text}.${field.text}`;
const hint = receiverHint(operand, bindings, handlerHints);
if (hint) route.handlerReceiver = hint;
}
}
out.push(route);
}
}
return out;
}

View file

@ -421,6 +421,16 @@ export interface ScopeResolver {
context?: ImportResolutionContext,
): string | readonly string[] | null;
/** Resolve names declared by the imported module after its files are known.
* Shared by route handlers and ordinary scope bindings. May return the input
* unchanged; changed bindings must be copies so extraction output and
* parse-cache entries remain unchanged. */
readonly resolveImportBinding?: (
parsedImport: ParsedImport,
resolveTargetFiles: () => readonly string[],
sourceTextFor: (filePath: string) => string | undefined,
) => ParsedImport;
/**
* Optionally reclassify an import as a namespace handle after target
* resolution proves the imported name is itself a module. Returning false

View file

@ -794,6 +794,31 @@ export function runScopeResolution(
logHeapProbe('sr-post-nodeLookup', `lang=${provider.language}`);
const resolutionConfig = input.resolutionConfig;
const resolveImportBinding = provider.resolveImportBinding;
if (resolveImportBinding) {
for (let i = 0; i < parsedFiles.length; i++) {
const parsed = parsedFiles[i];
parsedFiles[i] = {
...parsed,
parsedImports: parsed.parsedImports.map((parsedImport) => {
return resolveImportBinding(
parsedImport,
() => {
const targets = provider.resolveImportTarget(
parsedImport.targetRaw ?? '',
parsed.filePath,
allFilePaths,
resolutionConfig,
{ parsedFiles, parsedImport },
);
return typeof targets === 'string' ? [targets] : (targets ?? []);
},
(filePath) => getFileContents().get(filePath),
);
}),
};
}
}
const finalized = finalizeScopeModel(parsedFiles, {
hooks: {
importsBindAtLexicalScope: provider.importsBindAtLexicalScope === true,

View file

@ -344,6 +344,13 @@ export interface FetchWrapperDef {
functionName: string;
}
/** See {@link ExtractedDecoratorRoute.handlerReceiver}. */
export interface RouteHandlerReceiver {
kind: 'type' | 'constructor' | 'module';
name?: string;
qualifier?: string;
}
export interface ExtractedDecoratorRoute {
filePath: string;
routePath: string;
@ -388,6 +395,20 @@ export interface ExtractedDecoratorRoute {
* resolution then falls back (the Route node simply carries no handlerSymbolId).
*/
handlerName?: string;
/**
* Static hint for what the receiver of a qualified {@link handlerName}
* (`h.Method`, `pkg.Func`) is, read from the registering file's own syntax.
* The worker sees one file, so it records only what that file says:
* - `type` — the receiver was declared or built as `name` (`h := &T{}`,
* `var h *T`, a `h *pkg.T` parameter);
* - `constructor` — the receiver was returned by the function `name`
* (`h := NewT(...)`), whose declared result type names the owner;
* - `module` — the receiver is the import `qualifier` (`pkg.Func`).
* `qualifier` is the import local name the type or constructor was reached
* through, when there is one. Only the route file's provider reads this, via
* `LanguageProvider.resolveRouteHandler`; absent when nothing was inferred.
*/
handlerReceiver?: RouteHandlerReceiver;
/**
* Provenance for the `HANDLES_ROUTE` edge, overriding the default
* `decorator-<decoratorName>`. Present when the route was extracted from a

View file

@ -8788,6 +8788,54 @@ export class LocalBackend {
});
}
// ── Route enrichment (#3402) ──────────────────────────────────────────
// HTTP endpoints served by the target or any impacted symbol, read from
// (handler)-[HANDLES_ROUTE]->Route. Reported, not traversed: HANDLES_ROUTE
// stays out of the walk's relTypes. The Process path cannot stand in for
// this — a handler only heads a Process when its call chain is 3+ steps and
// it ranks in the repo-wide entry-point cap, so most handlers of a large
// router would never surface.
const routesById = new Map<string, Array<{ url: string; method?: string }>>();
const affectedRoutes: Array<{ url: string; method?: string }> = [];
if (!skipEnrichment) {
const routeIds = [
String(symId),
...impacted.map((item) => String(item.id ?? '')).filter(Boolean),
].slice(0, MAX_CHUNKS * CHUNK_SIZE);
const seenRoutes = new Set<string>();
for (const chunkIds of chunk(routeIds, CHUNK_SIZE)) {
const rows = await executeParameterized(
repo.lbugPath,
`
MATCH (h)-[:CodeRelation {type: 'HANDLES_ROUTE'}]->(route:Route)
WHERE h.id IN $ids
RETURN h.id AS hid, route.name AS url, route.method AS method
ORDER BY url, method
`,
{ ids: chunkIds },
).catch((err) => {
enrichmentDegraded = true;
logQueryError('impact:route-chunk', err);
return [];
});
for (const row of rows) {
const hid = String(row.hid ?? row[0] ?? '');
const url = row.url ?? row[1];
if (!hid || typeof url !== 'string') continue;
const method = row.method ?? row[2];
const route = typeof method === 'string' && method ? { url, method } : { url };
const list = routesById.get(hid);
if (list) list.push(route);
else routesById.set(hid, [route]);
const key = `${route.method ?? ''} ${url}`;
if (!seenRoutes.has(key)) {
seenRoutes.add(key);
affectedRoutes.push(route);
}
}
}
}
// Risk scoring
const processCount = affectedProcesses.length;
const moduleCount = affectedModules.length;
@ -8866,6 +8914,7 @@ export class LocalBackend {
byDepthCounts,
affected_processes: affectedProcesses,
affected_modules: affectedModules,
affected_routes: affectedRoutes,
};
if (summaryOnly) {
@ -8950,6 +8999,8 @@ export class LocalBackend {
for (const items of Object.values(paginatedGrouped)) {
for (const it of items) {
it.processes = perSymbolProcesses.get(String(it.id)) ?? [];
const routes = routesById.get(String(it.id));
if (routes) it.routes = routes;
}
}

View file

@ -527,6 +527,7 @@ Output includes:
- summary: direct callers, processes affected, modules affected
- affected_processes: which execution flows break and at which step
- affected_modules: which functional areas are hit (direct vs indirect; classification-unavailable when that secondary query fails)
- affected_routes (callgraph mode only): [{ url, method? }] — HTTP endpoints served by the target or any impacted symbol (via HANDLES_ROUTE). Matching callgraph byDepth items also carry routes:[{ url, method? }]. PDG results do not include route enrichment. Reported only; not counted in risk.
- byDepth: affected symbols grouped by traversal depth (paginated by limit/offset; omitted when summaryOnly:true — use byDepthCounts for totals per depth, pagination object when truncated). Each item includes a processes:[{id,label,processType,step}] field listing the execution flows that symbol participates in. Empty when the symbol has no process membership. Can ALSO be empty when partial:true is set — either the process-aggregation pass hit its cap before detecting affected processes, or per-symbol enrichment was capped on a very large page. When partial:true, do NOT treat processes:[] as proof of no participation; cross-check the top-level affected_processes list. An item carries staticGated:true only when the edge that reached it is provably unreachable at compile time from the indexed source (today: Zig calls inside an 'if (CONST_FALSE)' body or the else of 'if (CONST_TRUE)'); the field is absent when the edge is live or the language does not model it. Traversal and risk do NOT filter or rank on it: it is metadata for the caller to weigh.
- epistemic: 'exact' | 'lower-bound' — whether impactedCount is the whole story. 'lower-bound' means the count is a FLOOR: either the walk provably missed callers, or a probe that would have established completeness could not run (a failed callable-value-reference query says so in boundaries). It is not itself proof that an omitted caller exists — branch on causes and read boundaries. Absent only on skipped probes (ambiguous-candidate lists, group fan-out).
- boundaries: string[] — one plain-language sentence per reason the count is short. Prose for humans; branch on causes instead.

View file

@ -590,7 +590,13 @@ const acquireViaFile = async (
`Reclaiming stale index lock from dead analyze (pid ${holder.pid}, ` +
`invocation ${holder.invocationId}).`,
);
unlinkSync(lockPath);
try {
unlinkSync(lockPath);
} catch (error) {
// The holder may release and exit after our read. The guard still
// excludes new owners, so an already-removed file is safe to create.
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
}
tryCreate = true;
holder = null;
}

View file

@ -818,7 +818,11 @@ import { copyV8CacheIfPresent, tryLoadV8Cache, writeV8CacheFile } from './v8-sid
// v123 (#3408): FastAPI include records now carry `host`, router imports carry
// `modulePath`, and unprefixed child includes are emitted. Warm v122 records
// lack them, so nested router-prefix propagation would stay inert.
const SCHEMA_BUMP = 123;
// v124 (#3402): Go files now emit gin/echo `decoratorRoutes` carrying a
// `handlerReceiver` hint. Warm v123 Go worker results carry no routes.
// v125 (#3402): Go route hints now honor lexical declarations and captured writes;
// namespace imports retain whether their local name comes from the package clause.
const SCHEMA_BUMP = 125;
const GITNEXUS_PKG_VERSION = (() => {
try {
// package.json sits at gitnexus/package.json — two levels up from

View file

@ -117,11 +117,11 @@
},
"go-extern-qualified-signatures/memory/memory.go": {
"captureGroups": 47,
"digest": "f079bb9c78b2e38473d0fb7e88ad7b3017abdb5716405ac69929ee40afd5e316"
"digest": "34443b5a335baf160b5bc1f36a4009f4a8f544644087663db1049a7574ea7c89"
},
"go-extern-qualified-signatures/store/store.go": {
"captureGroups": 8,
"digest": "6b5ed1163020250ec52ae6b67cad03c68a5247971fc10c7b6281821434badfe3"
"digest": "974b077f60e3c65330aa6c5f74b9ae224c8c10e10c95bd578456816ae4134536"
},
"go-field-types/cmd/main.go": {
"captureGroups": 10,

View file

@ -0,0 +1,17 @@
package echoapp
import "github.com/labstack/echo/v4"
type UserHandler struct{}
func (u *UserHandler) List(c echo.Context) error { return nil }
func authMW(next echo.HandlerFunc) echo.HandlerFunc { return next }
func Setup() *echo.Echo {
e := echo.New()
h := &UserHandler{}
api := e.Group("/echo")
api.GET("/users", h.List, authMW)
return e
}

View file

@ -0,0 +1,8 @@
module example.com/ginapp
go 1.22
require (
github.com/gin-gonic/gin v1.10.0
github.com/labstack/echo/v4 v4.12.0
)

View file

@ -0,0 +1,7 @@
package handlers
import "github.com/gin-gonic/gin"
func Health(c *gin.Context) {
c.String(200, "ok")
}

View file

@ -0,0 +1,15 @@
package router
import "github.com/gin-gonic/gin"
type Authenticator interface {
Login(c *gin.Context)
}
type AuthHandler struct{}
func (a *AuthHandler) Login(c *gin.Context) {}
type AdminAuthHandler struct{}
func (a *AdminAuthHandler) Login(c *gin.Context) {}

View file

@ -0,0 +1,8 @@
package router
import "github.com/gin-gonic/gin"
func (h *MatchHandler) UnfinalizeRoundHandle(c *gin.Context) {
h.svc.UnfinalizeRound(c.Param("seasonId"), c.Param("roundId"))
c.JSON(200, gin.H{"ok": true})
}

View file

@ -0,0 +1,36 @@
package router
import (
"github.com/gin-gonic/gin"
"example.com/ginapp/handlers"
"example.com/ginapp/service"
"example.com/ginapp/status/v2"
)
func RegisterRoutes(r *gin.Engine, svc *service.Service) {
matchHandler := NewMatchHandler(svc)
auth := &AuthHandler{}
var anyAuth Authenticator = auth
v1 := r.Group("/api/v1")
{
admin := v1.Group("/admin")
admin.POST("/seasons/:seasonId/rounds/:roundId/unfinalize", matchHandler.UnfinalizeRoundHandle)
admin.POST("/login", anyAuth.Login)
}
v1.POST("/login", auth.Login)
v1.GET("/health", handlers.Health)
v1.GET("/status", status.Status)
v1.GET("/version", Version)
v1.GET("/ping", func(c *gin.Context) { c.String(200, "pong") })
registerLegacy(v1)
}
// Groups handed to another function are out of scope: no route below.
func registerLegacy(g *gin.RouterGroup) {
g.GET("/legacy", Version)
}
func Version(c *gin.Context) {
c.String(200, "1")
}

View file

@ -0,0 +1,11 @@
package router
import "example.com/ginapp/service"
type MatchHandler struct {
svc *service.Service
}
func NewMatchHandler(svc *service.Service) *MatchHandler {
return &MatchHandler{svc: svc}
}

View file

@ -0,0 +1,13 @@
package service
type Service struct{}
func (s *Service) UnfinalizeRound(seasonID, roundID string) {
s.reopen(roundID)
}
func (s *Service) reopen(roundID string) {
audit(roundID)
}
func audit(id string) {}

View file

@ -0,0 +1,7 @@
package status
import "github.com/gin-gonic/gin"
func Status(c *gin.Context) {
c.String(200, "ok")
}

View file

@ -90,7 +90,11 @@ function parseStdout(result: ReturnType<typeof runCliRaw>): unknown {
// ─── Typed result shapes (avoid `any`; just the fields these tests read) ──────
type CallBuckets = { calls?: unknown[]; accesses?: unknown[] };
type ContextResult = { incoming?: CallBuckets; outgoing?: CallBuckets; processes?: unknown[] };
type ImpactResult = { affected_processes?: unknown[]; affected_modules?: unknown[] };
type ImpactResult = {
affected_processes?: unknown[];
affected_modules?: unknown[];
affected_routes?: Array<{ url: string; method?: string }>;
};
type CypherTabular = { markdown?: string; row_count?: number };
type QueryResult = { processes?: unknown[] };
@ -124,6 +128,24 @@ beforeAll(() => {
suiteGitnexusHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-cli-limit-home-'));
MINI_REPO = path.join(tmpParent, 'mini-repo');
fs.cpSync(FIXTURE_SRC, MINI_REPO, { recursive: true });
// A separate handler serves two routes so the route cap cannot pass vacuously.
// Keep it independent of logMessage's existing caller/process baselines.
fs.writeFileSync(
path.join(MINI_REPO, 'routes.go'),
`package routes
import "github.com/gin-gonic/gin"
func RegisterRoutes(r *gin.Engine) {
r.GET("/health", Health)
r.GET("/ready", Health)
}
func Health(c *gin.Context) {
c.String(200, "ok")
}
`,
);
// Initialize as git repo
spawnSync('git', ['init'], { cwd: MINI_REPO, stdio: 'pipe' });
@ -250,6 +272,25 @@ describe('CLI --limit flag E2E', () => {
// ─── impact ─────────────────────────────────────────────────────────────
describe('impact --limit', () => {
it('caps affected_routes at --limit 1 while omitted and zero limits retain both routes', () => {
const args = ['impact', 'Health', '--direction', 'upstream', '--repo', 'mini-repo'];
const base = runJson<ImpactResult>(args);
expect(base.affected_routes).toHaveLength(2);
expect(base.affected_routes).toEqual(
expect.arrayContaining([
{ url: '/health', method: 'GET' },
{ url: '/ready', method: 'GET' },
]),
);
const limited = runJson<ImpactResult>([...args, '--limit', '1']);
expect(limited.affected_routes).toHaveLength(1);
expect(base.affected_routes).toContainEqual(limited.affected_routes?.[0]);
const zero = runJson<ImpactResult>([...args, '--limit', '0']);
expect(zero.affected_routes).toEqual(base.affected_routes);
});
it('truncates affected_processes/modules to --limit 1', () => {
const limited = runJson<ImpactResult>([
'impact',

View file

@ -0,0 +1,183 @@
/**
* End-to-end coverage of gin / echo route ingestion (#3402).
*
* The reported symptom was zero `Route` nodes for a gin backend, and `impact`
* on a service method ending at the HTTP handler. The unit suites pin what the
* extractor returns (`go-gin-echo-routes.test.ts`) and how the Go hook resolves
* a designator against a hand-built model (`go-route-handler.test.ts`). This
* file covers what neither can: the worker emitting routes for real Go files,
* `go.mod` driving the import that resolves `handlers.Health`, and a method
* whose struct is declared in a SIBLING file resolving through the parse-time
* owner id.
*
* Fixture: `test/fixtures/go-gin-route-app/`.
*/
import { describe, it, expect, beforeAll } from 'vitest';
import path from 'node:path';
import type { GraphNode, GraphRelationship } from 'gitnexus-shared';
import { runPipelineFromRepo } from '../../src/core/ingestion/pipeline.js';
import type { PipelineResult } from '../../src/types/pipeline.js';
const FIXTURE = path.resolve(__dirname, '..', 'fixtures', 'go-gin-route-app');
interface RouteView {
/** `${method} ${url}` — the `routeNodeKey` identity, as a sortable string. */
readonly identity: string;
readonly routeFile: string;
/** The handler the route resolved to, or the literal `'undefined'`. */
readonly handler: string;
readonly handlerLabel: string;
readonly handlerFile: string;
}
describe('gin / echo route ingestion pipeline', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(FIXTURE, () => {}, {});
}, 300_000);
const nodes = (): readonly GraphNode[] => {
const out: GraphNode[] = [];
result.graph.forEachNode((node) => void out.push(node));
return out;
};
const relationships = (): readonly GraphRelationship[] => {
const out: GraphRelationship[] = [];
result.graph.forEachRelationship((rel) => void out.push(rel));
return out;
};
/**
* Unresolved fields are stringified rather than branched on, so a route that
* lost its handler reads as the literal `'undefined'` in the diff.
*/
const routes = (): readonly RouteView[] =>
nodes()
.filter((node) => node.label === 'Route')
.map((node) => {
const handler = result.graph.getNode(String(node.properties.handlerSymbolId));
return {
identity: `${String(node.properties.method)} ${String(node.properties.name)}`,
routeFile: String(node.properties.filePath).replaceAll('\\', '/'),
handler: String(handler?.properties.name),
handlerLabel: String(handler?.label),
handlerFile: String(handler?.properties.filePath).replaceAll('\\', '/'),
};
})
.sort((a, b) => a.identity.localeCompare(b.identity));
it('joins Group prefixes and resolves every uniquely-named handler', () => {
expect(routes()).toEqual([
{
// Package function, reached through a go.mod-resolved import.
identity: 'GET /api/v1/health',
routeFile: 'router/router.go',
handler: 'Health',
handlerLabel: 'Function',
handlerFile: 'handlers/health.go',
},
{
// Inline func literal: the route stands, with no handler symbol.
identity: 'GET /api/v1/ping',
routeFile: 'router/router.go',
handler: 'undefined',
handlerLabel: 'undefined',
handlerFile: 'undefined',
},
{
// A version suffix belongs to the path, not the package qualifier.
identity: 'GET /api/v1/status',
routeFile: 'router/router.go',
handler: 'Status',
handlerLabel: 'Function',
handlerFile: 'status/v2/status.go',
},
{
identity: 'GET /api/v1/version',
routeFile: 'router/router.go',
handler: 'Version',
handlerLabel: 'Function',
handlerFile: 'router/router.go',
},
{
// echo: handler is the SECOND argument; trailing middleware is skipped.
identity: 'GET /echo/users',
routeFile: 'echoapp/routes.go',
handler: 'List',
handlerLabel: 'Method',
handlerFile: 'echoapp/routes.go',
},
{
// Interface-typed receiver: `Login` exists on two structs, so decline.
identity: 'POST /api/v1/admin/login',
routeFile: 'router/router.go',
handler: 'undefined',
handlerLabel: 'undefined',
handlerFile: 'undefined',
},
{
// The issue #3402 route: constructor-built receiver, struct declared in
// types.go, method in match_handler.go. The Route stays on the file
// that registers it.
identity: 'POST /api/v1/admin/seasons/:seasonId/rounds/:roundId/unfinalize',
routeFile: 'router/router.go',
handler: 'UnfinalizeRoundHandle',
handlerLabel: 'Method',
handlerFile: 'router/match_handler.go',
},
{
// `&AuthHandler{}` picks one of the two `Login` methods.
identity: 'POST /api/v1/login',
routeFile: 'router/router.go',
handler: 'Login',
handlerLabel: 'Method',
handlerFile: 'router/auth_handlers.go',
},
]);
});
it('does not mint a route from a RouterGroup handed to another function', () => {
expect(routes().map((route) => route.identity)).not.toContain('GET /api/v1/legacy');
expect(routes().map((route) => route.identity)).not.toContain('GET /legacy');
});
it('draws the symbol-level HANDLES_ROUTE edge with gin provenance', () => {
const edge = relationships().find(
(rel) =>
rel.type === 'HANDLES_ROUTE' &&
rel.targetId === 'Route:POST /api/v1/admin/seasons/:seasonId/rounds/:roundId/unfinalize' &&
result.graph.getNode(rel.sourceId)?.label === 'Method',
);
expect({
handler: result.graph.getNode(String(edge?.sourceId))?.properties.name,
reason: edge?.reason,
}).toEqual({ handler: 'UnfinalizeRoundHandle', reason: 'gin-route' });
});
it('draws HANDLES_ROUTE for an unaliased versioned handler package', () => {
const edge = relationships().find(
(rel) =>
rel.type === 'HANDLES_ROUTE' &&
rel.targetId === 'Route:GET /api/v1/status' &&
result.graph.getNode(rel.sourceId)?.label === 'Function',
);
expect(result.graph.getNode(String(edge?.sourceId))?.properties.filePath).toBe(
'status/v2/status.go',
);
});
it('links the handler to the service method it calls', () => {
const calls = relationships()
.filter((rel) => rel.type === 'CALLS')
.map(
(rel) =>
`${String(result.graph.getNode(rel.sourceId)?.properties.name)} -> ${String(
result.graph.getNode(rel.targetId)?.properties.name,
)}`,
);
expect(calls).toContain('UnfinalizeRoundHandle -> UnfinalizeRound');
});
});

View file

@ -0,0 +1,112 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { runPipelineFromRepo } from '../../src/core/ingestion/pipeline.js';
import type { PipelineResult } from '../../src/types/pipeline.js';
describe('Go route declaration identity through ingestion', () => {
let repo: string;
let result: PipelineResult;
beforeAll(async () => {
repo = await mkdtemp(path.join(os.tmpdir(), 'gitnexus-go-binding-'));
await mkdir(path.join(repo, 'handlers'));
await writeFile(path.join(repo, 'go.mod'), 'module example.com/bindings\n\ngo 1.24\n');
await writeFile(
path.join(repo, 'handlers', 'handler.go'),
`package handlers
import "github.com/gin-gonic/gin"
func Do(*gin.Context) {}
`,
);
await writeFile(
path.join(repo, 'routes.go'),
`package routes
import (
"github.com/gin-gonic/gin"
"example.com/bindings/handlers"
)
func Handle(*gin.Context) {}
type A struct{}
func (*A) Do(*gin.Context) {}
type B struct{}
func (*B) Do(*gin.Context) {}
func NewH() *A { return &A{} }
func Bare(r *gin.Engine, Handle gin.HandlerFunc) { r.GET("/bare", Handle) }
func Constructor(r *gin.Engine, NewH func() *B) {
h := NewH()
r.GET("/constructor", h.Do)
}
func Captured() {
_ = handlers.Do
handlers := &B{}
func() { r := gin.New(); r.GET("/captured", handlers.Do) }()
}
func Write(r *gin.Engine) {
var h interface{ Do(*gin.Context) } = &A{}
func() { h = &B{} }()
r.GET("/write", h.Do)
}
type FakeRouter struct{}
func (FakeRouter) GET(string, gin.HandlerFunc) {}
func Shadow(r *gin.Engine) {
{ var r FakeRouter; r.GET("/fake", Handle) }
r.GET("/real", Handle)
}
func TypeShadow(r *gin.Engine) {
type A = B
h := &A{}
r.GET("/type", h.Do)
}
func Independent(r *gin.Engine) {
h := &A{}
func() { h := &B{}; _ = h }()
r.GET("/independent", h.Do)
}
`,
);
result = await runPipelineFromRepo(repo, () => {}, {});
}, 300_000);
afterAll(async () => {
if (repo) await rm(repo, { recursive: true, force: true });
});
const route = (url: string) => {
const nodes: { id: string; handler: unknown }[] = [];
result.graph.forEachNode((node) => {
if (node.label === 'Route' && node.properties.name === url) {
nodes.push({ id: node.id, handler: node.properties.handlerSymbolId });
}
});
return nodes;
};
it.each(['/bare', '/constructor', '/type', '/write'])(
'keeps %s without an unproven handler edge',
(url) => {
const routes = route(url);
expect(routes).toHaveLength(1);
expect(routes[0]?.handler).toBeUndefined();
const incoming: string[] = [];
result.graph.forEachRelationship((edge) => {
if (edge.type === 'HANDLES_ROUTE' && edge.targetId === routes[0]?.id)
incoming.push(edge.sourceId);
});
// Unresolved routes retain their file-level ownership edge.
expect(incoming).toEqual(['File:routes.go']);
},
);
it('selects the captured local type instead of the imported function', () => {
expect(route('/captured')[0]?.handler).toMatch(/^Method:routes.go:B\.Do/);
});
it('rejects a shadowed non-framework receiver and retains the outer engine', () => {
expect(route('/fake')).toEqual([]);
expect(route('/real')[0]?.handler).toBe('Function:routes.go:Handle');
});
it('retains facts when a nested declaration does not write the captured variable', () => {
expect(route('/independent')[0]?.handler).toMatch(/^Method:routes.go:A\.Do/);
});
});

View file

@ -0,0 +1,72 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import type { PipelineResult } from '../../src/types/pipeline.js';
import { runPipelineFromRepo } from '../../src/core/ingestion/pipeline.js';
describe('Go imported package identities', () => {
let repo: string;
let result: PipelineResult;
beforeAll(async () => {
repo = await mkdtemp(path.join(os.tmpdir(), 'gitnexus-go-package-names-'));
const files: Record<string, string> = {
'go.mod': 'module example.com/app\n\ngo 1.24\n',
'api/v2/handlers.go':
'package v2\nimport "github.com/gin-gonic/gin"\nfunc Handle(*gin.Context) {}\n',
'storage/handlers.go':
'package endpoints\nimport "github.com/gin-gonic/gin"\nfunc Handle(*gin.Context) {}\n',
'endpoints/handlers.go':
'package competing\nimport "github.com/gin-gonic/gin"\nfunc Handle(*gin.Context) {}\n',
'aliased/handlers.go':
'package original\nimport "github.com/gin-gonic/gin"\nfunc Handle(*gin.Context) {}\n',
'routes.go': `package app
import (
"github.com/gin-gonic/gin"
"example.com/app/api/v2"
"example.com/app/storage"
"example.com/app/endpoints"
renamed "example.com/app/aliased"
)
func Register(r *gin.Engine) {
r.GET("/version", v2.Handle)
r.GET("/declared", endpoints.Handle)
r.GET("/competing", competing.Handle)
r.GET("/alias", renamed.Handle)
}
func CallVersion() { v2.Handle(nil) }
func CallDeclared() { endpoints.Handle(nil) }
func CallCompeting() { competing.Handle(nil) }
func CallAlias() { renamed.Handle(nil) }
`,
};
for (const [file, content] of Object.entries(files)) {
await mkdir(path.dirname(path.join(repo, file)), { recursive: true });
await writeFile(path.join(repo, file), content);
}
result = await runPipelineFromRepo(repo, () => {}, {});
}, 300_000);
afterAll(async () => {
if (repo) await rm(repo, { recursive: true, force: true });
});
it.each([
['/version', 'api/v2/handlers.go', 'CallVersion'],
['/declared', 'storage/handlers.go', 'CallDeclared'],
['/competing', 'endpoints/handlers.go', 'CallCompeting'],
['/alias', 'aliased/handlers.go', 'CallAlias'],
])('binds %s using the declared package name or explicit alias', (url, target, caller) => {
const route = result.graph.getNode(`Route:GET ${url}`);
const handler = result.graph.getNode(String(route?.properties.handlerSymbolId));
expect(handler?.properties.filePath).toBe(target);
const callTargets: string[] = [];
result.graph.forEachRelationship((edge) => {
if (edge.type !== 'CALLS') return;
if (result.graph.getNode(edge.sourceId)?.properties.name !== caller) return;
callTargets.push(String(result.graph.getNode(edge.targetId)?.properties.filePath));
});
expect(callTargets).toEqual([target]);
});
});

View file

@ -0,0 +1,93 @@
/**
* `impact` upstream from a service method names the HTTP route its handler
* serves (#3402), against a real LadybugDB.
*
* The seed is the shape `go-gin-route-pipeline.test.ts` proves the pipeline
* emits for the issue's router: the handler CALLS the service, and
* (handler)-[HANDLES_ROUTE]->Route. Before #3402 the walk stopped at the handler
* and the route never appeared; the unit test mocks the database, so this is
* the only place the enrichment query runs against the real engine.
*/
import { describe, it, expect, beforeAll, vi } from 'vitest';
import { LocalBackend } from '../../src/mcp/local/local-backend.js';
import { listRegisteredRepos } from '../../src/storage/repo-manager.js';
import { withTestLbugDB } from '../helpers/test-indexed-db.js';
vi.mock('../../src/storage/repo-manager.js', () => ({
listRegisteredRepos: vi.fn().mockResolvedValue([]),
cleanupOldKuzuFiles: vi.fn().mockResolvedValue({ found: false, needsReindex: false }),
findSiblingClones: vi.fn().mockResolvedValue([]),
}));
const HANDLER = 'Method:router/match_handler.go:MatchHandler.UnfinalizeRoundHandle#1';
const SERVICE = 'Method:service/match.go:Service.UnfinalizeRound#2';
const ROUTE = 'Route:POST /api/v1/admin/seasons/:seasonId/rounds/:roundId/unfinalize';
const SEED = [
`CREATE (m:Method {id:'${SERVICE}', name:'UnfinalizeRound', filePath:'service/match.go', startLine:4, endLine:6, isExported:true, content:'', description:''})`,
`CREATE (m:Method {id:'${HANDLER}', name:'UnfinalizeRoundHandle', filePath:'router/match_handler.go', startLine:4, endLine:7, isExported:true, content:'', description:''})`,
`CREATE (r:Route {id:'${ROUTE}', name:'/api/v1/admin/seasons/:seasonId/rounds/:roundId/unfinalize', filePath:'router/router.go', method:'POST', handlerSymbolId:'${HANDLER}'})`,
`MATCH (a:Method {id:'${HANDLER}'}), (b:Method {id:'${SERVICE}'}) CREATE (a)-[:CodeRelation {type:'CALLS', confidence:0.9, reason:'import-resolved', step:0}]->(b)`,
`MATCH (a:Method {id:'${HANDLER}'}), (r:Route {id:'${ROUTE}'}) CREATE (a)-[:CodeRelation {type:'HANDLES_ROUTE', confidence:1.0, reason:'gin-route', step:0}]->(r)`,
];
withTestLbugDB(
'impact-affected-routes',
(handle) => {
let backend: LocalBackend;
beforeAll(() => {
backend = (handle as unknown as { _backend: LocalBackend })._backend;
});
describe('impact: affected_routes', () => {
it('reaches the route through the handler that calls the service', async () => {
const result = await backend.callTool('impact', {
target: 'UnfinalizeRound',
direction: 'upstream',
});
const handlerItem = Object.values(result.byDepth as Record<string, Array<{ id: string }>>)
.flat()
.find((item) => item.id === HANDLER);
expect({
partial: result.partial,
affected_routes: result.affected_routes,
handlerRoutes: (handlerItem as { routes?: unknown } | undefined)?.routes,
}).toEqual({
partial: undefined,
affected_routes: [
{
url: '/api/v1/admin/seasons/:seasonId/rounds/:roundId/unfinalize',
method: 'POST',
},
],
handlerRoutes: [
{
url: '/api/v1/admin/seasons/:seasonId/rounds/:roundId/unfinalize',
method: 'POST',
},
],
});
});
});
},
{
seed: SEED,
poolAdapter: true,
afterSetup: async (handle) => {
vi.mocked(listRegisteredRepos).mockResolvedValue([
{
name: 'test-repo',
path: '/test/repo',
storagePath: handle.tmpHandle.dbPath,
indexedAt: new Date().toISOString(),
lastCommit: 'abc123',
stats: { files: 5, nodes: 3, communities: 0, processes: 0 },
},
]);
const backend = new LocalBackend();
await backend.init();
(handle as unknown as { _backend: LocalBackend })._backend = backend;
},
},
);

View file

@ -69,7 +69,7 @@ describe('Go external vs internal test packages — qualified vs bare NewThing (
'pkg/a_ext_test.go': [
'package a_test',
'',
'import "example.com/extpkg/pkg"',
'import pkg "example.com/extpkg/pkg"',
'',
'func CallQualifiedFromExternalTest() int {',
'\treturn pkg.NewThing()',

View file

@ -0,0 +1,396 @@
import { describe, expect, it } from 'vitest';
import Parser from 'tree-sitter';
import Go from 'tree-sitter-go';
import { extractGoGinEchoRoutes } from '../../src/core/ingestion/route-extractors/go-gin-echo.js';
const parser = new Parser();
parser.setLanguage(Go);
const GIN = `import "github.com/gin-gonic/gin"\n`;
const ECHO = `import "github.com/labstack/echo/v4"\n`;
const extract = (body: string, header = GIN) =>
extractGoGinEchoRoutes(parser.parse(`package router\n${header}${body}`), 'router/router.go');
/** `VERB url -> handler` per route, so a lost handler reads as `undefined` in the diff. */
const summary = (body: string, header = GIN) =>
extract(body, header).map((r) => `${r.httpMethod} ${r.routePath} -> ${r.handlerName}`);
describe('gin / echo route extraction', () => {
it('joins nested Group prefixes for the issue #3402 router', () => {
const routes = extract(`
func RegisterRoutes(r *gin.Engine, svc *service.Service) {
matchHandler := NewMatchHandler(svc.Match)
v1 := r.Group("/api/v1")
{
admin := v1.Group("/admin")
admin.POST("/seasons/:seasonId/rounds/:roundId/unfinalize", matchHandler.UnfinalizeRoundHandle)
}
}`);
expect(routes).toEqual([
{
filePath: 'router/router.go',
routePath: '/api/v1/admin/seasons/:seasonId/rounds/:roundId/unfinalize',
httpMethod: 'POST',
decoratorName: 'POST',
lineNumber: 9,
prefix: null,
source: 'gin-route',
handlerName: 'matchHandler.UnfinalizeRoundHandle',
handlerReceiver: { kind: 'constructor', name: 'NewMatchHandler' },
},
]);
});
it('follows chained Group calls and engine constructors', () => {
expect(
summary(`
func Setup() {
r := gin.Default()
r.Group("/api").Group("/v1").GET("/x", Health)
gin.New().GET("/root", Health)
}`),
).toEqual(['GET /api/v1/x -> Health', 'GET /root -> Health']);
});
it('takes the last argument as the gin handler, skipping middleware', () => {
expect(summary(`func S(r *gin.Engine) { r.GET("/x", authMW, h.List) }`)).toEqual([
'GET /x -> h.List',
]);
});
it('takes the second argument as the echo handler, skipping trailing middleware', () => {
const routes = extract(
`func S(e *echo.Echo) { g := e.Group("/api"); g.GET("/users", h.List, authMW) }`,
ECHO,
);
expect(routes.map((r) => `${r.source} ${r.routePath} -> ${r.handlerName}`)).toEqual([
'echo-route /api/users -> h.List',
]);
});
it('honors an aliased framework import', () => {
expect(
summary(
`func S() { e := g.Default(); e.PUT(\`/raw\`, H) }`,
`import g "github.com/gin-gonic/gin"\n`,
),
).toEqual(['PUT /raw -> H']);
});
describe('Go string literals', () => {
it.each([
[String.raw`"\x2fapi"`, String.raw`"\057users"`, '/api/users'],
[String.raw`"\u002fapi"`, String.raw`"/\U0001F600"`, '/api/😀'],
[String.raw`"/caf\xc3\xa9"`, String.raw`"/\303\251"`, '/café/é'],
[String.raw`"/a\"b"`, String.raw`"/c\\d"`, '/a"b/c\\d'],
['`/a\rb`', '`/c\rd`', '/ab/cd'],
])('decodes group %s and route %s', (group, route, expected) => {
expect(summary(`func S(r *gin.Engine) { r.Group(${group}).GET(${route}, H) }`)).toEqual([
`GET ${expected} -> H`,
]);
});
it.each([
String.raw`"/\q"`,
String.raw`"/\400"`,
String.raw`"/\uD800"`,
String.raw`"/\U00110000"`,
String.raw`"/\x2"`,
String.raw`"/\'"`,
// Go permits arbitrary byte strings, but these have no lossless UTF-8 URL.
String.raw`"/\xff"`,
])('declines invalid or unrepresentable path %s', (route) => {
expect(summary(`func S(r *gin.Engine) { r.GET(${route}, H) }`)).toEqual([]);
});
});
describe('framework alias shadowing', () => {
it.each([
'func S(gin Factory) { gin.New().GET("/x", H) }',
'func (gin Factory) S() { gin.New().GET("/x", H) }',
'func S() (gin Factory) { gin.New().GET("/x", H); return }',
'func S() { gin := Factory{}; gin.New().GET("/x", H) }',
'func S() { var gin Factory; gin.New().GET("/x", H) }',
'func S() { const gin = Factory(1); gin.New().GET("/x", H) }',
'func S() { type gin = Factory; gin.New(value).GET("/x", H) }',
'func S() { for gin := range factories { gin.New().GET("/x", H) } }',
'func S() { select { case gin := <-factories: gin.New().GET("/x", H) } }',
'func S(x any) { switch gin := x.(type) { case Factory: gin.New().GET("/x", H) } }',
'func S(gin Factory) { func() { gin.New().GET("/x", H) }() }',
])('does not trust a shadowed constructor: %s', (body) => {
expect(summary(body)).toEqual([]);
});
it('also checks an explicitly aliased echo import', () => {
expect(
summary(
'func S(e Factory) { r := e.New(); r.GET("/x", H) }',
'import e "github.com/labstack/echo/v4"\n',
),
).toEqual([]);
});
it('keeps independent functions using the real package', () => {
expect(
summary(`
func S(gin Factory) { gin.New().GET("/wrong", H) }
func T() { gin.New().GET("/right", H) }
`),
).toEqual(['GET /right -> H']);
});
});
describe('fails closed on unproven prefixes', () => {
it('drops routes on a RouterGroup parameter', () => {
expect(summary(`func registerAdmin(g *gin.RouterGroup) { g.GET("/x", h) }`)).toEqual([]);
});
it('preserves an incoming group prefix as unknown after a later assignment', () => {
expect(
summary(`func S(r *gin.Engine, g *gin.RouterGroup) {
g.GET("/before", H)
g = r.Group("/api")
}`),
).toEqual([]);
});
it('drops routes beneath a non-literal group path', () => {
expect(
summary(
`func S(r *gin.Engine) { v := r.Group(base); v.GET("/x", h); v.Group("/a").GET("/y", h) }`,
),
).toEqual([]);
});
it('drops routes on a name assigned two different routers', () => {
expect(
summary(`func S(r *gin.Engine) { v := r.Group("/a"); v = r.Group("/b"); v.GET("/x", h) }`),
).toEqual([]);
});
it('drops routes on a struct-field engine', () => {
expect(summary(`func (s *Server) routes() { s.router.GET("/x", h) }`)).toEqual([]);
});
it('drops routes on mutually-derived groups', () => {
expect(
summary(`func S(r *gin.Engine) { a := b.Group("/x"); b := a.Group("/y"); a.GET("/z", h) }`),
).toEqual([]);
});
it('drops routes a closure registers on an outer group', () => {
expect(
summary(`func S(r *gin.Engine) { v := r.Group("/a"); go func() { v.GET("/x", h) }() }`),
).toEqual([]);
});
it('drops routes on a name bound to the second value of a call', () => {
expect(summary(`func S(r *gin.Engine) { _, v := pair(r); v.GET("/x", h) }`)).toEqual([]);
});
it('drops routes whose path is not a literal', () => {
expect(summary(`func S(r *gin.Engine) { r.GET(path, h) }`)).toEqual([]);
});
});
it('pairs a parallel assignment of groups positionally', () => {
expect(
summary(
`func S(r *gin.Engine) { a, b := r.Group("/a"), r.Group("/b"); a.GET("/x", H); b.GET("/y", H) }`,
),
).toEqual(['GET /a/x -> H', 'GET /b/y -> H']);
});
describe('framework gate', () => {
it('ignores .GET calls in a file that imports neither framework', () => {
expect(
summary(`func C() { client.R().GET("/x", h) }`, `import "github.com/go-resty/resty/v2"\n`),
).toEqual([]);
});
it('ignores a file that imports both frameworks', () => {
expect(
summary(
`func S(r *gin.Engine) { r.GET("/x", h) }`,
`import (\n "github.com/gin-gonic/gin"\n "github.com/labstack/echo/v4"\n)\n`,
),
).toEqual([]);
});
});
describe('handler receiver hints', () => {
/** `extraParams` joins the engine parameter; `decl` opens the body. */
const hintFor = (decl: string, extraParams = '') =>
extract(`func S(r *gin.Engine${extraParams}) {
${decl}
r.GET("/x", h.Do)
}`)[0]?.handlerReceiver;
it.each([
['h := &T{}', { kind: 'type', name: 'T' }],
['h := T{}', { kind: 'type', name: 'T' }],
['h := &pkg.T{}', { kind: 'type', name: 'T', qualifier: 'pkg' }],
['var h *T', { kind: 'type', name: 'T' }],
['h, err := pkg.NewT(x)', { kind: 'constructor', name: 'NewT', qualifier: 'pkg' }],
['h := NewT()', { kind: 'constructor', name: 'NewT' }],
])('%s', (decl, expected) => {
expect(hintFor(decl)).toEqual(expected);
});
it('reads a typed parameter', () => {
expect(hintFor('', ', h *pkg.T')).toEqual({ kind: 'type', name: 'T', qualifier: 'pkg' });
});
it('preserves incoming interface evidence alongside a later concrete assignment', () => {
const routes = extract(`
type Handler interface{ Do(*gin.Context) }
func S(r *gin.Engine, h Handler) {
r.GET("/before", h.Do)
h = &A{}
}`);
expect(routes).toHaveLength(1);
expect(routes[0]).toMatchObject({ routePath: '/before', handlerName: 'h.Do' });
expect(routes[0].handlerReceiver).toBeUndefined();
});
it('marks an import qualifier as a module handler', () => {
const routes = extract(
`func S(r *gin.Engine) { r.GET("/health", handlers.Health) }`,
`import (\n "github.com/gin-gonic/gin"\n "example.com/app/handlers"\n)\n`,
);
expect(routes[0]).toMatchObject({
handlerName: 'handlers.Health',
handlerReceiver: { kind: 'module', qualifier: 'handlers' },
});
});
it('carries no hint when assignments disagree', () => {
expect(hintFor('h := &A{}; h = &B{}')).toBeUndefined();
});
it('emits a func-literal handler without a handler name', () => {
const routes = extract(`func S(r *gin.Engine) { r.GET("/x", func(c *gin.Context) {}) }`);
expect(routes.map((r) => [r.routePath, r.handlerName])).toEqual([['/x', undefined]]);
});
});
});
describe('Go lexical binding identity', () => {
it('does not attach shadowed bare handlers or constructors to package names', () => {
const routes = extract(`
func Handle(c *gin.Context) {}
func NewH() *A { return nil }
func S(r *gin.Engine, Handle gin.HandlerFunc, NewH func() *B) {
r.GET("/bare", Handle)
h := NewH()
r.GET("/constructor", h.Do)
}`);
expect(routes[0]?.handlerName).toBeUndefined();
expect(routes[1]?.handlerReceiver).toBeUndefined();
});
it('keeps inner and outer router declarations separate', () => {
expect(
summary(`func S(r *gin.Engine) {
{ var r FakeRouter; r.GET("/fake", H) }
r.GET("/real", H)
}`),
).toEqual(['GET /real -> H']);
});
it.each([
['switch', 'switch x { default: r := gin.New(); _ = r; case 1: r.GET("/fake", H) }'],
['select', 'select { default: r := gin.New(); _ = r; case <-ready: r.GET("/fake", H) }'],
])('keeps a %s default declaration inside its clause', (_kind, statement) => {
expect(summary(`func S(r FakeRouter, x int, ready chan bool) { ${statement} }`)).toEqual([]);
});
it('resolves a captured local before an imported qualifier', () => {
const routes = extract(
`func S(handlers *B) {
func() { r := gin.New(); r.GET("/captured", handlers.Do) }()
}`,
GIN + 'import "example.com/app/handlers"\n',
);
expect(routes[0]?.handlerReceiver).toEqual({ kind: 'type', name: 'B' });
});
it('declines local type names that shadow package types', () => {
const routes = extract(`func S(r *gin.Engine) {
type A = B
h := &A{}
r.GET("/type", h.Do)
}`);
expect(routes[0]?.handlerReceiver).toBeUndefined();
});
it('invalidates handler and router facts after captured writes', () => {
const routes = extract(`func S(r *gin.Engine) {
var h interface{ Do(*gin.Context) } = &A{}
g := r.Group("/a")
func() { h = &B{}; g = r.Group("/b") }()
r.GET("/handler", h.Do)
g.GET("/group", H)
}`);
expect(routes.map((r) => r.routePath)).toEqual(['/handler']);
expect(routes[0]?.handlerReceiver).toBeUndefined();
});
it.each([
['range', '[]', 'for _, h = range handlers {}; for _, g = range groups {}'],
['receive', 'chan ', 'select { case h = <-handlers: }; select { case g = <-groups: }'],
])(
'invalidates handler and router facts after captured %s assignments',
(_kind, container, writes) => {
const routes = extract(`
type Handler interface{ Do(*gin.Context) }
func S(r *gin.Engine, handlers ${container}Handler, groups ${container}*gin.RouterGroup) {
var h Handler = &A{}
g := r.Group("/a")
func() { ${writes} }()
r.GET("/handler", h.Do)
g.GET("/group", H)
}`);
expect(routes.map((r) => r.routePath)).toEqual(['/handler']);
expect(routes[0].handlerReceiver).toBeUndefined();
},
);
it('does not confuse nested declarations with captured writes', () => {
const routes = extract(`func S(r *gin.Engine) {
h := &A{}
func() { h := &B{}; _ = h }()
r.GET("/handler", h.Do)
}`);
expect(routes[0]?.handlerReceiver).toEqual({ kind: 'type', name: 'A' });
});
it('honors declaration order and independent block scopes', () => {
expect(
summary(`func S() {
gin.New().GET("/before", H)
{ gin := Factory{}; gin.New().GET("/fake", H) }
gin := Factory{}
gin.New().GET("/after", H)
}`),
).toEqual(['GET /before -> H']);
});
it('does not shadow package names in a short declaration initializer', () => {
expect(summary(`func S() { gin := gin.New(); gin.GET("/real", H) }`)).toEqual([
'GET /real -> H',
]);
});
});
describe('Go route type-parameter shadowing', () => {
it('does not treat a type parameter as a same-named package type', () => {
const routes = extract(`func S[T interface{ Do(*gin.Context) }](r *gin.Engine, h T) {
r.GET("/generic", h.Do)
}`);
expect(routes).toHaveLength(1);
expect(routes[0]).toMatchObject({ routePath: '/generic', handlerName: 'h.Do' });
expect(routes[0].handlerReceiver).toBeUndefined();
});
});

View file

@ -0,0 +1,308 @@
/**
* `resolveGoRouteHandler` (#3402): gin/echo handler designators resolved across
* the files of a Go package, unique-or-decline at every step.
*
* The model is built the way the parse phase leaves it: a Method's `ownerId` is
* the worker's `Struct:<methodFile>:<Receiver>` id, keyed on the method's own
* file — not the struct's node id when the struct lives in a sibling file.
*/
import { describe, expect, it } from 'vitest';
import { createSemanticModel } from '../../src/core/ingestion/model/index.js';
import { resolveGoRouteHandler } from '../../src/core/ingestion/languages/go/route-handler.js';
import type { ExtractedDecoratorRoute } from '../../src/core/ingestion/workers/parse-worker.js';
import { generateId } from '../../src/lib/utils.js';
const ROUTER = 'app/router/router.go';
type Model = ReturnType<typeof createSemanticModel>;
const struct = (model: Model, file: string, name: string) =>
model.symbols.add(file, name, `Struct:${file}:${name}`, 'Struct');
/** A method as the worker registers it: owner keyed on the METHOD's file. */
const method = (model: Model, file: string, receiver: string, name: string) =>
model.symbols.add(file, name, `Method:${file}:${receiver}.${name}`, 'Method', {
ownerId: generateId('Struct', `${file}:${receiver}`),
});
const fn = (model: Model, file: string, name: string, returnType?: string) =>
model.symbols.add(file, name, `Function:${file}:${name}`, 'Function', { returnType });
const route = (overrides: Partial<ExtractedDecoratorRoute>): ExtractedDecoratorRoute => ({
filePath: ROUTER,
routePath: '/x',
httpMethod: 'GET',
decoratorName: 'GET',
lineNumber: 1,
source: 'gin-route',
...overrides,
});
const resolve = (
model: Model,
overrides: Partial<ExtractedDecoratorRoute>,
imports: Readonly<Record<string, readonly string[]>> = {},
) =>
resolveGoRouteHandler(route(overrides), {
model,
importTargetsFor: (_from, localName) => imports[localName] ?? [],
});
describe('resolveGoRouteHandler', () => {
it('deduplicates a method indexed by both its canonical owner and receiver name', () => {
const model = createSemanticModel();
struct(model, 'app/router/h.go', 'H');
method(model, 'app/router/h.go', 'H', 'Do');
const context = { model, importTargetsFor: () => [] };
const registration = route({
handlerName: 'h.Do',
handlerReceiver: { kind: 'type', name: 'H' },
});
expect(resolveGoRouteHandler(registration, context)).toBe('Method:app/router/h.go:H.Do');
expect(resolveGoRouteHandler(registration, context)).toBe('Method:app/router/h.go:H.Do');
});
it('rebuilds package indexes for a new resolution pass over a changed model', () => {
const model = createSemanticModel();
struct(model, 'app/router/types.go', 'H');
method(model, 'app/router/h.go', 'H', 'Do');
const registration = {
handlerName: 'h.Do',
handlerReceiver: { kind: 'type', name: 'H' },
} as const;
expect(resolve(model, registration)).toBe('Method:app/router/h.go:H.Do');
method(model, 'app/router/other.go', 'H', 'Do');
expect(resolve(model, registration)).toBeUndefined();
});
it('resolves a constructor-built receiver to a method declared in a sibling file (issue #3402)', () => {
const model = createSemanticModel();
struct(model, 'app/router/types.go', 'MatchHandler');
fn(model, 'app/router/types.go', 'NewMatchHandler', '*MatchHandler');
method(model, 'app/router/match_handler.go', 'MatchHandler', 'UnfinalizeRoundHandle');
expect(
resolve(model, {
handlerName: 'matchHandler.UnfinalizeRoundHandle',
handlerReceiver: { kind: 'constructor', name: 'NewMatchHandler' },
}),
).toBe('Method:app/router/match_handler.go:MatchHandler.UnfinalizeRoundHandle');
});
it('reads the first result of a (T, error) constructor', () => {
const model = createSemanticModel();
struct(model, 'app/router/h.go', 'H');
fn(model, 'app/router/h.go', 'NewH', '(*H, error)');
method(model, 'app/router/h.go', 'H', 'Do');
expect(
resolve(model, {
handlerName: 'h.Do',
handlerReceiver: { kind: 'constructor', name: 'NewH' },
}),
).toBe('Method:app/router/h.go:H.Do');
});
it('declines a constructor whose result type lives in another package', () => {
const model = createSemanticModel();
fn(model, 'app/router/h.go', 'NewH', '*other.H');
method(model, 'app/router/h.go', 'H', 'Do');
expect(
resolve(model, {
handlerName: 'h.Do',
handlerReceiver: { kind: 'constructor', name: 'NewH' },
}),
).toBeUndefined();
});
describe.each(['Händler', '处理器', '𐐀Handler', '_Händler_١'])(
'a constructor returning the Go identifier %s',
(owner) => {
it.each(['%s', '*%s', '(*%s, error)'])(
'resolves its sibling-file method for result %s',
(result) => {
const model = createSemanticModel();
struct(model, 'app/router/types.go', owner);
fn(model, 'app/router/constructor.go', 'NewHandler', result.replace('%s', owner));
method(model, 'app/router/handler.go', owner, 'Do');
expect(
resolve(model, {
handlerName: 'h.Do',
handlerReceiver: { kind: 'constructor', name: 'NewHandler' },
}),
).toBe(`Method:app/router/handler.go:${owner}.Do`);
},
);
},
);
it.each([
'1Handler',
'١Handler',
'Ha\u0308ndler',
'Handler²',
'HandlerⅣ',
'other.Handler',
'[]Handler',
'map[string]Handler',
])('declines the unsupported constructor result %s even with matching metadata', (result) => {
const model = createSemanticModel();
struct(model, 'app/router/types.go', result);
fn(model, 'app/router/constructor.go', 'NewHandler', result);
method(model, 'app/router/handler.go', result, 'Do');
expect(
resolve(model, {
handlerName: 'h.Do',
handlerReceiver: { kind: 'constructor', name: 'NewHandler' },
}),
).toBeUndefined();
});
it('resolves a type hint through an import qualifier', () => {
const model = createSemanticModel();
struct(model, 'app/handlers/auth.go', 'Auth');
method(model, 'app/handlers/auth.go', 'Auth', 'Login');
expect(
resolve(
model,
{
handlerName: 'h.Login',
handlerReceiver: { kind: 'type', name: 'Auth', qualifier: 'hs' },
},
{ hs: ['app/handlers/auth.go', 'app/handlers/util.go'] },
),
).toBe('Method:app/handlers/auth.go:Auth.Login');
});
it('resolves a package function through its import', () => {
const model = createSemanticModel();
fn(model, 'app/handlers/health.go', 'Health');
fn(model, 'app/other/health.go', 'Health');
expect(
resolve(
model,
{
handlerName: 'handlers.Health',
handlerReceiver: { kind: 'module', qualifier: 'handlers' },
},
{ handlers: ['app/handlers/health.go'] },
),
).toBe('Function:app/handlers/health.go:Health');
});
it('declines a module handler whose import is not in the workspace', () => {
const model = createSemanticModel();
fn(model, 'app/router/x.go', 'Wrap');
expect(
resolve(model, {
handlerName: 'gin.Wrap',
handlerReceiver: { kind: 'module', qualifier: 'gin' },
}),
).toBeUndefined();
});
it('resolves a bare function in the route package only', () => {
const model = createSemanticModel();
fn(model, 'app/router/health.go', 'Health');
fn(model, 'app/other/health.go', 'Health');
expect(resolve(model, { handlerName: 'Health' })).toBe('Function:app/router/health.go:Health');
});
it('declines a receiver of unknown type even when the name is unique in the router package', () => {
// `h := deps.Users` — the real handler usually lives in another package, so a
// same-named method in the router's own directory belongs to an unrelated type.
const model = createSemanticModel();
method(model, 'app/router/page.go', 'Page', 'List');
expect(resolve(model, { handlerName: 'h.List' })).toBeUndefined();
});
it('ignores _test.go siblings when judging uniqueness', () => {
const model = createSemanticModel();
struct(model, 'app/router/auth.go', 'AuthHandler');
method(model, 'app/router/auth.go', 'AuthHandler', 'Login');
method(model, 'app/router/auth_test.go', 'AuthHandler', 'Login');
expect(
resolve(model, {
handlerName: 'h.Login',
handlerReceiver: { kind: 'type', name: 'AuthHandler' },
}),
).toBe('Method:app/router/auth.go:AuthHandler.Login');
});
it.each([
['a qualifier whose import spans two directories', { kind: 'module', qualifier: 'split' }],
['a type hint with no name', { kind: 'type' }],
] as const)('declines %s', (_label, handlerReceiver) => {
const model = createSemanticModel();
fn(model, 'app/a/h.go', 'Do');
method(model, 'app/router/t.go', 'T', 'Do');
expect(
resolve(
model,
{ handlerName: 'h.Do', handlerReceiver },
{ split: ['app/a/h.go', 'app/b/h.go'] },
),
).toBeUndefined();
});
it('declines a deeper selector', () => {
const model = createSemanticModel();
method(model, 'app/router/t.go', 'T', 'Do');
expect(resolve(model, { handlerName: 'a.b.Do' })).toBeUndefined();
});
it('declines when two same-named constructors exist in the package', () => {
const model = createSemanticModel();
struct(model, 'app/router/h.go', 'H');
fn(model, 'app/router/h.go', 'NewH', '*H');
fn(model, 'app/router/h2.go', 'NewH', '*H');
method(model, 'app/router/h.go', 'H', 'Do');
expect(
resolve(model, {
handlerName: 'h.Do',
handlerReceiver: { kind: 'constructor', name: 'NewH' },
}),
).toBeUndefined();
});
describe('a failed hint never falls back to a name-only match', () => {
it.each([
['the hinted type is not in the package', () => {}],
[
'two structs share the hinted name',
(model: Model) => {
struct(model, 'app/router/t1.go', 'T');
struct(model, 'app/router/t2.go', 'T');
},
],
[
'the hinted type has no such method',
(model: Model) => {
struct(model, 'app/router/t.go', 'T');
},
],
])('declines when %s', (_label, arrange) => {
const model = createSemanticModel();
arrange(model);
method(model, 'app/router/other.go', 'Other', 'Do');
expect(
resolve(model, { handlerName: 'h.Do', handlerReceiver: { kind: 'type', name: 'T' } }),
).toBeUndefined();
});
});
});

View file

@ -0,0 +1,142 @@
/**
* `impact` names the HTTP routes in its blast radius (#3402).
*
* The routes reach the result through (handler)-[HANDLES_ROUTE]->Route, read for
* the target and every impacted symbol. Before this, a service method's upstream
* impact stopped at the handler: the route and its frontend callers never
* appeared.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
const executeParameterizedMock = vi.fn();
vi.mock('../../src/core/lbug/pool-adapter.js', async (importOriginal) => {
const actual = await importOriginal();
return {
...(actual as object),
initLbug: vi.fn(),
executeQuery: vi.fn(async () => []),
executeParameterized: (...args: unknown[]) => executeParameterizedMock(...args),
closeLbug: vi.fn(),
isLbugReady: vi.fn().mockReturnValue(true),
};
});
vi.mock('../../src/mcp/core/lbug-adapter.js', async (importOriginal) => {
const actual = await importOriginal();
return {
...(actual as object),
initLbug: vi.fn(),
executeQuery: vi.fn(async () => []),
executeParameterized: (...args: unknown[]) => executeParameterizedMock(...args),
closeLbug: vi.fn(),
isLbugReady: vi.fn().mockReturnValue(true),
};
});
import { LocalBackend } from '../../src/mcp/local/local-backend';
const REPO = {
id: 'repo',
name: 'repo',
repoPath: '/tmp/repo',
storagePath: '/tmp/repo/.gitnexus',
lbugPath: '/tmp/repo/.gitnexus/lbug',
indexedAt: 'now',
lastCommit: 'c',
stats: {},
};
interface RouteRow {
readonly hid: string;
readonly url: string;
readonly method?: string;
}
/**
* Target `svc` is called by `handler` (depth 1). `routeRows` answers the
* HANDLES_ROUTE query; `routeQueryFails` makes it reject.
*/
async function runImpact(routeRows: readonly RouteRow[], routeQueryFails = false) {
executeParameterizedMock.mockImplementation(async (...args: unknown[]) => {
const query = String(args[1] ?? '');
const params = (args[2] ?? {}) as { ids?: string[]; frontierIds?: string[] };
if (query.includes("'HANDLES_ROUTE'")) {
if (routeQueryFails) throw new Error('route query failed');
return routeRows.filter((row) => params.ids?.includes(row.hid));
}
if (query.includes('$frontierIds')) {
return (params.frontierIds ?? []).includes('svc')
? [
{
sourceId: 'svc',
id: 'handler',
name: 'Handle',
type: 'Method',
filePath: 'h.go',
relType: 'CALLS',
confidence: 1,
},
]
: [];
}
if (query.includes('STEP_IN_PROCESS') || query.includes('MEMBER_OF')) return [];
return [{ id: 'svc', name: 'UnfinalizeRound', filePath: 'svc.go', type: 'Method' }];
});
const backend = new LocalBackend();
(backend as unknown as { repos: Map<string, unknown> }).repos.set(REPO.id, REPO);
(backend as unknown as { ensureInitialized: () => Promise<void> }).ensureInitialized = vi
.fn()
.mockResolvedValue(undefined);
return (
backend as unknown as {
_impactImpl: (repo: unknown, params: unknown) => Promise<Record<string, unknown>>;
}
)._impactImpl(REPO, { target: 'UnfinalizeRound', direction: 'upstream', maxDepth: 2 });
}
const depthOneItems = (res: Record<string, unknown>) =>
((res.byDepth as Record<number, Array<Record<string, unknown>>>)[1] ?? []).map((item) => ({
id: item.id,
routes: item.routes,
}));
describe('impact: route enrichment', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('lists the route an impacted handler serves, top-level and on the item', async () => {
const res = await runImpact([
{ hid: 'handler', url: '/api/v1/rounds/:id/unfinalize', method: 'POST' },
]);
expect(res.affected_routes).toEqual([{ url: '/api/v1/rounds/:id/unfinalize', method: 'POST' }]);
expect(depthOneItems(res)).toEqual([
{ id: 'handler', routes: [{ url: '/api/v1/rounds/:id/unfinalize', method: 'POST' }] },
]);
});
it('includes a route the target itself serves, deduplicated by method and url', async () => {
const res = await runImpact([
{ hid: 'svc', url: '/x', method: 'GET' },
{ hid: 'handler', url: '/x', method: 'GET' },
{ hid: 'handler', url: '/x', method: 'POST' },
]);
expect(res.affected_routes).toEqual([
{ url: '/x', method: 'GET' },
{ url: '/x', method: 'POST' },
]);
});
it('keeps the result and flags it partial when the route query fails', async () => {
const res = await runImpact([], true);
expect({
impactedCount: res.impactedCount,
affected_routes: res.affected_routes,
partial: res.partial,
}).toEqual({ impactedCount: 1, affected_routes: [], partial: true });
});
});

View file

@ -301,8 +301,9 @@ describe('PARSE_CACHE_VERSION', () => {
// Moved 120 -> 121 for the #3399 decorator-identity follow-up.
// Moved 121 -> 122 for #3414 restoring helper calls.
// Moved 122 -> 123 for #3408 FastAPI nested router-prefix capture fields.
it('pins SCHEMA_BUMP to 123 so concurrent bumps cannot silently collide (#2766, #3015, #3088, #2885, #3128, #2865, #3130, #1432, #3161, #3179, #3219, #3190, #3253, #3273, #3339, #3354, #3371, #2965, #3390, #3398, #3396, #3394, #3399, #3414, #3408)', () => {
expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).toBe(123);
// Moved 123 -> 124 for #3402 Go gin/echo decorator routes.
it('pins SCHEMA_BUMP to 125 so concurrent bumps cannot silently collide (#2766, #3015, #3088, #2885, #3128, #2865, #3130, #1432, #3161, #3179, #3219, #3190, #3253, #3273, #3339, #3354, #3371, #2965, #3390, #3398, #3396, #3394, #3399, #3414, #3408, #3402)', () => {
expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).toBe(125);
expect(PARSE_CACHE_BUCKET_COUNT).toBe(128);
// The PREVIOUS version must fail the reuse gate, not merely differ from the
// current one — a hardcoded number outside the conflict hunk rebases cleanly
@ -312,6 +313,7 @@ describe('PARSE_CACHE_VERSION', () => {
59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81,
82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99, 100, 101, 102, 103,
104, 105, 106, 107, 108, 109, 110, 111, 112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122,
123, 124,
]) {
expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).not.toBe(taken);
}

View file

@ -106,6 +106,56 @@ it('never admits B and C while A resumes a stale reclaim judgment', async () =>
}
});
it('acquires when the holder releases and exits between the record read and pid probe', async () => {
const owner = await acquireIndexLock(dir);
let releasedDuringProbe = false;
const probe = vi.spyOn(process, 'kill').mockImplementation((pid) => {
expect(pid).toBe(owner.record.pid);
expect(fs.existsSync(guardPath)).toBe(true);
owner.release();
releasedDuringProbe = true;
throw Object.assign(new Error('holder exited'), { code: 'ESRCH' });
});
try {
const successor = await acquireIndexLock(dir);
try {
expect(releasedDuringProbe).toBe(true);
expect(successor.lockFree).toBeUndefined();
expect(fs.existsSync(guardPath)).toBe(false);
owner.release();
expect(JSON.parse(fs.readFileSync(lockPath, 'utf8')).token).toBe(successor.record.token);
} finally {
successor.release();
}
} finally {
probe.mockRestore();
owner.release();
}
});
it.each(['EACCES', 'EIO'])(
'refuses acquisition when stale-lock removal fails with %s',
async (code) => {
const contents = JSON.stringify({
v: 1,
pid: 999999999,
hostname: os.hostname(),
startTime: null,
token: 'dead-holder',
invocationId: 'dead-holder',
acquiredAt: '',
});
fs.writeFileSync(lockPath, contents);
vi.mocked(fs.unlinkSync).mockImplementation((p) => {
if (p === lockPath) throw Object.assign(new Error('stale-lock removal failed'), { code });
actual.unlinkSync(p);
});
await expect(acquireIndexLock(dir)).rejects.toMatchObject({ code });
expect(fs.readFileSync(lockPath, 'utf8')).toBe(contents);
expect(fs.existsSync(guardPath)).toBe(false);
},
);
it('keeps an incomplete creator excluded beyond the old malformed grace', async () => {
// A stopped after O_EXCL creation, before either metadata write completed.
fs.writeFileSync(guardPath, '');

View file

@ -581,3 +581,94 @@ describe('resolveRouteHandlerSymbols — Laravel framework routes', () => {
expect(out.has(routeNodeKey('GET', '/orders'))).toBe(false);
});
});
describe('resolveRouteHandlerSymbols — provider-owned handler resolution', () => {
const GET_X = routeNodeKey('GET', '/x');
const ROUTE_FILE = 'app/router/router.go';
const baseContext = {
files: [
{
filePath: ROUTE_FILE,
localDefs: [],
parsedImports: [
{
kind: 'namespace' as const,
localName: 'handlers',
importedName: 'handlers',
targetRaw: 'example.com/app/handlers',
},
],
},
],
resolveImportTarget: () => null,
isExportedSymbol: () => false,
};
it('asks the route language hook instead of the same-file lookup', () => {
const model = createSemanticModel();
// A same-file def the default path would pick — the hook must win.
model.symbols.add(ROUTE_FILE, 'Login', 'function:same-file', 'Function');
const out = resolveRouteHandlerSymbols(
model,
[],
[decoratorRoute({ filePath: ROUTE_FILE, routePath: '/x', handlerName: 'Login' })],
{ ...baseContext, providerRouteHandler: () => () => 'method:from-hook' },
);
expect(out.get(GET_X)).toBe('method:from-hook');
});
it('hands the hook every file a unique import binding resolves to', () => {
const seen: (readonly string[])[] = [];
resolveRouteHandlerSymbols(
createSemanticModel(),
[],
[decoratorRoute({ filePath: ROUTE_FILE, routePath: '/x', handlerName: 'handlers.H' })],
{
...baseContext,
resolveImportTargets: () => ['app/handlers/a.go', 'app/handlers/b.go'],
providerRouteHandler: () => (route, ctx) => {
seen.push(ctx.importTargetsFor(route.filePath, 'handlers'));
seen.push(ctx.importTargetsFor(route.filePath, 'missing'));
return undefined;
},
},
);
expect(seen).toEqual([['app/handlers/a.go', 'app/handlers/b.go'], []]);
});
it('a declined hook still reserves the identity (first-writer-wins)', () => {
const model = createSemanticModel();
model.symbols.add(FILE, 'list', 'method:later', 'Method');
const out = resolveRouteHandlerSymbols(
model,
[],
[
decoratorRoute({ filePath: ROUTE_FILE, routePath: '/x', handlerName: 'h.List' }),
decoratorRoute({ routePath: '/x', handlerName: 'list' }),
],
{
...baseContext,
providerRouteHandler: (filePath) => (filePath === ROUTE_FILE ? () => undefined : undefined),
},
);
expect(out.has(GET_X)).toBe(false);
});
it('keeps the same-file lookup for a language without the hook', () => {
const model = createSemanticModel();
model.symbols.add(FILE, 'list', 'method:OrderController.list', 'Method');
const out = resolveRouteHandlerSymbols(model, [], [decoratorRoute()], {
...baseContext,
providerRouteHandler: () => undefined,
});
expect(out.get(routeNodeKey('GET', '/orders'))).toBe('method:OrderController.list');
});
});

View file

@ -6,6 +6,7 @@ import {
} from '../../../../src/core/ingestion/languages/go/index.js';
import { getGoParser } from '../../../../src/core/ingestion/languages/go/query.js';
import type { CaptureMatch } from 'gitnexus-shared';
import { resolveGoImportBinding } from '../../../../src/core/ingestion/languages/go/import-binding.js';
function parseThenSplit(src: string): CaptureMatch[] {
const tree = getGoParser().parse(src);
@ -30,6 +31,20 @@ describe('Go import decomposition', () => {
expect(matches[0]['@import.name']?.text).toBe('fmt');
});
it.each([
['"example.com/app/handlers/v2"', 'handlers'],
['h "example.com/app/handlers/v2"', 'h'],
['"example.com/app/v2/handlers"', 'handlers'],
['"example.com/app/v2beta"', 'v2beta'],
['"gopkg.in/yaml.v3"', 'yaml'],
['yamlv3 "gopkg.in/yaml.v3"', 'yamlv3'],
['"gopkg.in/yaml.v3beta"', 'yaml.v3beta'],
])('uses the same package qualifier for %s as route extraction', (spec, expected) => {
const matches = parseThenSplit(`import ${spec}`);
expect(matches[0]['@import.name']?.text).toBe(expected);
expect(interpretGoImport(matches[0])?.localName).toBe(expected);
});
it('decomposes grouped imports', () => {
const src = `import (
"fmt"
@ -71,6 +86,7 @@ describe('Go import interpretation', () => {
localName: 'models',
importedName: 'models',
targetRaw: 'example.com/app/models',
implicitLocalName: true,
});
});
@ -99,6 +115,51 @@ describe('Go import interpretation', () => {
});
});
describe('Go import binding names', () => {
it.each([
['"example.com/app/api/v2"', 'v2'],
['"example.com/app/storage"', 'endpoints'],
['alias "example.com/app/storage"', 'alias'],
['storage "example.com/app/storage"', 'storage'],
])('resolves %s from the package clause while preserving aliases', (spec, expected) => {
const parsed = interpretGoImport(parseThenSplit(`import ${spec}`)[0]);
if (!parsed) throw new Error('Expected parsed import');
const result = resolveGoImportBinding(
parsed,
() => ['pkg/one.go', 'pkg/two.go'],
() => `package ${expected === 'v2' ? 'v2' : 'endpoints'}\n`,
);
expect(result).toMatchObject({ kind: 'namespace', localName: expected });
});
it('retains only the dependency if package clauses conflict', () => {
const parsed = interpretGoImport(parseThenSplit('import "example.com/app/pkg"')[0]);
if (!parsed) throw new Error('Expected parsed import');
expect(
resolveGoImportBinding(
parsed,
() => ['a.go', 'b.go'],
(file) => (file === 'a.go' ? 'package a' : 'package b'),
),
).toEqual({ kind: 'side-effect', targetRaw: 'example.com/app/pkg' });
});
it('does not guess a binding from an unreadable target', () => {
const parsed = interpretGoImport(parseThenSplit('import "example.com/app/pkg"')[0]);
if (!parsed) throw new Error('Expected parsed import');
expect(
resolveGoImportBinding(
parsed,
() => ['missing.go'],
() => undefined,
),
).toEqual({
kind: 'side-effect',
targetRaw: 'example.com/app/pkg',
});
});
});
describe('Go import target resolution', () => {
it('resolves module root imports to root package files', () => {
const result = resolveGoImportTarget(