feat(go): index Gin and Echo routes through configured aliases (#3750)
Some checks are pending
CodeQL / Analyze (javascript-typescript) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
Gitleaks / gitleaks (push) Waiting to run
Publish / Classify release event (push) Waiting to run
Publish / RC guard (marker + release-PR skip) (push) Blocked by required conditions
Publish / ci (push) Blocked by required conditions
Publish / Publish to npm (push) Blocked by required conditions
Publish / Build & Push RC Docker images (push) Blocked by required conditions
Scorecard / Scorecard analysis (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-cli) (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-web) (push) Waiting to run

This commit is contained in:
breezefaith 2026-11-02 05:20:00 +08:00 • committed by GitHub
parent 347cc99b4a
commit 54db34d5cb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
43 changed files with 2620 additions and 102 deletions

View file

@ -940,6 +940,19 @@ jobs:
run: node --import tsx bench/trpc-route-extractor/measure.mjs --check
working-directory: gitnexus
- name: Go route-fact correctness and linear scaling (#3750)
if: ${{ !cancelled() }}
run: node --expose-gc --import tsx bench/go-route-facts/measure.js --check --output go-route-facts.json
working-directory: gitnexus
- name: Upload Go route-fact measurements
if: always()
uses: ./.github/actions/upload-test-artifact
with:
name: go-route-facts-benchmark
path: gitnexus/go-route-facts.json
retention-days: 5
- name: Cross-language scope-capture fingerprint + scaling guards
# Runs even after an earlier guard fails (#2895). Every step here was
# fail-fast, so the FIRST failing --check aborted the job and every guard

View file

@ -233,6 +233,10 @@ Most `analyze` knobs are also CLI flags (`--workers`, `--worker-timeout`, `--max
| `GITNEXUS_QUERY_MAX_RSS_MB` | max(8192 MiB, 25% of available RAM), capped at 75% of available RAM | Process-memory ceiling while pooled reads run; available RAM is the smaller of physical RAM and a reported runtime/container constraint. Positive MiB values override it; `0` disables the guard. Invalid, overflowing, or sub-byte values use the default. A breach exits the process with code 70. | Set in the CLI/MCP/serve environment when a legitimate read workload needs a different budget. It does not resize the database buffer pool. |
| `GITNEXUS_QUERY_TRACE` | unset | `1` writes query duration, row count, and sampled memory to stderr. Bound parameters are omitted; query text is included. | Diagnose an expensive read or a memory-guard exit. Inspect trace output before sharing it. |
| Analyze configuration | Default | Effect | When to tune |
| --- | --- | --- | --- |
| `GITNEXUS_RC` | unset | Absolute path to a JSON file with the same keys as `.gitnexusrc`. Its keys override repository configuration; CLI flags still win. Missing, invalid, or oversized files fail analysis. | Keep local configuration outside the checkout. Re-indexing hooks and watch processes must inherit it too. Changing or removing router aliases or handler wrappers rebuilds route evidence. |
| Variable | Default | Effect | Tune when… |
| ----------------------------------------------- | --------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `GITNEXUS_TEST_REPORT` | unset | Write a JSON execution receipt from `scripts/run-cross-platform.ts`; accepts a basename ending in `.json`. | CI platform shards use distinct filenames so the completeness check can require every report. |

View file

@ -409,6 +409,21 @@ anchors are deliberately omitted. Add common infrastructure fields such as `/hea
`--spring-actuator` is explicitly opt-in. The path may be a JSON bundle keyed by `mappings`, `beans`, `conditions`, `configprops`, and/or `env`, or a directory containing endpoint-named JSON files. Runtime mappings and beans confirm matching static nodes; conditions and configuration property keys enrich existing evidence, with conservative runtime-only nodes added when no match exists. The configured input is excluded from source scanning; only normalized repository-relative exclusions are retained for future scans, never absolute paths. Env/configprops values, origins, condition messages, and source names are never persisted or printed. Enabled runs always rebuild because runtime snapshots are external to git freshness; omitting the option later rebuilds once to remove runtime evidence. Project config can set the same path with `springActuator` in `.gitnexusrc`.
Go gin/echo routes are indexed when a file imports `github.com/gin-gonic/gin` or `github.com/labstack/echo` directly. A service that registers routes through an internal wrapper package — `func InitRoute(s *core.Engine)`, where the dependency declares `type Engine = gin.Engine` — produces no routes, because the wrapper's source is usually not in the repository and the equivalence cannot be proven. Declare it in `.gitnexusrc`, together with any application adapters that wrap the real handler (`common.Wrapper(api.CreateJob)`):
```json
{
"goRouterAliases": [
{ "importPath": "example.com/infra/pkg", "type": "Engine", "framework": "gin" }
],
"goHandlerWrappers": ["common.Wrapper", "common.WrapperWithSuccessCode"]
}
```
`framework` is `gin` or `echo` and selects the handler argument order. A wrapper entry is the callee exactly as written at the registration site; its first argument becomes the route's handler. Registrations on an undeclared wrapper type are dropped, and `analyze` logs how many were dropped per `importPath.Type` so you can see what to declare. Changing either key rebuilds Route evidence on the next `analyze` without `--force`.
To keep this configuration out of the working tree, set `GITNEXUS_RC` to the absolute path of a JSON file with the same shape as `.gitnexusrc`. Its keys override the repository's `.gitnexusrc` key by key, and CLI flags still win. Hooks, MCP servers, and watch processes that re-index must see the same variable; if a later run no longer has it, `analyze` warns before removing the Routes it produced.
`--asyncapi-spec` is explicitly opt-in and accepts a directory of AsyncAPI documents or a single document; the path is resolved against the repository root, so a committed `docs/asyncapi` and an absolute cache written by something else both work. Each `operations[]` entry of an **AsyncAPI 3.x** document can contribute a `Destination` node keyed by broker and address, with `action: send` emitting `PUBLISHES_TO` and `action: receive` emitting `CONSUMES_FROM`, so a document and source code that name one address on one broker land on the same node. Edges start at the document, not at a callable — a document states that the service talks to an address, not which method does — and no address a document names is ever attached to an unresolved source site.
An operation must name a protocol, either through its own `bindings` or through the `servers[].protocol` of the servers its channel resolves to (a channel that lists no `servers` resolves to all of them); operations that name none are refused, as are operations whose two readings name different brokers, and channels that inherit a multi-protocol server set without choosing. HTTP and WebSocket documents are refused for destination minting: there the host rather than the address names the place, and an HTTP endpoint is already modelled as a `Route`. A parameterized address — a channel declaring `parameters`, or an address containing `{` — is refused rather than keyed: two services publishing `{env}.orders` share a pattern, not a queue. AsyncAPI **2.x is refused** under its own counted reason and never mapped, because its `publish`/`subscribe` are inverted relative to 3.x `send`/`receive` and a naive mapping would reverse the async graph while leaving it connected. Every refusal is counted, and a configured path that yields nothing is reported rather than passed over in silence.

View file

@ -0,0 +1,114 @@
{
"_what": "Go route-fact collection and serialized replay (#3750). Run node --expose-gc --import tsx bench/go-route-facts/measure.js --check.",
"_method": "Three 4x sizes; eight warmups, seven interleaved adaptive batches per size, median per-call time. Parsing, serialization and identity checks are untimed. manyParameters grows parameters and routes together. Serialized bytes exclude worker envelopes and heap.",
"_triage": "Check identities before timing. A normalized factor near 1 is linear; quadratic growth produces factors near 4 per adjacent size and 16 across the full range. Re-run timing failures on an idle machine; do not raise budgets or regenerate fingerprints to conceal regressions.",
"version": 1,
"sizes": [128, 512, 2048],
"budgets": {
"collect_ms": 1.5,
"replay_ms": 1.5,
"serialized_bytes": 1.15
},
"workloads": {
"noRoutes": [
{
"size": 128,
"routes": 0,
"candidates": 0,
"fingerprint": "95d423889096132b39fc71a1ac71377b51265e98194b64803edf5db1fe7bf7b1"
},
{
"size": 512,
"routes": 0,
"candidates": 0,
"fingerprint": "95d423889096132b39fc71a1ac71377b51265e98194b64803edf5db1fe7bf7b1"
},
{
"size": 2048,
"routes": 0,
"candidates": 0,
"fingerprint": "95d423889096132b39fc71a1ac71377b51265e98194b64803edf5db1fe7bf7b1"
}
],
"direct": [
{
"size": 128,
"routes": 128,
"candidates": 128,
"fingerprint": "1db15d356dbdbf66795f95bedf9c241ac47e9de19140e169cdabf12f4ba64771"
},
{
"size": 512,
"routes": 512,
"candidates": 512,
"fingerprint": "8450e2f92cf78ce784f1412f52dead89ef7b994285643eef7da0f12ba646b83e"
},
{
"size": 2048,
"routes": 2048,
"candidates": 2048,
"fingerprint": "1d568993ecb20a35e23c21ffd2c89e8a6cbb4279e05252d1293fe510d5c92cdf"
}
],
"wrapped": [
{
"size": 128,
"routes": 128,
"candidates": 128,
"fingerprint": "f687fcd72ed2b722e6fa2210761a2ae1a9d090db935ba526da65764b3397bb68"
},
{
"size": 512,
"routes": 512,
"candidates": 512,
"fingerprint": "be46370d5d280ab594de5099814be36f3faf2ddbabe997b7cddd0a8df8850e0b"
},
{
"size": 2048,
"routes": 2048,
"candidates": 2048,
"fingerprint": "a5728b94aed95cd572a76cc5c6c3515795fe5c84101fc852968271d728231545"
}
],
"mixed": [
{
"size": 128,
"routes": 128,
"candidates": 128,
"fingerprint": "ae5a80d0b61dd6afb781ce1d763766ba42b91310d5b229bf271ea49958765dd2"
},
{
"size": 512,
"routes": 512,
"candidates": 512,
"fingerprint": "f930a0c5fb25631ca12c471bd8793d3fe7b354e2fcfa53accc11ea298496cf4e"
},
{
"size": 2048,
"routes": 2048,
"candidates": 2048,
"fingerprint": "b365479d3bfee17800dd01d748ebfa7e5fda39b8d650b1c04bb8bb75e81975f4"
}
],
"manyParameters": [
{
"size": 128,
"routes": 128,
"candidates": 128,
"fingerprint": "7e39887e880c081eb858b5d4d43543ac9e79835434d93230f8ddda0dc3999984"
},
{
"size": 512,
"routes": 512,
"candidates": 512,
"fingerprint": "97afb59ccc0fcd32aaa569f97b8d9e6d190830b1c064b3bf9dfb17bfba30eedf"
},
{
"size": 2048,
"routes": 2048,
"candidates": 2048,
"fingerprint": "be631b09b19df157c595b057469cc2c72a2fc60fae7995e43657a81752a72b11"
}
]
}
}

View file

@ -0,0 +1,299 @@
#!/usr/bin/env node
/**
* Go route-fact collection and replay, using the production provider hooks.
* Run: node --expose-gc --import tsx bench/go-route-facts/measure.js --check
* Record a reviewed baseline: same command with --record instead of --check.
*
* Parsing, serialization and fingerprints are outside timing. Each invocation
* collects fresh facts or replays serialized facts with a fresh resolution
* context. Parameters AND registrations grow in the manyParameters workload.
* Adaptive batches avoid sub-millisecond timing ratios; sample order rotates
* across three 4x sizes. Check adjacent sizes and the full 16x range.
* Serialized bytes cover collected routes/facts, not worker envelopes or heap.
*/
import assert from 'node:assert/strict';
import { createHash } from 'node:crypto';
import { readFileSync, writeFileSync } from 'node:fs';
import { performance } from 'node:perf_hooks';
import { parseArgs } from 'node:util';
import Parser from 'tree-sitter';
import Go from 'tree-sitter-go';
import { goProvider } from '../../src/core/ingestion/languages/go.ts';
const { values } = parseArgs({
options: {
check: { type: 'boolean' },
record: { type: 'boolean' },
baseline: { type: 'string' },
output: { type: 'string' },
},
});
assert(!(values.check && values.record), 'Choose --check or --record');
const baselinePath = values.baseline ?? new URL('./baseline.json', import.meta.url);
const shapes = ['noRoutes', 'direct', 'wrapped', 'mixed', 'manyParameters'];
const sizes = [128, 512, 2048];
const metrics = ['collect_ms', 'replay_ms', 'serialized_bytes'];
const budgets = { collect_ms: 1.5, replay_ms: 1.5, serialized_bytes: 1.15 };
const warmups = 8;
const samples = 7;
const batchMs = 40;
const filePath = 'routes.go';
const alias = { importPath: 'example.com/infra/pkg', type: 'Engine', framework: 'gin' };
const routeHookConfig = {
routeFrameworkAliases: [alias],
routeHandlerWrappers: ['common.Wrapper'],
};
const parsedFile = {
filePath,
moduleScope: 'bench',
scopes: [],
parsedImports: [],
localDefs: [],
referenceSites: [],
};
const expectedCount = (shape, size) => (shape === 'noRoutes' ? 0 : size);
const id = (i) => String(i).padStart(8, '0');
const lines = (size, line) => Array.from({ length: size }, (_, i) => line(i)).join('\n');
const median = (numbers) => [...numbers].sort((a, b) => a - b)[Math.floor(numbers.length / 2)];
function source(shape, size) {
if (shape === 'noRoutes') {
return `package routes\nimport "fmt"\n${lines(
size,
(i) => `func F${id(i)}(x int) int { fmt.Println(x); return x + 1 }`,
)}`;
}
const imports =
shape === 'direct'
? 'import "github.com/gin-gonic/gin"'
: shape === 'mixed'
? 'import "github.com/gin-gonic/gin"\nimport core "example.com/infra/pkg"'
: 'import core "example.com/infra/pkg"\nimport "example.com/app/common"';
const parameters =
shape === 'direct'
? 'r *gin.Engine'
: shape === 'mixed'
? 'r *gin.Engine, s *core.Engine'
: `r *core.Engine${
shape === 'manyParameters'
? `, ${Array.from({ length: size }, (_, i) => `x${id(i)} *core.T${id(i)}`).join(', ')}`
: ''
}`;
return `package routes\n${imports}\nfunc Register(${parameters}) {
g := r.Group("/api")
${shape === 'mixed' ? 'a := s.Group("/api")' : ''}
${lines(
size,
(i) =>
`${shape === 'mixed' && i % 2 ? 'a' : 'g'}.GET("/${id(i)}", Middleware, ${
shape === 'wrapped' ? `common.Wrapper(Handler${id(i)})` : `Handler${id(i)}`
})`,
)}
}`;
}
function collect(tree) {
return {
direct: goProvider.extractDecoratorRoutes?.(tree, filePath, 0) ?? [],
facts: goProvider.extractRouteFacts(tree, parsedFile, 0) ?? null,
};
}
function replay(collected) {
return [
...goProvider.resolveRouteFacts([{ filePath, facts: collected.facts }], {
resolveImportTargets: () => [],
routeHookConfig,
}),
...collected.direct,
];
}
function identity(shape, size, collected) {
const routes = replay(collected);
const count = expectedCount(shape, size);
assert.equal(routes.length, count, `${shape}/${size}: route count`);
assert.deepEqual(
routes.map((r) => r.routePath).sort(),
Array.from({ length: count }, (_, i) => `/api/${id(i)}`),
`${shape}/${size}: route paths`,
);
for (const route of routes) {
const handler = `Handler${route.routePath.slice(-8)}`;
assert.equal(route.httpMethod, 'GET');
assert.equal(route.source, 'gin-route');
assert.equal(route.filePath, filePath);
if (shape === 'wrapped') {
assert.deepEqual(route.handlerWrapper, { callee: 'common.Wrapper', handlerName: handler });
} else {
assert.equal(route.handlerName, handler);
}
}
const candidates = (collected.facts?.roots ?? []).reduce((n, root) => n + root.routes.length, 0);
assert.equal(candidates, count, `${shape}/${size}: candidate count`);
return {
size,
routes: routes.length,
candidates,
fingerprint: createHash('sha256').update(JSON.stringify({ collected, routes })).digest('hex'),
};
}
function validateBaseline(baseline) {
assert(baseline && typeof baseline === 'object' && !Array.isArray(baseline), 'Invalid baseline');
assert.equal(baseline.version, 1, 'Unknown baseline version');
assert.deepEqual(baseline.sizes, sizes, 'Baseline must retain all three sizes');
assert.deepEqual(baseline.budgets, budgets, 'Baseline must retain the reviewed scaling budgets');
assert.deepEqual(Object.keys(baseline.workloads).sort(), [...shapes].sort(), 'Missing workload');
for (const shape of shapes) {
const rows = baseline.workloads[shape];
assert(Array.isArray(rows) && rows.length === sizes.length, `${shape}: missing sizes`);
for (const [index, row] of rows.entries()) {
assert.deepEqual(Object.keys(row).sort(), ['candidates', 'fingerprint', 'routes', 'size']);
assert.equal(row.size, sizes[index]);
assert.equal(row.routes, expectedCount(shape, row.size));
assert.equal(row.candidates, row.routes);
assert.match(row.fingerprint, /^[a-f0-9]{64}$/);
}
}
}
function batch(action, iterations) {
let result;
const start = performance.now();
for (let i = 0; i < iterations; i++) result = action();
const elapsed = performance.now() - start;
assert(result !== undefined, 'Benchmark action returned no result');
return elapsed;
}
function calibrate(action) {
let iterations = 1;
while (iterations < 65536 && batch(action, iterations) < batchMs) iterations *= 2;
return iterations;
}
function scaling(rows) {
const pairs = [
[rows[0], rows[1]],
[rows[1], rows[2]],
[rows[0], rows[2]],
];
return pairs.map(([small, large]) => {
const inputRatio = large.size / small.size;
const factors = Object.fromEntries(
metrics.map((metric) => {
assert(Number.isFinite(small[metric]) && small[metric] > 0, `Invalid ${metric}`);
assert(Number.isFinite(large[metric]) && large[metric] > 0, `Invalid ${metric}`);
return [metric, large[metric] / small[metric] / inputRatio];
}),
);
return {
from: small.size,
to: large.size,
input_ratio: inputRatio,
factors,
passed: metrics.every((metric) => factors[metric] <= budgets[metric]),
};
});
}
const baseline = values.check ? JSON.parse(readFileSync(baselinePath, 'utf8')) : undefined;
if (values.check) validateBaseline(baseline);
assert.equal(typeof goProvider.extractRouteFacts, 'function');
assert.equal(typeof goProvider.resolveRouteFacts, 'function');
const parser = new Parser();
parser.setLanguage(Go);
const report = {
node: process.version,
warmups,
samples,
batch_ms: batchMs,
workloads: {},
errors: [],
};
const recorded = {};
for (const shape of shapes) {
const states = sizes.map((size) => {
const tree = parser.parse(source(shape, size));
assert(!tree.rootNode.hasError, `${shape}/${size}: invalid Go fixture`);
// The replay input survives JSON serialization, just like cached worker facts.
const collected = JSON.parse(JSON.stringify(collect(tree)));
const actual = identity(shape, size, collected);
if (values.check)
assert.deepEqual(
actual,
baseline.workloads[shape][sizes.indexOf(size)],
`${shape}/${size}: identity drift`,
);
const actions = { collect_ms: () => collect(tree), replay_ms: () => replay(collected) };
for (let i = 0; i < warmups; i++) for (const action of Object.values(actions)) action();
return {
size,
actual,
actions,
collected,
timings: { collect_ms: [], replay_ms: [] },
iterations: Object.fromEntries(
Object.entries(actions).map(([key, action]) => [key, calibrate(action)]),
),
};
});
for (let round = 0; round < samples; round++) {
for (let offset = 0; offset < states.length; offset++) {
const state = states[(round + offset) % states.length];
for (const [metric, action] of Object.entries(state.actions)) {
global.gc?.();
state.timings[metric].push(
batch(action, state.iterations[metric]) / state.iterations[metric],
);
}
}
}
const rows = states.map((state) => ({
...state.actual,
collect_ms: median(state.timings.collect_ms),
replay_ms: median(state.timings.replay_ms),
serialized_bytes: Buffer.byteLength(JSON.stringify(state.collected)),
iterations: state.iterations,
samples_ms: state.timings,
}));
const comparisons = scaling(rows);
for (const comparison of comparisons) {
for (const metric of metrics)
if (comparison.factors[metric] > budgets[metric]) {
report.errors.push(
`${shape} ${comparison.from}->${comparison.to}: ${metric} linear factor ${comparison.factors[metric].toFixed(3)} exceeds ${budgets[metric]}`,
);
}
}
report.workloads[shape] = { rows, comparisons };
recorded[shape] = states.map((state) => state.actual);
}
report.passed = report.errors.length === 0;
const json = `${JSON.stringify(report, null, 2)}\n`;
if (values.output) writeFileSync(values.output, json);
console.log(json);
if (values.record) {
assert(report.passed, 'Refusing to record a baseline that fails scaling');
writeFileSync(
baselinePath,
`${JSON.stringify(
{
_what:
'Go route-fact collection and serialized replay (#3750). Run node --expose-gc --import tsx bench/go-route-facts/measure.js --check.',
_method:
'Three 4x sizes; eight warmups, seven interleaved adaptive batches per size, median per-call time. Parsing, serialization and identity checks are untimed. manyParameters grows parameters and routes together. Serialized bytes exclude worker envelopes and heap.',
_triage:
'Check identities before timing. A normalized factor near 1 is linear; quadratic growth produces factors near 4 per adjacent size and 16 across the full range. Re-run timing failures on an idle machine; do not raise budgets or regenerate fingerprints to conceal regressions.',
version: 1,
sizes,
budgets,
workloads: recorded,
},
null,
2,
)}\n`,
);
}
if (values.check && !report.passed) process.exitCode = 1;

View file

@ -405,6 +405,8 @@ const FILESYSTEM = [
'test/integration/filesystem-walker.test.ts',
'test/integration/analyze-directory-symlinks.test.ts',
'test/integration/watch-filesystem.test.ts',
'test/unit/watch-external-config.test.ts',
'test/integration/server-route-hook-config.test.ts',
'test/integration/markdown-processor-crlf.test.ts',
'test/integration/ignore-and-skip-e2e.test.ts',
// Pins that the bridge pairing verdict is measured before the database is

View file

@ -30,7 +30,9 @@
import fs from 'node:fs';
import path from 'node:path';
import { readRepoControlFile } from '../config/repo-control-file.js';
import { MAX_REPO_CONTROL_FILE_BYTES, readRepoControlFile } from '../config/repo-control-file.js';
import { logger } from '../core/logger.js';
import { normalizeRouterAliases } from '../core/ingestion/languages/go/route-config.js';
import {
InvalidBranchError,
sanitizeDetectedBranch,
@ -64,7 +66,8 @@ type ValueKind =
| 'numeric-string'
| 'embeddings'
| 'branch'
| 'path';
| 'path'
| 'router-aliases';
interface KeySpec {
/** The `AnalyzeOptions` field this config key normalizes into. */
@ -116,6 +119,13 @@ const KEY_SPECS: Record<string, KeySpec> = {
// built-in convention set, is otherwise invisible to route_map consumers.
// Listing it here adds it to the cross-file consumer scan.
fetchWrappers: { target: 'fetchWrappers', kind: 'string-array' },
// Go router types reached through an in-house wrapper package that are the
// gin/echo engine (`type Engine = gin.Engine`). The indexer cannot read the
// dependency's source, so the equivalence is declared, never guessed.
goRouterAliases: { target: 'routeFrameworkAliases', kind: 'router-aliases' },
// Go functions that wrap a route handler (`common.Wrapper(api.CreateJob)`);
// the wrapped first argument becomes the route's handler.
goHandlerWrappers: { target: 'routeHandlerWrappers', kind: 'string-array' },
// Explicit local Actuator snapshot input (#2418). The path itself is safe in
// project config; payload contents are never copied into the graph wholesale.
springActuator: { target: 'springActuator', kind: 'path' },
@ -178,8 +188,13 @@ export function validateBranchName(value: string, source: string): string {
}
}
const normalizeValue = (kind: ValueKind, value: unknown, key: string): unknown => {
const source = `${GITNEXUS_RC_FILENAME} "${key}"`;
const normalizeValue = (
kind: ValueKind,
value: unknown,
key: string,
configSource: string,
): unknown => {
const source = `${configSource} "${key}"`;
switch (kind) {
case 'boolean':
if (typeof value !== 'boolean') {
@ -301,6 +316,12 @@ const normalizeValue = (kind: ValueKind, value: unknown, key: string): unknown =
`${source} must be a boolean or a non-negative integer (node cap; 0 disables the cap).`,
);
}
case 'router-aliases':
try {
return normalizeRouterAliases(value, source);
} catch (error) {
throw new GitNexusRcError((error as Error).message);
}
default:
// Exhaustive — kept for forward-compat if a new kind is added.
throw new GitNexusRcError(`${source}: unsupported config value kind.`);
@ -314,7 +335,7 @@ const normalizeValue = (kind: ValueKind, value: unknown, key: string): unknown =
*/
const normalizeLevel = (
obj: Record<string, unknown>,
{ allowNestedKey }: { allowNestedKey: boolean },
{ allowNestedKey, source }: { allowNestedKey: boolean; source: string },
): Partial<AnalyzeOptions> => {
const out: Partial<AnalyzeOptions> = {};
const setBy = new Map<keyof AnalyzeOptions, string>();
@ -326,19 +347,17 @@ const normalizeLevel = (
// truthy) and would slip past `if (!spec)`, hitting the wrong error branch
// instead of the documented "Unknown key" message (#1996 tri-review P3).
if (!Object.hasOwn(KEY_SPECS, key)) {
throw new GitNexusRcError(
`Unknown key "${key}" in ${GITNEXUS_RC_FILENAME}. ${ALLOWED_KEYS_HINT}`,
);
throw new GitNexusRcError(`Unknown key "${key}" in ${source}. ${ALLOWED_KEYS_HINT}`);
}
const spec = KEY_SPECS[key];
const prev = setBy.get(spec.target);
if (prev && prev !== key) {
throw new GitNexusRcError(
`${GITNEXUS_RC_FILENAME}: "${prev}" and "${key}" both configure the same option; set only one.`,
`${source}: "${prev}" and "${key}" both configure the same option; set only one.`,
);
}
setBy.set(spec.target, key);
(out as Record<string, unknown>)[spec.target] = normalizeValue(spec.kind, value, key);
(out as Record<string, unknown>)[spec.target] = normalizeValue(spec.kind, value, key, source);
}
return out;
@ -356,11 +375,11 @@ export function loadAnalyzeConfig(repoRoot: string): Partial<AnalyzeOptions> | u
try {
raw = fs.readFileSync(filePath, 'utf-8');
} catch (err) {
if ((err as NodeJS.ErrnoException)?.code === 'ENOENT') return undefined;
if ((err as NodeJS.ErrnoException)?.code === 'ENOENT') return withEnvConfig(undefined);
throw new GitNexusRcError(`Could not read ${GITNEXUS_RC_FILENAME}: ${(err as Error).message}`);
}
return parseAnalyzeConfig(raw);
return withEnvConfig(parseAnalyzeConfig(raw));
}
/** Load `.gitnexusrc` through the strict bounded reader used by watch mode. */
@ -373,10 +392,76 @@ export async function loadAnalyzeConfigStrict(
} catch (err) {
throw new GitNexusRcError(`Could not read ${GITNEXUS_RC_FILENAME}: ${(err as Error).message}`);
}
return raw === null ? undefined : parseAnalyzeConfig(raw);
return withEnvConfig(raw === null ? undefined : parseAnalyzeConfig(raw));
}
function parseAnalyzeConfig(rawInput: string): Partial<AnalyzeOptions> {
/** Environment variable naming an analyze config file kept outside the repository. */
export const GITNEXUS_RC_ENV = 'GITNEXUS_RC';
/**
* Read an explicitly named config file through one descriptor, never by path
* after the check, so it cannot be swapped between the check and the read.
*
* O_NONBLOCK makes opening a FIFO return at once instead of blocking in
* open(2) until a writer appears; fstat then rejects it. It changes nothing
* for a regular file (same reasoning as `readNestedGitignore`). The read stops
* one byte past the limit, so a file that grows after fstat is rejected
* before it is decoded rather than read to EOF.
*/
function readBoundedConfigFile(filePath: string): string {
const fd = fs.openSync(filePath, fs.constants.O_RDONLY | (fs.constants.O_NONBLOCK ?? 0));
try {
const stat = fs.fstatSync(fd);
if (!stat.isFile()) throw new Error('not a regular file');
const tooLarge = () => new Error(`larger than ${MAX_REPO_CONTROL_FILE_BYTES} bytes`);
if (stat.size > MAX_REPO_CONTROL_FILE_BYTES) throw tooLarge();
const buffer = Buffer.alloc(MAX_REPO_CONTROL_FILE_BYTES + 1);
let length = 0;
for (;;) {
const read = fs.readSync(fd, buffer, length, buffer.length - length, null);
if (read === 0) break;
length += read;
if (length > MAX_REPO_CONTROL_FILE_BYTES) throw tooLarge();
}
return buffer.toString('utf-8', 0, length);
} finally {
fs.closeSync(fd);
}
}
/**
* Layer the `GITNEXUS_RC` file over the repository's `.gitnexusrc`, key by key.
* It lets a checkout carry analyze config without a tracked or untracked file
* in the working tree. Being an explicit request, a relative path, a missing
* file, or an oversized file is an error rather than a silent no-op.
*/
function withEnvConfig(
repoConfig: Partial<AnalyzeOptions> | undefined,
): Partial<AnalyzeOptions> | undefined {
const envPath = process.env[GITNEXUS_RC_ENV]?.trim();
if (!envPath) return repoConfig;
const label = `${GITNEXUS_RC_ENV} file "${envPath}"`;
if (!path.isAbsolute(envPath)) {
throw new GitNexusRcError(`${GITNEXUS_RC_ENV} must be an absolute path (got "${envPath}").`);
}
let raw: string;
try {
raw = readBoundedConfigFile(envPath);
} catch (err) {
throw new GitNexusRcError(`Could not read ${label}: ${(err as Error).message}`);
}
const envConfig = parseAnalyzeConfig(raw, label);
logger.debug(
{ file: envPath, keys: Object.keys(envConfig), repoKeys: Object.keys(repoConfig ?? {}) },
'analyze config: applied GITNEXUS_RC over .gitnexusrc',
);
return { ...repoConfig, ...envConfig };
}
function parseAnalyzeConfig(
rawInput: string,
source = GITNEXUS_RC_FILENAME,
): Partial<AnalyzeOptions> {
let raw = rawInput;
// Strip a leading UTF-8 BOM: Node's 'utf-8' decode keeps it, and JSON.parse
@ -390,25 +475,28 @@ function parseAnalyzeConfig(rawInput: string): Partial<AnalyzeOptions> {
parsed = JSON.parse(raw);
} catch (err) {
throw new GitNexusRcError(
`${GITNEXUS_RC_FILENAME} is not valid JSON: ${(err as Error).message}. ` +
`${source} is not valid JSON: ${(err as Error).message}. ` +
`Expected a JSON object such as {"defaultBranch": "develop", "skipContextFiles": true}.`,
);
}
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new GitNexusRcError(`${GITNEXUS_RC_FILENAME} must contain a JSON object.`);
throw new GitNexusRcError(`${source} must contain a JSON object.`);
}
const obj = parsed as Record<string, unknown>;
const flat = normalizeLevel(obj, { allowNestedKey: true });
const flat = normalizeLevel(obj, { allowNestedKey: true, source });
let nested: Partial<AnalyzeOptions> = {};
if (Object.prototype.hasOwnProperty.call(obj, NESTED_KEY)) {
const nestedRaw = obj[NESTED_KEY];
if (nestedRaw === null || typeof nestedRaw !== 'object' || Array.isArray(nestedRaw)) {
throw new GitNexusRcError(`${GITNEXUS_RC_FILENAME} "${NESTED_KEY}" must be a JSON object.`);
throw new GitNexusRcError(`${source} "${NESTED_KEY}" must be a JSON object.`);
}
nested = normalizeLevel(nestedRaw as Record<string, unknown>, { allowNestedKey: false });
nested = normalizeLevel(nestedRaw as Record<string, unknown>, {
allowNestedKey: false,
source,
});
}
// Nested `analyze` block overrides flat top-level keys for the same option.

View file

@ -14,6 +14,8 @@
* back the other way put the two files, plus `core/run-analyze.ts`, in an
* import cycle. `analyze.ts` re-exports the type for existing importers.
*/
import type { RouteFrameworkAlias } from '../core/ingestion/language-provider.js';
export interface AnalyzeOptions {
/** Keep this repository current with serialized incremental refreshes. */
watch?: boolean;
@ -147,6 +149,16 @@ export interface AnalyzeOptions {
* outside the built-in convention still produces `route_map` consumers.
*/
fetchWrappers?: string[];
/**
* Router types reached through a wrapper package that are a framework's
* router root. Supplied via `.gitnexusrc` / `GITNEXUS_RC` `goRouterAliases`.
*/
routeFrameworkAliases?: RouteFrameworkAlias[];
/**
* Handler-wrapper callees whose first argument is the route handler.
* Supplied via `.gitnexusrc` / `GITNEXUS_RC` `goHandlerWrappers`.
*/
routeHandlerWrappers?: string[];
/**
* Explicit local Spring Boot Actuator snapshot input (#2418). Accepts a JSON
* bundle or a directory containing endpoint JSON files. Disabled by default.

View file

@ -1,6 +1,7 @@
/** Local incremental watch (`gitnexus analyze --watch`). Remote auto-sync lives in `auto-sync.ts`. */
import path from 'node:path';
import fs from 'node:fs/promises';
import { watchFile, unwatchFile } from 'node:fs';
import { watch, type FSWatcher } from 'chokidar';
import { createWatchIgnorePredicate } from '../config/ignore-service.js';
import {
@ -15,10 +16,12 @@ import type { AnalyzerRunnerIdentity } from '../storage/repo-manager.js';
import { ANALYZE_STORAGE_REQUIREMENTS, requireStoragePath } from '../storage/storage-resolver.js';
import { GITNEXUS_DIR } from '../storage/repo-meta.js';
import {
GITNEXUS_RC_ENV,
loadAnalyzeConfigStrict,
mergeAnalyzeOptions,
validateBranchName,
} from './analyze-config.js';
import { logger } from '../core/logger.js';
import type { AnalyzeOptions } from './analyze-options.js';
import { ensureHeap } from './analyze.js';
import { cliError, cliInfo, cliWarn } from './cli-message.js';
@ -60,8 +63,15 @@ function isIgnoreControlPath(filePath: string): boolean {
return normalized === '.gitignore' || normalized === '.gitnexusignore';
}
/**
* Queue entry for a change to the `GITNEXUS_RC` file. It lives outside the
* repository, so it has no repo-relative path; the leading NUL keeps it apart
* from every real path.
*/
export const EXTERNAL_CONFIG_WATCH_PATH = '\u0000GITNEXUS_RC';
function isConfigControlPath(filePath: string): boolean {
return posixWatchPath(filePath) === '.gitnexusrc';
return filePath === EXTERNAL_CONFIG_WATCH_PATH || posixWatchPath(filePath) === '.gitnexusrc';
}
function isAnalyzerOwnedWatchPath(filePath: string): boolean {
@ -202,6 +212,10 @@ export async function resolveWatchOptions(
maxProcessTraceDepth: processDetection.maxProcessTraceDepth,
maxEntryPointCandidates: processDetection.maxEntryPointCandidates,
fetchWrappers: merged.fetchWrappers,
routeHookConfig: {
routeFrameworkAliases: merged.routeFrameworkAliases,
routeHandlerWrappers: merged.routeHandlerWrappers,
},
skipAgentsMd: true,
skipSkills: true,
noStats: true,
@ -279,6 +293,8 @@ export async function startWatchFileLoop(
refresh: (paths: readonly string[]) => Promise<void>,
onError: WatchRefreshError,
onWatcherError: (error: unknown) => void = (error) => onError(error, []),
/** Absolute `GITNEXUS_RC` path; its edits, replacements and removal reload config. */
externalConfigPath?: string,
): Promise<WatchFileLoop> {
let ignorePath = await createWatchIgnorePredicate(repoPath);
let ignoreControlValid = true;
@ -366,6 +382,16 @@ export async function startWatchFileLoop(
let watcher: FSWatcher = createWatcher();
// Poll this one explicit path: stat follows symlinks, and continues observing
// the path after atomic saves, target deletion/restoration or link retargeting.
// Watching only the link's directory misses writes in the target directory.
const configPath =
externalConfigPath === undefined ? undefined : path.resolve(externalConfigPath);
const configChanged = () => {
logger.debug({ file: configPath }, 'watch: GITNEXUS_RC changed; reloading config');
queue.enqueue(EXTERNAL_CONFIG_WATCH_PATH);
};
// Chokidar emits `ready` once per instance and `add()` returns before the
// rescan it starts has finished, with no signal for that completion. A file
// an ignore-rule reload has just unignored is therefore still unregistered
@ -400,11 +426,13 @@ export async function startWatchFileLoop(
};
try {
if (configPath) watchFile(configPath, { interval: 100 }, configChanged);
await waitUntilReady(watcher);
await queue.runInitial();
} catch (error) {
closed = true;
await watcher.close();
if (configPath) unwatchFile(configPath, configChanged);
await queue.close();
throw error;
}
@ -414,6 +442,7 @@ export async function startWatchFileLoop(
close: async () => {
closed = true;
await watcher.close();
if (configPath) unwatchFile(configPath, configChanged);
await queue.close();
},
};
@ -497,7 +526,9 @@ export async function watchCommandWithRunnerIdentity(
repoPath,
debounceMs,
async (paths) => {
if (paths.some(isConfigControlPath) || !configControlValid) {
// Initial validation precedes watcher setup. Read again after it is
// armed so a change during startup cannot leave stale options live.
if (paths.length === 0 || paths.some(isConfigControlPath) || !configControlValid) {
const retryingInvalidConfig = !configControlValid;
try {
analyzeOptions = await resolveWatchOptions(
@ -578,6 +609,7 @@ export async function watchCommandWithRunnerIdentity(
);
stopWatching();
},
process.env[GITNEXUS_RC_ENV]?.trim() || undefined,
);
} catch (error) {
cliError(

View file

@ -1589,6 +1589,10 @@ const analyzeCommandImpl = async (
// Extra fetch-wrapper names from `.gitnexusrc` (#1589/#1852 residual);
// forwarded to the routes phase consumer scan.
fetchWrappers: options.fetchWrappers,
routeHookConfig: {
routeFrameworkAliases: options.routeFrameworkAliases,
routeHandlerWrappers: options.routeHandlerWrappers,
},
springActuatorPath: options.springActuator,
asyncApiSpecPath: options.asyncapiSpec,
// The CLI always process.exit()s after this returns (success path at the

View file

@ -22,7 +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 type { LanguageProvider, RouteHookConfig } from './language-provider.js';
import { normalizeFetchURL, routeMatches } from './route-extractors/nextjs.js';
import {
isTestRouteFile,
@ -59,6 +59,8 @@ interface RouteHandlerResolutionContext {
) => readonly string[];
/** The route file's `LanguageProvider.resolveRouteHandler`, when it defines one. */
readonly providerRouteHandler?: (filePath: string) => LanguageProvider['resolveRouteHandler'];
/** User route configuration forwarded to provider handler hooks. */
readonly routeHookConfig?: RouteHookConfig;
readonly isExportedSymbol: (nodeId: string) => boolean;
/** 0-based graph-node startLine for same-name tRPC handler disambiguation. */
readonly nodeStartLine?: (nodeId: string) => number | undefined;
@ -559,7 +561,11 @@ export function resolveRouteHandlerSymbols(
return routeContext.resolveImportTargets(parsedImport, fromFile);
};
const providerContext = { model, importTargetsFor };
const providerContext = {
model,
importTargetsFor,
routeHookConfig: routeContext?.routeHookConfig,
};
const decoratorHandlerId = (dr: ExtractedDecoratorRoute): string | undefined => {
if (dr.source === DATA_ROUTE_TABLE_SOURCE) return dataHandlerByRoute.get(dr);
const providerHandler = routeContext?.providerRouteHandler?.(dr.filePath);

View file

@ -56,8 +56,28 @@ import type {
import type { ExtractedDecoratorRoute, ExtractedToolDef } from './workers/parse-worker.js';
import type { SemanticModel } from './model/semantic-model.js';
/**
* A declared router-framework equivalence: values of type `type` reached
* through `importPath` are the `framework`'s router root. Framework names are
* opaque here; the provider that owns the framework interprets them.
*/
export interface RouteFrameworkAlias {
readonly importPath: string;
readonly type: string;
readonly framework: string;
}
/** User route configuration handed to provider route hooks on the main thread. */
export interface RouteHookConfig {
/** Declared router-framework aliases; empty when none are configured. */
readonly routeFrameworkAliases?: readonly RouteFrameworkAlias[];
/** Callee designators (`pkg.Wrap`) whose first argument is the real handler. */
readonly routeHandlerWrappers?: readonly string[];
}
/** What a provider's {@link LanguageProviderConfig.resolveRouteHandler} can see. */
export interface RouteHandlerResolutionHookContext {
readonly routeHookConfig?: RouteHookConfig;
/** The model after every file has been parsed; scope resolution has not run. */
readonly model: SemanticModel;
/**
@ -647,6 +667,7 @@ interface LanguageProviderConfig {
readonly resolveRouteFacts?: (
files: readonly { filePath: string; facts: unknown }[],
context: {
readonly routeHookConfig?: RouteHookConfig;
resolveImportTargets: (fromFile: string, moduleSpecifier: string) => readonly string[];
},
) => ExtractedDecoratorRoute[];

View file

@ -25,8 +25,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 { extractGoRouteFacts, resolveGoRouteFacts } from './go/route-facts.js';
import { goPackageDir } from './go/package-clause.js';
import { createCallExtractor } from '../call-extractors/generic.js';
import { goCallConfig } from '../call-extractors/configs/go.js';
@ -162,9 +162,10 @@ 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,
// One config-free collection for direct engines and wrapper-package roots.
extractRouteFacts: extractGoRouteFacts,
resolveRouteFacts: resolveGoRouteFacts,
// ── RFC #909 Ring 3: scope-based resolution hooks ──────────
emitScopeCaptures: emitGoScopeCaptures,

View file

@ -0,0 +1,92 @@
import type { RouteFrameworkAlias } from '../../language-provider.js';
import { goRouteFramework } from '../../route-extractors/go-gin-echo.js';
const FIELDS = new Set(['importPath', 'type', 'framework']);
// https://go.dev/ref/spec#Keywords — these cannot name a type declaration.
const GO_KEYWORDS = new Set([
'break',
'case',
'chan',
'const',
'continue',
'default',
'defer',
'else',
'fallthrough',
'for',
'func',
'go',
'goto',
'if',
'import',
'interface',
'map',
'package',
'range',
'return',
'select',
'struct',
'switch',
'type',
'var',
]);
/** Upper bound on declared aliases; a longer list is rejected, never truncated. */
export const MAX_ROUTER_ALIASES = 100;
/** Validate the Go-owned alias syntax before analysis starts. */
export function normalizeRouterAliases(value: unknown, source: string): RouteFrameworkAlias[] {
if (!Array.isArray(value) || value.length === 0) {
throw new Error(`${source} must be a non-empty array of alias objects.`);
}
if (value.length > MAX_ROUTER_ALIASES) {
throw new Error(
`${source} declares ${value.length} aliases; at most ${MAX_ROUTER_ALIASES} are allowed.`,
);
}
const out = new Map<string, RouteFrameworkAlias>();
for (const entry of value) {
if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) {
throw new Error(
`${source} entries must be objects like {"importPath": "...", "type": "Engine", "framework": "gin"}.`,
);
}
const record = entry as Record<string, unknown>;
for (const key of Object.keys(record)) {
if (!FIELDS.has(key)) throw new Error(`${source} entry has unknown field "${key}".`);
}
const field = (name: string, pattern: RegExp, hint: string): string => {
const raw = record[name];
if (typeof raw !== 'string' || !raw.trim()) {
throw new Error(`${source} entry field "${name}" must be a non-empty string.`);
}
const trimmed = raw.trim();
if (!pattern.test(trimmed)) throw new Error(`${source} entry field "${name}" ${hint}.`);
return trimmed;
};
const importPath = field(
'importPath',
/^[A-Za-z0-9._~-]+(\/[A-Za-z0-9._~-]+)*$/,
'must be a Go import path (e.g. "example.com/org/pkg")',
);
const type = field('type', /^[\p{L}_][\p{L}\p{Nd}_]*$/u, 'must be a Go type name');
if (type === '_' || GO_KEYWORDS.has(type)) {
throw new Error(
`${source} entry field "type" must be a Go type name, not a keyword or blank identifier.`,
);
}
const framework = field('framework', /^[a-z]+$/, 'must be "gin" or "echo"');
if (!goRouteFramework(framework)) {
throw new Error(`${source} entry field "framework" must be "gin" or "echo".`);
}
const key = `${importPath}\u0000${type}`;
const previous = out.get(key);
if (previous && previous.framework !== framework) {
throw new Error(
`${source} declares ${importPath}.${type} as both "${previous.framework}" and "${framework}".`,
);
}
out.set(key, { importPath, type, framework });
}
return [...out.values()];
}

View file

@ -0,0 +1,93 @@
import type Parser from 'tree-sitter';
import type { ParsedFile } from 'gitnexus-shared';
import { compareCodeUnits } from '../../../../lib/utils.js';
import { logger } from '../../../logger.js';
import type { LanguageProvider } from '../../language-provider.js';
import {
collectGoRouteFacts,
goRouteFramework,
materializeGoRoute,
type GoRouteCandidateFacts,
} from '../../route-extractors/go-gin-echo.js';
import type { ExtractedDecoratorRoute } from '../../workers/parse-worker.js';
/**
* Direct framework routes and registrations on a wrapper package's router
* (`func Init(s *core.Engine)`, where `core.Engine = gin.Engine` in a
* dependency the indexer cannot read).
*
* The worker records every such registration as a configuration-free
* candidate (`collectGoRouteFacts`), so the parse cache never depends on
* user config. Direct roots already identify their framework. Other roots
* become routes only when declared in `goRouterAliases` on the main thread.
*/
export const extractGoRouteFacts = (
tree: Parser.Tree,
_parsedFile: ParsedFile,
lineOffset: number,
): GoRouteCandidateFacts | undefined => collectGoRouteFacts(tree, lineOffset);
const isCandidateFacts = (facts: unknown): facts is GoRouteCandidateFacts =>
typeof facts === 'object' &&
facts !== null &&
Array.isArray((facts as GoRouteCandidateFacts).roots);
/** Root types logged in the "not configured" summary. */
const SUMMARY_LIMIT = 5;
export const resolveGoRouteFacts: NonNullable<LanguageProvider['resolveRouteFacts']> = (
files,
context,
) => {
const aliases = new Map<string, string>();
for (const alias of context.routeHookConfig?.routeFrameworkAliases ?? []) {
aliases.set(`${alias.importPath}\u0000${alias.type}`, alias.framework);
}
const out: ExtractedDecoratorRoute[] = [];
const emittedByRoot = new Map<string, number>();
const droppedByRoot = new Map<string, number>();
let candidates = 0;
for (const { filePath, facts } of files) {
if (!isCandidateFacts(facts)) continue;
for (const root of facts.roots) {
const rootKey = `${root.importPath}.${root.typeName}`;
const frameworkName =
root.framework ?? aliases.get(`${root.importPath}\u0000${root.typeName}`);
const framework = frameworkName && goRouteFramework(frameworkName);
if (!root.framework) candidates += root.routes.length;
if (!framework) {
droppedByRoot.set(rootKey, (droppedByRoot.get(rootKey) ?? 0) + root.routes.length);
continue;
}
if (!root.framework)
emittedByRoot.set(rootKey, (emittedByRoot.get(rootKey) ?? 0) + root.routes.length);
for (const candidate of root.routes) {
out.push(materializeGoRoute(filePath, candidate, framework));
}
}
}
for (const [rootKey, count] of emittedByRoot) {
logger.debug(
{ rootType: rootKey, routes: count },
'go routes: emitted routes through a configured router alias',
);
}
if (droppedByRoot.size > 0) {
const top = [...droppedByRoot.entries()]
.sort((a, b) => b[1] - a[1] || compareCodeUnits(a[0], b[0]))
.slice(0, SUMMARY_LIMIT)
.map(([rootType, dropped]) => ({ rootType, dropped }));
logger.info(
{
candidates,
emitted: [...emittedByRoot.values()].reduce((sum, count) => sum + count, 0),
unconfiguredRootTypes: droppedByRoot.size,
top,
},
'go routes: dropped verb registrations on unconfigured router types; if one is a ' +
'gin/echo engine alias, declare it in goRouterAliases',
);
}
return out;
};

View file

@ -2,6 +2,7 @@ import type { SymbolDefinition } from 'gitnexus-shared';
import type { RouteHandlerResolutionHookContext } from '../../language-provider.js';
import type { SemanticModel } from '../../model/semantic-model.js';
import type { ExtractedDecoratorRoute } from '../../workers/parse-worker.js';
import { logger } from '../../../logger.js';
import { goPackageDir } from './package-clause.js';
/**
@ -22,6 +23,8 @@ export function resolveGoRouteHandler(
route: ExtractedDecoratorRoute,
context: RouteHandlerResolutionHookContext,
): string | undefined {
const unwrapped = unwrapConfiguredHandler(route, context);
if (unwrapped !== route) return unwrapped && resolveGoRouteHandler(unwrapped, context);
const designator = route.handlerName;
if (!designator) return undefined;
const parts = designator.split('.');
@ -47,6 +50,43 @@ export function resolveGoRouteHandler(
: undefined;
}
/** Wrapper callees already reported as unconfigured, so the debug line fires once each. */
const reportedWrappers = new WeakMap<RouteHandlerResolutionHookContext, Set<string>>();
/**
* A route whose handler slot was `wrap(inner)`: the route re-addressed to
* `inner` when `wrap` is a configured handler wrapper, `undefined` when it is
* not, and the route itself when nothing was wrapped.
*/
function unwrapConfiguredHandler(
route: ExtractedDecoratorRoute,
context: RouteHandlerResolutionHookContext,
): ExtractedDecoratorRoute | undefined {
const wrapper = route.handlerWrapper;
if (route.handlerName || !wrapper) return route;
if (context.routeHookConfig?.routeHandlerWrappers?.includes(wrapper.callee)) {
return {
...route,
handlerName: wrapper.handlerName,
handlerReceiver: wrapper.handlerReceiver,
handlerWrapper: undefined,
};
}
let reported = reportedWrappers.get(context);
if (!reported) {
reported = new Set();
reportedWrappers.set(context, reported);
}
if (!reported.has(wrapper.callee)) {
reported.add(wrapper.callee);
logger.debug(
{ callee: wrapper.callee, file: route.filePath, line: route.lineNumber },
'go routes: handler is wrapped by an unconfigured function; add it to goHandlerWrappers to resolve it',
);
}
return undefined;
}
type DefinitionBuckets = Map<string, SymbolDefinition[]>;
type PackageIndex = Map<string, DefinitionBuckets>;
const bucketKey = (dir: string, name: string): string => JSON.stringify([dir, name]);

View file

@ -1620,6 +1620,11 @@ export async function runChunkedParseAndResolve(
allFetchWrapperDefs = resolvedFetchWrappers.definitions;
allFetchWrapperCalls = resolvedFetchWrappers.calls;
// User route configuration is applied here, on the main thread: workers only
// emit configuration-free candidates, so a config change never stales the
// parse cache.
const routeHookConfig = options?.routeHookConfig;
// A resolver's identity defines a fact family, including providers sharing
// one language implementation. Shared ingestion never inspects the payload.
const routeFactGroups = new Map<
@ -1635,7 +1640,10 @@ export async function runChunkedParseAndResolve(
}
const resolvedRoutesByFile = new Map<string, ExtractedDecoratorRoute[]>();
for (const [resolve, files] of routeFactGroups) {
for (const route of resolve(files, { resolveImportTargets: resolveRouteModuleTargets })) {
for (const route of resolve(files, {
resolveImportTargets: resolveRouteModuleTargets,
routeHookConfig,
})) {
const routes = resolvedRoutesByFile.get(route.filePath) ?? [];
routes.push(route);
resolvedRoutesByFile.set(route.filePath, routes);
@ -2023,6 +2031,7 @@ export async function runChunkedParseAndResolve(
resolveImportTarget: resolveRouteImportTarget,
resolveImportTargets: resolveRouteImportTargets,
providerRouteHandler: (filePath) => getProviderForFile(filePath)?.resolveRouteHandler,
routeHookConfig,
isExportedSymbol: (nodeId: string) => graph.getNode(nodeId)?.properties.isExported === true,
nodeStartLine: (id) => {
const n = graph.getNode(id);

View file

@ -17,6 +17,7 @@
import { createKnowledgeGraph } from '../graph/graph.js';
import type { KnowledgeGraph } from '../graph/types.js';
import type { RouteHookConfig } from './language-provider.js';
import { GLOBAL_NAME_FALLBACK_REASON } from '../graph/edge-reasons.js';
import { GraphEmitSink, type GraphEmitManifest } from '../lbug/graph-emit-sink.js';
import { type PipelineProgress } from 'gitnexus-shared';
@ -307,6 +308,13 @@ export interface PipelineOptions {
* traced. Empty/undefined leaves behavior unchanged.
*/
fetchWrappers?: readonly string[];
/**
* Declared router-framework aliases and handler-wrapper callees, threaded
* from `.gitnexusrc` / `GITNEXUS_RC`. Applied on the main thread by the
* provider route hooks — never sent to parse workers, so they never enter a
* parse-cache key. Empty/undefined leaves route extraction unchanged.
*/
routeHookConfig?: RouteHookConfig;
}
// ── Phase registry ─────────────────────────────────────────────────────────

View file

@ -45,9 +45,8 @@ const VERBS: ReadonlySet<string> = new Set([
]);
interface Framework {
readonly name: 'gin' | 'echo';
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. */
@ -56,13 +55,38 @@ interface Framework {
readonly handlerArg: 'last' | 'second';
}
const FRAMEWORKS: Readonly<Record<Framework['name'], Framework>> = {
gin: {
name: 'gin',
source: GIN_ROUTE_SOURCE,
engineType: 'Engine',
constructors: new Set(['Default', 'New']),
handlerArg: 'last',
},
echo: {
name: 'echo',
source: ECHO_ROUTE_SOURCE,
engineType: 'Echo',
constructors: new Set(['New']),
handlerArg: 'second',
},
};
export const goRouteFramework = (name: string): Framework | undefined =>
Object.hasOwn(FRAMEWORKS, name) ? FRAMEWORKS[name as Framework['name']] : undefined;
type ImportedFramework = Framework & { readonly alias: string; readonly importPath: string };
const FUNCTION_TYPE_LIST = ['function_declaration', 'method_declaration', 'func_literal'];
const FUNCTION_TYPES: ReadonlySet<string> = new Set(FUNCTION_TYPE_LIST);
/** The framework this file routes with, when exactly one is imported. */
function readImports(root: SyntaxNode): {
readonly framework: Framework | null;
readonly framework: ImportedFramework | null;
/** Every qualifier this file can call through, local name → import path. */
readonly imports: ReadonlyMap<string, string>;
} {
const imports = new Map<string, string>();
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.
@ -77,33 +101,24 @@ function readImports(root: SyntaxNode): {
if (explicit === '_' || explicit === '.') continue;
const local = explicit ?? goImportPackageName(importPath);
if (!local) continue;
imports.set(local, importPath);
if (isGinImportPath(importPath)) gin = local;
if (isEchoImportPath(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',
},
imports,
framework: { ...FRAMEWORKS.gin, alias: gin, importPath: imports.get(gin)! },
};
}
if (echo !== null && gin === null) {
return {
framework: {
source: ECHO_ROUTE_SOURCE,
alias: echo,
engineType: 'Echo',
constructors: new Set(['New']),
handlerArg: 'second',
},
imports,
framework: { ...FRAMEWORKS.echo, alias: echo, importPath: imports.get(echo)! },
};
}
return { framework: null };
return { imports, framework: null };
}
/** Named descendants of a function body, not descending into nested functions. */
@ -169,18 +184,30 @@ function valueHint(value: SyntaxNode, bindings: GoRouteBindings): RouteHandlerRe
const sameHint = (a: RouteHandlerReceiver, b: RouteHandlerReceiver): boolean =>
a.kind === b.kind && a.name === b.name && a.qualifier === b.qualifier;
interface RouterRoot {
readonly importPath: string;
readonly typeName: string;
readonly framework?: Framework['name'];
}
interface RouterPrefix {
readonly root: RouterRoot;
readonly path: string;
}
class RouterPrefixes {
private readonly memo = new Map<GoRouteBinding, string | null>();
private readonly memo = new Map<GoRouteBinding, RouterPrefix | null>();
private readonly visiting = new Set<GoRouteBinding>();
constructor(
private readonly bindings: GoRouteBindings,
private readonly fw: Framework,
private readonly roots: ReadonlyMap<string, RouterRoot>,
private readonly framework: ImportedFramework | null,
private readonly fn: SyntaxNode,
) {}
/** Proven prefix of a router expression, or null when it cannot be proven. */
of(node: SyntaxNode): string | null {
of(node: SyntaxNode): RouterPrefix | null {
if (node.type === 'parenthesized_expression') {
const inner = node.namedChild(0);
return inner ? this.of(inner) : null;
@ -197,19 +224,22 @@ class RouterPrefixes {
if (!operand || !field) return null;
if (
operand.type === 'identifier' &&
operand.text === this.fw.alias &&
operand.text === this.framework?.alias &&
!this.bindings.lookup(operand)
) {
return this.fw.constructors.has(field) ? '' : null;
const root = this.roots.get(`${this.framework.alias}.${this.framework.engineType}`);
return root && this.framework.constructors.has(field) ? { root, path: '' } : 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);
return base === null
? null
: { root: base.root, path: normalizeExtractedRoutePath(path, base.path) };
}
private ofBinding(binding: GoRouteBinding): string | null {
private ofBinding(binding: GoRouteBinding): RouterPrefix | 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);
@ -217,20 +247,23 @@ class RouterPrefixes {
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('');
const root =
hint?.kind === 'type' ? this.roots.get(`${hint.qualifier}.${hint.name}`) : undefined;
const candidates: (RouterPrefix | null)[] = [];
if (binding.isInputParameter && root) {
candidates.push({ root, path: '' });
} 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;
const result =
first &&
candidates.every(
(candidate) => candidate?.root === first.root && candidate.path === first.path,
)
? first
: null;
this.visiting.delete(binding);
this.memo.set(binding, result);
return result;
@ -283,58 +316,245 @@ function verbRegistration(call: SyntaxNode): VerbRegistration | null {
return path === null ? null : { call, verb, receiver, args, path };
}
/** The handler a registration names, read from one argument of the verb call. */
export interface GoRouteHandlerFact {
handlerName?: string;
handlerReceiver?: RouteHandlerReceiver;
/**
* `wrap(inner, …)` in the handler slot: the call's callee designator and the
* inner argument's handler. Only a configured wrapper is unwrapped (by the Go
* provider's `resolveRouteHandler`); the worker never decides that.
*/
handlerWrapper?: { callee: string; handlerName: string; handlerReceiver?: RouteHandlerReceiver };
}
/** A handler designator: a bare package function or a `x.Member` selector. */
function handlerDesignator(
node: SyntaxNode,
bindings: GoRouteBindings,
hints: Map<GoRouteBinding, RouteHandlerReceiver | undefined>,
): { handlerName: string; handlerReceiver?: RouteHandlerReceiver } | undefined {
if (node.type === 'identifier') {
return bindings.lookup(node) ? undefined : { handlerName: node.text };
}
if (node.type !== 'selector_expression') return undefined;
const operand = node.childForFieldName('operand');
const field = node.childForFieldName('field');
if (operand?.type !== 'identifier' || !field) return undefined;
const hint = receiverHint(operand, bindings, hints);
return hint
? { handlerName: `${operand.text}.${field.text}`, handlerReceiver: hint }
: { handlerName: `${operand.text}.${field.text}` };
}
/** `pkg.Wrap(h.Method, …)` / `Wrap(Func)` — a package-level callee wrapping a designator. */
function wrappedHandler(
node: SyntaxNode,
bindings: GoRouteBindings,
hints: Map<GoRouteBinding, RouteHandlerReceiver | undefined>,
): GoRouteHandlerFact['handlerWrapper'] {
if (node.type !== 'call_expression') return undefined;
const callee = node.childForFieldName('function');
if (!callee) return undefined;
if (callee.type === 'identifier') {
if (bindings.lookup(callee)) return undefined;
} else if (callee.type === 'selector_expression') {
const operand = callee.childForFieldName('operand');
// A local value's method (`m.Wrap`) has no stable designator to configure.
if (operand?.type !== 'identifier' || bindings.lookup(operand)) return undefined;
} else {
return undefined;
}
const inner = node.childForFieldName('arguments')?.namedChildren[0];
const designator = inner ? handlerDesignator(inner, bindings, hints) : undefined;
return designator ? { callee: callee.text, ...designator } : undefined;
}
function handlerFact(
node: SyntaxNode,
bindings: GoRouteBindings,
hints: Map<GoRouteBinding, RouteHandlerReceiver | undefined>,
): GoRouteHandlerFact {
const designator = handlerDesignator(node, bindings, hints);
if (designator) return designator;
const handlerWrapper = wrappedHandler(node, bindings, hints);
return handlerWrapper ? { handlerWrapper } : {};
}
/** Verb registrations of every function that has some, with the file's bindings. */
function* functionRegistrations(
root: SyntaxNode,
framework: ImportedFramework | null,
imports: ReadonlyMap<string, string>,
): Generator<{ fn: SyntaxNode; registrations: VerbRegistration[]; bindings: GoRouteBindings }> {
let bindings: GoRouteBindings | undefined;
for (const fn of root.descendantsOfType(FUNCTION_TYPE_LIST)) {
// Without a framework constructor, only imported parameter types can be
// router roots. Avoid walking ordinary Go function bodies in that case.
if (
!framework &&
!fn
.childForFieldName('parameters')
?.descendantsOfType('qualified_type')
.some((type) => imports.has(type.childForFieldName('package')?.text ?? ''))
)
continue;
const body = fn.childForFieldName('body');
if (!body) continue;
const registrations = bodyNodes(body).flatMap((node) => {
const registration = verbRegistration(node);
return registration === null ? [] : [registration];
});
// Most functions register nothing; skip their bindings.
if (registrations.length === 0) continue;
bindings ??= new GoRouteBindings(root);
yield { fn, registrations, bindings };
}
}
export function extractGoGinEchoRoutes(
tree: Parser.Tree,
filePath: string,
lineOffset = 0,
): ExtractedDecoratorRoute[] {
const root = tree.rootNode;
const { framework } = readImports(root);
if (framework === null) return [];
return (collectGoRouteFacts(tree, lineOffset)?.roots ?? []).flatMap((root) => {
const framework = root.framework && goRouteFramework(root.framework);
return framework
? root.routes.map((route) => materializeGoRoute(filePath, route, framework))
: [];
});
}
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];
/** One verb registration whose router root is a parameter of a wrapper-package type. */
export interface GoRouteCandidate {
readonly routePath: string;
readonly httpMethod: string;
readonly lineNumber: number;
/** Second and last handler arguments; one entry when they are the same slot. */
readonly handlers: readonly [GoRouteHandlerFact, GoRouteHandlerFact?];
}
/**
* Route candidates of one file, grouped by the root parameter's declared type.
* The worker cannot tell whether `pkg.T` is a gin/echo engine (that is user
* configuration, applied on the main thread), so it records registrations
* under each proven root type. Direct roots carry their known framework;
* other roots need configuration before their registrations become routes.
*/
export interface GoRouteCandidateFacts {
readonly roots: readonly (RouterRoot & {
readonly routes: readonly GoRouteCandidate[];
})[];
}
export function materializeGoRoute(
filePath: string,
candidate: GoRouteCandidate,
framework: Framework,
): ExtractedDecoratorRoute {
return {
filePath,
routePath: candidate.routePath,
httpMethod: candidate.httpMethod,
decoratorName: candidate.httpMethod,
lineNumber: candidate.lineNumber,
prefix: null,
source: framework.source,
...candidate.handlers[framework.handlerArg === 'last' ? candidate.handlers.length - 1 : 0],
};
}
/** `*Q.T` / `Q.T` parameters of `fn`, where `Q` is an import of this file. */
function importedRootTypes(
fn: SyntaxNode,
bindings: GoRouteBindings,
imports: ReadonlyMap<string, string>,
): { importPath: string; qualifier: string; typeName: string }[] {
const out = new Map<string, { importPath: string; qualifier: string; typeName: string }>();
const parameters = fn.childForFieldName('parameters');
for (const declaration of parameters?.namedChildren ?? []) {
if (declaration.type !== 'parameter_declaration') continue;
const hint = typeHint(declaration.childForFieldName('type'), bindings);
if (hint?.kind !== 'type' || !hint.qualifier || !hint.name) continue;
const importPath = imports.get(hint.qualifier);
// Framework packages are the direct extractor's job, not a candidate.
if (!importPath || isGinImportPath(importPath) || isEchoImportPath(importPath)) continue;
out.set(`${hint.qualifier}.${hint.name}`, {
importPath,
qualifier: hint.qualifier,
typeName: hint.name,
});
// 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);
}
return [...out.values()];
}
export function extractGoRouteCandidates(
tree: Parser.Tree,
lineOffset = 0,
): GoRouteCandidateFacts | undefined {
const roots = collectGoRouteFacts(tree, lineOffset)?.roots.filter((root) => !root.framework);
return roots?.length ? { roots } : undefined;
}
/** One config-free pass for both framework engines and configured aliases. */
export function collectGoRouteFacts(
tree: Parser.Tree,
lineOffset = 0,
): GoRouteCandidateFacts | undefined {
const root = tree.rootNode;
const { imports, framework } = readImports(root);
if (imports.size === 0) return undefined;
const directRoot: RouterRoot | undefined = framework
? {
importPath: framework.importPath,
typeName: framework.engineType,
framework: framework.name,
}
: undefined;
const directRoutes: GoRouteCandidate[] = [];
const byRoot = new Map<string, RouterRoot & { routes: GoRouteCandidate[] }>();
const handlerHints = new Map<GoRouteBinding, RouteHandlerReceiver | undefined>();
for (const { fn, registrations, bindings } of functionRegistrations(root, framework, imports)) {
const roots = new Map<string, RouterRoot>();
if (framework && directRoot)
roots.set(`${framework.alias}.${framework.engineType}`, directRoot);
for (const rootType of importedRootTypes(fn, bindings, imports)) {
roots.set(`${rootType.qualifier}.${rootType.typeName}`, {
importPath: rootType.importPath,
typeName: rootType.typeName,
});
}
const prefixes = new RouterPrefixes(bindings, roots, framework, fn);
const routesByRoot = new Map<RouterRoot, GoRouteCandidate[]>();
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),
const last = handlerFact(args[args.length - 1], bindings, handlerHints);
const routes = routesByRoot.get(prefix.root) ?? [];
routes.push({
routePath: normalizeExtractedRoutePath(path, prefix.path),
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;
}
handlers: args.length === 2 ? [last] : [handlerFact(args[1], bindings, handlerHints), last],
});
routesByRoot.set(prefix.root, routes);
}
// Preserve parameter-type ordering when duplicate URLs compete.
for (const rootType of roots.values()) {
const routes = routesByRoot.get(rootType);
if (!routes) continue;
if (rootType === directRoot) {
directRoutes.push(...routes);
continue;
}
out.push(route);
const key = `${rootType.importPath}\u0000${rootType.typeName}`;
const group = byRoot.get(key);
if (group) group.routes.push(...routes);
else byRoot.set(key, { ...rootType, routes });
}
}
return out;
const roots = [...byRoot.values()];
// The parse dispatcher inserted configured facts before direct routes.
if (directRoot && directRoutes.length) roots.push({ ...directRoot, routes: directRoutes });
return roots.length ? { roots } : undefined;
}

View file

@ -0,0 +1,16 @@
import { compareCodeUnits } from '../../lib/utils.js';
import type { RouteHookConfig } from './language-provider.js';
/**
* Stable metadata value for the declared route configuration. Sorting makes
* equivalent lists independent of declaration order; no config is `[[],[]]`.
*/
export function routeHookConfigKey(config?: RouteHookConfig): string {
const aliasKeys = (config?.routeFrameworkAliases ?? [])
.map((alias) => [alias.importPath, alias.type, alias.framework])
.sort((a, b) => compareCodeUnits(a.join('\u0000'), b.join('\u0000')));
return JSON.stringify([
aliasKeys,
[...new Set(config?.routeHandlerWrappers)].sort(compareCodeUnits),
]);
}

View file

@ -425,6 +425,18 @@ export interface ExtractedDecoratorRoute {
* `LanguageProvider.resolveRouteHandler`; absent when nothing was inferred.
*/
handlerReceiver?: RouteHandlerReceiver;
/**
* The handler slot held a call `callee(inner, …)` instead of a designator.
* `callee` is the call's designator as written (`common.Wrapper`); the inner
* argument's designator and receiver hint travel alongside. A provider uses
* it only when the user configured `callee` as a handler wrapper; otherwise
* the route keeps no handler, as before.
*/
handlerWrapper?: {
callee: string;
handlerName: string;
handlerReceiver?: RouteHandlerReceiver;
};
/**
* Provenance for the `HANDLES_ROUTE` edge, overriding the default
* `decorator-<decoratorName>`. Present when the route was extracted from a

View file

@ -28,6 +28,7 @@ export const REBUILD_REASON_KEYS = [
'graph-write-collapse',
'analysis-features',
'spring-vendor-prefixes',
'route-hook-config',
'runner-identity',
'parser-identity',
'cjk-segmentation',

View file

@ -55,6 +55,8 @@ import {
countCallsByLanguage,
} from './ingestion/scope-resolution/name-fallback-summary.js';
import { runPipelineFromRepo } from './ingestion/pipeline.js';
import type { RouteHookConfig } from './ingestion/language-provider.js';
import { routeHookConfigKey } from './ingestion/route-hook-config.js';
import {
logUnresolvedReceiverFiles,
summarizeUnresolvedReceivers,
@ -631,6 +633,11 @@ export interface AnalyzeOptions {
* consumer scan unchanged.
*/
fetchWrappers?: string[];
/**
* Declared router-framework aliases and handler-wrapper callees, forwarded to
* `PipelineOptions` and stamped into meta so a changed value rebuilds Routes.
*/
routeHookConfig?: RouteHookConfig;
/**
* Explicit local Spring Boot Actuator snapshot input (#2418), forwarded to
* the Spring enrichment phase. Undefined keeps static-only analysis.
@ -2425,6 +2432,23 @@ async function runFullAnalysisInner(
});
}
// Declared route aliases / handler wrappers change Route evidence without
// moving the commit or dirtying the tree, so a changed value must rebuild.
const currentRouteHookConfig = routeHookConfigKey(options.routeHookConfig);
const persistedRouteHookConfig = existingMeta?.routeHookConfig ?? routeHookConfigKey();
if (existingMeta && persistedRouteHookConfig !== currentRouteHookConfig) {
if (currentRouteHookConfig === routeHookConfigKey()) {
log(
'Warning: route aliases / handler wrappers used by the previous index are no longer ' +
'configured (was GITNEXUS_RC unset?) — Routes they produced will be removed.',
);
}
addGateReason({
key: 'route-hook-config',
text: 'route aliases or handler wrappers changed — persisted Route evidence will be rebuilt.',
});
}
const currentSpringVendorPrefixes = springVendorPrefixesKey();
const persistedRouteBindings = existingMeta?.analysisFeatures?.[SPRING_ROUTE_BINDINGS_FEATURE.id];
if (
@ -3189,6 +3213,7 @@ async function runFullAnalysisInner(
? resolveNativeSafeStorageDir(storagePath, 'graph-csv')
: undefined,
fetchWrappers: options.fetchWrappers,
routeHookConfig: options.routeHookConfig,
skipDerivedGraphPhases,
springActuatorPath: options.springActuatorPath,
asyncApiSpecPath: options.asyncApiSpecPath,
@ -5476,6 +5501,7 @@ async function runFullAnalysisInner(
: summarizeUndecidedSatisfaction(pipelineResult.undecidedSatisfaction),
analysisFeatures: currentAnalysisFeatures,
springVendorPrefixes: currentSpringVendorPrefixes,
routeHookConfig: currentRouteHookConfig,
// Always stamped with the live resolved mode (#2331/#2339) — unlike
// `pdg` below, 'none' is a meaningful value to compare, not an
// absence, so this is never conditionally omitted.

View file

@ -25,6 +25,8 @@ import {
} from '../storage/storage-resolver.js';
import { BRANCHES_DIR, branchSlug } from '../storage/branch-index.js';
import { logger } from '../core/logger.js';
import { loadAnalyzeConfig } from '../cli/analyze-config.js';
import type { RouteHookConfig } from '../core/ingestion/language-provider.js';
import { autoHeapCapMb } from '../core/ingestion/utils/effective-ram.js';
import { isTerminalJobStatus, type JobManager } from './analyze-job.js';
import type { WorkerMessage } from './analyze-worker.js';
@ -197,6 +199,19 @@ const waitForSettledIndex = async (
}
};
/**
* The route configuration a CLI `analyze` of `repoPath` would use: the
* repository's `.gitnexusrc` overlaid by the `GITNEXUS_RC` file. Throws
* `GitNexusRcError` on invalid explicit configuration.
*/
export function resolveLaunchRouteHookConfig(repoPath: string): RouteHookConfig {
const config = loadAnalyzeConfig(repoPath);
return {
routeFrameworkAliases: config?.routeFrameworkAliases,
routeHandlerWrappers: config?.routeHandlerWrappers,
};
}
export function createLaunchAnalysisWorker(deps: LaunchDeps) {
const { jobManager, backend, acquireRepoLock, releaseRepoLock, closeDbHandle } = deps;
@ -231,6 +246,30 @@ export function createLaunchAnalysisWorker(deps: LaunchDeps) {
releaseRepoLock(analyzeLockKey);
};
// Route aliases / handler wrappers come from `.gitnexusrc` and the
// `GITNEXUS_RC` file the CLI reads. The worker bypasses that loader, so
// resolve them here: an omitted value would read as "configuration
// removed" and rebuild the index without the Routes they produce. An
// invalid explicit configuration fails the job and keeps the old index.
let routeHookConfig: RouteHookConfig;
try {
routeHookConfig = resolveLaunchRouteHookConfig(targetPath);
} catch (error) {
releaseLockOnce();
const message = error instanceof Error ? error.message : String(error);
logger.warn({ repoPath: targetPath, error: message }, 'analyze config rejected');
jobManager.updateJob(job.id, { status: 'failed', error: message });
return;
}
logger.debug(
{
repoPath: targetPath,
aliases: routeHookConfig.routeFrameworkAliases?.length ?? 0,
wrappers: routeHookConfig.routeHandlerWrappers?.length ?? 0,
},
'analyze launch: forwarding route configuration to the worker',
);
jobManager.updateJob(job.id, { repoPath: targetPath, status: 'analyzing' });
// ── Worker fork with auto-retry ──────────────────────────────
@ -558,6 +597,7 @@ export function createLaunchAnalysisWorker(deps: LaunchDeps) {
...(opts.asyncApiSpecPath ? { asyncApiSpecPath: opts.asyncApiSpecPath } : {}),
...(opts.registryName ? { registryName: opts.registryName } : {}),
...(opts.branch ? { branch: opts.branch } : {}),
routeHookConfig,
},
});
};

View file

@ -286,6 +286,11 @@ export interface RepoMeta {
* annotations. A changed value invalidates persisted JVM Route evidence.
*/
springVendorPrefixes?: string;
/**
* Canonical declared route aliases + handler wrappers (`goRouterAliases`,
* `goHandlerWrappers`). A changed value invalidates persisted Route evidence.
*/
routeHookConfig?: string;
/**
* The resolved GITNEXUS_FTS_CJK_SEGMENTATION mode ('none' | 'bigram') the
* existing index's content/description columns were last written under

View file

@ -0,0 +1,19 @@
package api
import (
"github.com/gin-gonic/gin"
"example.com/wrappedapp/service"
)
// API holds the console handlers.
type API struct{ svc *service.Jobs }
// CreateJob creates a job.
func (a *API) CreateJob(c *gin.Context) (interface{}, error) { return a.svc.Create(), nil }
// JobStatus reports a job's status.
func (a *API) JobStatus(c *gin.Context) (interface{}, error) { return nil, nil }
// UserInfo is a plain gin handler.
func UserInfo(c *gin.Context) {}

View file

@ -0,0 +1,19 @@
package common
import "github.com/gin-gonic/gin"
// HandlerFunc is the application handler shape the wrappers adapt.
type HandlerFunc func(c *gin.Context) (interface{}, error)
// Wrapper adapts an application handler to gin.
func Wrapper(handler HandlerFunc) func(c *gin.Context) {
return WrapperWithSuccessCode(handler, "0")
}
// WrapperWithSuccessCode adapts a handler using the given success code.
func WrapperWithSuccessCode(handler HandlerFunc, code string) func(c *gin.Context) {
return func(c *gin.Context) { _, _ = handler(c) }
}
// Auth is middleware, not a handler wrapper.
func Auth() func(c *gin.Context) { return func(c *gin.Context) {} }

View file

@ -0,0 +1,8 @@
module example.com/wrappedapp
go 1.22
require (
example.com/infra v1.0.0
github.com/gin-gonic/gin v1.10.0
)

View file

@ -0,0 +1,18 @@
package routes
import (
consoleApi "example.com/wrappedapp/api"
"example.com/wrappedapp/common"
core "example.com/infra/pkg"
)
// InitRoute registers every route on the wrapper package's engine alias.
func InitRoute(s *core.Engine, api *consoleApi.API) {
v1 := s.Group("/console/v1")
{
jobs := v1.Group("/jobs")
jobs.POST("/create", common.Wrapper(api.CreateJob))
jobs.GET("/status", common.Auth(), common.WrapperWithSuccessCode(api.JobStatus, "OK"))
jobs.GET("/userinfo", consoleApi.UserInfo)
}
}

View file

@ -0,0 +1,7 @@
package service
// Jobs is the job service.
type Jobs struct{}
// Create creates a job.
func (j *Jobs) Create() string { return "job" }

View file

@ -0,0 +1,162 @@
/**
* Routes registered on a wrapper package's engine alias (`*core.Engine`, where
* the dependency declares `type Engine = gin.Engine`) with handlers wrapped by
* an application adapter (`common.Wrapper(api.CreateJob)`).
*
* Neither fact is provable from the repository: the dependency's source is not
* vendored, and a call in the handler slot may be middleware. Both are user
* configuration (`goRouterAliases`, `goHandlerWrappers`), applied on the main
* thread — so the same parse output yields no routes without it.
*
* Fixture: `test/fixtures/go-wrapped-gin-route-app/`.
*/
import { describe, it, expect, beforeAll } from 'vitest';
import path from 'node:path';
import fs from 'node:fs/promises';
import os from 'node:os';
import { runPipelineFromRepo } from '../../src/core/ingestion/pipeline.js';
import type { PipelineResult } from '../../src/types/pipeline.js';
import { loadParseCache, saveParseCache } from '../../src/storage/parse-cache.js';
import { runtimeParseIdentity } from '../helpers/runtime-parse-identity.js';
import {
clearParsedFileStore,
getDurableParsedFileDir,
pruneAndSaveDurableParsedFileStore,
} from '../../src/storage/parsedfile-store.js';
const FIXTURE = path.resolve(__dirname, '..', 'fixtures', 'go-wrapped-gin-route-app');
const routeView = (result: PipelineResult): string[] => {
const out: string[] = [];
result.graph.forEachNode((node) => {
if (node.label !== 'Route') return;
const handler = result.graph.getNode(String(node.properties.handlerSymbolId));
out.push(
`${String(node.properties.method)} ${String(node.properties.name)} -> ${String(
handler?.properties.name,
)}`,
);
});
return out.sort();
};
describe('wrapper-package gin route ingestion', () => {
let configured: PipelineResult;
let unconfigured: PipelineResult;
beforeAll(async () => {
[configured, unconfigured] = await Promise.all([
runPipelineFromRepo(FIXTURE, () => {}, {
routeHookConfig: {
routeFrameworkAliases: [
{ importPath: 'example.com/infra/pkg', type: 'Engine', framework: 'gin' },
],
routeHandlerWrappers: ['common.Wrapper', 'common.WrapperWithSuccessCode'],
},
}),
runPipelineFromRepo(FIXTURE, () => {}, {}),
]);
}, 300_000);
it('emits prefixed routes and resolves wrapped handlers when configured', () => {
expect(routeView(configured)).toEqual([
'GET /console/v1/jobs/status -> JobStatus',
'GET /console/v1/jobs/userinfo -> UserInfo',
'POST /console/v1/jobs/create -> CreateJob',
]);
});
it('draws HANDLES_ROUTE from the wrapped method with gin provenance', () => {
let edge: { handler: unknown; reason: unknown } | undefined;
configured.graph.forEachRelationship((rel) => {
if (rel.type === 'HANDLES_ROUTE' && rel.targetId === 'Route:POST /console/v1/jobs/create') {
edge = {
handler: configured.graph.getNode(rel.sourceId)?.properties.name,
reason: rel.reason,
};
}
});
expect(edge).toEqual({ handler: 'CreateJob', reason: 'gin-route' });
});
it('emits no route without the alias configuration', () => {
expect(routeView(unconfigured)).toEqual([]);
});
it('preserves the configured route winner when a direct route declares the same URL', async () => {
const repo = await fs.mkdtemp(path.join(os.tmpdir(), 'go-route-collision-'));
try {
await fs.writeFile(path.join(repo, 'go.mod'), 'module example.com/collision\n\ngo 1.22\n');
await fs.writeFile(
path.join(repo, 'routes.go'),
`package routes
import "github.com/gin-gonic/gin"
import core "example.com/infra/pkg"
func Direct(c *gin.Context) {}
func Aliased(c *gin.Context) {}
func R(r *gin.Engine, s *core.Engine) {
r.GET("/same", Direct)
s.GET("/same", Aliased)
}`,
);
const result = await runPipelineFromRepo(repo, () => {}, {
routeHookConfig: {
routeFrameworkAliases: [
{ importPath: 'example.com/infra/pkg', type: 'Engine', framework: 'gin' },
],
},
});
expect(routeView(result)).toEqual(['GET /same -> Aliased']);
} finally {
await fs.rm(repo, { recursive: true, force: true });
}
}, 300_000);
it('applies alias and wrapper changes to persisted worker facts without reparsing', async () => {
const storage = await fs.mkdtemp(path.join(os.tmpdir(), 'go-route-facts-cache-'));
try {
const identity = await runtimeParseIdentity();
const cache = await loadParseCache(storage, identity, storage);
const cold = await runPipelineFromRepo(FIXTURE, () => {}, {
parseCache: cache,
workerPoolSize: 1,
});
expect(routeView(cold)).toEqual([]);
expect(cold.reparsedFileCount).toBeGreaterThan(0);
const keys = await saveParseCache(storage, cache);
await pruneAndSaveDurableParsedFileStore(
getDurableParsedFileDir(storage),
identity.base,
new Set(keys),
);
await clearParsedFileStore(storage);
const aliases = [{ importPath: 'example.com/infra/pkg', type: 'Engine', framework: 'gin' }];
const replay = async (wrappers: string[], includeAlias = true) => {
const result = await runPipelineFromRepo(FIXTURE, () => {}, {
parseCache: await loadParseCache(storage, identity, storage),
workerPoolSize: 1,
routeHookConfig: {
routeFrameworkAliases: includeAlias ? aliases : [],
routeHandlerWrappers: wrappers,
},
});
expect(result.reparsedFileCount).toBe(0);
expect(result.parseCacheHitFileCount).toBe(cold.reparsedFileCount);
expect(result.scopeExtractionFailures).toEqual([]);
return routeView(result);
};
expect(await replay(['common.Wrapper', 'common.WrapperWithSuccessCode'])).toEqual(
routeView(configured),
);
expect(await replay([])).toEqual([
'GET /console/v1/jobs/status -> undefined',
'GET /console/v1/jobs/userinfo -> UserInfo',
'POST /console/v1/jobs/create -> undefined',
]);
expect(await replay([], false)).toEqual([]);
} finally {
await fs.rm(storage, { recursive: true, force: true });
}
}, 300_000);
});

View file

@ -0,0 +1,144 @@
/**
* Server reindex keeps configured Go routes (#3750 review).
*
* The HTTP analyze path forks a worker that bypasses the CLI config loader. If
* the launch forwarded no route configuration, the rebuild gate would read the
* omission as "configuration removed" and drop alias Routes and wrapped
* HANDLES_ROUTE edges, even with the same `GITNEXUS_RC` in the environment.
*
* Drives CLI-style indexing, then the real server launcher and its forked
* worker, so omitting configuration from the start message loses evidence.
*/
import fs from 'node:fs/promises';
import path from 'node:path';
import { once } from 'node:events';
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import { runFullAnalysis } from '../../src/core/run-analyze.js';
import {
createLaunchAnalysisWorker,
resolveLaunchRouteHookConfig,
} from '../../src/server/analyze-launch.js';
import { JobManager } from '../../src/server/analyze-job.js';
import { getStoragePaths } from '../../src/storage/repo-manager.js';
import { commitAll, initGitRepo } from '../helpers/temp-git-repo.js';
import { createTempDir } from '../helpers/test-db.js';
const FIXTURE = path.resolve(__dirname, '..', 'fixtures', 'go-wrapped-gin-route-app');
async function routeEvidence(repo: string): Promise<{ routes: number; handled: number }> {
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
await adapter.initLbug(getStoragePaths(repo).lbugPath);
try {
const [routes] = (await adapter.executeQuery('MATCH (r:Route) RETURN count(r) AS n')) as Array<{
n: number;
}>;
const [handled] = (await adapter.executeQuery(
"MATCH (h:Method)-[e:CodeRelation]->(r:Route) WHERE e.type = 'HANDLES_ROUTE' " +
'RETURN count(DISTINCT r) AS n',
)) as Array<{ n: number }>;
return { routes: Number(routes?.n ?? 0), handled: Number(handled?.n ?? 0) };
} finally {
await adapter.closeLbug();
}
}
describe('server reindex with route aliases from GITNEXUS_RC', () => {
let temp: Awaited<ReturnType<typeof createTempDir>>;
let repo: string;
beforeAll(async () => {
temp = await createTempDir();
const root = await fs.realpath(temp.dbPath);
const userHome = path.join(root, 'user-home');
await fs.mkdir(userHome);
vi.stubEnv('HOME', userHome);
vi.stubEnv('USERPROFILE', userHome);
vi.stubEnv('GITNEXUS_HOME', path.join(root, 'home'));
vi.stubEnv('GITNEXUS_STORAGE_PATH', undefined);
vi.stubEnv('GITNEXUS_STORAGE_ROOT', undefined);
vi.stubEnv('GITNEXUS_SHARED_STORE', 'off');
vi.stubEnv('GITNEXUS_LBUG_EXTENSION_INSTALL', 'load-only');
vi.stubEnv('GITNEXUS_WAL_MANUAL_CHECKPOINT', '1');
repo = path.join(root, 'repo');
await fs.cp(FIXTURE, repo, { recursive: true });
await fs.writeFile(path.join(repo, '.gitignore'), '.gitnexus/\n');
initGitRepo(repo);
commitAll(repo, 'wrapped gin fixture');
const rc = path.join(root, 'service.json');
await fs.writeFile(
rc,
JSON.stringify({
goRouterAliases: [
{ importPath: 'example.com/infra/pkg', type: 'Engine', framework: 'gin' },
],
goHandlerWrappers: ['common.Wrapper', 'common.WrapperWithSuccessCode'],
}),
);
vi.stubEnv('GITNEXUS_RC', rc);
}, 60_000);
afterAll(async () => {
vi.unstubAllEnvs();
await temp.cleanup();
});
it('keeps alias Routes and wrapped handler edges across a server-launched reindex', async () => {
const routeHookConfig = resolveLaunchRouteHookConfig(repo);
await runFullAnalysis(
repo,
{ skipAgentsMd: true, skipSkills: true, routeHookConfig },
{ onProgress: () => {} },
);
expect(await routeEvidence(repo)).toEqual({ routes: 3, handled: 2 });
const jobManager = new JobManager();
const childExits: Promise<unknown>[] = [];
const registerChild = jobManager.registerChild.bind(jobManager);
vi.spyOn(jobManager, 'registerChild').mockImplementation((id, child) => {
childExits.push(once(child, 'exit'));
registerChild(id, child);
});
const releaseRepoLock = vi.fn();
const backendInit = vi.fn(async () => undefined);
const launch = createLaunchAnalysisWorker({
jobManager,
backend: { init: backendInit },
acquireRepoLock: () => null,
releaseRepoLock,
closeDbHandle: async () => (await import('../../src/core/lbug/lbug-adapter.js')).closeLbug(),
});
const job = jobManager.createJob({ repoPath: repo });
try {
await launch(job, repo, { force: true });
await vi.waitFor(
() => expect(['complete', 'failed']).toContain(jobManager.getJob(job.id)?.status),
{ timeout: 180_000, interval: 100 },
);
// The completion IPC precedes process exit, which releases the worker's
// native database handle. Only then can this process reopen the graph.
await Promise.all(childExits);
expect(jobManager.getJob(job.id)?.status).toBe('complete');
expect(backendInit).toHaveBeenCalledOnce();
expect(releaseRepoLock).toHaveBeenCalledOnce();
} finally {
jobManager.dispose();
}
expect(await routeEvidence(repo)).toEqual({ routes: 3, handled: 2 });
}, 300_000);
it('drops them when a reindex really carries no configuration (the pre-fix launch)', async () => {
await runFullAnalysis(
repo,
{ skipAgentsMd: true, skipSkills: true, routeHookConfig: resolveLaunchRouteHookConfig(repo) },
{ onProgress: () => {} },
);
expect(await routeEvidence(repo)).toEqual({ routes: 3, handled: 2 });
// The launch used to send only force/embeddings: the removal gate rebuilds without Routes.
await runFullAnalysis(
repo,
{ force: true, embeddings: false, dropEmbeddings: false, skipNativeCloseOnExit: false },
{ onProgress: () => {} },
);
expect(await routeEvidence(repo)).toEqual({ routes: 0, handled: 0 });
}, 300_000);
});

View file

@ -3,7 +3,11 @@ import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { startWatchFileLoop, type WatchFileLoop } from '../../src/cli/analyze-watch.js';
import {
EXTERNAL_CONFIG_WATCH_PATH,
startWatchFileLoop,
type WatchFileLoop,
} from '../../src/cli/analyze-watch.js';
import { cleanupTempDir } from '../helpers/test-db.js';
const tempDirs: string[] = [];
@ -270,4 +274,46 @@ describe('watch filesystem integration', () => {
await fs.writeFile(path.join(repo, '.gitnexusrc'), '{}\n', 'utf8');
await waitFor(() => batches.flat().includes('.gitnexusrc'));
});
it('reloads on edit, atomic replacement and removal of the external GITNEXUS_RC file', async () => {
const repo = await makeRepo();
const outside = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-watch-rc-'));
tempDirs.push(outside);
const rc = path.join(outside, 'service.json');
await fs.writeFile(rc, '{}\n', 'utf8');
await fs.writeFile(path.join(outside, 'unrelated.json'), '{}\n', 'utf8');
const batches: string[][] = [];
const loop = await startWatchFileLoop(
repo,
25,
async (paths) => {
batches.push([...paths]);
},
(error) => {
throw error;
},
undefined,
rc,
);
loops.push(loop);
const configReloads = () =>
batches.filter((batch) => batch.includes(EXTERNAL_CONFIG_WATCH_PATH)).length;
await fs.writeFile(rc, '{"skipSkills": true}\n', 'utf8');
await waitFor(() => configReloads() >= 1);
// Editor-style save: write a sibling, then rename it over the original.
const staged = path.join(outside, 'service.json.tmp');
await fs.writeFile(staged, '{"skipSkills": false}\n', 'utf8');
await fs.rename(staged, rc);
await waitFor(() => configReloads() >= 2);
await fs.rm(rc);
await waitFor(() => configReloads() >= 3);
// Siblings of the config file never reach the queue.
await fs.writeFile(path.join(outside, 'unrelated.json'), '{"x": 1}\n', 'utf8');
await new Promise((resolve) => setTimeout(resolve, 200));
expect(batches.flat().filter((entry) => entry !== EXTERNAL_CONFIG_WATCH_PATH)).toEqual([]);
});
});

View file

@ -289,6 +289,227 @@ describe('analyze-config (.gitnexusrc support, #243)', () => {
expect(() => loadAnalyzeConfig(dir)).toThrow(/at least one string/);
});
// ── Go route aliases and handler wrappers ──────────────────────────
const CORE = 'example.com/infra/pkg';
it('normalizes goRouterAliases and goHandlerWrappers (de-duped)', async () => {
await writeRc(
JSON.stringify({
goRouterAliases: [
{ importPath: ` ${CORE} `, type: 'Engine', framework: 'gin' },
{ importPath: CORE, type: 'Engine', framework: 'gin' },
],
goHandlerWrappers: ['common.Wrapper', 'common.WrapperWithSuccessCode', 'common.Wrapper'],
}),
);
expect(loadAnalyzeConfig(dir)).toEqual({
routeFrameworkAliases: [{ importPath: CORE, type: 'Engine', framework: 'gin' }],
routeHandlerWrappers: ['common.Wrapper', 'common.WrapperWithSuccessCode'],
});
});
it.each([
[[{ importPath: CORE, type: 'Engine', framework: 'chi' }], /"gin" or "echo"/],
[
[
{ importPath: CORE, type: 'Engine', framework: 'gin' },
{ importPath: CORE, type: 'Engine', framework: 'echo' },
],
/as both "gin" and "echo"/,
],
[[{ type: 'Engine', framework: 'gin' }], /"importPath" must be a non-empty string/],
[[{ importPath: CORE, type: 'Engine', framework: 'gin', extra: 1 }], /unknown field "extra"/],
[[{ importPath: CORE, type: '*Engine', framework: 'gin' }], /must be a Go type name/],
[[{ importPath: 'a b', type: 'Engine', framework: 'gin' }], /must be a Go import path/],
[[], /non-empty array/],
[['gin'], /entries must be objects/],
])('rejects malformed goRouterAliases %j', async (aliases, message) => {
await writeRc(JSON.stringify({ goRouterAliases: aliases }));
expect(() => loadAnalyzeConfig(dir)).toThrow(message);
});
it.each(['func', 'type', 'return', '_'])('rejects unusable Go alias type %s', async (type) => {
await writeRc(
JSON.stringify({ goRouterAliases: [{ importPath: CORE, type, framework: 'gin' }] }),
);
expect(() => loadAnalyzeConfig(dir)).toThrow(/must be a Go type name/);
});
it.each(['ΔEngine', 'ÉEngine٢'])('accepts Unicode Go alias type %s', async (type) => {
const alias = { importPath: CORE, type, framework: 'gin' };
await writeRc(JSON.stringify({ goRouterAliases: [alias] }));
expect(loadAnalyzeConfig(dir)?.routeFrameworkAliases).toEqual([alias]);
});
it.each(['٢Engine', 'Engine²', 'E\u0301ngine'])(
'rejects invalid Go identifier %s',
async (type) => {
await writeRc(
JSON.stringify({ goRouterAliases: [{ importPath: CORE, type, framework: 'gin' }] }),
);
expect(() => loadAnalyzeConfig(dir)).toThrow(/must be a Go type name/);
},
);
it('rejects more router aliases than the cap instead of truncating', async () => {
const alias = (i: number) => ({ importPath: `${CORE}${i}`, type: 'Engine', framework: 'gin' });
await writeRc(
JSON.stringify({ goRouterAliases: Array.from({ length: 100 }, (_, i) => alias(i)) }),
);
expect(loadAnalyzeConfig(dir)?.routeFrameworkAliases).toHaveLength(100);
await writeRc(
JSON.stringify({ goRouterAliases: Array.from({ length: 101 }, (_, i) => alias(i)) }),
);
expect(() => loadAnalyzeConfig(dir)).toThrow(/declares 101 aliases; at most 100/);
await writeRc(JSON.stringify({ goRouterAliases: Array.from({ length: 101 }, () => alias(0)) }));
expect(() => loadAnalyzeConfig(dir)).toThrow(/declares 101 aliases; at most 100/);
});
describe('GITNEXUS_RC out-of-repo config', () => {
let outside: string;
const saved = process.env.GITNEXUS_RC;
beforeEach(async () => {
outside = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-rc-env-'));
});
afterEach(async () => {
if (saved === undefined) delete process.env.GITNEXUS_RC;
else process.env.GITNEXUS_RC = saved;
await fs.rm(outside, { recursive: true, force: true });
});
const writeEnvRc = async (contents: unknown): Promise<string> => {
const file = path.join(outside, 'service.json');
await fs.writeFile(file, JSON.stringify(contents));
process.env.GITNEXUS_RC = file;
return file;
};
it('applies the file when the repository has no .gitnexusrc', async () => {
await writeEnvRc({ goHandlerWrappers: ['common.Wrapper'] });
expect(loadAnalyzeConfig(dir)).toEqual({ routeHandlerWrappers: ['common.Wrapper'] });
await expect(loadAnalyzeConfigStrict(dir)).resolves.toEqual({
routeHandlerWrappers: ['common.Wrapper'],
});
});
it('overrides the repository .gitnexusrc key by key, and CLI still wins', async () => {
await writeRc(JSON.stringify({ defaultBranch: 'develop', skipSkills: true }));
await writeEnvRc({ defaultBranch: 'release' });
const config = loadAnalyzeConfig(dir);
expect(config).toEqual({ defaultBranch: 'release', skipSkills: true });
expect(mergeAnalyzeOptions({ skipSkills: false }, config).skipSkills).toBe(false);
});
it('rejects a relative path, a missing file, and invalid content', async () => {
process.env.GITNEXUS_RC = 'relative/rc.json';
expect(() => loadAnalyzeConfig(dir)).toThrow(/must be an absolute path/);
process.env.GITNEXUS_RC = path.join(outside, 'missing.json');
expect(() => loadAnalyzeConfig(dir)).toThrow(/Could not read GITNEXUS_RC file/);
await writeEnvRc({ goRouterAliases: 'gin' });
expect(() => loadAnalyzeConfig(dir)).toThrow(/GITNEXUS_RC file .*non-empty array/);
});
it.each([
['invalid JSON', '{', /not valid JSON/],
['non-object', '[]', /must contain a JSON object/],
['unknown key', '{"unknownOption": true}', /Unknown key/],
[
'conflicting keys',
'{"defaultBranch": "main", "branch": "develop"}',
/both configure the same option/,
],
['invalid value', '{"skipSkills": "yes"}', /must be a boolean/],
['invalid nested block', '{"analyze": []}', /"analyze" must be a JSON object/],
['invalid nested value', '{"analyze": {"goRouterAliases": "gin"}}', /non-empty array/],
])('names only the external file for %s errors', async (_name, raw, reason) => {
const file = await writeEnvRc({});
await fs.writeFile(file, raw);
const expected = expect.objectContaining({
message: expect.stringContaining(`GITNEXUS_RC file "${file}"`),
});
expect(() => loadAnalyzeConfig(dir)).toThrow(expected);
expect(() => loadAnalyzeConfig(dir)).toThrow(reason);
expect(() => loadAnalyzeConfig(dir)).not.toThrow(/\.gitnexusrc/);
await expect(loadAnalyzeConfigStrict(dir)).rejects.toThrow(expected);
await expect(loadAnalyzeConfigStrict(dir)).rejects.toThrow(reason);
await expect(loadAnalyzeConfigStrict(dir)).rejects.not.toThrow(/\.gitnexusrc/);
});
it('rejects a directory and an oversized file', async () => {
process.env.GITNEXUS_RC = outside;
expect(() => loadAnalyzeConfig(dir)).toThrow(/not a regular file/);
const big = path.join(outside, 'big.json');
await fs.writeFile(big, `{"skipSkills": true}${' '.repeat(MAX_REPO_CONTROL_FILE_BYTES)}`);
process.env.GITNEXUS_RC = big;
expect(() => loadAnalyzeConfig(dir)).toThrow(/larger than/);
});
it('rejects a file that grows past the cap after fstat, without reading it all', async () => {
const file = await writeEnvRc({ skipSkills: true });
const realFstat = fsSync.fstatSync;
const readSpy = vi.spyOn(fsSync, 'readSync');
const readFileSpy = vi.spyOn(fsSync, 'readFileSync');
const fstatSpy = vi.spyOn(fsSync, 'fstatSync').mockImplementation(((fd: number) => {
const stat = realFstat(fd);
// The writer appends after the size check has already passed.
fsSync.appendFileSync(file, ' '.repeat(2 * MAX_REPO_CONTROL_FILE_BYTES));
return stat;
}) as typeof fsSync.fstatSync);
try {
expect(() => loadAnalyzeConfig(dir)).toThrow(/larger than/);
// Bytes pulled from the file by either read API: the bound must hold
// regardless of how the reader is written.
const bytesRead =
readSpy.mock.results.reduce(
(sum, result) => sum + (result.type === 'return' ? (result.value as number) : 0),
0,
) +
readFileSpy.mock.results.reduce(
(sum, result) =>
sum + (result.type === 'return' ? Buffer.byteLength(result.value as string) : 0),
0,
);
expect(bytesRead).toBeLessThanOrEqual(MAX_REPO_CONTROL_FILE_BYTES + 1);
} finally {
fstatSpy.mockRestore();
readSpy.mockRestore();
readFileSpy.mockRestore();
}
});
it.skipIf(process.platform === 'win32')(
'rejects a FIFO promptly instead of blocking on open',
async () => {
const fifo = path.join(outside, 'rc.fifo');
execFileSync('mkfifo', [fifo]);
const script = path.join(outside, 'load.mts');
const configModule = path.resolve(__dirname, '../../src/cli/analyze-config.ts');
await fs.writeFile(
script,
`import { loadAnalyzeConfig } from ${JSON.stringify(configModule)};\n` +
`try { loadAnalyzeConfig(${JSON.stringify(dir)}); console.log('loaded'); }\n` +
`catch (error) { console.log(String((error as Error).message)); }\n`,
);
const tsx = path.resolve(__dirname, '../../node_modules/.bin/tsx');
const output = execFileSync(tsx, [script], {
env: { ...process.env, GITNEXUS_RC: fifo },
encoding: 'utf-8',
timeout: 20_000,
});
expect(output).toMatch(/not a regular file/);
},
30_000,
);
it('is ignored when unset or blank', async () => {
process.env.GITNEXUS_RC = ' ';
expect(loadAnalyzeConfig(dir)).toBeUndefined();
});
});
// ── Spring Actuator runtime enrichment (#2418) ────────────────────
it('normalizes a Spring Actuator snapshot path', async () => {

View file

@ -191,6 +191,101 @@ describe('createLaunchAnalysisWorker — collapsed index is never published', ()
);
});
describe('route configuration', () => {
const launchWith = async () => {
const launch = createLaunchAnalysisWorker({
jobManager,
backend: { init: backendInit },
acquireRepoLock: () => null,
releaseRepoLock: () => {
calls.push('releaseRepoLock');
},
closeDbHandle,
});
const job = jobManager.createJob({ repoPath: REPO_PATH });
await launch(job, REPO_PATH, {});
return jobManager.getJob(job.id);
};
let rcDir: string;
beforeEach(async () => {
const fsp = await vi.importActual<typeof import('node:fs/promises')>('node:fs/promises');
const os = await vi.importActual<typeof import('node:os')>('node:os');
rcDir = await fsp.mkdtemp(`${os.tmpdir()}/gitnexus-launch-rc-`);
});
afterEach(async () => {
vi.unstubAllEnvs();
const fsp = await vi.importActual<typeof import('node:fs/promises')>('node:fs/promises');
await fsp.rm(rcDir, { recursive: true, force: true });
});
it('forwards the GITNEXUS_RC aliases and wrappers the CLI would use', async () => {
const fsp = await vi.importActual<typeof import('node:fs/promises')>('node:fs/promises');
const rc = `${rcDir}/service.json`;
await fsp.writeFile(
rc,
JSON.stringify({
goRouterAliases: [
{ importPath: 'example.com/infra/pkg', type: 'Engine', framework: 'gin' },
],
goHandlerWrappers: ['common.Wrapper'],
}),
);
vi.stubEnv('GITNEXUS_RC', rc);
await launchWith();
expect(child.send).toHaveBeenCalledWith(
expect.objectContaining({
options: expect.objectContaining({
routeHookConfig: {
routeFrameworkAliases: [
{ importPath: 'example.com/infra/pkg', type: 'Engine', framework: 'gin' },
],
routeHandlerWrappers: ['common.Wrapper'],
},
}),
}),
);
});
it('fails the job without forking a worker when the explicit config is invalid', async () => {
vi.stubEnv('GITNEXUS_RC', `${rcDir}/missing.json`);
const job = await launchWith();
expect(forkMock).not.toHaveBeenCalled();
expect(job?.status).toBe('failed');
expect(job?.error).toMatch(/Could not read GITNEXUS_RC file/);
expect(calls).toContain('releaseRepoLock');
});
it('preserves cancellation during storage resolution when config is invalid', async () => {
vi.stubEnv('GITNEXUS_RC', `${rcDir}/missing.json`);
const launch = createLaunchAnalysisWorker({
jobManager,
backend: { init: backendInit },
acquireRepoLock: () => null,
releaseRepoLock: () => calls.push('releaseRepoLock'),
closeDbHandle,
});
const queued = jobManager.createJob({ repoPath: REPO_PATH });
H.requireStoragePath.mockImplementationOnce(async () => {
jobManager.cancelJob(queued.id, 'Cancelled during storage resolution');
return H.STORAGE_PATH;
});
await launch(queued, REPO_PATH, {});
const job = jobManager.getJob(queued.id);
expect(job?.error).toBe('Cancelled during storage resolution');
expect(jobManager.hasPendingCancel(queued.id)).toBe(false);
expect(forkMock).not.toHaveBeenCalled();
expect(calls.filter((call) => call === 'releaseRepoLock')).toHaveLength(1);
});
});
it('uses the force storage set only when launch options request force', async () => {
const launch = createLaunchAnalysisWorker({
jobManager,

View file

@ -1,7 +1,10 @@
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';
import {
extractGoGinEchoRoutes,
extractGoRouteCandidates,
} from '../../src/core/ingestion/route-extractors/go-gin-echo.js';
const parser = new Parser();
parser.setLanguage(Go);
@ -394,3 +397,74 @@ describe('Go route type-parameter shadowing', () => {
expect(routes[0].handlerReceiver).toBeUndefined();
});
});
describe('wrapper-package route candidates', () => {
const CORE_PATH = 'example.com/infra/pkg';
const CORE = `import (
consoleApi "example.com/app/api"
"example.com/app/common"
mw "example.com/app/middleware"
core "${CORE_PATH}"
)
`;
const candidates = (body: string, header = CORE) =>
extractGoRouteCandidates(parser.parse(`package routes\n${header}${body}`));
it('records Group prefixes under the root parameter type, not as routes', () => {
const body = `func initConsoleRoutes(s *core.Engine, api *consoleApi.API) {
v1 := s.Group("/console/v1")
{
oauth := v1.Group("/oauth")
oauth.GET("/iam/oauthback", mw.AppTag(), consoleApi.IamOauthBack)
oauth.POST("/sso/sign", common.Wrapper(api.SingleSignOn))
}
}`;
expect(extractGoGinEchoRoutes(parser.parse(`package routes\n${CORE}${body}`), 'r.go')).toEqual(
[],
);
const facts = candidates(body);
expect(facts?.roots).toHaveLength(1);
expect(facts?.roots[0]).toMatchObject({ importPath: CORE_PATH, typeName: 'Engine' });
expect(
facts?.roots[0].routes.map((r) => ({
route: `${r.httpMethod} ${r.routePath}`,
gin: r.handlers.at(-1),
})),
).toEqual([
{
route: 'GET /console/v1/oauth/iam/oauthback',
gin: {
handlerName: 'consoleApi.IamOauthBack',
handlerReceiver: { kind: 'module', qualifier: 'consoleApi' },
},
},
{
route: 'POST /console/v1/oauth/sso/sign',
gin: {
handlerWrapper: {
callee: 'common.Wrapper',
handlerName: 'api.SingleSignOn',
handlerReceiver: { kind: 'type', name: 'API', qualifier: 'consoleApi' },
},
},
},
]);
});
it('records the echo-order handler too, without deciding the framework', () => {
const facts = candidates(`func R(s *core.Engine) { s.GET("/x", First, mw.Log()) }`);
expect(facts?.roots[0].routes[0].handlers[0]).toEqual({ handlerName: 'First' });
// gin order reads the trailing middleware call: no designator, no wrapper.
expect(facts?.roots[0].routes[0].handlers.at(-1)).toEqual({});
});
it('emits no candidate for a direct gin engine or an unimported type', () => {
expect(candidates(`func R(r *gin.Engine) { r.GET("/x", H) }`, GIN)).toBeUndefined();
expect(candidates(`func R(r *Engine) { r.GET("/x", H) }`)).toBeUndefined();
});
it('does not treat a wrapper on a local value as configurable', () => {
const facts = candidates(`func R(s *core.Engine, m Wrapper) { s.GET("/x", m.Wrap(H)) }`);
expect(facts?.roots[0].routes[0].handlers.at(-1)).toEqual({});
});
});

View file

@ -0,0 +1,202 @@
/**
* `resolveGoRouteFacts`: wrapper-package route candidates become gin/echo
* routes only when their root type is declared in `goRouterAliases`.
*/
import { describe, expect, it } from 'vitest';
import Parser from 'tree-sitter';
import Go from 'tree-sitter-go';
import {
collectGoRouteFacts,
extractGoRouteCandidates,
} from '../../src/core/ingestion/route-extractors/go-gin-echo.js';
import { resolveGoRouteFacts } from '../../src/core/ingestion/languages/go/route-facts.js';
import type { RouteFrameworkAlias } from '../../src/core/ingestion/language-provider.js';
import { routeHookConfigKey } from '../../src/core/ingestion/route-hook-config.js';
import { normalizeRouterAliases } from '../../src/core/ingestion/languages/go/route-config.js';
const parser = new Parser();
parser.setLanguage(Go);
const CORE_PATH = 'example.com/infra/pkg';
const SOURCE = `package routes
import (
core "${CORE_PATH}"
mw "example.com/app/middleware"
)
func R(s *core.Engine) {
g := s.Group("/api")
g.GET("/users", mw.Auth(), First, Last)
}
`;
const resolve = (aliases: RouteFrameworkAlias[]) =>
resolveGoRouteFacts(
[{ filePath: 'routes/r.go', facts: extractGoRouteCandidates(parser.parse(SOURCE)) }],
{ resolveImportTargets: () => [], routeHookConfig: { routeFrameworkAliases: aliases } },
);
describe('resolveGoRouteFacts', () => {
it('matches a configured Unicode type name against parsed route facts', () => {
const facts = collectGoRouteFacts(
parser.parse(`package routes
import core "${CORE_PATH}"
func R(s *core.ΔEngine) {
s.GET("/unicode", Handler)
}`),
);
const routes = resolveGoRouteFacts([{ filePath: 'r.go', facts }], {
resolveImportTargets: () => [],
routeHookConfig: {
routeFrameworkAliases: normalizeRouterAliases(
[{ importPath: CORE_PATH, type: 'ΔEngine', framework: 'gin' }],
'goRouterAliases',
),
},
});
expect(routes.map((route) => [route.routePath, route.handlerName])).toEqual([
['/unicode', 'Handler'],
]);
});
it('replays serialized direct and alias facts with changed or removed configuration', () => {
const facts = JSON.parse(
JSON.stringify(
collectGoRouteFacts(
parser.parse(`package routes
import "github.com/gin-gonic/gin"
import core "${CORE_PATH}"
func R(r *gin.Engine, s *core.Engine) {
s.Group("/alias").GET("/x", First, Last)
r.Group("/direct").GET("/x", Middleware, Direct)
}`),
10,
),
),
);
const before = JSON.stringify(facts);
const replay = (framework?: string) =>
resolveGoRouteFacts([{ filePath: 'r.go', facts }], {
resolveImportTargets: () => [],
routeHookConfig: framework
? { routeFrameworkAliases: [{ importPath: CORE_PATH, type: 'Engine', framework }] }
: undefined,
}).map((r) => [r.routePath, r.handlerName, r.source, r.lineNumber]);
expect(replay('gin')).toEqual([
['/alias/x', 'Last', 'gin-route', 15],
['/direct/x', 'Direct', 'gin-route', 16],
]);
expect(replay('echo')).toEqual([
['/alias/x', 'First', 'echo-route', 15],
['/direct/x', 'Direct', 'gin-route', 16],
]);
expect(replay()).toEqual([['/direct/x', 'Direct', 'gin-route', 16]]);
expect(JSON.stringify(facts)).toBe(before);
});
it('declines assignments between different root types even when their prefixes agree', () => {
const facts = collectGoRouteFacts(
parser.parse(`package routes
import core "${CORE_PATH}"
func R(a *core.First, b *core.Second) {
g := a.Group("/api")
g = b.Group("/api")
g.GET("/ambiguous", H)
a.GET("/first", H)
b.GET("/second", H)
}`),
);
const routes = resolveGoRouteFacts([{ filePath: 'r.go', facts }], {
resolveImportTargets: () => [],
routeHookConfig: {
routeFrameworkAliases: ['First', 'Second'].map((type) => ({
importPath: CORE_PATH,
type,
framework: 'gin',
})),
},
});
expect(routes.map((r) => r.routePath)).toEqual(['/first', '/second']);
});
it('preserves alias parameter order when registrations use the reverse order', () => {
const facts = collectGoRouteFacts(
parser.parse(`package routes
import core "${CORE_PATH}"
func R(a *core.First, b *core.Second) {
b.GET("/same", Second)
a.GET("/same", First)
}`),
);
expect(facts?.roots.map((root) => root.typeName)).toEqual(['First', 'Second']);
});
it('promotes candidates on a gin alias, taking the last argument as handler', () => {
expect(resolve([{ importPath: CORE_PATH, type: 'Engine', framework: 'gin' }])).toEqual([
{
filePath: 'routes/r.go',
routePath: '/api/users',
httpMethod: 'GET',
decoratorName: 'GET',
lineNumber: 8,
prefix: null,
source: 'gin-route',
handlerName: 'Last',
},
]);
});
it('takes the second argument on an echo alias', () => {
// echo's second argument here is the `mw.Auth()` call: not a designator,
// and not a wrapper of one, so the route stands without a handler.
const [route] = resolve([{ importPath: CORE_PATH, type: 'Engine', framework: 'echo' }]);
expect(route.source).toBe('echo-route');
expect(route.handlerName).toBeUndefined();
expect(route.handlerWrapper).toBeUndefined();
});
it('drops every candidate whose root type is not declared', () => {
expect(resolve([])).toEqual([]);
expect(resolve([{ importPath: CORE_PATH, type: 'Router', framework: 'gin' }])).toEqual([]);
});
it('ignores facts it did not produce', () => {
expect(
resolveGoRouteFacts([{ filePath: 'x.go', facts: { other: true } }], {
resolveImportTargets: () => [],
}),
).toEqual([]);
});
});
describe('routeHookConfigKey', () => {
it('is independent of declaration order and uses code-unit order', () => {
const a = { importPath: 'example.com/a', type: 'Engine', framework: 'gin' };
const b = { importPath: 'example.com/B', type: 'Engine', framework: 'echo' };
expect(
routeHookConfigKey({
routeFrameworkAliases: [a, b],
routeHandlerWrappers: ['z.Wrap', 'Z.Wrap'],
}),
).toBe(
routeHookConfigKey({
routeFrameworkAliases: [b, a],
routeHandlerWrappers: ['Z.Wrap', 'z.Wrap'],
}),
);
// Uppercase sorts before lowercase by code unit, regardless of host locale.
expect(
routeHookConfigKey({
routeFrameworkAliases: [a, b],
routeHandlerWrappers: ['z.Wrap', 'Z.Wrap'],
}),
).toBe(
JSON.stringify([
[
['example.com/B', 'Engine', 'echo'],
['example.com/a', 'Engine', 'gin'],
],
['Z.Wrap', 'z.Wrap'],
]),
);
});
});

View file

@ -306,4 +306,47 @@ describe('resolveGoRouteHandler', () => {
).toBeUndefined();
});
});
describe('configured handler wrappers', () => {
const wrapped = {
handlerWrapper: {
callee: 'common.Wrapper',
handlerName: 'api.CreateJob',
handlerReceiver: { kind: 'type', name: 'API', qualifier: 'consoleApi' },
},
} as const;
const model = (): Model => {
const m = createSemanticModel();
struct(m, 'app/api/api.go', 'API');
method(m, 'app/api/job.go', 'API', 'CreateJob');
return m;
};
const imports = { consoleApi: ['app/api/api.go', 'app/api/job.go'] };
it('resolves the wrapped handler when the callee is configured', () => {
expect(
resolveGoRouteHandler(route(wrapped), {
model: model(),
importTargetsFor: (_from, name) => imports[name as keyof typeof imports] ?? [],
routeHookConfig: { routeHandlerWrappers: ['common.Wrapper'] },
}),
).toBe('Method:app/api/job.go:API.CreateJob');
});
it('declines an unconfigured wrapper, as before wrappers were recorded', () => {
expect(resolve(model(), wrapped, imports)).toBeUndefined();
});
it('prefers a direct handler designator over a wrapper', () => {
const m = model();
fn(m, ROUTER, 'Direct');
expect(
resolveGoRouteHandler(route({ ...wrapped, handlerName: 'Direct' }), {
model: m,
importTargetsFor: () => [],
routeHookConfig: { routeHandlerWrappers: ['common.Wrapper'] },
}),
).toBe(`Function:${ROUTER}:Direct`);
});
});
});

View file

@ -59,6 +59,7 @@ import {
SPRING_ROUTE_BINDINGS_FEATURE,
} from '../../src/core/ingestion/frameworks/spring/analysis-features.js';
import { springVendorPrefixesKey } from '../../src/core/ingestion/frameworks/spring/vendor-prefixes.js';
import { routeHookConfigKey } from '../../src/core/ingestion/route-hook-config.js';
import {
decodeSpringAopReason,
SPRING_AOP_EVIDENCE_ID_PREFIX,
@ -896,6 +897,43 @@ describe('runFullAnalysis — incremental orchestration', () => {
}
}, 300_000);
it('rebuilds when the declared route aliases or handler wrappers change', async () => {
const repo = await setupSpringBeanIncrementalRepo();
try {
const { runFullAnalysis } = await import('../../src/core/run-analyze.js');
const config = {
skipAgentsMd: true,
routeHookConfig: {
routeFrameworkAliases: [
{ importPath: 'example.com/infra/pkg', type: 'Engine', framework: 'gin' },
],
routeHandlerWrappers: ['common.Wrapper'],
},
};
await runFullAnalysis(repo.dbPath, config, { onProgress: () => {} });
const { storagePath } = getStoragePaths(repo.dbPath);
expect((await loadMeta(storagePath))?.routeHookConfig).toBe(
routeHookConfigKey(config.routeHookConfig),
);
const steady = await runFullAnalysis(repo.dbPath, config, { onProgress: () => {} });
expect(steady.alreadyUpToDate).toBe(true);
const logs: string[] = [];
const rebuilt = await runFullAnalysis(
repo.dbPath,
{ skipAgentsMd: true },
{ onProgress: () => {}, onLog: (message) => logs.push(message) },
);
expect(rebuilt.alreadyUpToDate).toBeUndefined();
expect(rebuilt.rebuildReasons).toContain('route-hook-config');
expect(logs.join('\n')).toContain('no longer configured');
expect((await loadMeta(storagePath))?.routeHookConfig).toBe(routeHookConfigKey());
} finally {
await repo.cleanup();
}
}, 300_000);
it('rebuilds a JVM index when the registered Spring vendor prefixes change', async () => {
const repo = await setupSpringBeanIncrementalRepo();
try {

View file

@ -297,6 +297,7 @@ const REBUILD_REASON_COVERAGE = [
{ key: 'graph-write-collapse', file: 'test/unit/incremental-orchestration.test.ts' },
{ key: 'analysis-features', file: 'test/unit/incremental-orchestration.test.ts' },
{ key: 'spring-vendor-prefixes', file: 'test/unit/incremental-orchestration.test.ts' },
{ key: 'route-hook-config', file: 'test/unit/incremental-orchestration.test.ts' },
{ key: 'runner-identity', file: 'test/unit/incremental-orchestration.test.ts' },
{ key: 'parser-identity', file: 'test/integration/parse-cache-identity-lifecycle.test.ts' },
{ key: 'cjk-segmentation', file: 'test/unit/incremental-orchestration.test.ts' },

View file

@ -0,0 +1,207 @@
/**
* Watch mode and the external `GITNEXUS_RC` file: edits, atomic replacement
* and removal reload configuration before the next refresh, and an invalid or
* missing explicit file blocks analysis instead of reusing stale settings.
*
* Drives the real filesystem watcher through `watchCommandWithRunnerIdentity`;
* only the analysis itself is stubbed, so the assertion is on the options each
* refresh would analyze with.
*/
import { execFileSync } from 'node:child_process';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { afterEach, describe, expect, it, vi } from 'vitest';
const H = vi.hoisted(() => ({
calls: [] as unknown[],
afterConfigLoad: undefined as (() => Promise<void>) | undefined,
}));
vi.mock('../../src/cli/analyze-config.js', async (importActual) => {
const actual = await importActual<typeof import('../../src/cli/analyze-config.js')>();
return {
...actual,
loadAnalyzeConfigStrict: async (repo: string) => {
const options = await actual.loadAnalyzeConfigStrict(repo);
const afterLoad = H.afterConfigLoad;
H.afterConfigLoad = undefined;
await afterLoad?.();
return options;
},
};
});
vi.mock('../../src/core/run-analyze.js', () => ({
analyzeFailureMayHaveMutatedLiveIndex: () => false,
runFullAnalysis: vi.fn(async (_repo: string, options: { routeHookConfig?: unknown }) => {
H.calls.push(options.routeHookConfig);
return { alreadyUpToDate: true };
}),
}));
vi.mock('../../src/cli/analyze.js', () => ({ ensureHeap: async () => false }));
vi.mock('../../src/storage/storage-resolver.js', async (importActual) => ({
...(await importActual<typeof import('../../src/storage/storage-resolver.js')>()),
requireStoragePath: async () => undefined,
}));
import { watchCommandWithRunnerIdentity } from '../../src/cli/analyze-watch.js';
import type { AnalyzerRunnerIdentity } from '../../src/storage/repo-manager.js';
const dirs: string[] = [];
afterEach(async () => {
vi.unstubAllEnvs();
H.calls.length = 0;
H.afterConfigLoad = undefined;
await Promise.all(dirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })));
});
async function waitFor(predicate: () => boolean, timeoutMs = 8_000): Promise<void> {
const deadline = Date.now() + timeoutMs;
while (!predicate()) {
if (Date.now() >= deadline) throw new Error(`timed out after ${timeoutMs}ms`);
await new Promise((resolve) => setTimeout(resolve, 25));
}
}
const aliases = (importPath: string) => ({
goRouterAliases: [{ importPath, type: 'Engine', framework: 'gin' }],
});
describe('watch mode with an external GITNEXUS_RC file', () => {
it('rereads configuration changed between validation and watcher startup', async () => {
const repo = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-watch-rc-start-'));
const outside = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-watch-rc-'));
dirs.push(repo, outside);
execFileSync('git', ['init', '-q'], { cwd: repo });
const rc = path.join(outside, 'service.json');
await fs.writeFile(rc, JSON.stringify(aliases('example.com/old')));
vi.stubEnv('GITNEXUS_RC', rc);
H.afterConfigLoad = () => fs.writeFile(rc, JSON.stringify(aliases('example.com/new')));
const command = watchCommandWithRunnerIdentity({} as AnalyzerRunnerIdentity, repo, {
debounce: '25',
});
try {
await waitFor(() => H.calls.length > 0);
expect(H.calls[0]).toEqual({
routeFrameworkAliases: aliases('example.com/new').goRouterAliases,
routeHandlerWrappers: undefined,
});
} finally {
process.emit('SIGINT');
await command;
}
});
it('reloads symlink targets after edits, replacement, removal, restoration and retargeting', async ({
skip,
}) => {
const repo = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-watch-rc-link-'));
const outside = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-watch-rc-'));
dirs.push(repo, outside);
execFileSync('git', ['init', '-q'], { cwd: repo });
await fs.mkdir(path.join(outside, 'targets'));
const target = path.join(outside, 'targets', 'service.json');
const rc = path.join(outside, 'config.json');
await fs.writeFile(target, JSON.stringify(aliases('example.com/one')));
try {
await fs.symlink(target, rc, 'file');
} catch (error) {
if (process.platform === 'win32' && (error as NodeJS.ErrnoException).code === 'EPERM') {
skip('Creating file symlinks requires Windows Developer Mode or elevation.');
}
throw error;
}
vi.stubEnv('GITNEXUS_RC', rc);
const command = watchCommandWithRunnerIdentity({} as AnalyzerRunnerIdentity, repo, {
debounce: '25',
});
const latestConfig = () => H.calls.at(-1);
const config = (name: string) => ({
routeFrameworkAliases: aliases(`example.com/${name}`).goRouterAliases,
routeHandlerWrappers: undefined,
});
try {
await waitFor(() => H.calls.length > 0);
expect(latestConfig()).toEqual(config('one'));
await fs.writeFile(target, JSON.stringify(aliases('example.com/two')));
await vi.waitFor(() => expect(latestConfig()).toEqual(config('two')), { timeout: 8_000 });
await fs.writeFile(`${target}.tmp`, JSON.stringify(aliases('example.com/three')));
await fs.rename(`${target}.tmp`, target);
await vi.waitFor(() => expect(latestConfig()).toEqual(config('three')), { timeout: 8_000 });
await fs.rm(target);
await new Promise((resolve) => setTimeout(resolve, 600));
const settled = H.calls.length;
await fs.writeFile(path.join(repo, 'main.go'), 'package main\n');
await new Promise((resolve) => setTimeout(resolve, 600));
expect(H.calls).toHaveLength(settled);
await fs.writeFile(target, JSON.stringify(aliases('example.com/four')));
await vi.waitFor(() => expect(latestConfig()).toEqual(config('four')), { timeout: 8_000 });
const nextTarget = path.join(outside, 'targets', 'next.json');
await fs.writeFile(nextTarget, JSON.stringify(aliases('example.com/five')));
await fs.rm(rc);
await fs.symlink(nextTarget, rc, 'file');
await vi.waitFor(() => expect(latestConfig()).toEqual(config('five')), { timeout: 8_000 });
await fs.writeFile(nextTarget, JSON.stringify(aliases('example.com/six')));
await vi.waitFor(() => expect(latestConfig()).toEqual(config('six')), { timeout: 8_000 });
} finally {
process.emit('SIGINT');
await command;
}
}, 45_000);
it('reloads edits and replacements, and blocks refreshes while the file is invalid or gone', async () => {
const repo = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-watch-rc-repo-'));
const outside = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-watch-rc-'));
dirs.push(repo, outside);
execFileSync('git', ['init', '-q'], { cwd: repo });
const rc = path.join(outside, 'service.json');
await fs.writeFile(rc, JSON.stringify(aliases('example.com/one')), 'utf8');
vi.stubEnv('GITNEXUS_RC', rc);
const command = watchCommandWithRunnerIdentity({} as AnalyzerRunnerIdentity, repo, {
debounce: '25',
});
const importPaths = () =>
H.calls.map(
(config) =>
(config as { routeFrameworkAliases?: { importPath: string }[] } | undefined)
?.routeFrameworkAliases?.[0]?.importPath,
);
try {
await waitFor(() => H.calls.length === 1);
expect(importPaths()).toEqual(['example.com/one']);
await fs.writeFile(rc, JSON.stringify(aliases('example.com/two')), 'utf8');
await waitFor(() => importPaths().includes('example.com/two'));
// Editor-style save: a sibling renamed over the original replaces the inode.
const staged = `${rc}.tmp`;
await fs.writeFile(staged, JSON.stringify(aliases('example.com/three')), 'utf8');
await fs.rename(staged, rc);
await waitFor(() => importPaths().includes('example.com/three'));
const settled = H.calls.length;
await fs.writeFile(rc, JSON.stringify({ goRouterAliases: 'gin' }), 'utf8');
await new Promise((resolve) => setTimeout(resolve, 400));
// A source change while the config is invalid must not analyze with stale settings.
await fs.writeFile(path.join(repo, 'main.go'), 'package main\n', 'utf8');
await new Promise((resolve) => setTimeout(resolve, 600));
expect(H.calls).toHaveLength(settled);
await fs.rm(rc);
await fs.writeFile(path.join(repo, 'main.go'), 'package main\n\nfunc main() {}\n', 'utf8');
await new Promise((resolve) => setTimeout(resolve, 600));
expect(H.calls).toHaveLength(settled);
await fs.writeFile(rc, JSON.stringify(aliases('example.com/four')), 'utf8');
await waitFor(() => importPaths().includes('example.com/four'));
} finally {
process.emit('SIGINT');
await command;
}
}, 30_000);
});

View file

@ -24,6 +24,39 @@ afterEach(async () => {
});
describe('watch path selection', () => {
it('forwards and removes route configuration on subsequent refreshes', async () => {
const aliases = [{ importPath: 'example.com/core', type: 'Engine', framework: 'gin' }];
const baseline = { maxFileSize: undefined, workerTimeout: undefined, verbose: undefined };
for (const name of [
'GITNEXUS_MAX_FILE_SIZE',
'GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS',
'GITNEXUS_VERBOSE',
]) {
vi.stubEnv(name, process.env[name]);
}
vi.stubEnv('GITNEXUS_RC', '');
try {
await fs.writeFile(
path.join(repoPath, '.gitnexusrc'),
JSON.stringify({
goRouterAliases: aliases,
goHandlerWrappers: ['common.Wrapper'],
}),
);
expect((await resolveWatchOptions(repoPath, {}, baseline)).routeHookConfig).toEqual({
routeFrameworkAliases: aliases,
routeHandlerWrappers: ['common.Wrapper'],
});
await fs.writeFile(path.join(repoPath, '.gitnexusrc'), '{}');
expect((await resolveWatchOptions(repoPath, {}, baseline)).routeHookConfig).toEqual({
routeFrameworkAliases: undefined,
routeHandlerWrappers: undefined,
});
} finally {
vi.unstubAllEnvs();
}
});
it('forwards explicit FTS opt-out without changing the default', async () => {
const names = [
'GITNEXUS_MAX_FILE_SIZE',

View file

@ -24,7 +24,13 @@ export default defineConfig({
// caller's `atomicIncremental: true`, so an exported `=0` would silently
// turn off every staging test. Empty is neither '0' nor '1', i.e. unset;
// a test that needs a value stubs it.
env: { GITNEXUS_MEMORY: 'off', GITNEXUS_LANG: 'en', GITNEXUS_ATOMIC_INCREMENTAL: '' },
// Config tests supply their own files; never read a developer's GITNEXUS_RC.
env: {
GITNEXUS_MEMORY: 'off',
GITNEXUS_LANG: 'en',
GITNEXUS_ATOMIC_INCREMENTAL: '',
GITNEXUS_RC: '',
},
// N-API destructors can crash worker forks on macOS during process exit.
// This is independent of the QueryResult lifetime fix in @ladybugdb/core 0.15.2 —
// it's a vitest forks + native addon interaction where destructors run in
@ -85,6 +91,7 @@ export default defineConfig({
'test/integration/incremental-worktree-context.test.ts',
'test/integration/incremental-method-integrity.test.ts',
'test/integration/go-incremental-method-owners.test.ts',
'test/integration/server-route-hook-config.test.ts',
'test/integration/query-symbol-integrity.test.ts',
'test/integration/mcp-index-generation.test.ts',
'test/integration/mcp-refresh-index-e2e.test.ts',
@ -201,6 +208,7 @@ export default defineConfig({
'test/integration/incremental-worktree-context.test.ts',
'test/integration/incremental-method-integrity.test.ts',
'test/integration/go-incremental-method-owners.test.ts',
'test/integration/server-route-hook-config.test.ts',
'test/integration/query-symbol-integrity.test.ts',
'test/integration/mcp-index-generation.test.ts',
'test/integration/mcp-refresh-index-e2e.test.ts',