mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-11-11 08:37:35 +00:00
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
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:
parent
347cc99b4a
commit
54db34d5cb
43 changed files with 2620 additions and 102 deletions
13
.github/workflows/ci-tests.yml
vendored
13
.github/workflows/ci-tests.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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. |
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
114
gitnexus/bench/go-route-facts/baseline.json
Normal file
114
gitnexus/bench/go-route-facts/baseline.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
299
gitnexus/bench/go-route-facts/measure.js
Normal file
299
gitnexus/bench/go-route-facts/measure.js
Normal 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;
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
92
gitnexus/src/core/ingestion/languages/go/route-config.ts
Normal file
92
gitnexus/src/core/ingestion/languages/go/route-config.ts
Normal 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()];
|
||||
}
|
||||
93
gitnexus/src/core/ingestion/languages/go/route-facts.ts
Normal file
93
gitnexus/src/core/ingestion/languages/go/route-facts.ts
Normal 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;
|
||||
};
|
||||
|
|
@ -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]);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 ─────────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
16
gitnexus/src/core/ingestion/route-hook-config.ts
Normal file
16
gitnexus/src/core/ingestion/route-hook-config.ts
Normal 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),
|
||||
]);
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
19
gitnexus/test/fixtures/go-wrapped-gin-route-app/api/api.go
vendored
Normal file
19
gitnexus/test/fixtures/go-wrapped-gin-route-app/api/api.go
vendored
Normal 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) {}
|
||||
19
gitnexus/test/fixtures/go-wrapped-gin-route-app/common/context.go
vendored
Normal file
19
gitnexus/test/fixtures/go-wrapped-gin-route-app/common/context.go
vendored
Normal 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) {} }
|
||||
8
gitnexus/test/fixtures/go-wrapped-gin-route-app/go.mod
vendored
Normal file
8
gitnexus/test/fixtures/go-wrapped-gin-route-app/go.mod
vendored
Normal 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
|
||||
)
|
||||
18
gitnexus/test/fixtures/go-wrapped-gin-route-app/routes/routes.go
vendored
Normal file
18
gitnexus/test/fixtures/go-wrapped-gin-route-app/routes/routes.go
vendored
Normal 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)
|
||||
}
|
||||
}
|
||||
7
gitnexus/test/fixtures/go-wrapped-gin-route-app/service/jobs.go
vendored
Normal file
7
gitnexus/test/fixtures/go-wrapped-gin-route-app/service/jobs.go
vendored
Normal 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" }
|
||||
162
gitnexus/test/integration/go-wrapped-gin-route-pipeline.test.ts
Normal file
162
gitnexus/test/integration/go-wrapped-gin-route-pipeline.test.ts
Normal 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);
|
||||
});
|
||||
144
gitnexus/test/integration/server-route-hook-config.test.ts
Normal file
144
gitnexus/test/integration/server-route-hook-config.test.ts
Normal 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);
|
||||
});
|
||||
|
|
@ -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([]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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({});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
202
gitnexus/test/unit/go-route-facts.test.ts
Normal file
202
gitnexus/test/unit/go-route-facts.test.ts
Normal 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'],
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
|
|
|
|||
207
gitnexus/test/unit/watch-external-config.test.ts
Normal file
207
gitnexus/test/unit/watch-external-config.test.ts
Normal 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);
|
||||
});
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue