From e42122a0f6e25e485c6e1774c7e6fadbd62bb124 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Magyar?= Date: Thu, 1 Oct 2026 10:22:45 +0100 Subject: [PATCH] feat(go): index gin/echo routes and report them in impact (#3402) (#3417) --- gitnexus-shared/src/scope-resolution/types.ts | 2 + .../bench/go-route-resolution/baseline.json | 294 +++++++++++++ .../bench/go-route-resolution/measure.cjs | 322 ++++++++++++++ gitnexus/bench/scope-capture/baselines.json | 3 +- gitnexus/src/cli/i18n/en.ts | 2 +- gitnexus/src/cli/i18n/zh-CN.ts | 3 +- gitnexus/src/cli/index.ts | 2 +- gitnexus/src/cli/tool.ts | 2 + gitnexus/src/core/ingestion/call-processor.ts | 38 +- .../src/core/ingestion/language-provider.ts | 34 +- gitnexus/src/core/ingestion/languages/go.ts | 5 + .../ingestion/languages/go/import-binding.ts | 21 + .../languages/go/import-decomposer.ts | 3 +- .../languages/go/import-package-name.ts | 7 + .../core/ingestion/languages/go/interpret.ts | 8 +- .../ingestion/languages/go/route-bindings.ts | 212 ++++++++++ .../ingestion/languages/go/route-handler.ts | 187 +++++++++ .../ingestion/languages/go/scope-resolver.ts | 3 + .../ingestion/pipeline-phases/parse-impl.ts | 56 ++- .../ingestion/route-extractors/go-gin-echo.ts | 392 +++++++++++++++++ .../contract/scope-resolver.ts | 10 + .../scope-resolution/pipeline/run.ts | 25 ++ .../core/ingestion/workers/parse-worker.ts | 21 + gitnexus/src/mcp/local/local-backend.ts | 51 +++ gitnexus/src/mcp/tools.ts | 1 + gitnexus/src/storage/index-lock.ts | 8 +- gitnexus/src/storage/parse-cache.ts | 6 +- .../go-captures-golden/expected-captures.json | 4 +- .../go-gin-route-app/echoapp/routes.go | 17 + .../test/fixtures/go-gin-route-app/go.mod | 8 + .../go-gin-route-app/handlers/health.go | 7 + .../go-gin-route-app/router/auth_handlers.go | 15 + .../go-gin-route-app/router/match_handler.go | 8 + .../go-gin-route-app/router/router.go | 36 ++ .../fixtures/go-gin-route-app/router/types.go | 11 + .../go-gin-route-app/service/match.go | 13 + .../go-gin-route-app/status/v2/status.go | 7 + .../test/integration/cli-limit-e2e.test.ts | 43 +- .../integration/go-gin-route-pipeline.test.ts | 183 ++++++++ .../go-route-binding-identity.test.ts | 112 +++++ .../go-route-package-names.test.ts | 72 ++++ .../impact-affected-routes.test.ts | 93 ++++ .../go-external-test-package.test.ts | 2 +- gitnexus/test/unit/go-gin-echo-routes.test.ts | 396 ++++++++++++++++++ gitnexus/test/unit/go-route-handler.test.ts | 308 ++++++++++++++ .../test/unit/impact-route-enrichment.test.ts | 142 +++++++ .../test/unit/incremental-parse-cache.test.ts | 6 +- .../unit/index-lock-reclaim-guard.test.ts | 50 +++ .../resolve-route-handler-symbols.test.ts | 91 ++++ .../scope-resolution/go/go-imports.test.ts | 61 +++ 50 files changed, 3373 insertions(+), 30 deletions(-) create mode 100644 gitnexus/bench/go-route-resolution/baseline.json create mode 100644 gitnexus/bench/go-route-resolution/measure.cjs create mode 100644 gitnexus/src/core/ingestion/languages/go/import-binding.ts create mode 100644 gitnexus/src/core/ingestion/languages/go/import-package-name.ts create mode 100644 gitnexus/src/core/ingestion/languages/go/route-bindings.ts create mode 100644 gitnexus/src/core/ingestion/languages/go/route-handler.ts create mode 100644 gitnexus/src/core/ingestion/route-extractors/go-gin-echo.ts create mode 100644 gitnexus/test/fixtures/go-gin-route-app/echoapp/routes.go create mode 100644 gitnexus/test/fixtures/go-gin-route-app/go.mod create mode 100644 gitnexus/test/fixtures/go-gin-route-app/handlers/health.go create mode 100644 gitnexus/test/fixtures/go-gin-route-app/router/auth_handlers.go create mode 100644 gitnexus/test/fixtures/go-gin-route-app/router/match_handler.go create mode 100644 gitnexus/test/fixtures/go-gin-route-app/router/router.go create mode 100644 gitnexus/test/fixtures/go-gin-route-app/router/types.go create mode 100644 gitnexus/test/fixtures/go-gin-route-app/service/match.go create mode 100644 gitnexus/test/fixtures/go-gin-route-app/status/v2/status.go create mode 100644 gitnexus/test/integration/go-gin-route-pipeline.test.ts create mode 100644 gitnexus/test/integration/go-route-binding-identity.test.ts create mode 100644 gitnexus/test/integration/go-route-package-names.test.ts create mode 100644 gitnexus/test/integration/impact-affected-routes.test.ts create mode 100644 gitnexus/test/unit/go-gin-echo-routes.test.ts create mode 100644 gitnexus/test/unit/go-route-handler.test.ts create mode 100644 gitnexus/test/unit/impact-route-enrichment.test.ts diff --git a/gitnexus-shared/src/scope-resolution/types.ts b/gitnexus-shared/src/scope-resolution/types.ts index c5c1b4438..7f9a1b919 100644 --- a/gitnexus-shared/src/scope-resolution/types.ts +++ b/gitnexus-shared/src/scope-resolution/types.ts @@ -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`). */ diff --git a/gitnexus/bench/go-route-resolution/baseline.json b/gitnexus/bench/go-route-resolution/baseline.json new file mode 100644 index 000000000..1cedd8ae9 --- /dev/null +++ b/gitnexus/bench/go-route-resolution/baseline.json @@ -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." + } +} diff --git a/gitnexus/bench/go-route-resolution/measure.cjs b/gitnexus/bench/go-route-resolution/measure.cjs new file mode 100644 index 000000000..c10ee5dba --- /dev/null +++ b/gitnexus/bench/go-route-resolution/measure.cjs @@ -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 ?? ''}`), + ), + }; + } + + 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; +}); diff --git a/gitnexus/bench/scope-capture/baselines.json b/gitnexus/bench/scope-capture/baselines.json index c4098a56f..4845f23fb 100644 --- a/gitnexus/bench/scope-capture/baselines.json +++ b/gitnexus/bench/scope-capture/baselines.json @@ -1,7 +1,8 @@ { "_comment": "Per-language baselines for bench/scope-capture/measure.mjs --check. fingerprint = order-independent sha256 over the lang-resolution/-* fixture corpus + a 20-entity synthetic source (correctness gate; re-baseline intentionally on a legitimate capture change). scaling_budget = max allowed (t800/t250)/(800/250); ~1.0 is linear, ~3.2 is quadratic. 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, diff --git a/gitnexus/src/cli/i18n/en.ts b/gitnexus/src/cli/i18n/en.ts index e24e5a928..981071f64 100644 --- a/gitnexus/src/cli/i18n/en.ts +++ b/gitnexus/src/cli/i18n/en.ts @@ -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)', diff --git a/gitnexus/src/cli/i18n/zh-CN.ts b/gitnexus/src/cli/i18n/zh-CN.ts index 3092b9c5e..606a9e4e6 100644 --- a/gitnexus/src/cli/i18n/zh-CN.ts +++ b/gitnexus/src/cli/i18n/zh-CN.ts @@ -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(零歧义查找)', diff --git a/gitnexus/src/cli/index.ts b/gitnexus/src/cli/index.ts index 9141c9d85..37dfe1ddb 100644 --- a/gitnexus/src/cli/index.ts +++ b/gitnexus/src/cli/index.ts @@ -514,7 +514,7 @@ program .option('--include-tests', 'Include test files in results') .option( '-l, --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', ) .option('--offset ', 'Skip N symbols per depth level for pagination') .option('--summary-only', 'Return counts and risk only, omit symbol list') diff --git a/gitnexus/src/cli/tool.ts b/gitnexus/src/cli/tool.ts index 35f973d91..30574f430 100644 --- a/gitnexus/src/cli/tool.ts +++ b/gitnexus/src/cli/tool.ts @@ -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) { diff --git a/gitnexus/src/core/ingestion/call-processor.ts b/gitnexus/src/core/ingestion/call-processor.ts index 1d8fe5f16..8a3eef1cd 100644 --- a/gitnexus/src/core/ingestion/call-processor.ts +++ b/gitnexus/src/core/ingestion/call-processor.ts @@ -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; diff --git a/gitnexus/src/core/ingestion/language-provider.ts b/gitnexus/src/core/ingestion/language-provider.ts index 64c4736e7..90122aacc 100644 --- a/gitnexus/src/core/ingestion/language-provider.ts +++ b/gitnexus/src/core/ingestion/language-provider.ts @@ -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. diff --git a/gitnexus/src/core/ingestion/languages/go.ts b/gitnexus/src/core/ingestion/languages/go.ts index 915e241a3..e39963c56 100644 --- a/gitnexus/src/core/ingestion/languages/go.ts +++ b/gitnexus/src/core/ingestion/languages/go.ts @@ -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, diff --git a/gitnexus/src/core/ingestion/languages/go/import-binding.ts b/gitnexus/src/core/ingestion/languages/go/import-binding.ts new file mode 100644 index 000000000..f0c3a3e89 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/go/import-binding.ts @@ -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 }; +} diff --git a/gitnexus/src/core/ingestion/languages/go/import-decomposer.ts b/gitnexus/src/core/ingestion/languages/go/import-decomposer.ts index 7a20ca791..e3fb33318 100644 --- a/gitnexus/src/core/ingestion/languages/go/import-decomposer.ts +++ b/gitnexus/src/core/ingestion/languages/go/import-decomposer.ts @@ -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'; diff --git a/gitnexus/src/core/ingestion/languages/go/import-package-name.ts b/gitnexus/src/core/ingestion/languages/go/import-package-name.ts new file mode 100644 index 000000000..03b6a4253 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/go/import-package-name.ts @@ -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+$/, ''); +} diff --git a/gitnexus/src/core/ingestion/languages/go/interpret.ts b/gitnexus/src/core/ingestion/languages/go/interpret.ts index cb9fcc047..c652cf303 100644 --- a/gitnexus/src/core/ingestion/languages/go/interpret.ts +++ b/gitnexus/src/core/ingestion/languages/go/interpret.ts @@ -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; } diff --git a/gitnexus/src/core/ingestion/languages/go/route-bindings.ts b/gitnexus/src/core/ingestion/languages/go/route-bindings.ts new file mode 100644 index 000000000..59ac3ec29 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/go/route-bindings.ts @@ -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>(); + + 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; + } +} diff --git a/gitnexus/src/core/ingestion/languages/go/route-handler.ts b/gitnexus/src/core/ingestion/languages/go/route-handler.ts new file mode 100644 index 000000000..086698dac --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/go/route-handler.ts @@ -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::` 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; +type PackageIndex = Map; +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(); + private readonly types = new Map(); + private readonly methods = new Map(); + + 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(); +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)); +} diff --git a/gitnexus/src/core/ingestion/languages/go/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/go/scope-resolver.ts index 7c03e10f6..a525949a3 100644 --- a/gitnexus/src/core/ingestion/languages/go/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/go/scope-resolver.ts @@ -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), diff --git a/gitnexus/src/core/ingestion/pipeline-phases/parse-impl.ts b/gitnexus/src/core/ingestion/pipeline-phases/parse-impl.ts index 59a0574b7..f745ea16a 100644 --- a/gitnexus/src/core/ingestion/pipeline-phases/parse-impl.ts +++ b/gitnexus/src/core/ingestion/pipeline-phases/parse-impl.ts @@ -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(); @@ -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(); + 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); diff --git a/gitnexus/src/core/ingestion/route-extractors/go-gin-echo.ts b/gitnexus/src/core/ingestion/route-extractors/go-gin-echo.ts new file mode 100644 index 000000000..f7cdd1c64 --- /dev/null +++ b/gitnexus/src/core/ingestion/route-extractors/go-gin-echo.ts @@ -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()` 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 = 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; + /** 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 = 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> = { + 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(); + private readonly visiting = new Set(); + + 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, +): 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("", …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(); + 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; +} diff --git a/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts b/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts index 2fcdba04b..46a80060a 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts @@ -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 diff --git a/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts b/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts index bbb1db11f..e694ba32e 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts @@ -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, diff --git a/gitnexus/src/core/ingestion/workers/parse-worker.ts b/gitnexus/src/core/ingestion/workers/parse-worker.ts index 2c397b5d8..e6333c65d 100644 --- a/gitnexus/src/core/ingestion/workers/parse-worker.ts +++ b/gitnexus/src/core/ingestion/workers/parse-worker.ts @@ -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-`. Present when the route was extracted from a diff --git a/gitnexus/src/mcp/local/local-backend.ts b/gitnexus/src/mcp/local/local-backend.ts index 5396cd497..71864e674 100644 --- a/gitnexus/src/mcp/local/local-backend.ts +++ b/gitnexus/src/mcp/local/local-backend.ts @@ -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>(); + 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(); + 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; } } diff --git a/gitnexus/src/mcp/tools.ts b/gitnexus/src/mcp/tools.ts index 02b1bacd1..2965fcd6f 100644 --- a/gitnexus/src/mcp/tools.ts +++ b/gitnexus/src/mcp/tools.ts @@ -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. diff --git a/gitnexus/src/storage/index-lock.ts b/gitnexus/src/storage/index-lock.ts index 9ba7cd898..5588127e6 100644 --- a/gitnexus/src/storage/index-lock.ts +++ b/gitnexus/src/storage/index-lock.ts @@ -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; } diff --git a/gitnexus/src/storage/parse-cache.ts b/gitnexus/src/storage/parse-cache.ts index 456036162..37eb0baf7 100644 --- a/gitnexus/src/storage/parse-cache.ts +++ b/gitnexus/src/storage/parse-cache.ts @@ -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 diff --git a/gitnexus/test/fixtures/go-captures-golden/expected-captures.json b/gitnexus/test/fixtures/go-captures-golden/expected-captures.json index 0035a60c4..ec6c29dfa 100644 --- a/gitnexus/test/fixtures/go-captures-golden/expected-captures.json +++ b/gitnexus/test/fixtures/go-captures-golden/expected-captures.json @@ -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, diff --git a/gitnexus/test/fixtures/go-gin-route-app/echoapp/routes.go b/gitnexus/test/fixtures/go-gin-route-app/echoapp/routes.go new file mode 100644 index 000000000..79b33056f --- /dev/null +++ b/gitnexus/test/fixtures/go-gin-route-app/echoapp/routes.go @@ -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 +} diff --git a/gitnexus/test/fixtures/go-gin-route-app/go.mod b/gitnexus/test/fixtures/go-gin-route-app/go.mod new file mode 100644 index 000000000..78574ca11 --- /dev/null +++ b/gitnexus/test/fixtures/go-gin-route-app/go.mod @@ -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 +) diff --git a/gitnexus/test/fixtures/go-gin-route-app/handlers/health.go b/gitnexus/test/fixtures/go-gin-route-app/handlers/health.go new file mode 100644 index 000000000..00656bf7d --- /dev/null +++ b/gitnexus/test/fixtures/go-gin-route-app/handlers/health.go @@ -0,0 +1,7 @@ +package handlers + +import "github.com/gin-gonic/gin" + +func Health(c *gin.Context) { + c.String(200, "ok") +} diff --git a/gitnexus/test/fixtures/go-gin-route-app/router/auth_handlers.go b/gitnexus/test/fixtures/go-gin-route-app/router/auth_handlers.go new file mode 100644 index 000000000..dfd688299 --- /dev/null +++ b/gitnexus/test/fixtures/go-gin-route-app/router/auth_handlers.go @@ -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) {} diff --git a/gitnexus/test/fixtures/go-gin-route-app/router/match_handler.go b/gitnexus/test/fixtures/go-gin-route-app/router/match_handler.go new file mode 100644 index 000000000..bae768a5e --- /dev/null +++ b/gitnexus/test/fixtures/go-gin-route-app/router/match_handler.go @@ -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}) +} diff --git a/gitnexus/test/fixtures/go-gin-route-app/router/router.go b/gitnexus/test/fixtures/go-gin-route-app/router/router.go new file mode 100644 index 000000000..c4d96f997 --- /dev/null +++ b/gitnexus/test/fixtures/go-gin-route-app/router/router.go @@ -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") +} diff --git a/gitnexus/test/fixtures/go-gin-route-app/router/types.go b/gitnexus/test/fixtures/go-gin-route-app/router/types.go new file mode 100644 index 000000000..fbf59b46e --- /dev/null +++ b/gitnexus/test/fixtures/go-gin-route-app/router/types.go @@ -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} +} diff --git a/gitnexus/test/fixtures/go-gin-route-app/service/match.go b/gitnexus/test/fixtures/go-gin-route-app/service/match.go new file mode 100644 index 000000000..1b0404c47 --- /dev/null +++ b/gitnexus/test/fixtures/go-gin-route-app/service/match.go @@ -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) {} diff --git a/gitnexus/test/fixtures/go-gin-route-app/status/v2/status.go b/gitnexus/test/fixtures/go-gin-route-app/status/v2/status.go new file mode 100644 index 000000000..3c49a75ab --- /dev/null +++ b/gitnexus/test/fixtures/go-gin-route-app/status/v2/status.go @@ -0,0 +1,7 @@ +package status + +import "github.com/gin-gonic/gin" + +func Status(c *gin.Context) { + c.String(200, "ok") +} diff --git a/gitnexus/test/integration/cli-limit-e2e.test.ts b/gitnexus/test/integration/cli-limit-e2e.test.ts index ec7a0d08c..4fef9e282 100644 --- a/gitnexus/test/integration/cli-limit-e2e.test.ts +++ b/gitnexus/test/integration/cli-limit-e2e.test.ts @@ -90,7 +90,11 @@ function parseStdout(result: ReturnType): 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(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([...args, '--limit', '1']); + expect(limited.affected_routes).toHaveLength(1); + expect(base.affected_routes).toContainEqual(limited.affected_routes?.[0]); + + const zero = runJson([...args, '--limit', '0']); + expect(zero.affected_routes).toEqual(base.affected_routes); + }); + it('truncates affected_processes/modules to --limit 1', () => { const limited = runJson([ 'impact', diff --git a/gitnexus/test/integration/go-gin-route-pipeline.test.ts b/gitnexus/test/integration/go-gin-route-pipeline.test.ts new file mode 100644 index 000000000..eeca7a530 --- /dev/null +++ b/gitnexus/test/integration/go-gin-route-pipeline.test.ts @@ -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'); + }); +}); diff --git a/gitnexus/test/integration/go-route-binding-identity.test.ts b/gitnexus/test/integration/go-route-binding-identity.test.ts new file mode 100644 index 000000000..ec3376a9b --- /dev/null +++ b/gitnexus/test/integration/go-route-binding-identity.test.ts @@ -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/); + }); +}); diff --git a/gitnexus/test/integration/go-route-package-names.test.ts b/gitnexus/test/integration/go-route-package-names.test.ts new file mode 100644 index 000000000..414164cb3 --- /dev/null +++ b/gitnexus/test/integration/go-route-package-names.test.ts @@ -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 = { + '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]); + }); +}); diff --git a/gitnexus/test/integration/impact-affected-routes.test.ts b/gitnexus/test/integration/impact-affected-routes.test.ts new file mode 100644 index 000000000..88a09e83a --- /dev/null +++ b/gitnexus/test/integration/impact-affected-routes.test.ts @@ -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>) + .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; + }, + }, +); diff --git a/gitnexus/test/integration/resolvers/go-external-test-package.test.ts b/gitnexus/test/integration/resolvers/go-external-test-package.test.ts index 5eebda4e8..b7b4ef663 100644 --- a/gitnexus/test/integration/resolvers/go-external-test-package.test.ts +++ b/gitnexus/test/integration/resolvers/go-external-test-package.test.ts @@ -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()', diff --git a/gitnexus/test/unit/go-gin-echo-routes.test.ts b/gitnexus/test/unit/go-gin-echo-routes.test.ts new file mode 100644 index 000000000..c527f7d2c --- /dev/null +++ b/gitnexus/test/unit/go-gin-echo-routes.test.ts @@ -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(); + }); +}); diff --git a/gitnexus/test/unit/go-route-handler.test.ts b/gitnexus/test/unit/go-route-handler.test.ts new file mode 100644 index 000000000..64fbfa4f9 --- /dev/null +++ b/gitnexus/test/unit/go-route-handler.test.ts @@ -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::` 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; + +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 => ({ + filePath: ROUTER, + routePath: '/x', + httpMethod: 'GET', + decoratorName: 'GET', + lineNumber: 1, + source: 'gin-route', + ...overrides, +}); + +const resolve = ( + model: Model, + overrides: Partial, + imports: Readonly> = {}, +) => + 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(); + }); + }); +}); diff --git a/gitnexus/test/unit/impact-route-enrichment.test.ts b/gitnexus/test/unit/impact-route-enrichment.test.ts new file mode 100644 index 000000000..9e9cc8791 --- /dev/null +++ b/gitnexus/test/unit/impact-route-enrichment.test.ts @@ -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 }).repos.set(REPO.id, REPO); + (backend as unknown as { ensureInitialized: () => Promise }).ensureInitialized = vi + .fn() + .mockResolvedValue(undefined); + return ( + backend as unknown as { + _impactImpl: (repo: unknown, params: unknown) => Promise>; + } + )._impactImpl(REPO, { target: 'UnfinalizeRound', direction: 'upstream', maxDepth: 2 }); +} + +const depthOneItems = (res: Record) => + ((res.byDepth as Record>>)[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 }); + }); +}); diff --git a/gitnexus/test/unit/incremental-parse-cache.test.ts b/gitnexus/test/unit/incremental-parse-cache.test.ts index 149e088a9..b86c19926 100644 --- a/gitnexus/test/unit/incremental-parse-cache.test.ts +++ b/gitnexus/test/unit/incremental-parse-cache.test.ts @@ -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); } diff --git a/gitnexus/test/unit/index-lock-reclaim-guard.test.ts b/gitnexus/test/unit/index-lock-reclaim-guard.test.ts index 455b448d4..8dde06608 100644 --- a/gitnexus/test/unit/index-lock-reclaim-guard.test.ts +++ b/gitnexus/test/unit/index-lock-reclaim-guard.test.ts @@ -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, ''); diff --git a/gitnexus/test/unit/resolve-route-handler-symbols.test.ts b/gitnexus/test/unit/resolve-route-handler-symbols.test.ts index 0eb67d995..017a945f8 100644 --- a/gitnexus/test/unit/resolve-route-handler-symbols.test.ts +++ b/gitnexus/test/unit/resolve-route-handler-symbols.test.ts @@ -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'); + }); +}); diff --git a/gitnexus/test/unit/scope-resolution/go/go-imports.test.ts b/gitnexus/test/unit/scope-resolution/go/go-imports.test.ts index c3e4eac26..6c8c28561 100644 --- a/gitnexus/test/unit/scope-resolution/go/go-imports.test.ts +++ b/gitnexus/test/unit/scope-resolution/go/go-imports.test.ts @@ -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(