mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-06 02:49:56 +00:00
This commit is contained in:
parent
acb65b95b6
commit
e42122a0f6
50 changed files with 3373 additions and 30 deletions
|
|
@ -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`). */
|
||||
|
|
|
|||
294
gitnexus/bench/go-route-resolution/baseline.json
Normal file
294
gitnexus/bench/go-route-resolution/baseline.json
Normal file
|
|
@ -0,0 +1,294 @@
|
|||
{
|
||||
"_what": "Go route extraction, package-scoped symbolic resolution and import binding (#3417). Run from gitnexus: node --import tsx bench/go-route-resolution/measure.cjs --check",
|
||||
"_method": "5 warmups, minimum of 15 samples per scale; each workload grows 4x. Parsing/model setup and identity checks are untimed. A fresh resolution context and its lazy indexes are timed in every sample. Ratio-only gates: linear_factor = (large_ms / small_ms) / 4; linear work is about 1, quadratic work about 4.",
|
||||
"_arms": {
|
||||
"spread": "One typed receiver and route per function.",
|
||||
"concentrated": "One receiver declaration with growing same-type writes and registrations in one function.",
|
||||
"lexical": "Captured writes, callable parameters, inner router shadowing and outer package handlers; exact identities include declines.",
|
||||
"method_bucket": "Repeated type/function/method names across growing packages; type hits, absent methods, bare functions and constructors. Methods use sibling-file worker owner IDs.",
|
||||
"import_bindings": "Implicit package clauses across eight files and explicit aliases; exact callback counts guard unnecessary target/source reads."
|
||||
},
|
||||
"_triage": "Verify identities and counts before considering a timing failure. Re-run without competing CPU load. Budgets retain headroom above five observed runs while rejecting the pre-optimization quadratic paths. Do not update identities or raise budgets to hide missing symbols or repeated scans. The pre-optimization implementation was also run with --check: identities passed and the concentrated/method_bucket scaling gates failed. Isolated mutations rejected null/false/0/empty-string/array baselines, missing fingerprint/count/budget fields, and an extractor returning no routes.",
|
||||
"arms": {
|
||||
"spread": {
|
||||
"linear_scaling_budget": 1.8,
|
||||
"small": {
|
||||
"units": 96,
|
||||
"routes": 96,
|
||||
"named_handlers": 96,
|
||||
"receiver_hints": 96,
|
||||
"fingerprint": "9caeb2c583ef673e17d87e8f374c8dace551edcb8b9dd992b22da10d1ba93c8d"
|
||||
},
|
||||
"large": {
|
||||
"units": 384,
|
||||
"routes": 384,
|
||||
"named_handlers": 384,
|
||||
"receiver_hints": 384,
|
||||
"fingerprint": "8cc6e9756f0603f1957eb397f8cf3027ab99fdec8b123a5e4ed1261149660469"
|
||||
}
|
||||
},
|
||||
"concentrated": {
|
||||
"linear_scaling_budget": 2.2,
|
||||
"small": {
|
||||
"units": 96,
|
||||
"routes": 96,
|
||||
"named_handlers": 96,
|
||||
"receiver_hints": 96,
|
||||
"fingerprint": "a479f138f41b6e01680d424412e44974feacde8b2981c6acf675efcd1ff245c7"
|
||||
},
|
||||
"large": {
|
||||
"units": 384,
|
||||
"routes": 384,
|
||||
"named_handlers": 384,
|
||||
"receiver_hints": 384,
|
||||
"fingerprint": "655d462758f16beaa3ac12d5fcc64d714e431def943c3acf875473bb1576be37"
|
||||
}
|
||||
},
|
||||
"lexical": {
|
||||
"linear_scaling_budget": 1.8,
|
||||
"small": {
|
||||
"units": 96,
|
||||
"routes": 288,
|
||||
"named_handlers": 192,
|
||||
"receiver_hints": 0,
|
||||
"fingerprint": "ac23e05109a57a9eb4d8be1fa271af2bb76b0781f60701610d94e17a53b9828b"
|
||||
},
|
||||
"large": {
|
||||
"units": 384,
|
||||
"routes": 1152,
|
||||
"named_handlers": 768,
|
||||
"receiver_hints": 0,
|
||||
"fingerprint": "3f5d49b2a00eecaa4244fa32203a9010a152ceb2f21c730c3b4cb086f4c38a89"
|
||||
}
|
||||
},
|
||||
"method_bucket": {
|
||||
"linear_scaling_budget": 1.8,
|
||||
"small": {
|
||||
"units": 256,
|
||||
"sites": 1024,
|
||||
"resolved": 768,
|
||||
"declined": 256,
|
||||
"fingerprint": "4b7f06062ed85991ade1412555ee3c23b3cd74aa83ab89e6218a61f94e6996c6"
|
||||
},
|
||||
"large": {
|
||||
"units": 1024,
|
||||
"sites": 4096,
|
||||
"resolved": 3072,
|
||||
"declined": 1024,
|
||||
"fingerprint": "777eb3833387d15d36c029bbdbc0f3f011dfaa89377d088322d43986e2f2cace"
|
||||
}
|
||||
},
|
||||
"import_bindings": {
|
||||
"linear_scaling_budget": 1.8,
|
||||
"small": {
|
||||
"units": 512,
|
||||
"imports": 1024,
|
||||
"target_lookups": 512,
|
||||
"source_reads": 4096,
|
||||
"fingerprint": "aa4f723bda1bdd450cf5254f4e32bb819bac83d19c4bfa9efb546d9afa69d0c6"
|
||||
},
|
||||
"large": {
|
||||
"units": 2048,
|
||||
"imports": 4096,
|
||||
"target_lookups": 2048,
|
||||
"source_reads": 16384,
|
||||
"fingerprint": "4da12f2bcf51eeb649feb1dae0a35259d196ff28011fd6bd399244601e321bdc"
|
||||
}
|
||||
}
|
||||
},
|
||||
"_measured": {
|
||||
"date_utc": "2026-09-30",
|
||||
"before_commit": "39c1c0fae318693bcce5254548a361a76f0da6b7",
|
||||
"after_implementation": "The receiver memoization and per-pass package indexes in this change.",
|
||||
"environment": {
|
||||
"node": "v24.19.0",
|
||||
"platform": "Linux x86_64",
|
||||
"cpu": "AMD EPYC 9V74 80-Core Processor"
|
||||
},
|
||||
"comparison": "Same harness and correctness identities, serial runs on one machine; before is one run and after is five runs. Milliseconds are observations, not gates. Speedups use the median after large minimum.",
|
||||
"results": {
|
||||
"spread": {
|
||||
"before": {
|
||||
"small_min_ms": 21.189,
|
||||
"large_min_ms": 95.47,
|
||||
"linear_factor": 1.126
|
||||
},
|
||||
"after_runs": [
|
||||
{
|
||||
"small_min_ms": 18.894,
|
||||
"large_min_ms": 83.253,
|
||||
"linear_factor": 1.102
|
||||
},
|
||||
{
|
||||
"small_min_ms": 19.741,
|
||||
"large_min_ms": 80.441,
|
||||
"linear_factor": 1.019
|
||||
},
|
||||
{
|
||||
"small_min_ms": 19.006,
|
||||
"large_min_ms": 81.562,
|
||||
"linear_factor": 1.073
|
||||
},
|
||||
{
|
||||
"small_min_ms": 19.699,
|
||||
"large_min_ms": 88.138,
|
||||
"linear_factor": 1.119
|
||||
},
|
||||
{
|
||||
"small_min_ms": 20.602,
|
||||
"large_min_ms": 86.424,
|
||||
"linear_factor": 1.049
|
||||
}
|
||||
],
|
||||
"large_speedup_vs_before": 1.15
|
||||
},
|
||||
"concentrated": {
|
||||
"before": {
|
||||
"small_min_ms": 173.134,
|
||||
"large_min_ms": 2800.987,
|
||||
"linear_factor": 4.045
|
||||
},
|
||||
"after_runs": [
|
||||
{
|
||||
"small_min_ms": 10.03,
|
||||
"large_min_ms": 48.801,
|
||||
"linear_factor": 1.216
|
||||
},
|
||||
{
|
||||
"small_min_ms": 10.113,
|
||||
"large_min_ms": 56.996,
|
||||
"linear_factor": 1.409
|
||||
},
|
||||
{
|
||||
"small_min_ms": 10.135,
|
||||
"large_min_ms": 43.464,
|
||||
"linear_factor": 1.072
|
||||
},
|
||||
{
|
||||
"small_min_ms": 10.613,
|
||||
"large_min_ms": 60.817,
|
||||
"linear_factor": 1.433
|
||||
},
|
||||
{
|
||||
"small_min_ms": 10.574,
|
||||
"large_min_ms": 51.15,
|
||||
"linear_factor": 1.209
|
||||
}
|
||||
],
|
||||
"large_speedup_vs_before": 54.76
|
||||
},
|
||||
"lexical": {
|
||||
"before": {
|
||||
"small_min_ms": 47.881,
|
||||
"large_min_ms": 228.503,
|
||||
"linear_factor": 1.193
|
||||
},
|
||||
"after_runs": [
|
||||
{
|
||||
"small_min_ms": 52.334,
|
||||
"large_min_ms": 224.091,
|
||||
"linear_factor": 1.07
|
||||
},
|
||||
{
|
||||
"small_min_ms": 53.196,
|
||||
"large_min_ms": 226.357,
|
||||
"linear_factor": 1.064
|
||||
},
|
||||
{
|
||||
"small_min_ms": 51.727,
|
||||
"large_min_ms": 235.301,
|
||||
"linear_factor": 1.137
|
||||
},
|
||||
{
|
||||
"small_min_ms": 58.227,
|
||||
"large_min_ms": 232.055,
|
||||
"linear_factor": 0.996
|
||||
},
|
||||
{
|
||||
"small_min_ms": 53.33,
|
||||
"large_min_ms": 233.07,
|
||||
"linear_factor": 1.093
|
||||
}
|
||||
],
|
||||
"large_speedup_vs_before": 0.98
|
||||
},
|
||||
"method_bucket": {
|
||||
"before": {
|
||||
"small_min_ms": 39.84,
|
||||
"large_min_ms": 607.851,
|
||||
"linear_factor": 3.814
|
||||
},
|
||||
"after_runs": [
|
||||
{
|
||||
"small_min_ms": 1.298,
|
||||
"large_min_ms": 4.987,
|
||||
"linear_factor": 0.961
|
||||
},
|
||||
{
|
||||
"small_min_ms": 1.225,
|
||||
"large_min_ms": 4.554,
|
||||
"linear_factor": 0.929
|
||||
},
|
||||
{
|
||||
"small_min_ms": 1.229,
|
||||
"large_min_ms": 4.969,
|
||||
"linear_factor": 1.011
|
||||
},
|
||||
{
|
||||
"small_min_ms": 1.189,
|
||||
"large_min_ms": 5.675,
|
||||
"linear_factor": 1.194
|
||||
},
|
||||
{
|
||||
"small_min_ms": 1.28,
|
||||
"large_min_ms": 4.819,
|
||||
"linear_factor": 0.941
|
||||
}
|
||||
],
|
||||
"large_speedup_vs_before": 122.32
|
||||
},
|
||||
"import_bindings": {
|
||||
"before": {
|
||||
"small_min_ms": 0.789,
|
||||
"large_min_ms": 3.257,
|
||||
"linear_factor": 1.032
|
||||
},
|
||||
"after_runs": [
|
||||
{
|
||||
"small_min_ms": 0.763,
|
||||
"large_min_ms": 2.983,
|
||||
"linear_factor": 0.978
|
||||
},
|
||||
{
|
||||
"small_min_ms": 0.774,
|
||||
"large_min_ms": 3.249,
|
||||
"linear_factor": 1.049
|
||||
},
|
||||
{
|
||||
"small_min_ms": 0.785,
|
||||
"large_min_ms": 3.31,
|
||||
"linear_factor": 1.054
|
||||
},
|
||||
{
|
||||
"small_min_ms": 0.811,
|
||||
"large_min_ms": 3.182,
|
||||
"linear_factor": 0.981
|
||||
},
|
||||
{
|
||||
"small_min_ms": 0.757,
|
||||
"large_min_ms": 3.514,
|
||||
"linear_factor": 1.161
|
||||
}
|
||||
],
|
||||
"large_speedup_vs_before": 1.0
|
||||
}
|
||||
},
|
||||
"before_checkout_commit": "dbcf2921f7b46f1fdebf6ca231ebde3bd231d79f",
|
||||
"before_tree": "8602ce55a5ecf170f7463691b74aadf657ca89ac"
|
||||
},
|
||||
"_verified": {
|
||||
"performance_negative_control": "The same harness on the pre-optimization checkout passed every identity/count check and failed concentrated and method_bucket scaling gates.",
|
||||
"empty_output_negative_control": "An isolated faithful current-tree snapshot with extraction returning [] failed route counts and fingerprints in all three extraction arms.",
|
||||
"malformed_baselines": "null, false, 0, empty string, array, missing fingerprint, missing route count, missing units, missing budget and missing arm all failed before timing."
|
||||
}
|
||||
}
|
||||
322
gitnexus/bench/go-route-resolution/measure.cjs
Normal file
322
gitnexus/bench/go-route-resolution/measure.cjs
Normal file
|
|
@ -0,0 +1,322 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* Build-free Go route extraction and symbolic-resolution benchmark (#3417).
|
||||
*
|
||||
* node --import tsx bench/go-route-resolution/measure.cjs
|
||||
* node --import tsx bench/go-route-resolution/measure.cjs --check
|
||||
*
|
||||
* Parsing, model construction, fingerprints and counts are outside timing.
|
||||
* Resolution creates a fresh pass context inside each timed sample, including
|
||||
* lazy index construction rather than measuring only warmed lookups.
|
||||
* Each arm grows 4x. Only normalized scaling ratios are timing gates; exact
|
||||
* route/target identities and positive counts prevent fast empty answers.
|
||||
* BENCH_SOURCE_ROOT selects another repository checkout for comparison.
|
||||
*/
|
||||
const assert = require('node:assert/strict');
|
||||
const { existsSync, readFileSync } = require('node:fs');
|
||||
const { resolve, join } = require('node:path');
|
||||
const { pathToFileURL } = require('node:url');
|
||||
|
||||
const ROOT = resolve(__dirname, '../..');
|
||||
const SOURCE_ROOT = process.env.BENCH_SOURCE_ROOT
|
||||
? resolve(process.env.BENCH_SOURCE_ROOT, 'gitnexus')
|
||||
: ROOT;
|
||||
const BASELINE = join(__dirname, 'baseline.json');
|
||||
const WARMUP = 5;
|
||||
const REPS = 15;
|
||||
const SCALES = { extraction: [96, 384], resolution: [256, 1024], imports: [512, 2048] };
|
||||
const FILE = 'app/routes.go';
|
||||
const ARM_FIELDS = {
|
||||
spread: ['routes', 'named_handlers', 'receiver_hints'],
|
||||
concentrated: ['routes', 'named_handlers', 'receiver_hints'],
|
||||
lexical: ['routes', 'named_handlers', 'receiver_hints'],
|
||||
method_bucket: ['sites', 'resolved', 'declined'],
|
||||
import_bindings: ['imports', 'target_lookups', 'source_reads'],
|
||||
};
|
||||
|
||||
const load = (file) => import(pathToFileURL(join(SOURCE_ROOT, 'src', file)).href);
|
||||
|
||||
function source(shape, count) {
|
||||
const body = ['package app', 'import "github.com/gin-gonic/gin"'];
|
||||
if (shape === 'concentrated') {
|
||||
body.push('func Register(r *gin.Engine) {', 'h := &Handler{}');
|
||||
for (let i = 0; i < count; i++) {
|
||||
body.push('h = &Handler{}', `r.GET("/route-${i}", h.Do)`);
|
||||
}
|
||||
body.push('}');
|
||||
} else {
|
||||
for (let i = 0; i < count; i++) {
|
||||
if (shape === 'spread') {
|
||||
body.push(
|
||||
`func Register${i}(r *gin.Engine) {`,
|
||||
'h := &Handler{}',
|
||||
`r.GET("/route-${i}", h.Do)`,
|
||||
'}',
|
||||
);
|
||||
} else {
|
||||
body.push(
|
||||
`func Register${i}(r *gin.Engine, Handle func(*gin.Context)) {`,
|
||||
'h := &A{}',
|
||||
'func() { h = &B{} }()',
|
||||
`r.GET("/capture-${i}", h.Do)`,
|
||||
`r.GET("/bare-${i}", Handle)`,
|
||||
`{ var r FakeRouter; r.GET("/fake-${i}", PackageHandler) }`,
|
||||
`r.GET("/outer-${i}", PackageHandler)`,
|
||||
'}',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
return body.join('\n');
|
||||
}
|
||||
|
||||
function routeIdentity(route) {
|
||||
return JSON.stringify([
|
||||
route.filePath,
|
||||
route.httpMethod,
|
||||
route.routePath,
|
||||
route.handlerName ?? null,
|
||||
route.handlerReceiver ?? null,
|
||||
route.lineNumber,
|
||||
]);
|
||||
}
|
||||
|
||||
function pair(small, large) {
|
||||
return { small, large, linear_factor: large.min_ms / small.min_ms / (large.units / small.units) };
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const checkRequested = process.argv.includes('--check');
|
||||
const baseline = checkRequested ? JSON.parse(readFileSync(BASELINE, 'utf8')) : undefined;
|
||||
if (checkRequested) {
|
||||
assert.ok(
|
||||
baseline !== null && typeof baseline === 'object' && !Array.isArray(baseline),
|
||||
'baseline must be a non-null object',
|
||||
);
|
||||
assert.deepEqual(
|
||||
Object.keys(baseline.arms).sort(),
|
||||
Object.keys(ARM_FIELDS).sort(),
|
||||
'baseline must retain exactly all five arms',
|
||||
);
|
||||
for (const [shape, fields] of Object.entries(ARM_FIELDS)) {
|
||||
const arm = baseline.arms[shape];
|
||||
assert.ok(
|
||||
Number.isFinite(arm.linear_scaling_budget) && arm.linear_scaling_budget > 0,
|
||||
`${shape}: missing or invalid scaling budget`,
|
||||
);
|
||||
for (const scale of ['small', 'large']) {
|
||||
const expected = arm[scale];
|
||||
assert.deepEqual(
|
||||
Object.keys(expected).sort(),
|
||||
['units', ...fields, 'fingerprint'].sort(),
|
||||
`${shape}.${scale}: missing or unexpected correctness fields`,
|
||||
);
|
||||
assert.match(expected.fingerprint, /^[a-f0-9]{64}$/);
|
||||
for (const key of ['units', ...fields]) {
|
||||
assert.ok(
|
||||
Number.isSafeInteger(expected[key]) && expected[key] >= 0,
|
||||
`${shape}.${scale}.${key}: invalid count`,
|
||||
);
|
||||
}
|
||||
assert.ok(expected.units > 0, `${shape}.${scale}: empty workload`);
|
||||
}
|
||||
}
|
||||
}
|
||||
const [
|
||||
{ default: Parser },
|
||||
{ default: Go },
|
||||
{ extractGoGinEchoRoutes },
|
||||
{ resolveGoRouteHandler },
|
||||
{ createSemanticModel },
|
||||
{ minSample, fingerprintIds },
|
||||
] = await Promise.all([
|
||||
import('tree-sitter'),
|
||||
import('tree-sitter-go'),
|
||||
load('core/ingestion/route-extractors/go-gin-echo.ts'),
|
||||
load('core/ingestion/languages/go/route-handler.ts'),
|
||||
load('core/ingestion/model/semantic-model.ts'),
|
||||
import('../lib/identity-guard.mjs'),
|
||||
]);
|
||||
const parser = new Parser();
|
||||
parser.setLanguage(Go);
|
||||
|
||||
function extraction(shape, units) {
|
||||
const text = source(shape, units);
|
||||
const tree = parser.parse(text, null, { bufferSize: Buffer.byteLength(text) + 1 });
|
||||
assert.equal(tree.rootNode.hasError, false, `${shape}: invalid Go syntax`);
|
||||
const { last, ms } = minSample(() => extractGoGinEchoRoutes(tree, FILE), WARMUP, REPS);
|
||||
return {
|
||||
units,
|
||||
min_ms: ms,
|
||||
routes: last.length,
|
||||
named_handlers: last.filter((r) => r.handlerName !== undefined).length,
|
||||
receiver_hints: last.filter((r) => r.handlerReceiver !== undefined).length,
|
||||
fingerprint: fingerprintIds(last.map(routeIdentity)),
|
||||
};
|
||||
}
|
||||
|
||||
function resolution(units) {
|
||||
const model = createSemanticModel();
|
||||
const routes = [];
|
||||
for (let i = 0; i < units; i++) {
|
||||
const dir = `app/pkg${i}`;
|
||||
for (const name of ['Handler', 'Empty']) {
|
||||
const file = `${dir}/types.go`;
|
||||
model.symbols.add(file, name, `Struct:${file}:${name}`, 'Struct');
|
||||
routes.push({
|
||||
filePath: `${dir}/routes.go`,
|
||||
routePath: `/${i}/${name}`,
|
||||
httpMethod: 'GET',
|
||||
decoratorName: 'GET',
|
||||
lineNumber: i + 1,
|
||||
source: 'gin-route',
|
||||
handlerName: 'h.Do',
|
||||
handlerReceiver: { kind: 'type', name },
|
||||
});
|
||||
}
|
||||
// Worker ownership uses the method's file, deliberately different from
|
||||
// the struct's file. A canonical-owner-only lookup would lose every hit.
|
||||
const file = `${dir}/methods.go`;
|
||||
model.symbols.add(file, 'Do', `Method:${file}:Handler.Do`, 'Method', {
|
||||
ownerId: `Struct:${file}:Handler`,
|
||||
});
|
||||
for (const name of ['Handle', 'NewHandler']) {
|
||||
model.symbols.add(file, name, `Function:${file}:${name}`, 'Function', {
|
||||
returnType: name === 'NewHandler' ? '*Handler' : undefined,
|
||||
});
|
||||
routes.push({
|
||||
filePath: `${dir}/routes.go`,
|
||||
routePath: `/${i}/${name}`,
|
||||
httpMethod: 'GET',
|
||||
decoratorName: 'GET',
|
||||
lineNumber: i + 1,
|
||||
source: 'gin-route',
|
||||
handlerName: name === 'Handle' ? name : 'h.Do',
|
||||
...(name === 'NewHandler' ? { handlerReceiver: { kind: 'constructor', name } } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
const run = () => {
|
||||
const context = { model, importTargetsFor: () => [] };
|
||||
return routes.map((r) => resolveGoRouteHandler(r, context));
|
||||
};
|
||||
const { last, ms } = minSample(run, WARMUP, REPS);
|
||||
const resolved = last.filter((id) => id !== undefined).length;
|
||||
assert.equal(resolved, units * 3, 'symbol resolution lost its positive control');
|
||||
return {
|
||||
units,
|
||||
min_ms: ms,
|
||||
sites: routes.length,
|
||||
resolved,
|
||||
declined: routes.length - resolved,
|
||||
fingerprint: fingerprintIds(
|
||||
last.map((id, i) => `${routes[i].routePath} -> ${id ?? '<none>'}`),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function imports(resolveGoImportBinding, units) {
|
||||
const texts = new Map();
|
||||
const inputs = [];
|
||||
for (let i = 0; i < units; i++) {
|
||||
const files = Array.from({ length: 8 }, (_, j) => `pkg${i}/file${j}.go`);
|
||||
files.forEach((file) =>
|
||||
texts.set(file, `/* package old is a comment */\npackage declared${i}\n`),
|
||||
);
|
||||
const targetRaw = `example.com/app/pkg${i}/v2`;
|
||||
inputs.push({
|
||||
parsed: {
|
||||
kind: 'namespace',
|
||||
targetRaw,
|
||||
localName: `pkg${i}`,
|
||||
importedName: `pkg${i}`,
|
||||
implicitLocalName: true,
|
||||
},
|
||||
files,
|
||||
});
|
||||
inputs.push({
|
||||
parsed: { kind: 'namespace', targetRaw, localName: `alias${i}`, importedName: `alias${i}` },
|
||||
files,
|
||||
});
|
||||
}
|
||||
let targetLookups = 0;
|
||||
let sourceReads = 0;
|
||||
const run = () => {
|
||||
targetLookups = 0;
|
||||
sourceReads = 0;
|
||||
return inputs.map(({ parsed, files }) =>
|
||||
resolveGoImportBinding(
|
||||
parsed,
|
||||
() => {
|
||||
targetLookups++;
|
||||
return files;
|
||||
},
|
||||
(file) => {
|
||||
sourceReads++;
|
||||
return texts.get(file);
|
||||
},
|
||||
),
|
||||
);
|
||||
};
|
||||
const { last, ms } = minSample(run, WARMUP, REPS);
|
||||
assert.equal(targetLookups, units, 'explicit aliases must not resolve targets again');
|
||||
assert.equal(sourceReads, units * 8, 'implicit imports must read their package clauses');
|
||||
return {
|
||||
units,
|
||||
min_ms: ms,
|
||||
imports: last.length,
|
||||
target_lookups: targetLookups,
|
||||
source_reads: sourceReads,
|
||||
fingerprint: fingerprintIds(last.map((item, i) => `${i}:${item.kind}:${item.localName}`)),
|
||||
};
|
||||
}
|
||||
|
||||
const report = { reps: REPS, warmup: WARMUP, source_root: SOURCE_ROOT };
|
||||
for (const shape of ['spread', 'concentrated', 'lexical']) {
|
||||
report[shape] = pair(...SCALES.extraction.map((n) => extraction(shape, n)));
|
||||
}
|
||||
report.method_bucket = pair(...SCALES.resolution.map(resolution));
|
||||
const importPath = join(SOURCE_ROOT, 'src/core/ingestion/languages/go/import-binding.ts');
|
||||
if (existsSync(importPath)) {
|
||||
const { resolveGoImportBinding } = await import(pathToFileURL(importPath).href);
|
||||
report.import_bindings = pair(...SCALES.imports.map((n) => imports(resolveGoImportBinding, n)));
|
||||
}
|
||||
|
||||
if (checkRequested) {
|
||||
const errors = [];
|
||||
for (const [shape, expected] of Object.entries(baseline.arms)) {
|
||||
const actual = report[shape];
|
||||
if (!actual) {
|
||||
errors.push(`${shape}: missing arm`);
|
||||
continue;
|
||||
}
|
||||
for (const scale of ['small', 'large']) {
|
||||
for (const [key, value] of Object.entries(expected[scale])) {
|
||||
if (actual[scale][key] !== value)
|
||||
errors.push(`${shape}.${scale}.${key}: ${actual[scale][key]} != ${value}`);
|
||||
}
|
||||
if (!Number.isFinite(actual[scale].min_ms) || actual[scale].min_ms <= 0) {
|
||||
errors.push(`${shape}.${scale}: invalid timing`);
|
||||
}
|
||||
}
|
||||
const budget = expected.linear_scaling_budget;
|
||||
if (
|
||||
!Number.isFinite(budget) ||
|
||||
budget <= 0 ||
|
||||
!Number.isFinite(actual.linear_factor) ||
|
||||
actual.linear_factor > budget
|
||||
) {
|
||||
errors.push(`${shape}.linear_factor: ${actual.linear_factor} exceeds ${budget}`);
|
||||
}
|
||||
}
|
||||
console.log(JSON.stringify({ ok: errors.length === 0, report, errors }, null, 2));
|
||||
if (errors.length) process.exitCode = 1;
|
||||
} else {
|
||||
console.log(JSON.stringify(report, null, 2));
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
|
|
@ -1,7 +1,8 @@
|
|||
{
|
||||
"_comment": "Per-language baselines for bench/scope-capture/measure.mjs --check. fingerprint = order-independent sha256 over the lang-resolution/<lang>-* fixture corpus + a 20-entity synthetic source (correctness gate; re-baseline intentionally on a legitimate capture change). scaling_budget = max allowed (t800/t250)/(800/250); ~1.0 is linear, ~3.2 is quadratic. The synthetic source is now HERITAGE-BEARING for every language (each Entity extends/implements/embeds/uses-trait/conforms-to a shared base) so the #1951 @reference.inherits synth is gated at scale, not just the base capture loop. All languages thread the tree-sitter captured node instead of re-deriving it with findNodeAtRange(tree.rootNode,...) per match, so all are linear (go #1915, python #1918, ruby/php/rust/csharp #1951, java #1956).",
|
||||
"go": {
|
||||
"fingerprint": "2e3099db962f82f9d8641707a8b2bcf6875dafec1f54904f2dec7ac65ba22e9f",
|
||||
"fingerprint": "fdc3a691e2ebbc5b0d6fe1da328864293587a75e296e106c705c7e31ee622a5b",
|
||||
"_rebaselined_3417_versioned_import_name": "#3417: splitGoImportStatement now uses the conventional package qualifier before a /vN module suffix. Compared the pre-fix 2539b08 and HEAD emitters over the SAME HEAD corpus: exactly two @import.name texts changed from v2 to bar for github.com/foo/bar/v2, in go-extern-qualified-signatures/memory/memory.go and store/store.go; every other capture tag, text, range and match order is unchanged. The capture golden already records both changes. fixture_count 122 and capture_groups_fp 2579 are unchanged, as are synthetic counts 5508/17608; scaling 0.977 < the unchanged 1.5 budget. Prior 2e3099db962f82f9d8641707a8b2bcf6875dafec1f54904f2dec7ac65ba22e9f -> fdc3a691e2ebbc5b0d6fe1da328864293587a75e296e106c705c7e31ee622a5b.",
|
||||
"_rebaselined_3190_ctor_qualified_name": "#3190: generic composite-literal constructors now carry @reference.qualified-name with the written pkg.Box[T] spelling so constructor fallback can tell a qualifier from a bare unique-name guess. DIGEST DRIFT ONLY: the tag is added to existing constructor matches (captureGroups unchanged on go-constructor-type-inference/cmd/main.go). Prior 990e4921a7ef0e6aa4d7da92b671de91fa2621cb27918adb27742740dc6ad902 -> 2e3099db962f82f9d8641707a8b2bcf6875dafec1f54904f2dec7ac65ba22e9f; capture_groups_fp 2579, fixture_count 122.",
|
||||
"_rebaselined_3190": "Corrected go-method-enrichment/app.go to share package animal with its declarations; its capture count is now 16 rather than 15. No Go emitter change. Scaling budget unchanged.",
|
||||
"scaling_budget": 1.5,
|
||||
|
|
|
|||
|
|
@ -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)',
|
||||
|
|
|
|||
|
|
@ -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(零歧义查找)',
|
||||
|
|
|
|||
|
|
@ -514,7 +514,7 @@ program
|
|||
.option('--include-tests', 'Include test files in results')
|
||||
.option(
|
||||
'-l, --limit <n>',
|
||||
'Max symbols per depth level and affected processes/modules to return (default: 100)',
|
||||
'Max symbols per depth level (default: 100); explicit --limit also caps affected processes/modules/routes',
|
||||
)
|
||||
.option('--offset <n>', 'Skip N symbols per depth level for pagination')
|
||||
.option('--summary-only', 'Return counts and risk only, omit symbol list')
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
21
gitnexus/src/core/ingestion/languages/go/import-binding.ts
Normal file
21
gitnexus/src/core/ingestion/languages/go/import-binding.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import type { ParsedImport } from 'gitnexus-shared';
|
||||
import { inferGoPackageName } from './package-clause.js';
|
||||
|
||||
/** An omitted alias binds the imported package clause, never its path spelling. */
|
||||
export function resolveGoImportBinding(
|
||||
parsedImport: ParsedImport,
|
||||
resolveTargetFiles: () => readonly string[],
|
||||
sourceTextFor: (filePath: string) => string | undefined,
|
||||
): ParsedImport {
|
||||
if (parsedImport.kind !== 'namespace' || !parsedImport.implicitLocalName) return parsedImport;
|
||||
const targetFiles = resolveTargetFiles();
|
||||
if (targetFiles.length === 0) return parsedImport;
|
||||
|
||||
const names = new Set(targetFiles.map((file) => inferGoPackageName(sourceTextFor(file) ?? '')));
|
||||
const name = names.size === 1 ? names.values().next().value : undefined;
|
||||
if (!name) {
|
||||
// Keep the dependency without inventing a scope-visible package qualifier.
|
||||
return { kind: 'side-effect', targetRaw: parsedImport.targetRaw };
|
||||
}
|
||||
return { ...parsedImport, localName: name, importedName: name };
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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+$/, '');
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
212
gitnexus/src/core/ingestion/languages/go/route-bindings.ts
Normal file
212
gitnexus/src/core/ingestion/languages/go/route-bindings.ts
Normal file
|
|
@ -0,0 +1,212 @@
|
|||
import type { SyntaxNode } from 'tree-sitter';
|
||||
|
||||
const FUNCTIONS = new Set(['function_declaration', 'method_declaration', 'func_literal']);
|
||||
const SCOPES = new Set([
|
||||
'block',
|
||||
'if_statement',
|
||||
'for_statement',
|
||||
'expression_switch_statement',
|
||||
'type_switch_statement',
|
||||
'select_statement',
|
||||
'expression_case',
|
||||
'type_case',
|
||||
'communication_case',
|
||||
'default_case',
|
||||
]);
|
||||
|
||||
const BINDING_NODES = new Set([
|
||||
'short_var_declaration',
|
||||
'var_spec',
|
||||
'const_spec',
|
||||
'assignment_statement',
|
||||
'range_clause',
|
||||
'receive_statement',
|
||||
'type_switch_statement',
|
||||
'type_spec',
|
||||
'type_alias',
|
||||
]);
|
||||
|
||||
/** A declaration, shared by every use and assignment that resolves to it. */
|
||||
export interface GoRouteBinding {
|
||||
readonly visibleFrom: number;
|
||||
readonly ownerFunction: SyntaxNode;
|
||||
readonly type: SyntaxNode | null;
|
||||
readonly isInputParameter: boolean;
|
||||
readonly values: (SyntaxNode | null)[];
|
||||
capturedWrite: boolean;
|
||||
}
|
||||
|
||||
function enclosingGoFunction(node: SyntaxNode): SyntaxNode | undefined {
|
||||
for (let current: SyntaxNode | null = node; current; current = current.parent) {
|
||||
if (FUNCTIONS.has(current.type)) return current;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function lexicalScope(node: SyntaxNode): SyntaxNode | undefined {
|
||||
for (let current = node.parent; current; current = current.parent) {
|
||||
if (SCOPES.has(current.type)) return current;
|
||||
if (FUNCTIONS.has(current.type)) return current.childForFieldName('body') ?? undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Route-local binding evidence. Declaration identity and visibility follow Go
|
||||
* blocks; value inference deliberately remains conservative across all writes.
|
||||
* Nested functions are visited so writes to captured locals cannot leave stale
|
||||
* proofs. Their own declarations remain separate from the captured bindings.
|
||||
*/
|
||||
export class GoRouteBindings {
|
||||
private readonly scopes = new Map<number, Map<string, GoRouteBinding>>();
|
||||
|
||||
constructor(root: SyntaxNode) {
|
||||
const assignments: { name: SyntaxNode; value: SyntaxNode | null }[] = [];
|
||||
const stack = [root];
|
||||
while (stack.length > 0) {
|
||||
const node = stack.pop();
|
||||
if (!node) break;
|
||||
if (FUNCTIONS.has(node.type)) this.parameters(node);
|
||||
this.collectNode(node, assignments);
|
||||
for (let i = node.namedChildCount - 1; i >= 0; i--) {
|
||||
const child = node.namedChild(i);
|
||||
if (child) stack.push(child);
|
||||
}
|
||||
}
|
||||
// Resolve writes after collecting declarations, but at each write's source
|
||||
// position. A short declaration's LHS can refer to its newly declared name;
|
||||
// its initializer still sees the previous scope through ordinary lookup.
|
||||
for (const { name, value } of assignments) {
|
||||
const parent = name.parent?.type === 'expression_list' ? name.parent.parent : name.parent;
|
||||
const declaration = parent?.type === 'short_var_declaration';
|
||||
const scope = declaration && parent ? lexicalScope(parent) : undefined;
|
||||
const binding = scope ? this.scopes.get(scope.id)?.get(name.text) : this.lookup(name);
|
||||
if (!binding) continue;
|
||||
binding.values.push(value);
|
||||
if (enclosingGoFunction(name)?.id !== binding.ownerFunction.id) binding.capturedWrite = true;
|
||||
}
|
||||
}
|
||||
|
||||
private collectNode(
|
||||
node: SyntaxNode,
|
||||
assignments: { name: SyntaxNode; value: SyntaxNode | null }[],
|
||||
): void {
|
||||
if (!BINDING_NODES.has(node.type)) return;
|
||||
const scope = lexicalScope(node);
|
||||
if (!scope) return;
|
||||
if (node.type === 'short_var_declaration') {
|
||||
const names = node.childForFieldName('left')?.namedChildren ?? [];
|
||||
const values = node.childForFieldName('right')?.namedChildren ?? [];
|
||||
names.forEach((name, index) => {
|
||||
if (name.type !== 'identifier' || name.text === '_') return;
|
||||
const existing = this.scopes.get(scope.id)?.get(name.text);
|
||||
if (!existing) this.declare(name, scope, node.endIndex);
|
||||
assignments.push({ name, value: this.pairedValue(values, names.length, index) });
|
||||
});
|
||||
} else if (node.type === 'var_spec' || node.type === 'const_spec') {
|
||||
const names = node.childrenForFieldName('name');
|
||||
const values = node.childForFieldName('value')?.namedChildren ?? [];
|
||||
for (const [index, name] of names.entries()) {
|
||||
const binding = this.declare(name, scope, node.endIndex, node.childForFieldName('type'));
|
||||
if (binding && values.length > 0) {
|
||||
binding.values.push(this.pairedValue(values, names.length, index));
|
||||
}
|
||||
}
|
||||
} else if (node.type === 'assignment_statement') {
|
||||
const names = node.childForFieldName('left')?.namedChildren ?? [];
|
||||
const values = node.childForFieldName('right')?.namedChildren ?? [];
|
||||
names.forEach((name, index) => {
|
||||
if (name.type === 'identifier') {
|
||||
assignments.push({ name, value: this.pairedValue(values, names.length, index) });
|
||||
}
|
||||
});
|
||||
} else if (node.type === 'range_clause' || node.type === 'receive_statement') {
|
||||
const declares = node.children.some((child) => child.type === ':=');
|
||||
for (const name of node.childForFieldName('left')?.namedChildren ?? []) {
|
||||
if (name.type !== 'identifier') continue;
|
||||
if (declares) this.declare(name, scope, node.endIndex)?.values.push(null);
|
||||
else assignments.push({ name, value: null });
|
||||
}
|
||||
} else if (node.type === 'type_switch_statement') {
|
||||
for (const name of node.childForFieldName('alias')?.namedChildren ?? []) {
|
||||
this.declare(name, node, name.endIndex)?.values.push(null);
|
||||
}
|
||||
} else if (node.type === 'type_spec' || node.type === 'type_alias') {
|
||||
const name = node.childForFieldName('name');
|
||||
if (name) this.declare(name, scope, name.endIndex);
|
||||
}
|
||||
}
|
||||
|
||||
lookup(node: SyntaxNode): GoRouteBinding | undefined {
|
||||
for (let current: SyntaxNode | null = node; current; current = current.parent) {
|
||||
const binding = this.scopes.get(current.id)?.get(node.text);
|
||||
if (binding && binding.visibleFrom <= node.startIndex) return binding;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private declare(
|
||||
name: SyntaxNode,
|
||||
scope: SyntaxNode,
|
||||
visibleFrom: number,
|
||||
type: SyntaxNode | null = null,
|
||||
isInputParameter = false,
|
||||
): GoRouteBinding | undefined {
|
||||
if (name.text === '_') return undefined;
|
||||
const ownerFunction = enclosingGoFunction(scope);
|
||||
if (!ownerFunction) return undefined;
|
||||
let names = this.scopes.get(scope.id);
|
||||
if (!names) {
|
||||
names = new Map();
|
||||
this.scopes.set(scope.id, names);
|
||||
}
|
||||
const binding: GoRouteBinding = {
|
||||
visibleFrom,
|
||||
ownerFunction,
|
||||
type,
|
||||
isInputParameter,
|
||||
values: [],
|
||||
capturedWrite: false,
|
||||
};
|
||||
names.set(name.text, binding);
|
||||
return binding;
|
||||
}
|
||||
|
||||
private parameters(fn: SyntaxNode): void {
|
||||
const body = fn.childForFieldName('body');
|
||||
if (!body) return;
|
||||
for (const parameter of fn.childForFieldName('type_parameters')?.namedChildren ?? []) {
|
||||
for (const name of parameter.childrenForFieldName('name')) {
|
||||
this.declare(name, fn, name.endIndex);
|
||||
}
|
||||
}
|
||||
for (const field of ['receiver', 'parameters', 'result']) {
|
||||
for (const parameter of fn.childForFieldName(field)?.namedChildren ?? []) {
|
||||
if (
|
||||
parameter.type !== 'parameter_declaration' &&
|
||||
parameter.type !== 'variadic_parameter_declaration'
|
||||
)
|
||||
continue;
|
||||
const fixed = parameter.type === 'parameter_declaration';
|
||||
for (const name of parameter.childrenForFieldName('name')) {
|
||||
this.declare(
|
||||
name,
|
||||
body,
|
||||
body.startIndex,
|
||||
fixed ? parameter.childForFieldName('type') : null,
|
||||
fixed && field !== 'result',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private pairedValue(
|
||||
values: readonly SyntaxNode[],
|
||||
count: number,
|
||||
index: number,
|
||||
): SyntaxNode | null {
|
||||
// A multi-result call proves only the first result's constructor hint.
|
||||
return (values.length === count ? values[index] : index === 0 ? values[0] : null) ?? null;
|
||||
}
|
||||
}
|
||||
187
gitnexus/src/core/ingestion/languages/go/route-handler.ts
Normal file
187
gitnexus/src/core/ingestion/languages/go/route-handler.ts
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
import type { SymbolDefinition } from 'gitnexus-shared';
|
||||
import { generateId } from '../../../../lib/utils.js';
|
||||
import type { RouteHandlerResolutionHookContext } from '../../language-provider.js';
|
||||
import type { SemanticModel } from '../../model/semantic-model.js';
|
||||
import type { ExtractedDecoratorRoute } from '../../workers/parse-worker.js';
|
||||
import { goPackageDir } from './package-clause.js';
|
||||
|
||||
/**
|
||||
* Resolve a gin/echo handler designator (`Login`, `pkg.Login`, `h.Login`) to a
|
||||
* symbol, looking across the files of a Go package — a package is a directory,
|
||||
* so a handler registered in `router.go` is usually a method declared in a
|
||||
* sibling file (#3402).
|
||||
*
|
||||
* Every step is unique-or-decline, and nothing falls back to a name-only match:
|
||||
* with the router and the handlers in different packages, a same-named method
|
||||
* in the router's own directory belongs to an unrelated type.
|
||||
*
|
||||
* Runs at the end of the parse phase, before scope resolution, so a Method's
|
||||
* `ownerId` is still the worker's `Struct:<methodFile>:<Receiver>` id — keyed on
|
||||
* the METHOD's file, which is not the struct's node id when the struct is
|
||||
* declared in another file of the package. Ownership is therefore matched on
|
||||
* either id.
|
||||
*/
|
||||
export function resolveGoRouteHandler(
|
||||
route: ExtractedDecoratorRoute,
|
||||
context: RouteHandlerResolutionHookContext,
|
||||
): string | undefined {
|
||||
const designator = route.handlerName;
|
||||
if (!designator) return undefined;
|
||||
const parts = designator.split('.');
|
||||
const routeDir = goPackageDir(route.filePath);
|
||||
if (parts.length === 1) return uniqueId(functionsIn(context, routeDir, designator));
|
||||
if (parts.length !== 2) return undefined;
|
||||
const member = parts[1];
|
||||
|
||||
// A receiver of unknown type (`h := deps.Users`, a package-level var) declines.
|
||||
const hint = route.handlerReceiver;
|
||||
if (hint === undefined) return undefined;
|
||||
|
||||
const dir = hint.qualifier === undefined ? routeDir : packageDir(context, route, hint.qualifier);
|
||||
if (dir === undefined) return undefined;
|
||||
if (hint.kind === 'module') return uniqueId(functionsIn(context, dir, member));
|
||||
if (hint.name === undefined) return undefined;
|
||||
if (hint.kind === 'type') return methodOfType(context, dir, hint.name, member);
|
||||
|
||||
const constructor = unique(functionsIn(context, dir, hint.name));
|
||||
const owner = constructor && ownerTypeName(constructor.returnType);
|
||||
return constructor && owner
|
||||
? methodOfType(context, goPackageDir(constructor.filePath), owner, member)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
type DefinitionBuckets = Map<string, SymbolDefinition[]>;
|
||||
type PackageIndex = Map<string, DefinitionBuckets>;
|
||||
const bucketKey = (dir: string, name: string): string => JSON.stringify([dir, name]);
|
||||
|
||||
function append(bucket: DefinitionBuckets, key: string, def: SymbolDefinition): void {
|
||||
const values = bucket.get(key);
|
||||
if (values) values.push(def);
|
||||
else bucket.set(key, [def]);
|
||||
}
|
||||
|
||||
function packageDefinitions(
|
||||
cache: PackageIndex,
|
||||
name: string,
|
||||
definitions: () => readonly SymbolDefinition[],
|
||||
): DefinitionBuckets {
|
||||
let packages = cache.get(name);
|
||||
if (!packages) {
|
||||
packages = new Map();
|
||||
for (const def of definitions()) {
|
||||
if (!def.filePath.endsWith('_test.go')) append(packages, goPackageDir(def.filePath), def);
|
||||
}
|
||||
cache.set(name, packages);
|
||||
}
|
||||
return packages;
|
||||
}
|
||||
|
||||
/** One immutable post-parse model per context; no cache survives a new pass. */
|
||||
class GoRouteHandlerIndex {
|
||||
private readonly functions = new Map<string, DefinitionBuckets>();
|
||||
private readonly types = new Map<string, DefinitionBuckets>();
|
||||
private readonly methods = new Map<string, DefinitionBuckets>();
|
||||
|
||||
constructor(private readonly model: SemanticModel) {}
|
||||
|
||||
functionsIn(dir: string, name: string): readonly SymbolDefinition[] {
|
||||
return (
|
||||
packageDefinitions(this.functions, name, () =>
|
||||
this.model.symbols.lookupCallableByName(name).filter((def) => def.type === 'Function'),
|
||||
).get(dir) ?? []
|
||||
);
|
||||
}
|
||||
|
||||
typesIn(dir: string, name: string): readonly SymbolDefinition[] {
|
||||
return (
|
||||
packageDefinitions(this.types, name, () =>
|
||||
this.model.types.lookupClassByName(name).filter((def) => def.type === 'Struct'),
|
||||
).get(dir) ?? []
|
||||
);
|
||||
}
|
||||
|
||||
methodsOf(
|
||||
dir: string,
|
||||
ownerId: string,
|
||||
typeName: string,
|
||||
member: string,
|
||||
): readonly SymbolDefinition[] {
|
||||
let index = this.methods.get(member);
|
||||
if (!index) {
|
||||
index = new Map();
|
||||
for (const def of this.model.methods.lookupMethodByName(member)) {
|
||||
if (def.filePath.endsWith('_test.go') || def.ownerId === undefined) continue;
|
||||
const packageDir = goPackageDir(def.filePath);
|
||||
const prefix = generateId('Struct', `${def.filePath}:`);
|
||||
if (def.ownerId.startsWith(prefix)) {
|
||||
append(index, bucketKey(packageDir, def.ownerId.slice(prefix.length)), def);
|
||||
}
|
||||
}
|
||||
this.methods.set(member, index);
|
||||
}
|
||||
return [
|
||||
...this.model.methods
|
||||
.lookupAllByOwner(ownerId, member)
|
||||
.filter((def) => !def.filePath.endsWith('_test.go') && goPackageDir(def.filePath) === dir),
|
||||
...(index.get(bucketKey(dir, typeName)) ?? []),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
const routeIndexes = new WeakMap<RouteHandlerResolutionHookContext, GoRouteHandlerIndex>();
|
||||
function indexFor(context: RouteHandlerResolutionHookContext): GoRouteHandlerIndex {
|
||||
let index = routeIndexes.get(context);
|
||||
if (!index) {
|
||||
index = new GoRouteHandlerIndex(context.model);
|
||||
routeIndexes.set(context, index);
|
||||
}
|
||||
return index;
|
||||
}
|
||||
|
||||
function unique(defs: readonly SymbolDefinition[]): SymbolDefinition | undefined {
|
||||
const byId = new Map(defs.map((def) => [def.nodeId, def]));
|
||||
return byId.size === 1 ? byId.values().next().value : undefined;
|
||||
}
|
||||
|
||||
const uniqueId = (defs: readonly SymbolDefinition[]): string | undefined => unique(defs)?.nodeId;
|
||||
|
||||
function functionsIn(
|
||||
context: RouteHandlerResolutionHookContext,
|
||||
dir: string,
|
||||
name: string,
|
||||
): readonly SymbolDefinition[] {
|
||||
return indexFor(context).functionsIn(dir, name);
|
||||
}
|
||||
|
||||
/** The one package directory an import local name resolves to, if any. */
|
||||
function packageDir(
|
||||
context: RouteHandlerResolutionHookContext,
|
||||
route: ExtractedDecoratorRoute,
|
||||
localName: string,
|
||||
): string | undefined {
|
||||
const dirs = new Set(context.importTargetsFor(route.filePath, localName).map(goPackageDir));
|
||||
return dirs.size === 1 ? dirs.values().next().value : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* `*T`, `T`, `(*T, error)` → `T`. A qualified (`pkg.T`) or composite result is
|
||||
* declined: the owner would live in another package this hint cannot name.
|
||||
*/
|
||||
function ownerTypeName(returnType: string | undefined): string | undefined {
|
||||
if (returnType === undefined) return undefined;
|
||||
const first = returnType.replace(/^\(/, '').split(',')[0]?.trim() ?? '';
|
||||
const name = first.replace(/^\*/, '');
|
||||
return /^[_\p{L}][_\p{L}\p{Nd}]*$/u.test(name) ? name : undefined;
|
||||
}
|
||||
|
||||
function methodOfType(
|
||||
context: RouteHandlerResolutionHookContext,
|
||||
dir: string,
|
||||
typeName: string,
|
||||
member: string,
|
||||
): string | undefined {
|
||||
const index = indexFor(context);
|
||||
const owner = unique(index.typesIn(dir, typeName));
|
||||
if (owner === undefined) return undefined;
|
||||
return uniqueId(index.methodsOf(dir, owner.nodeId, typeName, member));
|
||||
}
|
||||
|
|
@ -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),
|
||||
|
||||
|
|
|
|||
|
|
@ -1834,9 +1834,16 @@ export async function runChunkedParseAndResolve(
|
|||
`exportedTypeMap=${exportedTypeMap.size} parsedFiles=${allParsedFiles.length} nodes=${graph.nodeCount}`,
|
||||
);
|
||||
const routeFilePaths = new Set(allPaths);
|
||||
// Route files whose handlers resolve through imports: data route tables, and
|
||||
// routes of a language that resolves its own handlers (`resolveRouteHandler`).
|
||||
// Both need the file's parsed imports and its language's resolution config.
|
||||
const dataRouteFilePaths = new Set(
|
||||
allDecoratorRoutes
|
||||
.filter((route) => route.source === DATA_ROUTE_TABLE_SOURCE)
|
||||
.filter(
|
||||
(route) =>
|
||||
route.source === DATA_ROUTE_TABLE_SOURCE ||
|
||||
getProviderForFile(route.filePath)?.resolveRouteHandler !== undefined,
|
||||
)
|
||||
.map((route) => route.filePath),
|
||||
);
|
||||
const routeResolutionConfigs = new Map<SupportedLanguages, unknown>();
|
||||
|
|
@ -1852,12 +1859,12 @@ export async function runChunkedParseAndResolve(
|
|||
);
|
||||
}
|
||||
let routeResolutionFiles = allParsedFiles;
|
||||
const resolveRouteImportTarget = (
|
||||
const resolveRouteImportTargets = (
|
||||
parsedImport: ParsedImport,
|
||||
fromFile: string,
|
||||
): string | null => {
|
||||
): readonly string[] => {
|
||||
const language = getLanguageFromFilename(fromFile);
|
||||
if (language === null) return null;
|
||||
if (language === null) return [];
|
||||
const target = SCOPE_RESOLVERS.get(language)?.resolveImportTarget(
|
||||
parsedImport.targetRaw ?? '',
|
||||
fromFile,
|
||||
|
|
@ -1865,8 +1872,15 @@ export async function runChunkedParseAndResolve(
|
|||
routeResolutionConfigs.get(language),
|
||||
{ parsedFiles: routeResolutionFiles, parsedImport },
|
||||
);
|
||||
if (typeof target === 'string') return target;
|
||||
return target?.length === 1 ? target[0] : null;
|
||||
if (typeof target === 'string') return [target];
|
||||
return target ?? [];
|
||||
};
|
||||
const resolveRouteImportTarget = (
|
||||
parsedImport: ParsedImport,
|
||||
fromFile: string,
|
||||
): string | null => {
|
||||
const targets = resolveRouteImportTargets(parsedImport, fromFile);
|
||||
return targets.length === 1 ? targets[0] : null;
|
||||
};
|
||||
if (parsedFileStorePath !== undefined && dataRouteFilePaths.size > 0) {
|
||||
const byPath = await loadParsedFilesForPaths(parsedFileStorePath, dataRouteFilePaths);
|
||||
|
|
@ -1887,6 +1901,34 @@ export async function runChunkedParseAndResolve(
|
|||
}
|
||||
// Part 2 (#2138): resolve each route's handler to a real symbol UID now that
|
||||
// the model is fully populated and decorator-route prefixes are finalized.
|
||||
const routeSourceTexts = new Map<string, string | undefined>();
|
||||
const routeSourceTextFor = (filePath: string): string | undefined => {
|
||||
if (!routeSourceTexts.has(filePath)) {
|
||||
try {
|
||||
routeSourceTexts.set(filePath, fs.readFileSync(path.join(repoPath, filePath), 'utf-8'));
|
||||
} catch {
|
||||
routeSourceTexts.set(filePath, undefined);
|
||||
}
|
||||
}
|
||||
return routeSourceTexts.get(filePath);
|
||||
};
|
||||
routeResolutionFiles = routeResolutionFiles.map((parsed) => {
|
||||
if (!dataRouteFilePaths.has(parsed.filePath)) return parsed;
|
||||
const language = getLanguageFromFilename(parsed.filePath);
|
||||
const resolveBinding =
|
||||
language === null ? undefined : SCOPE_RESOLVERS.get(language)?.resolveImportBinding;
|
||||
if (!resolveBinding) return parsed;
|
||||
return {
|
||||
...parsed,
|
||||
parsedImports: parsed.parsedImports.map((parsedImport) =>
|
||||
resolveBinding(
|
||||
parsedImport,
|
||||
() => resolveRouteImportTargets(parsedImport, parsed.filePath),
|
||||
routeSourceTextFor,
|
||||
),
|
||||
),
|
||||
};
|
||||
});
|
||||
const routeHandlerSymbols = resolveRouteHandlerSymbols(
|
||||
model,
|
||||
allExtractedRoutes,
|
||||
|
|
@ -1894,6 +1936,8 @@ export async function runChunkedParseAndResolve(
|
|||
{
|
||||
files: routeResolutionFiles,
|
||||
resolveImportTarget: resolveRouteImportTarget,
|
||||
resolveImportTargets: resolveRouteImportTargets,
|
||||
providerRouteHandler: (filePath) => getProviderForFile(filePath)?.resolveRouteHandler,
|
||||
isExportedSymbol: (nodeId: string) => graph.getNode(nodeId)?.properties.isExported === true,
|
||||
nodeStartLine: (id) => {
|
||||
const n = graph.getNode(id);
|
||||
|
|
|
|||
392
gitnexus/src/core/ingestion/route-extractors/go-gin-echo.ts
Normal file
392
gitnexus/src/core/ingestion/route-extractors/go-gin-echo.ts
Normal file
|
|
@ -0,0 +1,392 @@
|
|||
/**
|
||||
* gin / echo routes for the indexer (#3402).
|
||||
*
|
||||
* A gin or echo endpoint is a verb call on a router value —
|
||||
* `admin.POST("/rounds/:id", h.Finalize)` — whose full URL is the join of every
|
||||
* `Group("/p")` the router value was derived from. Neither the verb call nor
|
||||
* any one `Group` call is the route on its own, so this walks each function
|
||||
* body, tracks which local names hold a router with a PROVEN prefix, and emits
|
||||
* a route only for a verb call on such a name.
|
||||
*
|
||||
* Proven means: the name is an engine (`*gin.Engine` / `*echo.Echo` parameter,
|
||||
* `gin.Default()`, `gin.New()`, `echo.New()`) or a `Group(<string literal>)` of
|
||||
* a proven router, and every assignment to it in the function agrees. Anything
|
||||
* else — a `*gin.RouterGroup` parameter, a computed group path, a struct field,
|
||||
* a name assigned two different routers — is unknown, and a verb call on it is
|
||||
* dropped. A route stored under the wrong URL is a false fact that `route_map`
|
||||
* and FETCHES matching would repeat; a missing route is a documented gap
|
||||
* (groups handed to another function are the known one).
|
||||
*
|
||||
* The handler travels as `handlerName` (the raw designator) plus a
|
||||
* `handlerReceiver` hint read from this file's own syntax. Resolving it to a
|
||||
* symbol needs the rest of the package, so that is the Go provider's
|
||||
* `resolveRouteHandler` hook, not this file.
|
||||
*/
|
||||
|
||||
import type Parser from 'tree-sitter';
|
||||
import { goImportPackageName } from '../languages/go/import-package-name.js';
|
||||
import { GoRouteBindings, type GoRouteBinding } from '../languages/go/route-bindings.js';
|
||||
import { normalizeExtractedRoutePath } from './route-path.js';
|
||||
import type { SyntaxNode } from 'tree-sitter';
|
||||
import type { ExtractedDecoratorRoute, RouteHandlerReceiver } from '../workers/parse-worker.js';
|
||||
|
||||
export const GIN_ROUTE_SOURCE = 'gin-route';
|
||||
export const ECHO_ROUTE_SOURCE = 'echo-route';
|
||||
|
||||
const VERBS: ReadonlySet<string> = new Set([
|
||||
'GET',
|
||||
'POST',
|
||||
'PUT',
|
||||
'PATCH',
|
||||
'DELETE',
|
||||
'HEAD',
|
||||
'OPTIONS',
|
||||
]);
|
||||
|
||||
interface Framework {
|
||||
readonly source: string;
|
||||
/** Import local name of the framework package. */
|
||||
readonly alias: string;
|
||||
/** Engine type name reached through the alias (`gin.Engine`, `echo.Echo`). */
|
||||
readonly engineType: string;
|
||||
/** Engine constructors reached through the alias. */
|
||||
readonly constructors: ReadonlySet<string>;
|
||||
/** gin takes the handler last (middleware first); echo takes it second. */
|
||||
readonly handlerArg: 'last' | 'second';
|
||||
}
|
||||
|
||||
const FUNCTION_TYPE_LIST = ['function_declaration', 'method_declaration', 'func_literal'];
|
||||
const FUNCTION_TYPES: ReadonlySet<string> = new Set(FUNCTION_TYPE_LIST);
|
||||
|
||||
function stringLiteral(node: SyntaxNode | null | undefined): string | null {
|
||||
if (!node || node.hasError) return null;
|
||||
const body = node.text.slice(1, -1);
|
||||
// Go discards carriage returns in raw strings, including CRLF source files.
|
||||
if (node.type === 'raw_string_literal') return body.replace(/\r/g, '');
|
||||
if (node.type !== 'interpreted_string_literal') return null;
|
||||
if (!body.includes('\\')) return body;
|
||||
|
||||
const simple: Readonly<Record<string, string>> = {
|
||||
a: '\x07',
|
||||
b: '\b',
|
||||
f: '\f',
|
||||
n: '\n',
|
||||
r: '\r',
|
||||
t: '\t',
|
||||
v: '\v',
|
||||
'\\': '\\',
|
||||
'"': '"',
|
||||
};
|
||||
const chunks: Buffer[] = [];
|
||||
const tokens =
|
||||
/\\(?:[abfnrtv\\"]|[0-7]{3}|x[\da-fA-F]{2}|u[\da-fA-F]{4}|U[\da-fA-F]{8})|[^\\"\n]+/g;
|
||||
let consumed = 0;
|
||||
for (const match of body.matchAll(tokens)) {
|
||||
if (match.index !== consumed) return null;
|
||||
const token = match[0];
|
||||
consumed += token.length;
|
||||
if (!token.startsWith('\\')) {
|
||||
chunks.push(Buffer.from(token));
|
||||
} else if (simple[token[1]] !== undefined) {
|
||||
chunks.push(Buffer.from(simple[token[1]]));
|
||||
} else {
|
||||
const octal = /[0-7]/.test(token[1]);
|
||||
const value = Number.parseInt(token.slice(octal ? 1 : 2), octal ? 8 : 16);
|
||||
if (octal || token[1] === 'x') {
|
||||
// Octal and hex escapes encode bytes, not Unicode code points.
|
||||
if (value > 255) return null;
|
||||
chunks.push(Buffer.from([value]));
|
||||
} else {
|
||||
if (value > 0x10ffff || (value >= 0xd800 && value <= 0xdfff)) return null;
|
||||
chunks.push(Buffer.from(String.fromCodePoint(value)));
|
||||
}
|
||||
}
|
||||
}
|
||||
if (consumed !== body.length) return null;
|
||||
try {
|
||||
// Arbitrary non-UTF-8 Go byte strings cannot be represented losslessly in a URL.
|
||||
return new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(Buffer.concat(chunks));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** The framework this file routes with, when exactly one is imported. */
|
||||
function readImports(root: SyntaxNode): {
|
||||
readonly framework: Framework | null;
|
||||
} {
|
||||
let gin: string | null = null;
|
||||
let echo: string | null = null;
|
||||
// Imports sit only at file scope; this runs on every Go file, so skip bodies.
|
||||
const specs = root.namedChildren
|
||||
.filter((node) => node.type === 'import_declaration')
|
||||
.flatMap((decl) => decl.descendantsOfType('import_spec'));
|
||||
for (const spec of specs) {
|
||||
const importPath = stringLiteral(spec.childForFieldName('path'));
|
||||
if (importPath === null) continue;
|
||||
const explicit = spec.childForFieldName('name')?.text;
|
||||
// `_` and `.` imports bind no qualifier this file can call through.
|
||||
if (explicit === '_' || explicit === '.') continue;
|
||||
const local = explicit ?? goImportPackageName(importPath);
|
||||
if (!local) continue;
|
||||
if (importPath === 'github.com/gin-gonic/gin') gin = local;
|
||||
if (/^github\.com\/labstack\/echo(\/v\d+)?$/.test(importPath)) echo = local;
|
||||
}
|
||||
// Both, or neither: no way to tell which argument is the handler.
|
||||
if (gin !== null && echo === null) {
|
||||
return {
|
||||
framework: {
|
||||
source: GIN_ROUTE_SOURCE,
|
||||
alias: gin,
|
||||
engineType: 'Engine',
|
||||
constructors: new Set(['Default', 'New']),
|
||||
handlerArg: 'last',
|
||||
},
|
||||
};
|
||||
}
|
||||
if (echo !== null && gin === null) {
|
||||
return {
|
||||
framework: {
|
||||
source: ECHO_ROUTE_SOURCE,
|
||||
alias: echo,
|
||||
engineType: 'Echo',
|
||||
constructors: new Set(['New']),
|
||||
handlerArg: 'second',
|
||||
},
|
||||
};
|
||||
}
|
||||
return { framework: null };
|
||||
}
|
||||
|
||||
/** Named descendants of a function body, not descending into nested functions. */
|
||||
function bodyNodes(body: SyntaxNode): SyntaxNode[] {
|
||||
const out: SyntaxNode[] = [];
|
||||
const stack: SyntaxNode[] = [...body.namedChildren].reverse();
|
||||
while (stack.length > 0) {
|
||||
const node = stack.pop() as SyntaxNode;
|
||||
out.push(node);
|
||||
if (FUNCTION_TYPES.has(node.type)) continue;
|
||||
for (let i = node.namedChildCount - 1; i >= 0; i--) {
|
||||
const child = node.namedChild(i);
|
||||
if (child) stack.push(child);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** `T`, `*T`, `pkg.T`, `*pkg.T` → type hint; anything else → undefined. */
|
||||
function typeHint(
|
||||
typeNode: SyntaxNode | null | undefined,
|
||||
bindings: GoRouteBindings,
|
||||
): RouteHandlerReceiver | undefined {
|
||||
if (!typeNode) return undefined;
|
||||
if (typeNode.type === 'pointer_type') return typeHint(typeNode.namedChild(0), bindings);
|
||||
if (typeNode.type === 'type_identifier') {
|
||||
return bindings.lookup(typeNode) ? undefined : { kind: 'type', name: typeNode.text };
|
||||
}
|
||||
if (typeNode.type === 'qualified_type') {
|
||||
const packageNode = typeNode.childForFieldName('package');
|
||||
if (packageNode && bindings.lookup(packageNode)) return undefined;
|
||||
const pkg = packageNode?.text;
|
||||
const name = typeNode.childForFieldName('name')?.text;
|
||||
return pkg && name ? { kind: 'type', name, qualifier: pkg } : undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Receiver hint for the value a name was assigned. */
|
||||
function valueHint(value: SyntaxNode, bindings: GoRouteBindings): RouteHandlerReceiver | undefined {
|
||||
if (value.type === 'unary_expression' && value.childForFieldName('operator')?.text === '&') {
|
||||
const operand = value.childForFieldName('operand');
|
||||
return operand ? valueHint(operand, bindings) : undefined;
|
||||
}
|
||||
if (value.type === 'composite_literal')
|
||||
return typeHint(value.childForFieldName('type'), bindings);
|
||||
if (value.type === 'call_expression') {
|
||||
const fn = value.childForFieldName('function');
|
||||
if (fn?.type === 'identifier') {
|
||||
return bindings.lookup(fn) ? undefined : { kind: 'constructor', name: fn.text };
|
||||
}
|
||||
if (fn?.type === 'selector_expression') {
|
||||
const operand = fn.childForFieldName('operand');
|
||||
const field = fn.childForFieldName('field');
|
||||
if (operand?.type === 'identifier' && field && !bindings.lookup(operand)) {
|
||||
return { kind: 'constructor', name: field.text, qualifier: operand.text };
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const sameHint = (a: RouteHandlerReceiver, b: RouteHandlerReceiver): boolean =>
|
||||
a.kind === b.kind && a.name === b.name && a.qualifier === b.qualifier;
|
||||
|
||||
class RouterPrefixes {
|
||||
private readonly memo = new Map<GoRouteBinding, string | null>();
|
||||
private readonly visiting = new Set<GoRouteBinding>();
|
||||
|
||||
constructor(
|
||||
private readonly bindings: GoRouteBindings,
|
||||
private readonly fw: Framework,
|
||||
private readonly fn: SyntaxNode,
|
||||
) {}
|
||||
|
||||
/** Proven prefix of a router expression, or null when it cannot be proven. */
|
||||
of(node: SyntaxNode): string | null {
|
||||
if (node.type === 'parenthesized_expression') {
|
||||
const inner = node.namedChild(0);
|
||||
return inner ? this.of(inner) : null;
|
||||
}
|
||||
if (node.type === 'identifier') {
|
||||
const binding = this.bindings.lookup(node);
|
||||
return binding ? this.ofBinding(binding) : null;
|
||||
}
|
||||
if (node.type !== 'call_expression') return null;
|
||||
const fn = node.childForFieldName('function');
|
||||
if (fn?.type !== 'selector_expression') return null;
|
||||
const operand = fn.childForFieldName('operand');
|
||||
const field = fn.childForFieldName('field')?.text;
|
||||
if (!operand || !field) return null;
|
||||
if (
|
||||
operand.type === 'identifier' &&
|
||||
operand.text === this.fw.alias &&
|
||||
!this.bindings.lookup(operand)
|
||||
) {
|
||||
return this.fw.constructors.has(field) ? '' : null;
|
||||
}
|
||||
if (field !== 'Group') return null;
|
||||
const path = stringLiteral(node.childForFieldName('arguments')?.namedChild(0));
|
||||
if (path === null) return null;
|
||||
const base = this.of(operand);
|
||||
return base === null ? null : normalizeExtractedRoutePath(path, base);
|
||||
}
|
||||
|
||||
private ofBinding(binding: GoRouteBinding): string | null {
|
||||
// Captured routers remain outside this extractor's supported route forms.
|
||||
if (binding.ownerFunction.id !== this.fn.id || binding.capturedWrite) return null;
|
||||
const cached = this.memo.get(binding);
|
||||
if (cached !== undefined) return cached;
|
||||
if (this.visiting.has(binding)) return null;
|
||||
this.visiting.add(binding);
|
||||
const hint = typeHint(binding.type, this.bindings);
|
||||
const candidates: (string | null)[] = [];
|
||||
if (
|
||||
binding.isInputParameter &&
|
||||
hint?.kind === 'type' &&
|
||||
hint.qualifier === this.fw.alias &&
|
||||
hint.name === this.fw.engineType
|
||||
) {
|
||||
candidates.push('');
|
||||
} else if (binding.isInputParameter || binding.values.length === 0) {
|
||||
candidates.push(null);
|
||||
}
|
||||
for (const value of binding.values) candidates.push(value === null ? null : this.of(value));
|
||||
const first = candidates[0] ?? null;
|
||||
const result = candidates.every((candidate) => candidate === first) ? first : null;
|
||||
this.visiting.delete(binding);
|
||||
this.memo.set(binding, result);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
/** Hints refer only to visible declarations; unsupported local types decline. */
|
||||
function receiverHint(
|
||||
node: SyntaxNode,
|
||||
bindings: GoRouteBindings,
|
||||
memo: Map<GoRouteBinding, RouteHandlerReceiver | undefined>,
|
||||
): RouteHandlerReceiver | undefined {
|
||||
const binding = bindings.lookup(node);
|
||||
// Workspace resolution checks the actual imported package's declared name.
|
||||
if (!binding) return { kind: 'module', qualifier: node.text };
|
||||
if (memo.has(binding)) return memo.get(binding);
|
||||
if (binding.capturedWrite) return undefined;
|
||||
const hints = binding.values.map((value) =>
|
||||
value === null ? undefined : valueHint(value, bindings),
|
||||
);
|
||||
if (binding.isInputParameter || binding.values.length === 0) {
|
||||
hints.push(typeHint(binding.type, bindings));
|
||||
}
|
||||
const first = hints[0];
|
||||
const result =
|
||||
first && hints.every((hint) => hint !== undefined && sameHint(hint, first)) ? first : undefined;
|
||||
memo.set(binding, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
interface VerbRegistration {
|
||||
readonly call: SyntaxNode;
|
||||
readonly verb: string;
|
||||
readonly receiver: SyntaxNode;
|
||||
readonly args: readonly SyntaxNode[];
|
||||
readonly path: string;
|
||||
}
|
||||
|
||||
/** `recv.VERB("<literal>", …handler)` — the shape, before its receiver is proven. */
|
||||
function verbRegistration(call: SyntaxNode): VerbRegistration | null {
|
||||
if (call.type !== 'call_expression') return null;
|
||||
const callee = call.childForFieldName('function');
|
||||
if (callee?.type !== 'selector_expression') return null;
|
||||
const verb = callee.childForFieldName('field')?.text;
|
||||
const receiver = callee.childForFieldName('operand');
|
||||
if (!verb || !VERBS.has(verb) || !receiver) return null;
|
||||
const args = call.childForFieldName('arguments')?.namedChildren ?? [];
|
||||
if (args.length < 2) return null;
|
||||
const path = stringLiteral(args[0]);
|
||||
return path === null ? null : { call, verb, receiver, args, path };
|
||||
}
|
||||
|
||||
export function extractGoGinEchoRoutes(
|
||||
tree: Parser.Tree,
|
||||
filePath: string,
|
||||
lineOffset = 0,
|
||||
): ExtractedDecoratorRoute[] {
|
||||
const root = tree.rootNode;
|
||||
const { framework } = readImports(root);
|
||||
if (framework === null) return [];
|
||||
|
||||
const out: ExtractedDecoratorRoute[] = [];
|
||||
const handlerHints = new Map<GoRouteBinding, RouteHandlerReceiver | undefined>();
|
||||
let bindings: GoRouteBindings | undefined;
|
||||
for (const fn of root.descendantsOfType(FUNCTION_TYPE_LIST)) {
|
||||
const body = fn.childForFieldName('body');
|
||||
if (!body) continue;
|
||||
const nodes = bodyNodes(body);
|
||||
const registrations = nodes.flatMap((node) => {
|
||||
const registration = verbRegistration(node);
|
||||
return registration === null ? [] : [registration];
|
||||
});
|
||||
// Most functions in a gin-importing file register nothing; skip their bindings.
|
||||
if (registrations.length === 0) continue;
|
||||
bindings ??= new GoRouteBindings(root);
|
||||
const prefixes = new RouterPrefixes(bindings, framework, fn);
|
||||
|
||||
for (const { call, verb, receiver, args, path } of registrations) {
|
||||
const prefix = prefixes.of(receiver);
|
||||
if (prefix === null) continue;
|
||||
|
||||
const handler = framework.handlerArg === 'last' ? args[args.length - 1] : args[1];
|
||||
const route: ExtractedDecoratorRoute = {
|
||||
filePath,
|
||||
routePath: normalizeExtractedRoutePath(path, prefix),
|
||||
httpMethod: verb,
|
||||
decoratorName: verb,
|
||||
lineNumber: call.startPosition.row + 1 + lineOffset,
|
||||
prefix: null,
|
||||
source: framework.source,
|
||||
};
|
||||
if (handler.type === 'identifier' && !bindings.lookup(handler)) {
|
||||
route.handlerName = handler.text;
|
||||
} else if (handler.type === 'selector_expression') {
|
||||
const operand = handler.childForFieldName('operand');
|
||||
const field = handler.childForFieldName('field');
|
||||
if (operand?.type === 'identifier' && field) {
|
||||
route.handlerName = `${operand.text}.${field.text}`;
|
||||
const hint = receiverHint(operand, bindings, handlerHints);
|
||||
if (hint) route.handlerReceiver = hint;
|
||||
}
|
||||
}
|
||||
out.push(route);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -344,6 +344,13 @@ export interface FetchWrapperDef {
|
|||
functionName: string;
|
||||
}
|
||||
|
||||
/** See {@link ExtractedDecoratorRoute.handlerReceiver}. */
|
||||
export interface RouteHandlerReceiver {
|
||||
kind: 'type' | 'constructor' | 'module';
|
||||
name?: string;
|
||||
qualifier?: string;
|
||||
}
|
||||
|
||||
export interface ExtractedDecoratorRoute {
|
||||
filePath: string;
|
||||
routePath: string;
|
||||
|
|
@ -388,6 +395,20 @@ export interface ExtractedDecoratorRoute {
|
|||
* resolution then falls back (the Route node simply carries no handlerSymbolId).
|
||||
*/
|
||||
handlerName?: string;
|
||||
/**
|
||||
* Static hint for what the receiver of a qualified {@link handlerName}
|
||||
* (`h.Method`, `pkg.Func`) is, read from the registering file's own syntax.
|
||||
* The worker sees one file, so it records only what that file says:
|
||||
* - `type` — the receiver was declared or built as `name` (`h := &T{}`,
|
||||
* `var h *T`, a `h *pkg.T` parameter);
|
||||
* - `constructor` — the receiver was returned by the function `name`
|
||||
* (`h := NewT(...)`), whose declared result type names the owner;
|
||||
* - `module` — the receiver is the import `qualifier` (`pkg.Func`).
|
||||
* `qualifier` is the import local name the type or constructor was reached
|
||||
* through, when there is one. Only the route file's provider reads this, via
|
||||
* `LanguageProvider.resolveRouteHandler`; absent when nothing was inferred.
|
||||
*/
|
||||
handlerReceiver?: RouteHandlerReceiver;
|
||||
/**
|
||||
* Provenance for the `HANDLES_ROUTE` edge, overriding the default
|
||||
* `decorator-<decoratorName>`. Present when the route was extracted from a
|
||||
|
|
|
|||
|
|
@ -8788,6 +8788,54 @@ export class LocalBackend {
|
|||
});
|
||||
}
|
||||
|
||||
// ── Route enrichment (#3402) ──────────────────────────────────────────
|
||||
// HTTP endpoints served by the target or any impacted symbol, read from
|
||||
// (handler)-[HANDLES_ROUTE]->Route. Reported, not traversed: HANDLES_ROUTE
|
||||
// stays out of the walk's relTypes. The Process path cannot stand in for
|
||||
// this — a handler only heads a Process when its call chain is 3+ steps and
|
||||
// it ranks in the repo-wide entry-point cap, so most handlers of a large
|
||||
// router would never surface.
|
||||
const routesById = new Map<string, Array<{ url: string; method?: string }>>();
|
||||
const affectedRoutes: Array<{ url: string; method?: string }> = [];
|
||||
if (!skipEnrichment) {
|
||||
const routeIds = [
|
||||
String(symId),
|
||||
...impacted.map((item) => String(item.id ?? '')).filter(Boolean),
|
||||
].slice(0, MAX_CHUNKS * CHUNK_SIZE);
|
||||
const seenRoutes = new Set<string>();
|
||||
for (const chunkIds of chunk(routeIds, CHUNK_SIZE)) {
|
||||
const rows = await executeParameterized(
|
||||
repo.lbugPath,
|
||||
`
|
||||
MATCH (h)-[:CodeRelation {type: 'HANDLES_ROUTE'}]->(route:Route)
|
||||
WHERE h.id IN $ids
|
||||
RETURN h.id AS hid, route.name AS url, route.method AS method
|
||||
ORDER BY url, method
|
||||
`,
|
||||
{ ids: chunkIds },
|
||||
).catch((err) => {
|
||||
enrichmentDegraded = true;
|
||||
logQueryError('impact:route-chunk', err);
|
||||
return [];
|
||||
});
|
||||
for (const row of rows) {
|
||||
const hid = String(row.hid ?? row[0] ?? '');
|
||||
const url = row.url ?? row[1];
|
||||
if (!hid || typeof url !== 'string') continue;
|
||||
const method = row.method ?? row[2];
|
||||
const route = typeof method === 'string' && method ? { url, method } : { url };
|
||||
const list = routesById.get(hid);
|
||||
if (list) list.push(route);
|
||||
else routesById.set(hid, [route]);
|
||||
const key = `${route.method ?? ''} ${url}`;
|
||||
if (!seenRoutes.has(key)) {
|
||||
seenRoutes.add(key);
|
||||
affectedRoutes.push(route);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Risk scoring
|
||||
const processCount = affectedProcesses.length;
|
||||
const moduleCount = affectedModules.length;
|
||||
|
|
@ -8866,6 +8914,7 @@ export class LocalBackend {
|
|||
byDepthCounts,
|
||||
affected_processes: affectedProcesses,
|
||||
affected_modules: affectedModules,
|
||||
affected_routes: affectedRoutes,
|
||||
};
|
||||
|
||||
if (summaryOnly) {
|
||||
|
|
@ -8950,6 +8999,8 @@ export class LocalBackend {
|
|||
for (const items of Object.values(paginatedGrouped)) {
|
||||
for (const it of items) {
|
||||
it.processes = perSymbolProcesses.get(String(it.id)) ?? [];
|
||||
const routes = routesById.get(String(it.id));
|
||||
if (routes) it.routes = routes;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
17
gitnexus/test/fixtures/go-gin-route-app/echoapp/routes.go
vendored
Normal file
17
gitnexus/test/fixtures/go-gin-route-app/echoapp/routes.go
vendored
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
package echoapp
|
||||
|
||||
import "github.com/labstack/echo/v4"
|
||||
|
||||
type UserHandler struct{}
|
||||
|
||||
func (u *UserHandler) List(c echo.Context) error { return nil }
|
||||
|
||||
func authMW(next echo.HandlerFunc) echo.HandlerFunc { return next }
|
||||
|
||||
func Setup() *echo.Echo {
|
||||
e := echo.New()
|
||||
h := &UserHandler{}
|
||||
api := e.Group("/echo")
|
||||
api.GET("/users", h.List, authMW)
|
||||
return e
|
||||
}
|
||||
8
gitnexus/test/fixtures/go-gin-route-app/go.mod
vendored
Normal file
8
gitnexus/test/fixtures/go-gin-route-app/go.mod
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
module example.com/ginapp
|
||||
|
||||
go 1.22
|
||||
|
||||
require (
|
||||
github.com/gin-gonic/gin v1.10.0
|
||||
github.com/labstack/echo/v4 v4.12.0
|
||||
)
|
||||
7
gitnexus/test/fixtures/go-gin-route-app/handlers/health.go
vendored
Normal file
7
gitnexus/test/fixtures/go-gin-route-app/handlers/health.go
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
package handlers
|
||||
|
||||
import "github.com/gin-gonic/gin"
|
||||
|
||||
func Health(c *gin.Context) {
|
||||
c.String(200, "ok")
|
||||
}
|
||||
15
gitnexus/test/fixtures/go-gin-route-app/router/auth_handlers.go
vendored
Normal file
15
gitnexus/test/fixtures/go-gin-route-app/router/auth_handlers.go
vendored
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
package router
|
||||
|
||||
import "github.com/gin-gonic/gin"
|
||||
|
||||
type Authenticator interface {
|
||||
Login(c *gin.Context)
|
||||
}
|
||||
|
||||
type AuthHandler struct{}
|
||||
|
||||
func (a *AuthHandler) Login(c *gin.Context) {}
|
||||
|
||||
type AdminAuthHandler struct{}
|
||||
|
||||
func (a *AdminAuthHandler) Login(c *gin.Context) {}
|
||||
8
gitnexus/test/fixtures/go-gin-route-app/router/match_handler.go
vendored
Normal file
8
gitnexus/test/fixtures/go-gin-route-app/router/match_handler.go
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
package router
|
||||
|
||||
import "github.com/gin-gonic/gin"
|
||||
|
||||
func (h *MatchHandler) UnfinalizeRoundHandle(c *gin.Context) {
|
||||
h.svc.UnfinalizeRound(c.Param("seasonId"), c.Param("roundId"))
|
||||
c.JSON(200, gin.H{"ok": true})
|
||||
}
|
||||
36
gitnexus/test/fixtures/go-gin-route-app/router/router.go
vendored
Normal file
36
gitnexus/test/fixtures/go-gin-route-app/router/router.go
vendored
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
package router
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"example.com/ginapp/handlers"
|
||||
"example.com/ginapp/service"
|
||||
"example.com/ginapp/status/v2"
|
||||
)
|
||||
|
||||
func RegisterRoutes(r *gin.Engine, svc *service.Service) {
|
||||
matchHandler := NewMatchHandler(svc)
|
||||
auth := &AuthHandler{}
|
||||
var anyAuth Authenticator = auth
|
||||
v1 := r.Group("/api/v1")
|
||||
{
|
||||
admin := v1.Group("/admin")
|
||||
admin.POST("/seasons/:seasonId/rounds/:roundId/unfinalize", matchHandler.UnfinalizeRoundHandle)
|
||||
admin.POST("/login", anyAuth.Login)
|
||||
}
|
||||
v1.POST("/login", auth.Login)
|
||||
v1.GET("/health", handlers.Health)
|
||||
v1.GET("/status", status.Status)
|
||||
v1.GET("/version", Version)
|
||||
v1.GET("/ping", func(c *gin.Context) { c.String(200, "pong") })
|
||||
registerLegacy(v1)
|
||||
}
|
||||
|
||||
// Groups handed to another function are out of scope: no route below.
|
||||
func registerLegacy(g *gin.RouterGroup) {
|
||||
g.GET("/legacy", Version)
|
||||
}
|
||||
|
||||
func Version(c *gin.Context) {
|
||||
c.String(200, "1")
|
||||
}
|
||||
11
gitnexus/test/fixtures/go-gin-route-app/router/types.go
vendored
Normal file
11
gitnexus/test/fixtures/go-gin-route-app/router/types.go
vendored
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
package router
|
||||
|
||||
import "example.com/ginapp/service"
|
||||
|
||||
type MatchHandler struct {
|
||||
svc *service.Service
|
||||
}
|
||||
|
||||
func NewMatchHandler(svc *service.Service) *MatchHandler {
|
||||
return &MatchHandler{svc: svc}
|
||||
}
|
||||
13
gitnexus/test/fixtures/go-gin-route-app/service/match.go
vendored
Normal file
13
gitnexus/test/fixtures/go-gin-route-app/service/match.go
vendored
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
package service
|
||||
|
||||
type Service struct{}
|
||||
|
||||
func (s *Service) UnfinalizeRound(seasonID, roundID string) {
|
||||
s.reopen(roundID)
|
||||
}
|
||||
|
||||
func (s *Service) reopen(roundID string) {
|
||||
audit(roundID)
|
||||
}
|
||||
|
||||
func audit(id string) {}
|
||||
7
gitnexus/test/fixtures/go-gin-route-app/status/v2/status.go
vendored
Normal file
7
gitnexus/test/fixtures/go-gin-route-app/status/v2/status.go
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
package status
|
||||
|
||||
import "github.com/gin-gonic/gin"
|
||||
|
||||
func Status(c *gin.Context) {
|
||||
c.String(200, "ok")
|
||||
}
|
||||
|
|
@ -90,7 +90,11 @@ function parseStdout(result: ReturnType<typeof runCliRaw>): unknown {
|
|||
// ─── Typed result shapes (avoid `any`; just the fields these tests read) ──────
|
||||
type CallBuckets = { calls?: unknown[]; accesses?: unknown[] };
|
||||
type ContextResult = { incoming?: CallBuckets; outgoing?: CallBuckets; processes?: unknown[] };
|
||||
type ImpactResult = { affected_processes?: unknown[]; affected_modules?: unknown[] };
|
||||
type ImpactResult = {
|
||||
affected_processes?: unknown[];
|
||||
affected_modules?: unknown[];
|
||||
affected_routes?: Array<{ url: string; method?: string }>;
|
||||
};
|
||||
type CypherTabular = { markdown?: string; row_count?: number };
|
||||
type QueryResult = { processes?: unknown[] };
|
||||
|
||||
|
|
@ -124,6 +128,24 @@ beforeAll(() => {
|
|||
suiteGitnexusHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-cli-limit-home-'));
|
||||
MINI_REPO = path.join(tmpParent, 'mini-repo');
|
||||
fs.cpSync(FIXTURE_SRC, MINI_REPO, { recursive: true });
|
||||
// A separate handler serves two routes so the route cap cannot pass vacuously.
|
||||
// Keep it independent of logMessage's existing caller/process baselines.
|
||||
fs.writeFileSync(
|
||||
path.join(MINI_REPO, 'routes.go'),
|
||||
`package routes
|
||||
|
||||
import "github.com/gin-gonic/gin"
|
||||
|
||||
func RegisterRoutes(r *gin.Engine) {
|
||||
r.GET("/health", Health)
|
||||
r.GET("/ready", Health)
|
||||
}
|
||||
|
||||
func Health(c *gin.Context) {
|
||||
c.String(200, "ok")
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
// Initialize as git repo
|
||||
spawnSync('git', ['init'], { cwd: MINI_REPO, stdio: 'pipe' });
|
||||
|
|
@ -250,6 +272,25 @@ describe('CLI --limit flag E2E', () => {
|
|||
// ─── impact ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('impact --limit', () => {
|
||||
it('caps affected_routes at --limit 1 while omitted and zero limits retain both routes', () => {
|
||||
const args = ['impact', 'Health', '--direction', 'upstream', '--repo', 'mini-repo'];
|
||||
const base = runJson<ImpactResult>(args);
|
||||
expect(base.affected_routes).toHaveLength(2);
|
||||
expect(base.affected_routes).toEqual(
|
||||
expect.arrayContaining([
|
||||
{ url: '/health', method: 'GET' },
|
||||
{ url: '/ready', method: 'GET' },
|
||||
]),
|
||||
);
|
||||
|
||||
const limited = runJson<ImpactResult>([...args, '--limit', '1']);
|
||||
expect(limited.affected_routes).toHaveLength(1);
|
||||
expect(base.affected_routes).toContainEqual(limited.affected_routes?.[0]);
|
||||
|
||||
const zero = runJson<ImpactResult>([...args, '--limit', '0']);
|
||||
expect(zero.affected_routes).toEqual(base.affected_routes);
|
||||
});
|
||||
|
||||
it('truncates affected_processes/modules to --limit 1', () => {
|
||||
const limited = runJson<ImpactResult>([
|
||||
'impact',
|
||||
|
|
|
|||
183
gitnexus/test/integration/go-gin-route-pipeline.test.ts
Normal file
183
gitnexus/test/integration/go-gin-route-pipeline.test.ts
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
/**
|
||||
* End-to-end coverage of gin / echo route ingestion (#3402).
|
||||
*
|
||||
* The reported symptom was zero `Route` nodes for a gin backend, and `impact`
|
||||
* on a service method ending at the HTTP handler. The unit suites pin what the
|
||||
* extractor returns (`go-gin-echo-routes.test.ts`) and how the Go hook resolves
|
||||
* a designator against a hand-built model (`go-route-handler.test.ts`). This
|
||||
* file covers what neither can: the worker emitting routes for real Go files,
|
||||
* `go.mod` driving the import that resolves `handlers.Health`, and a method
|
||||
* whose struct is declared in a SIBLING file resolving through the parse-time
|
||||
* owner id.
|
||||
*
|
||||
* Fixture: `test/fixtures/go-gin-route-app/`.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll } from 'vitest';
|
||||
import path from 'node:path';
|
||||
import type { GraphNode, GraphRelationship } from 'gitnexus-shared';
|
||||
import { runPipelineFromRepo } from '../../src/core/ingestion/pipeline.js';
|
||||
import type { PipelineResult } from '../../src/types/pipeline.js';
|
||||
|
||||
const FIXTURE = path.resolve(__dirname, '..', 'fixtures', 'go-gin-route-app');
|
||||
|
||||
interface RouteView {
|
||||
/** `${method} ${url}` — the `routeNodeKey` identity, as a sortable string. */
|
||||
readonly identity: string;
|
||||
readonly routeFile: string;
|
||||
/** The handler the route resolved to, or the literal `'undefined'`. */
|
||||
readonly handler: string;
|
||||
readonly handlerLabel: string;
|
||||
readonly handlerFile: string;
|
||||
}
|
||||
|
||||
describe('gin / echo route ingestion pipeline', () => {
|
||||
let result: PipelineResult;
|
||||
|
||||
beforeAll(async () => {
|
||||
result = await runPipelineFromRepo(FIXTURE, () => {}, {});
|
||||
}, 300_000);
|
||||
|
||||
const nodes = (): readonly GraphNode[] => {
|
||||
const out: GraphNode[] = [];
|
||||
result.graph.forEachNode((node) => void out.push(node));
|
||||
return out;
|
||||
};
|
||||
|
||||
const relationships = (): readonly GraphRelationship[] => {
|
||||
const out: GraphRelationship[] = [];
|
||||
result.graph.forEachRelationship((rel) => void out.push(rel));
|
||||
return out;
|
||||
};
|
||||
|
||||
/**
|
||||
* Unresolved fields are stringified rather than branched on, so a route that
|
||||
* lost its handler reads as the literal `'undefined'` in the diff.
|
||||
*/
|
||||
const routes = (): readonly RouteView[] =>
|
||||
nodes()
|
||||
.filter((node) => node.label === 'Route')
|
||||
.map((node) => {
|
||||
const handler = result.graph.getNode(String(node.properties.handlerSymbolId));
|
||||
return {
|
||||
identity: `${String(node.properties.method)} ${String(node.properties.name)}`,
|
||||
routeFile: String(node.properties.filePath).replaceAll('\\', '/'),
|
||||
handler: String(handler?.properties.name),
|
||||
handlerLabel: String(handler?.label),
|
||||
handlerFile: String(handler?.properties.filePath).replaceAll('\\', '/'),
|
||||
};
|
||||
})
|
||||
.sort((a, b) => a.identity.localeCompare(b.identity));
|
||||
|
||||
it('joins Group prefixes and resolves every uniquely-named handler', () => {
|
||||
expect(routes()).toEqual([
|
||||
{
|
||||
// Package function, reached through a go.mod-resolved import.
|
||||
identity: 'GET /api/v1/health',
|
||||
routeFile: 'router/router.go',
|
||||
handler: 'Health',
|
||||
handlerLabel: 'Function',
|
||||
handlerFile: 'handlers/health.go',
|
||||
},
|
||||
{
|
||||
// Inline func literal: the route stands, with no handler symbol.
|
||||
identity: 'GET /api/v1/ping',
|
||||
routeFile: 'router/router.go',
|
||||
handler: 'undefined',
|
||||
handlerLabel: 'undefined',
|
||||
handlerFile: 'undefined',
|
||||
},
|
||||
{
|
||||
// A version suffix belongs to the path, not the package qualifier.
|
||||
identity: 'GET /api/v1/status',
|
||||
routeFile: 'router/router.go',
|
||||
handler: 'Status',
|
||||
handlerLabel: 'Function',
|
||||
handlerFile: 'status/v2/status.go',
|
||||
},
|
||||
{
|
||||
identity: 'GET /api/v1/version',
|
||||
routeFile: 'router/router.go',
|
||||
handler: 'Version',
|
||||
handlerLabel: 'Function',
|
||||
handlerFile: 'router/router.go',
|
||||
},
|
||||
{
|
||||
// echo: handler is the SECOND argument; trailing middleware is skipped.
|
||||
identity: 'GET /echo/users',
|
||||
routeFile: 'echoapp/routes.go',
|
||||
handler: 'List',
|
||||
handlerLabel: 'Method',
|
||||
handlerFile: 'echoapp/routes.go',
|
||||
},
|
||||
{
|
||||
// Interface-typed receiver: `Login` exists on two structs, so decline.
|
||||
identity: 'POST /api/v1/admin/login',
|
||||
routeFile: 'router/router.go',
|
||||
handler: 'undefined',
|
||||
handlerLabel: 'undefined',
|
||||
handlerFile: 'undefined',
|
||||
},
|
||||
{
|
||||
// The issue #3402 route: constructor-built receiver, struct declared in
|
||||
// types.go, method in match_handler.go. The Route stays on the file
|
||||
// that registers it.
|
||||
identity: 'POST /api/v1/admin/seasons/:seasonId/rounds/:roundId/unfinalize',
|
||||
routeFile: 'router/router.go',
|
||||
handler: 'UnfinalizeRoundHandle',
|
||||
handlerLabel: 'Method',
|
||||
handlerFile: 'router/match_handler.go',
|
||||
},
|
||||
{
|
||||
// `&AuthHandler{}` picks one of the two `Login` methods.
|
||||
identity: 'POST /api/v1/login',
|
||||
routeFile: 'router/router.go',
|
||||
handler: 'Login',
|
||||
handlerLabel: 'Method',
|
||||
handlerFile: 'router/auth_handlers.go',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not mint a route from a RouterGroup handed to another function', () => {
|
||||
expect(routes().map((route) => route.identity)).not.toContain('GET /api/v1/legacy');
|
||||
expect(routes().map((route) => route.identity)).not.toContain('GET /legacy');
|
||||
});
|
||||
|
||||
it('draws the symbol-level HANDLES_ROUTE edge with gin provenance', () => {
|
||||
const edge = relationships().find(
|
||||
(rel) =>
|
||||
rel.type === 'HANDLES_ROUTE' &&
|
||||
rel.targetId === 'Route:POST /api/v1/admin/seasons/:seasonId/rounds/:roundId/unfinalize' &&
|
||||
result.graph.getNode(rel.sourceId)?.label === 'Method',
|
||||
);
|
||||
expect({
|
||||
handler: result.graph.getNode(String(edge?.sourceId))?.properties.name,
|
||||
reason: edge?.reason,
|
||||
}).toEqual({ handler: 'UnfinalizeRoundHandle', reason: 'gin-route' });
|
||||
});
|
||||
|
||||
it('draws HANDLES_ROUTE for an unaliased versioned handler package', () => {
|
||||
const edge = relationships().find(
|
||||
(rel) =>
|
||||
rel.type === 'HANDLES_ROUTE' &&
|
||||
rel.targetId === 'Route:GET /api/v1/status' &&
|
||||
result.graph.getNode(rel.sourceId)?.label === 'Function',
|
||||
);
|
||||
expect(result.graph.getNode(String(edge?.sourceId))?.properties.filePath).toBe(
|
||||
'status/v2/status.go',
|
||||
);
|
||||
});
|
||||
|
||||
it('links the handler to the service method it calls', () => {
|
||||
const calls = relationships()
|
||||
.filter((rel) => rel.type === 'CALLS')
|
||||
.map(
|
||||
(rel) =>
|
||||
`${String(result.graph.getNode(rel.sourceId)?.properties.name)} -> ${String(
|
||||
result.graph.getNode(rel.targetId)?.properties.name,
|
||||
)}`,
|
||||
);
|
||||
expect(calls).toContain('UnfinalizeRoundHandle -> UnfinalizeRound');
|
||||
});
|
||||
});
|
||||
112
gitnexus/test/integration/go-route-binding-identity.test.ts
Normal file
112
gitnexus/test/integration/go-route-binding-identity.test.ts
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { runPipelineFromRepo } from '../../src/core/ingestion/pipeline.js';
|
||||
import type { PipelineResult } from '../../src/types/pipeline.js';
|
||||
|
||||
describe('Go route declaration identity through ingestion', () => {
|
||||
let repo: string;
|
||||
let result: PipelineResult;
|
||||
beforeAll(async () => {
|
||||
repo = await mkdtemp(path.join(os.tmpdir(), 'gitnexus-go-binding-'));
|
||||
await mkdir(path.join(repo, 'handlers'));
|
||||
await writeFile(path.join(repo, 'go.mod'), 'module example.com/bindings\n\ngo 1.24\n');
|
||||
await writeFile(
|
||||
path.join(repo, 'handlers', 'handler.go'),
|
||||
`package handlers
|
||||
import "github.com/gin-gonic/gin"
|
||||
func Do(*gin.Context) {}
|
||||
`,
|
||||
);
|
||||
await writeFile(
|
||||
path.join(repo, 'routes.go'),
|
||||
`package routes
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"example.com/bindings/handlers"
|
||||
)
|
||||
func Handle(*gin.Context) {}
|
||||
type A struct{}
|
||||
func (*A) Do(*gin.Context) {}
|
||||
type B struct{}
|
||||
func (*B) Do(*gin.Context) {}
|
||||
func NewH() *A { return &A{} }
|
||||
func Bare(r *gin.Engine, Handle gin.HandlerFunc) { r.GET("/bare", Handle) }
|
||||
func Constructor(r *gin.Engine, NewH func() *B) {
|
||||
h := NewH()
|
||||
r.GET("/constructor", h.Do)
|
||||
}
|
||||
func Captured() {
|
||||
_ = handlers.Do
|
||||
handlers := &B{}
|
||||
func() { r := gin.New(); r.GET("/captured", handlers.Do) }()
|
||||
}
|
||||
func Write(r *gin.Engine) {
|
||||
var h interface{ Do(*gin.Context) } = &A{}
|
||||
func() { h = &B{} }()
|
||||
r.GET("/write", h.Do)
|
||||
}
|
||||
type FakeRouter struct{}
|
||||
func (FakeRouter) GET(string, gin.HandlerFunc) {}
|
||||
func Shadow(r *gin.Engine) {
|
||||
{ var r FakeRouter; r.GET("/fake", Handle) }
|
||||
r.GET("/real", Handle)
|
||||
}
|
||||
func TypeShadow(r *gin.Engine) {
|
||||
type A = B
|
||||
h := &A{}
|
||||
r.GET("/type", h.Do)
|
||||
}
|
||||
func Independent(r *gin.Engine) {
|
||||
h := &A{}
|
||||
func() { h := &B{}; _ = h }()
|
||||
r.GET("/independent", h.Do)
|
||||
}
|
||||
`,
|
||||
);
|
||||
result = await runPipelineFromRepo(repo, () => {}, {});
|
||||
}, 300_000);
|
||||
afterAll(async () => {
|
||||
if (repo) await rm(repo, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const route = (url: string) => {
|
||||
const nodes: { id: string; handler: unknown }[] = [];
|
||||
result.graph.forEachNode((node) => {
|
||||
if (node.label === 'Route' && node.properties.name === url) {
|
||||
nodes.push({ id: node.id, handler: node.properties.handlerSymbolId });
|
||||
}
|
||||
});
|
||||
return nodes;
|
||||
};
|
||||
|
||||
it.each(['/bare', '/constructor', '/type', '/write'])(
|
||||
'keeps %s without an unproven handler edge',
|
||||
(url) => {
|
||||
const routes = route(url);
|
||||
expect(routes).toHaveLength(1);
|
||||
expect(routes[0]?.handler).toBeUndefined();
|
||||
const incoming: string[] = [];
|
||||
result.graph.forEachRelationship((edge) => {
|
||||
if (edge.type === 'HANDLES_ROUTE' && edge.targetId === routes[0]?.id)
|
||||
incoming.push(edge.sourceId);
|
||||
});
|
||||
// Unresolved routes retain their file-level ownership edge.
|
||||
expect(incoming).toEqual(['File:routes.go']);
|
||||
},
|
||||
);
|
||||
|
||||
it('selects the captured local type instead of the imported function', () => {
|
||||
expect(route('/captured')[0]?.handler).toMatch(/^Method:routes.go:B\.Do/);
|
||||
});
|
||||
|
||||
it('rejects a shadowed non-framework receiver and retains the outer engine', () => {
|
||||
expect(route('/fake')).toEqual([]);
|
||||
expect(route('/real')[0]?.handler).toBe('Function:routes.go:Handle');
|
||||
});
|
||||
|
||||
it('retains facts when a nested declaration does not write the captured variable', () => {
|
||||
expect(route('/independent')[0]?.handler).toMatch(/^Method:routes.go:A\.Do/);
|
||||
});
|
||||
});
|
||||
72
gitnexus/test/integration/go-route-package-names.test.ts
Normal file
72
gitnexus/test/integration/go-route-package-names.test.ts
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import type { PipelineResult } from '../../src/types/pipeline.js';
|
||||
import { runPipelineFromRepo } from '../../src/core/ingestion/pipeline.js';
|
||||
|
||||
describe('Go imported package identities', () => {
|
||||
let repo: string;
|
||||
let result: PipelineResult;
|
||||
|
||||
beforeAll(async () => {
|
||||
repo = await mkdtemp(path.join(os.tmpdir(), 'gitnexus-go-package-names-'));
|
||||
const files: Record<string, string> = {
|
||||
'go.mod': 'module example.com/app\n\ngo 1.24\n',
|
||||
'api/v2/handlers.go':
|
||||
'package v2\nimport "github.com/gin-gonic/gin"\nfunc Handle(*gin.Context) {}\n',
|
||||
'storage/handlers.go':
|
||||
'package endpoints\nimport "github.com/gin-gonic/gin"\nfunc Handle(*gin.Context) {}\n',
|
||||
'endpoints/handlers.go':
|
||||
'package competing\nimport "github.com/gin-gonic/gin"\nfunc Handle(*gin.Context) {}\n',
|
||||
'aliased/handlers.go':
|
||||
'package original\nimport "github.com/gin-gonic/gin"\nfunc Handle(*gin.Context) {}\n',
|
||||
'routes.go': `package app
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"example.com/app/api/v2"
|
||||
"example.com/app/storage"
|
||||
"example.com/app/endpoints"
|
||||
renamed "example.com/app/aliased"
|
||||
)
|
||||
func Register(r *gin.Engine) {
|
||||
r.GET("/version", v2.Handle)
|
||||
r.GET("/declared", endpoints.Handle)
|
||||
r.GET("/competing", competing.Handle)
|
||||
r.GET("/alias", renamed.Handle)
|
||||
}
|
||||
func CallVersion() { v2.Handle(nil) }
|
||||
func CallDeclared() { endpoints.Handle(nil) }
|
||||
func CallCompeting() { competing.Handle(nil) }
|
||||
func CallAlias() { renamed.Handle(nil) }
|
||||
`,
|
||||
};
|
||||
for (const [file, content] of Object.entries(files)) {
|
||||
await mkdir(path.dirname(path.join(repo, file)), { recursive: true });
|
||||
await writeFile(path.join(repo, file), content);
|
||||
}
|
||||
result = await runPipelineFromRepo(repo, () => {}, {});
|
||||
}, 300_000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (repo) await rm(repo, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it.each([
|
||||
['/version', 'api/v2/handlers.go', 'CallVersion'],
|
||||
['/declared', 'storage/handlers.go', 'CallDeclared'],
|
||||
['/competing', 'endpoints/handlers.go', 'CallCompeting'],
|
||||
['/alias', 'aliased/handlers.go', 'CallAlias'],
|
||||
])('binds %s using the declared package name or explicit alias', (url, target, caller) => {
|
||||
const route = result.graph.getNode(`Route:GET ${url}`);
|
||||
const handler = result.graph.getNode(String(route?.properties.handlerSymbolId));
|
||||
expect(handler?.properties.filePath).toBe(target);
|
||||
const callTargets: string[] = [];
|
||||
result.graph.forEachRelationship((edge) => {
|
||||
if (edge.type !== 'CALLS') return;
|
||||
if (result.graph.getNode(edge.sourceId)?.properties.name !== caller) return;
|
||||
callTargets.push(String(result.graph.getNode(edge.targetId)?.properties.filePath));
|
||||
});
|
||||
expect(callTargets).toEqual([target]);
|
||||
});
|
||||
});
|
||||
93
gitnexus/test/integration/impact-affected-routes.test.ts
Normal file
93
gitnexus/test/integration/impact-affected-routes.test.ts
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
/**
|
||||
* `impact` upstream from a service method names the HTTP route its handler
|
||||
* serves (#3402), against a real LadybugDB.
|
||||
*
|
||||
* The seed is the shape `go-gin-route-pipeline.test.ts` proves the pipeline
|
||||
* emits for the issue's router: the handler CALLS the service, and
|
||||
* (handler)-[HANDLES_ROUTE]->Route. Before #3402 the walk stopped at the handler
|
||||
* and the route never appeared; the unit test mocks the database, so this is
|
||||
* the only place the enrichment query runs against the real engine.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, vi } from 'vitest';
|
||||
import { LocalBackend } from '../../src/mcp/local/local-backend.js';
|
||||
import { listRegisteredRepos } from '../../src/storage/repo-manager.js';
|
||||
import { withTestLbugDB } from '../helpers/test-indexed-db.js';
|
||||
|
||||
vi.mock('../../src/storage/repo-manager.js', () => ({
|
||||
listRegisteredRepos: vi.fn().mockResolvedValue([]),
|
||||
cleanupOldKuzuFiles: vi.fn().mockResolvedValue({ found: false, needsReindex: false }),
|
||||
findSiblingClones: vi.fn().mockResolvedValue([]),
|
||||
}));
|
||||
|
||||
const HANDLER = 'Method:router/match_handler.go:MatchHandler.UnfinalizeRoundHandle#1';
|
||||
const SERVICE = 'Method:service/match.go:Service.UnfinalizeRound#2';
|
||||
const ROUTE = 'Route:POST /api/v1/admin/seasons/:seasonId/rounds/:roundId/unfinalize';
|
||||
|
||||
const SEED = [
|
||||
`CREATE (m:Method {id:'${SERVICE}', name:'UnfinalizeRound', filePath:'service/match.go', startLine:4, endLine:6, isExported:true, content:'', description:''})`,
|
||||
`CREATE (m:Method {id:'${HANDLER}', name:'UnfinalizeRoundHandle', filePath:'router/match_handler.go', startLine:4, endLine:7, isExported:true, content:'', description:''})`,
|
||||
`CREATE (r:Route {id:'${ROUTE}', name:'/api/v1/admin/seasons/:seasonId/rounds/:roundId/unfinalize', filePath:'router/router.go', method:'POST', handlerSymbolId:'${HANDLER}'})`,
|
||||
`MATCH (a:Method {id:'${HANDLER}'}), (b:Method {id:'${SERVICE}'}) CREATE (a)-[:CodeRelation {type:'CALLS', confidence:0.9, reason:'import-resolved', step:0}]->(b)`,
|
||||
`MATCH (a:Method {id:'${HANDLER}'}), (r:Route {id:'${ROUTE}'}) CREATE (a)-[:CodeRelation {type:'HANDLES_ROUTE', confidence:1.0, reason:'gin-route', step:0}]->(r)`,
|
||||
];
|
||||
|
||||
withTestLbugDB(
|
||||
'impact-affected-routes',
|
||||
(handle) => {
|
||||
let backend: LocalBackend;
|
||||
beforeAll(() => {
|
||||
backend = (handle as unknown as { _backend: LocalBackend })._backend;
|
||||
});
|
||||
|
||||
describe('impact: affected_routes', () => {
|
||||
it('reaches the route through the handler that calls the service', async () => {
|
||||
const result = await backend.callTool('impact', {
|
||||
target: 'UnfinalizeRound',
|
||||
direction: 'upstream',
|
||||
});
|
||||
const handlerItem = Object.values(result.byDepth as Record<string, Array<{ id: string }>>)
|
||||
.flat()
|
||||
.find((item) => item.id === HANDLER);
|
||||
|
||||
expect({
|
||||
partial: result.partial,
|
||||
affected_routes: result.affected_routes,
|
||||
handlerRoutes: (handlerItem as { routes?: unknown } | undefined)?.routes,
|
||||
}).toEqual({
|
||||
partial: undefined,
|
||||
affected_routes: [
|
||||
{
|
||||
url: '/api/v1/admin/seasons/:seasonId/rounds/:roundId/unfinalize',
|
||||
method: 'POST',
|
||||
},
|
||||
],
|
||||
handlerRoutes: [
|
||||
{
|
||||
url: '/api/v1/admin/seasons/:seasonId/rounds/:roundId/unfinalize',
|
||||
method: 'POST',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
},
|
||||
{
|
||||
seed: SEED,
|
||||
poolAdapter: true,
|
||||
afterSetup: async (handle) => {
|
||||
vi.mocked(listRegisteredRepos).mockResolvedValue([
|
||||
{
|
||||
name: 'test-repo',
|
||||
path: '/test/repo',
|
||||
storagePath: handle.tmpHandle.dbPath,
|
||||
indexedAt: new Date().toISOString(),
|
||||
lastCommit: 'abc123',
|
||||
stats: { files: 5, nodes: 3, communities: 0, processes: 0 },
|
||||
},
|
||||
]);
|
||||
const backend = new LocalBackend();
|
||||
await backend.init();
|
||||
(handle as unknown as { _backend: LocalBackend })._backend = backend;
|
||||
},
|
||||
},
|
||||
);
|
||||
|
|
@ -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()',
|
||||
|
|
|
|||
396
gitnexus/test/unit/go-gin-echo-routes.test.ts
Normal file
396
gitnexus/test/unit/go-gin-echo-routes.test.ts
Normal file
|
|
@ -0,0 +1,396 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import Parser from 'tree-sitter';
|
||||
import Go from 'tree-sitter-go';
|
||||
import { extractGoGinEchoRoutes } from '../../src/core/ingestion/route-extractors/go-gin-echo.js';
|
||||
|
||||
const parser = new Parser();
|
||||
parser.setLanguage(Go);
|
||||
|
||||
const GIN = `import "github.com/gin-gonic/gin"\n`;
|
||||
const ECHO = `import "github.com/labstack/echo/v4"\n`;
|
||||
|
||||
const extract = (body: string, header = GIN) =>
|
||||
extractGoGinEchoRoutes(parser.parse(`package router\n${header}${body}`), 'router/router.go');
|
||||
|
||||
/** `VERB url -> handler` per route, so a lost handler reads as `undefined` in the diff. */
|
||||
const summary = (body: string, header = GIN) =>
|
||||
extract(body, header).map((r) => `${r.httpMethod} ${r.routePath} -> ${r.handlerName}`);
|
||||
|
||||
describe('gin / echo route extraction', () => {
|
||||
it('joins nested Group prefixes for the issue #3402 router', () => {
|
||||
const routes = extract(`
|
||||
func RegisterRoutes(r *gin.Engine, svc *service.Service) {
|
||||
matchHandler := NewMatchHandler(svc.Match)
|
||||
v1 := r.Group("/api/v1")
|
||||
{
|
||||
admin := v1.Group("/admin")
|
||||
admin.POST("/seasons/:seasonId/rounds/:roundId/unfinalize", matchHandler.UnfinalizeRoundHandle)
|
||||
}
|
||||
}`);
|
||||
expect(routes).toEqual([
|
||||
{
|
||||
filePath: 'router/router.go',
|
||||
routePath: '/api/v1/admin/seasons/:seasonId/rounds/:roundId/unfinalize',
|
||||
httpMethod: 'POST',
|
||||
decoratorName: 'POST',
|
||||
lineNumber: 9,
|
||||
prefix: null,
|
||||
source: 'gin-route',
|
||||
handlerName: 'matchHandler.UnfinalizeRoundHandle',
|
||||
handlerReceiver: { kind: 'constructor', name: 'NewMatchHandler' },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('follows chained Group calls and engine constructors', () => {
|
||||
expect(
|
||||
summary(`
|
||||
func Setup() {
|
||||
r := gin.Default()
|
||||
r.Group("/api").Group("/v1").GET("/x", Health)
|
||||
gin.New().GET("/root", Health)
|
||||
}`),
|
||||
).toEqual(['GET /api/v1/x -> Health', 'GET /root -> Health']);
|
||||
});
|
||||
|
||||
it('takes the last argument as the gin handler, skipping middleware', () => {
|
||||
expect(summary(`func S(r *gin.Engine) { r.GET("/x", authMW, h.List) }`)).toEqual([
|
||||
'GET /x -> h.List',
|
||||
]);
|
||||
});
|
||||
|
||||
it('takes the second argument as the echo handler, skipping trailing middleware', () => {
|
||||
const routes = extract(
|
||||
`func S(e *echo.Echo) { g := e.Group("/api"); g.GET("/users", h.List, authMW) }`,
|
||||
ECHO,
|
||||
);
|
||||
expect(routes.map((r) => `${r.source} ${r.routePath} -> ${r.handlerName}`)).toEqual([
|
||||
'echo-route /api/users -> h.List',
|
||||
]);
|
||||
});
|
||||
|
||||
it('honors an aliased framework import', () => {
|
||||
expect(
|
||||
summary(
|
||||
`func S() { e := g.Default(); e.PUT(\`/raw\`, H) }`,
|
||||
`import g "github.com/gin-gonic/gin"\n`,
|
||||
),
|
||||
).toEqual(['PUT /raw -> H']);
|
||||
});
|
||||
|
||||
describe('Go string literals', () => {
|
||||
it.each([
|
||||
[String.raw`"\x2fapi"`, String.raw`"\057users"`, '/api/users'],
|
||||
[String.raw`"\u002fapi"`, String.raw`"/\U0001F600"`, '/api/😀'],
|
||||
[String.raw`"/caf\xc3\xa9"`, String.raw`"/\303\251"`, '/café/é'],
|
||||
[String.raw`"/a\"b"`, String.raw`"/c\\d"`, '/a"b/c\\d'],
|
||||
['`/a\rb`', '`/c\rd`', '/ab/cd'],
|
||||
])('decodes group %s and route %s', (group, route, expected) => {
|
||||
expect(summary(`func S(r *gin.Engine) { r.Group(${group}).GET(${route}, H) }`)).toEqual([
|
||||
`GET ${expected} -> H`,
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
String.raw`"/\q"`,
|
||||
String.raw`"/\400"`,
|
||||
String.raw`"/\uD800"`,
|
||||
String.raw`"/\U00110000"`,
|
||||
String.raw`"/\x2"`,
|
||||
String.raw`"/\'"`,
|
||||
// Go permits arbitrary byte strings, but these have no lossless UTF-8 URL.
|
||||
String.raw`"/\xff"`,
|
||||
])('declines invalid or unrepresentable path %s', (route) => {
|
||||
expect(summary(`func S(r *gin.Engine) { r.GET(${route}, H) }`)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('framework alias shadowing', () => {
|
||||
it.each([
|
||||
'func S(gin Factory) { gin.New().GET("/x", H) }',
|
||||
'func (gin Factory) S() { gin.New().GET("/x", H) }',
|
||||
'func S() (gin Factory) { gin.New().GET("/x", H); return }',
|
||||
'func S() { gin := Factory{}; gin.New().GET("/x", H) }',
|
||||
'func S() { var gin Factory; gin.New().GET("/x", H) }',
|
||||
'func S() { const gin = Factory(1); gin.New().GET("/x", H) }',
|
||||
'func S() { type gin = Factory; gin.New(value).GET("/x", H) }',
|
||||
'func S() { for gin := range factories { gin.New().GET("/x", H) } }',
|
||||
'func S() { select { case gin := <-factories: gin.New().GET("/x", H) } }',
|
||||
'func S(x any) { switch gin := x.(type) { case Factory: gin.New().GET("/x", H) } }',
|
||||
'func S(gin Factory) { func() { gin.New().GET("/x", H) }() }',
|
||||
])('does not trust a shadowed constructor: %s', (body) => {
|
||||
expect(summary(body)).toEqual([]);
|
||||
});
|
||||
|
||||
it('also checks an explicitly aliased echo import', () => {
|
||||
expect(
|
||||
summary(
|
||||
'func S(e Factory) { r := e.New(); r.GET("/x", H) }',
|
||||
'import e "github.com/labstack/echo/v4"\n',
|
||||
),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps independent functions using the real package', () => {
|
||||
expect(
|
||||
summary(`
|
||||
func S(gin Factory) { gin.New().GET("/wrong", H) }
|
||||
func T() { gin.New().GET("/right", H) }
|
||||
`),
|
||||
).toEqual(['GET /right -> H']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fails closed on unproven prefixes', () => {
|
||||
it('drops routes on a RouterGroup parameter', () => {
|
||||
expect(summary(`func registerAdmin(g *gin.RouterGroup) { g.GET("/x", h) }`)).toEqual([]);
|
||||
});
|
||||
|
||||
it('preserves an incoming group prefix as unknown after a later assignment', () => {
|
||||
expect(
|
||||
summary(`func S(r *gin.Engine, g *gin.RouterGroup) {
|
||||
g.GET("/before", H)
|
||||
g = r.Group("/api")
|
||||
}`),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('drops routes beneath a non-literal group path', () => {
|
||||
expect(
|
||||
summary(
|
||||
`func S(r *gin.Engine) { v := r.Group(base); v.GET("/x", h); v.Group("/a").GET("/y", h) }`,
|
||||
),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('drops routes on a name assigned two different routers', () => {
|
||||
expect(
|
||||
summary(`func S(r *gin.Engine) { v := r.Group("/a"); v = r.Group("/b"); v.GET("/x", h) }`),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('drops routes on a struct-field engine', () => {
|
||||
expect(summary(`func (s *Server) routes() { s.router.GET("/x", h) }`)).toEqual([]);
|
||||
});
|
||||
|
||||
it('drops routes on mutually-derived groups', () => {
|
||||
expect(
|
||||
summary(`func S(r *gin.Engine) { a := b.Group("/x"); b := a.Group("/y"); a.GET("/z", h) }`),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('drops routes a closure registers on an outer group', () => {
|
||||
expect(
|
||||
summary(`func S(r *gin.Engine) { v := r.Group("/a"); go func() { v.GET("/x", h) }() }`),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('drops routes on a name bound to the second value of a call', () => {
|
||||
expect(summary(`func S(r *gin.Engine) { _, v := pair(r); v.GET("/x", h) }`)).toEqual([]);
|
||||
});
|
||||
|
||||
it('drops routes whose path is not a literal', () => {
|
||||
expect(summary(`func S(r *gin.Engine) { r.GET(path, h) }`)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
it('pairs a parallel assignment of groups positionally', () => {
|
||||
expect(
|
||||
summary(
|
||||
`func S(r *gin.Engine) { a, b := r.Group("/a"), r.Group("/b"); a.GET("/x", H); b.GET("/y", H) }`,
|
||||
),
|
||||
).toEqual(['GET /a/x -> H', 'GET /b/y -> H']);
|
||||
});
|
||||
|
||||
describe('framework gate', () => {
|
||||
it('ignores .GET calls in a file that imports neither framework', () => {
|
||||
expect(
|
||||
summary(`func C() { client.R().GET("/x", h) }`, `import "github.com/go-resty/resty/v2"\n`),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('ignores a file that imports both frameworks', () => {
|
||||
expect(
|
||||
summary(
|
||||
`func S(r *gin.Engine) { r.GET("/x", h) }`,
|
||||
`import (\n "github.com/gin-gonic/gin"\n "github.com/labstack/echo/v4"\n)\n`,
|
||||
),
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('handler receiver hints', () => {
|
||||
/** `extraParams` joins the engine parameter; `decl` opens the body. */
|
||||
const hintFor = (decl: string, extraParams = '') =>
|
||||
extract(`func S(r *gin.Engine${extraParams}) {
|
||||
${decl}
|
||||
r.GET("/x", h.Do)
|
||||
}`)[0]?.handlerReceiver;
|
||||
|
||||
it.each([
|
||||
['h := &T{}', { kind: 'type', name: 'T' }],
|
||||
['h := T{}', { kind: 'type', name: 'T' }],
|
||||
['h := &pkg.T{}', { kind: 'type', name: 'T', qualifier: 'pkg' }],
|
||||
['var h *T', { kind: 'type', name: 'T' }],
|
||||
['h, err := pkg.NewT(x)', { kind: 'constructor', name: 'NewT', qualifier: 'pkg' }],
|
||||
['h := NewT()', { kind: 'constructor', name: 'NewT' }],
|
||||
])('%s', (decl, expected) => {
|
||||
expect(hintFor(decl)).toEqual(expected);
|
||||
});
|
||||
|
||||
it('reads a typed parameter', () => {
|
||||
expect(hintFor('', ', h *pkg.T')).toEqual({ kind: 'type', name: 'T', qualifier: 'pkg' });
|
||||
});
|
||||
|
||||
it('preserves incoming interface evidence alongside a later concrete assignment', () => {
|
||||
const routes = extract(`
|
||||
type Handler interface{ Do(*gin.Context) }
|
||||
func S(r *gin.Engine, h Handler) {
|
||||
r.GET("/before", h.Do)
|
||||
h = &A{}
|
||||
}`);
|
||||
expect(routes).toHaveLength(1);
|
||||
expect(routes[0]).toMatchObject({ routePath: '/before', handlerName: 'h.Do' });
|
||||
expect(routes[0].handlerReceiver).toBeUndefined();
|
||||
});
|
||||
|
||||
it('marks an import qualifier as a module handler', () => {
|
||||
const routes = extract(
|
||||
`func S(r *gin.Engine) { r.GET("/health", handlers.Health) }`,
|
||||
`import (\n "github.com/gin-gonic/gin"\n "example.com/app/handlers"\n)\n`,
|
||||
);
|
||||
expect(routes[0]).toMatchObject({
|
||||
handlerName: 'handlers.Health',
|
||||
handlerReceiver: { kind: 'module', qualifier: 'handlers' },
|
||||
});
|
||||
});
|
||||
|
||||
it('carries no hint when assignments disagree', () => {
|
||||
expect(hintFor('h := &A{}; h = &B{}')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('emits a func-literal handler without a handler name', () => {
|
||||
const routes = extract(`func S(r *gin.Engine) { r.GET("/x", func(c *gin.Context) {}) }`);
|
||||
expect(routes.map((r) => [r.routePath, r.handlerName])).toEqual([['/x', undefined]]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Go lexical binding identity', () => {
|
||||
it('does not attach shadowed bare handlers or constructors to package names', () => {
|
||||
const routes = extract(`
|
||||
func Handle(c *gin.Context) {}
|
||||
func NewH() *A { return nil }
|
||||
func S(r *gin.Engine, Handle gin.HandlerFunc, NewH func() *B) {
|
||||
r.GET("/bare", Handle)
|
||||
h := NewH()
|
||||
r.GET("/constructor", h.Do)
|
||||
}`);
|
||||
expect(routes[0]?.handlerName).toBeUndefined();
|
||||
expect(routes[1]?.handlerReceiver).toBeUndefined();
|
||||
});
|
||||
|
||||
it('keeps inner and outer router declarations separate', () => {
|
||||
expect(
|
||||
summary(`func S(r *gin.Engine) {
|
||||
{ var r FakeRouter; r.GET("/fake", H) }
|
||||
r.GET("/real", H)
|
||||
}`),
|
||||
).toEqual(['GET /real -> H']);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['switch', 'switch x { default: r := gin.New(); _ = r; case 1: r.GET("/fake", H) }'],
|
||||
['select', 'select { default: r := gin.New(); _ = r; case <-ready: r.GET("/fake", H) }'],
|
||||
])('keeps a %s default declaration inside its clause', (_kind, statement) => {
|
||||
expect(summary(`func S(r FakeRouter, x int, ready chan bool) { ${statement} }`)).toEqual([]);
|
||||
});
|
||||
|
||||
it('resolves a captured local before an imported qualifier', () => {
|
||||
const routes = extract(
|
||||
`func S(handlers *B) {
|
||||
func() { r := gin.New(); r.GET("/captured", handlers.Do) }()
|
||||
}`,
|
||||
GIN + 'import "example.com/app/handlers"\n',
|
||||
);
|
||||
expect(routes[0]?.handlerReceiver).toEqual({ kind: 'type', name: 'B' });
|
||||
});
|
||||
|
||||
it('declines local type names that shadow package types', () => {
|
||||
const routes = extract(`func S(r *gin.Engine) {
|
||||
type A = B
|
||||
h := &A{}
|
||||
r.GET("/type", h.Do)
|
||||
}`);
|
||||
expect(routes[0]?.handlerReceiver).toBeUndefined();
|
||||
});
|
||||
|
||||
it('invalidates handler and router facts after captured writes', () => {
|
||||
const routes = extract(`func S(r *gin.Engine) {
|
||||
var h interface{ Do(*gin.Context) } = &A{}
|
||||
g := r.Group("/a")
|
||||
func() { h = &B{}; g = r.Group("/b") }()
|
||||
r.GET("/handler", h.Do)
|
||||
g.GET("/group", H)
|
||||
}`);
|
||||
expect(routes.map((r) => r.routePath)).toEqual(['/handler']);
|
||||
expect(routes[0]?.handlerReceiver).toBeUndefined();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['range', '[]', 'for _, h = range handlers {}; for _, g = range groups {}'],
|
||||
['receive', 'chan ', 'select { case h = <-handlers: }; select { case g = <-groups: }'],
|
||||
])(
|
||||
'invalidates handler and router facts after captured %s assignments',
|
||||
(_kind, container, writes) => {
|
||||
const routes = extract(`
|
||||
type Handler interface{ Do(*gin.Context) }
|
||||
func S(r *gin.Engine, handlers ${container}Handler, groups ${container}*gin.RouterGroup) {
|
||||
var h Handler = &A{}
|
||||
g := r.Group("/a")
|
||||
func() { ${writes} }()
|
||||
r.GET("/handler", h.Do)
|
||||
g.GET("/group", H)
|
||||
}`);
|
||||
expect(routes.map((r) => r.routePath)).toEqual(['/handler']);
|
||||
expect(routes[0].handlerReceiver).toBeUndefined();
|
||||
},
|
||||
);
|
||||
|
||||
it('does not confuse nested declarations with captured writes', () => {
|
||||
const routes = extract(`func S(r *gin.Engine) {
|
||||
h := &A{}
|
||||
func() { h := &B{}; _ = h }()
|
||||
r.GET("/handler", h.Do)
|
||||
}`);
|
||||
expect(routes[0]?.handlerReceiver).toEqual({ kind: 'type', name: 'A' });
|
||||
});
|
||||
|
||||
it('honors declaration order and independent block scopes', () => {
|
||||
expect(
|
||||
summary(`func S() {
|
||||
gin.New().GET("/before", H)
|
||||
{ gin := Factory{}; gin.New().GET("/fake", H) }
|
||||
gin := Factory{}
|
||||
gin.New().GET("/after", H)
|
||||
}`),
|
||||
).toEqual(['GET /before -> H']);
|
||||
});
|
||||
|
||||
it('does not shadow package names in a short declaration initializer', () => {
|
||||
expect(summary(`func S() { gin := gin.New(); gin.GET("/real", H) }`)).toEqual([
|
||||
'GET /real -> H',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Go route type-parameter shadowing', () => {
|
||||
it('does not treat a type parameter as a same-named package type', () => {
|
||||
const routes = extract(`func S[T interface{ Do(*gin.Context) }](r *gin.Engine, h T) {
|
||||
r.GET("/generic", h.Do)
|
||||
}`);
|
||||
expect(routes).toHaveLength(1);
|
||||
expect(routes[0]).toMatchObject({ routePath: '/generic', handlerName: 'h.Do' });
|
||||
expect(routes[0].handlerReceiver).toBeUndefined();
|
||||
});
|
||||
});
|
||||
308
gitnexus/test/unit/go-route-handler.test.ts
Normal file
308
gitnexus/test/unit/go-route-handler.test.ts
Normal file
|
|
@ -0,0 +1,308 @@
|
|||
/**
|
||||
* `resolveGoRouteHandler` (#3402): gin/echo handler designators resolved across
|
||||
* the files of a Go package, unique-or-decline at every step.
|
||||
*
|
||||
* The model is built the way the parse phase leaves it: a Method's `ownerId` is
|
||||
* the worker's `Struct:<methodFile>:<Receiver>` id, keyed on the method's own
|
||||
* file — not the struct's node id when the struct lives in a sibling file.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { createSemanticModel } from '../../src/core/ingestion/model/index.js';
|
||||
import { resolveGoRouteHandler } from '../../src/core/ingestion/languages/go/route-handler.js';
|
||||
import type { ExtractedDecoratorRoute } from '../../src/core/ingestion/workers/parse-worker.js';
|
||||
import { generateId } from '../../src/lib/utils.js';
|
||||
|
||||
const ROUTER = 'app/router/router.go';
|
||||
|
||||
type Model = ReturnType<typeof createSemanticModel>;
|
||||
|
||||
const struct = (model: Model, file: string, name: string) =>
|
||||
model.symbols.add(file, name, `Struct:${file}:${name}`, 'Struct');
|
||||
|
||||
/** A method as the worker registers it: owner keyed on the METHOD's file. */
|
||||
const method = (model: Model, file: string, receiver: string, name: string) =>
|
||||
model.symbols.add(file, name, `Method:${file}:${receiver}.${name}`, 'Method', {
|
||||
ownerId: generateId('Struct', `${file}:${receiver}`),
|
||||
});
|
||||
|
||||
const fn = (model: Model, file: string, name: string, returnType?: string) =>
|
||||
model.symbols.add(file, name, `Function:${file}:${name}`, 'Function', { returnType });
|
||||
|
||||
const route = (overrides: Partial<ExtractedDecoratorRoute>): ExtractedDecoratorRoute => ({
|
||||
filePath: ROUTER,
|
||||
routePath: '/x',
|
||||
httpMethod: 'GET',
|
||||
decoratorName: 'GET',
|
||||
lineNumber: 1,
|
||||
source: 'gin-route',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const resolve = (
|
||||
model: Model,
|
||||
overrides: Partial<ExtractedDecoratorRoute>,
|
||||
imports: Readonly<Record<string, readonly string[]>> = {},
|
||||
) =>
|
||||
resolveGoRouteHandler(route(overrides), {
|
||||
model,
|
||||
importTargetsFor: (_from, localName) => imports[localName] ?? [],
|
||||
});
|
||||
|
||||
describe('resolveGoRouteHandler', () => {
|
||||
it('deduplicates a method indexed by both its canonical owner and receiver name', () => {
|
||||
const model = createSemanticModel();
|
||||
struct(model, 'app/router/h.go', 'H');
|
||||
method(model, 'app/router/h.go', 'H', 'Do');
|
||||
const context = { model, importTargetsFor: () => [] };
|
||||
const registration = route({
|
||||
handlerName: 'h.Do',
|
||||
handlerReceiver: { kind: 'type', name: 'H' },
|
||||
});
|
||||
|
||||
expect(resolveGoRouteHandler(registration, context)).toBe('Method:app/router/h.go:H.Do');
|
||||
expect(resolveGoRouteHandler(registration, context)).toBe('Method:app/router/h.go:H.Do');
|
||||
});
|
||||
|
||||
it('rebuilds package indexes for a new resolution pass over a changed model', () => {
|
||||
const model = createSemanticModel();
|
||||
struct(model, 'app/router/types.go', 'H');
|
||||
method(model, 'app/router/h.go', 'H', 'Do');
|
||||
const registration = {
|
||||
handlerName: 'h.Do',
|
||||
handlerReceiver: { kind: 'type', name: 'H' },
|
||||
} as const;
|
||||
expect(resolve(model, registration)).toBe('Method:app/router/h.go:H.Do');
|
||||
|
||||
method(model, 'app/router/other.go', 'H', 'Do');
|
||||
expect(resolve(model, registration)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('resolves a constructor-built receiver to a method declared in a sibling file (issue #3402)', () => {
|
||||
const model = createSemanticModel();
|
||||
struct(model, 'app/router/types.go', 'MatchHandler');
|
||||
fn(model, 'app/router/types.go', 'NewMatchHandler', '*MatchHandler');
|
||||
method(model, 'app/router/match_handler.go', 'MatchHandler', 'UnfinalizeRoundHandle');
|
||||
|
||||
expect(
|
||||
resolve(model, {
|
||||
handlerName: 'matchHandler.UnfinalizeRoundHandle',
|
||||
handlerReceiver: { kind: 'constructor', name: 'NewMatchHandler' },
|
||||
}),
|
||||
).toBe('Method:app/router/match_handler.go:MatchHandler.UnfinalizeRoundHandle');
|
||||
});
|
||||
|
||||
it('reads the first result of a (T, error) constructor', () => {
|
||||
const model = createSemanticModel();
|
||||
struct(model, 'app/router/h.go', 'H');
|
||||
fn(model, 'app/router/h.go', 'NewH', '(*H, error)');
|
||||
method(model, 'app/router/h.go', 'H', 'Do');
|
||||
|
||||
expect(
|
||||
resolve(model, {
|
||||
handlerName: 'h.Do',
|
||||
handlerReceiver: { kind: 'constructor', name: 'NewH' },
|
||||
}),
|
||||
).toBe('Method:app/router/h.go:H.Do');
|
||||
});
|
||||
|
||||
it('declines a constructor whose result type lives in another package', () => {
|
||||
const model = createSemanticModel();
|
||||
fn(model, 'app/router/h.go', 'NewH', '*other.H');
|
||||
method(model, 'app/router/h.go', 'H', 'Do');
|
||||
|
||||
expect(
|
||||
resolve(model, {
|
||||
handlerName: 'h.Do',
|
||||
handlerReceiver: { kind: 'constructor', name: 'NewH' },
|
||||
}),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
describe.each(['Händler', '处理器', '𐐀Handler', '_Händler_١'])(
|
||||
'a constructor returning the Go identifier %s',
|
||||
(owner) => {
|
||||
it.each(['%s', '*%s', '(*%s, error)'])(
|
||||
'resolves its sibling-file method for result %s',
|
||||
(result) => {
|
||||
const model = createSemanticModel();
|
||||
struct(model, 'app/router/types.go', owner);
|
||||
fn(model, 'app/router/constructor.go', 'NewHandler', result.replace('%s', owner));
|
||||
method(model, 'app/router/handler.go', owner, 'Do');
|
||||
|
||||
expect(
|
||||
resolve(model, {
|
||||
handlerName: 'h.Do',
|
||||
handlerReceiver: { kind: 'constructor', name: 'NewHandler' },
|
||||
}),
|
||||
).toBe(`Method:app/router/handler.go:${owner}.Do`);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
'1Handler',
|
||||
'١Handler',
|
||||
'Ha\u0308ndler',
|
||||
'Handler²',
|
||||
'HandlerⅣ',
|
||||
'other.Handler',
|
||||
'[]Handler',
|
||||
'map[string]Handler',
|
||||
])('declines the unsupported constructor result %s even with matching metadata', (result) => {
|
||||
const model = createSemanticModel();
|
||||
struct(model, 'app/router/types.go', result);
|
||||
fn(model, 'app/router/constructor.go', 'NewHandler', result);
|
||||
method(model, 'app/router/handler.go', result, 'Do');
|
||||
|
||||
expect(
|
||||
resolve(model, {
|
||||
handlerName: 'h.Do',
|
||||
handlerReceiver: { kind: 'constructor', name: 'NewHandler' },
|
||||
}),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('resolves a type hint through an import qualifier', () => {
|
||||
const model = createSemanticModel();
|
||||
struct(model, 'app/handlers/auth.go', 'Auth');
|
||||
method(model, 'app/handlers/auth.go', 'Auth', 'Login');
|
||||
|
||||
expect(
|
||||
resolve(
|
||||
model,
|
||||
{
|
||||
handlerName: 'h.Login',
|
||||
handlerReceiver: { kind: 'type', name: 'Auth', qualifier: 'hs' },
|
||||
},
|
||||
{ hs: ['app/handlers/auth.go', 'app/handlers/util.go'] },
|
||||
),
|
||||
).toBe('Method:app/handlers/auth.go:Auth.Login');
|
||||
});
|
||||
|
||||
it('resolves a package function through its import', () => {
|
||||
const model = createSemanticModel();
|
||||
fn(model, 'app/handlers/health.go', 'Health');
|
||||
fn(model, 'app/other/health.go', 'Health');
|
||||
|
||||
expect(
|
||||
resolve(
|
||||
model,
|
||||
{
|
||||
handlerName: 'handlers.Health',
|
||||
handlerReceiver: { kind: 'module', qualifier: 'handlers' },
|
||||
},
|
||||
{ handlers: ['app/handlers/health.go'] },
|
||||
),
|
||||
).toBe('Function:app/handlers/health.go:Health');
|
||||
});
|
||||
|
||||
it('declines a module handler whose import is not in the workspace', () => {
|
||||
const model = createSemanticModel();
|
||||
fn(model, 'app/router/x.go', 'Wrap');
|
||||
|
||||
expect(
|
||||
resolve(model, {
|
||||
handlerName: 'gin.Wrap',
|
||||
handlerReceiver: { kind: 'module', qualifier: 'gin' },
|
||||
}),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('resolves a bare function in the route package only', () => {
|
||||
const model = createSemanticModel();
|
||||
fn(model, 'app/router/health.go', 'Health');
|
||||
fn(model, 'app/other/health.go', 'Health');
|
||||
|
||||
expect(resolve(model, { handlerName: 'Health' })).toBe('Function:app/router/health.go:Health');
|
||||
});
|
||||
|
||||
it('declines a receiver of unknown type even when the name is unique in the router package', () => {
|
||||
// `h := deps.Users` — the real handler usually lives in another package, so a
|
||||
// same-named method in the router's own directory belongs to an unrelated type.
|
||||
const model = createSemanticModel();
|
||||
method(model, 'app/router/page.go', 'Page', 'List');
|
||||
|
||||
expect(resolve(model, { handlerName: 'h.List' })).toBeUndefined();
|
||||
});
|
||||
|
||||
it('ignores _test.go siblings when judging uniqueness', () => {
|
||||
const model = createSemanticModel();
|
||||
struct(model, 'app/router/auth.go', 'AuthHandler');
|
||||
method(model, 'app/router/auth.go', 'AuthHandler', 'Login');
|
||||
method(model, 'app/router/auth_test.go', 'AuthHandler', 'Login');
|
||||
|
||||
expect(
|
||||
resolve(model, {
|
||||
handlerName: 'h.Login',
|
||||
handlerReceiver: { kind: 'type', name: 'AuthHandler' },
|
||||
}),
|
||||
).toBe('Method:app/router/auth.go:AuthHandler.Login');
|
||||
});
|
||||
|
||||
it.each([
|
||||
['a qualifier whose import spans two directories', { kind: 'module', qualifier: 'split' }],
|
||||
['a type hint with no name', { kind: 'type' }],
|
||||
] as const)('declines %s', (_label, handlerReceiver) => {
|
||||
const model = createSemanticModel();
|
||||
fn(model, 'app/a/h.go', 'Do');
|
||||
method(model, 'app/router/t.go', 'T', 'Do');
|
||||
|
||||
expect(
|
||||
resolve(
|
||||
model,
|
||||
{ handlerName: 'h.Do', handlerReceiver },
|
||||
{ split: ['app/a/h.go', 'app/b/h.go'] },
|
||||
),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('declines a deeper selector', () => {
|
||||
const model = createSemanticModel();
|
||||
method(model, 'app/router/t.go', 'T', 'Do');
|
||||
|
||||
expect(resolve(model, { handlerName: 'a.b.Do' })).toBeUndefined();
|
||||
});
|
||||
|
||||
it('declines when two same-named constructors exist in the package', () => {
|
||||
const model = createSemanticModel();
|
||||
struct(model, 'app/router/h.go', 'H');
|
||||
fn(model, 'app/router/h.go', 'NewH', '*H');
|
||||
fn(model, 'app/router/h2.go', 'NewH', '*H');
|
||||
method(model, 'app/router/h.go', 'H', 'Do');
|
||||
|
||||
expect(
|
||||
resolve(model, {
|
||||
handlerName: 'h.Do',
|
||||
handlerReceiver: { kind: 'constructor', name: 'NewH' },
|
||||
}),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
describe('a failed hint never falls back to a name-only match', () => {
|
||||
it.each([
|
||||
['the hinted type is not in the package', () => {}],
|
||||
[
|
||||
'two structs share the hinted name',
|
||||
(model: Model) => {
|
||||
struct(model, 'app/router/t1.go', 'T');
|
||||
struct(model, 'app/router/t2.go', 'T');
|
||||
},
|
||||
],
|
||||
[
|
||||
'the hinted type has no such method',
|
||||
(model: Model) => {
|
||||
struct(model, 'app/router/t.go', 'T');
|
||||
},
|
||||
],
|
||||
])('declines when %s', (_label, arrange) => {
|
||||
const model = createSemanticModel();
|
||||
arrange(model);
|
||||
method(model, 'app/router/other.go', 'Other', 'Do');
|
||||
|
||||
expect(
|
||||
resolve(model, { handlerName: 'h.Do', handlerReceiver: { kind: 'type', name: 'T' } }),
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
142
gitnexus/test/unit/impact-route-enrichment.test.ts
Normal file
142
gitnexus/test/unit/impact-route-enrichment.test.ts
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
/**
|
||||
* `impact` names the HTTP routes in its blast radius (#3402).
|
||||
*
|
||||
* The routes reach the result through (handler)-[HANDLES_ROUTE]->Route, read for
|
||||
* the target and every impacted symbol. Before this, a service method's upstream
|
||||
* impact stopped at the handler: the route and its frontend callers never
|
||||
* appeared.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
const executeParameterizedMock = vi.fn();
|
||||
|
||||
vi.mock('../../src/core/lbug/pool-adapter.js', async (importOriginal) => {
|
||||
const actual = await importOriginal();
|
||||
return {
|
||||
...(actual as object),
|
||||
initLbug: vi.fn(),
|
||||
executeQuery: vi.fn(async () => []),
|
||||
executeParameterized: (...args: unknown[]) => executeParameterizedMock(...args),
|
||||
closeLbug: vi.fn(),
|
||||
isLbugReady: vi.fn().mockReturnValue(true),
|
||||
};
|
||||
});
|
||||
vi.mock('../../src/mcp/core/lbug-adapter.js', async (importOriginal) => {
|
||||
const actual = await importOriginal();
|
||||
return {
|
||||
...(actual as object),
|
||||
initLbug: vi.fn(),
|
||||
executeQuery: vi.fn(async () => []),
|
||||
executeParameterized: (...args: unknown[]) => executeParameterizedMock(...args),
|
||||
closeLbug: vi.fn(),
|
||||
isLbugReady: vi.fn().mockReturnValue(true),
|
||||
};
|
||||
});
|
||||
|
||||
import { LocalBackend } from '../../src/mcp/local/local-backend';
|
||||
|
||||
const REPO = {
|
||||
id: 'repo',
|
||||
name: 'repo',
|
||||
repoPath: '/tmp/repo',
|
||||
storagePath: '/tmp/repo/.gitnexus',
|
||||
lbugPath: '/tmp/repo/.gitnexus/lbug',
|
||||
indexedAt: 'now',
|
||||
lastCommit: 'c',
|
||||
stats: {},
|
||||
};
|
||||
|
||||
interface RouteRow {
|
||||
readonly hid: string;
|
||||
readonly url: string;
|
||||
readonly method?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Target `svc` is called by `handler` (depth 1). `routeRows` answers the
|
||||
* HANDLES_ROUTE query; `routeQueryFails` makes it reject.
|
||||
*/
|
||||
async function runImpact(routeRows: readonly RouteRow[], routeQueryFails = false) {
|
||||
executeParameterizedMock.mockImplementation(async (...args: unknown[]) => {
|
||||
const query = String(args[1] ?? '');
|
||||
const params = (args[2] ?? {}) as { ids?: string[]; frontierIds?: string[] };
|
||||
if (query.includes("'HANDLES_ROUTE'")) {
|
||||
if (routeQueryFails) throw new Error('route query failed');
|
||||
return routeRows.filter((row) => params.ids?.includes(row.hid));
|
||||
}
|
||||
if (query.includes('$frontierIds')) {
|
||||
return (params.frontierIds ?? []).includes('svc')
|
||||
? [
|
||||
{
|
||||
sourceId: 'svc',
|
||||
id: 'handler',
|
||||
name: 'Handle',
|
||||
type: 'Method',
|
||||
filePath: 'h.go',
|
||||
relType: 'CALLS',
|
||||
confidence: 1,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
}
|
||||
if (query.includes('STEP_IN_PROCESS') || query.includes('MEMBER_OF')) return [];
|
||||
return [{ id: 'svc', name: 'UnfinalizeRound', filePath: 'svc.go', type: 'Method' }];
|
||||
});
|
||||
|
||||
const backend = new LocalBackend();
|
||||
(backend as unknown as { repos: Map<string, unknown> }).repos.set(REPO.id, REPO);
|
||||
(backend as unknown as { ensureInitialized: () => Promise<void> }).ensureInitialized = vi
|
||||
.fn()
|
||||
.mockResolvedValue(undefined);
|
||||
return (
|
||||
backend as unknown as {
|
||||
_impactImpl: (repo: unknown, params: unknown) => Promise<Record<string, unknown>>;
|
||||
}
|
||||
)._impactImpl(REPO, { target: 'UnfinalizeRound', direction: 'upstream', maxDepth: 2 });
|
||||
}
|
||||
|
||||
const depthOneItems = (res: Record<string, unknown>) =>
|
||||
((res.byDepth as Record<number, Array<Record<string, unknown>>>)[1] ?? []).map((item) => ({
|
||||
id: item.id,
|
||||
routes: item.routes,
|
||||
}));
|
||||
|
||||
describe('impact: route enrichment', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('lists the route an impacted handler serves, top-level and on the item', async () => {
|
||||
const res = await runImpact([
|
||||
{ hid: 'handler', url: '/api/v1/rounds/:id/unfinalize', method: 'POST' },
|
||||
]);
|
||||
|
||||
expect(res.affected_routes).toEqual([{ url: '/api/v1/rounds/:id/unfinalize', method: 'POST' }]);
|
||||
expect(depthOneItems(res)).toEqual([
|
||||
{ id: 'handler', routes: [{ url: '/api/v1/rounds/:id/unfinalize', method: 'POST' }] },
|
||||
]);
|
||||
});
|
||||
|
||||
it('includes a route the target itself serves, deduplicated by method and url', async () => {
|
||||
const res = await runImpact([
|
||||
{ hid: 'svc', url: '/x', method: 'GET' },
|
||||
{ hid: 'handler', url: '/x', method: 'GET' },
|
||||
{ hid: 'handler', url: '/x', method: 'POST' },
|
||||
]);
|
||||
|
||||
expect(res.affected_routes).toEqual([
|
||||
{ url: '/x', method: 'GET' },
|
||||
{ url: '/x', method: 'POST' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps the result and flags it partial when the route query fails', async () => {
|
||||
const res = await runImpact([], true);
|
||||
|
||||
expect({
|
||||
impactedCount: res.impactedCount,
|
||||
affected_routes: res.affected_routes,
|
||||
partial: res.partial,
|
||||
}).toEqual({ impactedCount: 1, affected_routes: [], partial: true });
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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, '');
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue