diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml index 7e91c8201..a75e207bf 100644 --- a/.github/workflows/ci-tests.yml +++ b/.github/workflows/ci-tests.yml @@ -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 diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 3744c7c58..62c6b3fd3 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -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. | diff --git a/gitnexus/README.md b/gitnexus/README.md index 6e2cc70d9..f45d173e4 100644 --- a/gitnexus/README.md +++ b/gitnexus/README.md @@ -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. diff --git a/gitnexus/bench/go-route-facts/baseline.json b/gitnexus/bench/go-route-facts/baseline.json new file mode 100644 index 000000000..c7708e528 --- /dev/null +++ b/gitnexus/bench/go-route-facts/baseline.json @@ -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" + } + ] + } +} diff --git a/gitnexus/bench/go-route-facts/measure.js b/gitnexus/bench/go-route-facts/measure.js new file mode 100644 index 000000000..03cad0995 --- /dev/null +++ b/gitnexus/bench/go-route-facts/measure.js @@ -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; diff --git a/gitnexus/scripts/cross-platform-tests.ts b/gitnexus/scripts/cross-platform-tests.ts index c61fca51f..451bfe1fa 100644 --- a/gitnexus/scripts/cross-platform-tests.ts +++ b/gitnexus/scripts/cross-platform-tests.ts @@ -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 diff --git a/gitnexus/src/cli/analyze-config.ts b/gitnexus/src/cli/analyze-config.ts index 3d2219db2..45c4b2a2a 100644 --- a/gitnexus/src/cli/analyze-config.ts +++ b/gitnexus/src/cli/analyze-config.ts @@ -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 = { // 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, - { allowNestedKey }: { allowNestedKey: boolean }, + { allowNestedKey, source }: { allowNestedKey: boolean; source: string }, ): Partial => { const out: Partial = {}; const setBy = new Map(); @@ -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)[spec.target] = normalizeValue(spec.kind, value, key); + (out as Record)[spec.target] = normalizeValue(spec.kind, value, key, source); } return out; @@ -356,11 +375,11 @@ export function loadAnalyzeConfig(repoRoot: string): Partial | 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 { +/** 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 | undefined, +): Partial | 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 { 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 { 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; - const flat = normalizeLevel(obj, { allowNestedKey: true }); + const flat = normalizeLevel(obj, { allowNestedKey: true, source }); let nested: Partial = {}; 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, { allowNestedKey: false }); + nested = normalizeLevel(nestedRaw as Record, { + allowNestedKey: false, + source, + }); } // Nested `analyze` block overrides flat top-level keys for the same option. diff --git a/gitnexus/src/cli/analyze-options.ts b/gitnexus/src/cli/analyze-options.ts index d548484e0..2083487e1 100644 --- a/gitnexus/src/cli/analyze-options.ts +++ b/gitnexus/src/cli/analyze-options.ts @@ -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. diff --git a/gitnexus/src/cli/analyze-watch.ts b/gitnexus/src/cli/analyze-watch.ts index 88080bd06..a5f19d4e1 100644 --- a/gitnexus/src/cli/analyze-watch.ts +++ b/gitnexus/src/cli/analyze-watch.ts @@ -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, onError: WatchRefreshError, onWatcherError: (error: unknown) => void = (error) => onError(error, []), + /** Absolute `GITNEXUS_RC` path; its edits, replacements and removal reload config. */ + externalConfigPath?: string, ): Promise { 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( diff --git a/gitnexus/src/cli/analyze.ts b/gitnexus/src/cli/analyze.ts index 483b889de..3ac4a1020 100644 --- a/gitnexus/src/cli/analyze.ts +++ b/gitnexus/src/cli/analyze.ts @@ -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 diff --git a/gitnexus/src/core/ingestion/call-processor.ts b/gitnexus/src/core/ingestion/call-processor.ts index fa69b72e2..b0f17b7ea 100644 --- a/gitnexus/src/core/ingestion/call-processor.ts +++ b/gitnexus/src/core/ingestion/call-processor.ts @@ -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); diff --git a/gitnexus/src/core/ingestion/language-provider.ts b/gitnexus/src/core/ingestion/language-provider.ts index 8e6d915b5..4d9e55634 100644 --- a/gitnexus/src/core/ingestion/language-provider.ts +++ b/gitnexus/src/core/ingestion/language-provider.ts @@ -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[]; diff --git a/gitnexus/src/core/ingestion/languages/go.ts b/gitnexus/src/core/ingestion/languages/go.ts index dd6ea6895..590ddfb2b 100644 --- a/gitnexus/src/core/ingestion/languages/go.ts +++ b/gitnexus/src/core/ingestion/languages/go.ts @@ -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, diff --git a/gitnexus/src/core/ingestion/languages/go/route-config.ts b/gitnexus/src/core/ingestion/languages/go/route-config.ts new file mode 100644 index 000000000..ef7d8ac56 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/go/route-config.ts @@ -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(); + 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; + 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()]; +} diff --git a/gitnexus/src/core/ingestion/languages/go/route-facts.ts b/gitnexus/src/core/ingestion/languages/go/route-facts.ts new file mode 100644 index 000000000..01cacd236 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/go/route-facts.ts @@ -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 = ( + files, + context, +) => { + const aliases = new Map(); + for (const alias of context.routeHookConfig?.routeFrameworkAliases ?? []) { + aliases.set(`${alias.importPath}\u0000${alias.type}`, alias.framework); + } + const out: ExtractedDecoratorRoute[] = []; + const emittedByRoot = new Map(); + const droppedByRoot = new Map(); + 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; +}; diff --git a/gitnexus/src/core/ingestion/languages/go/route-handler.ts b/gitnexus/src/core/ingestion/languages/go/route-handler.ts index 538ec0839..244701004 100644 --- a/gitnexus/src/core/ingestion/languages/go/route-handler.ts +++ b/gitnexus/src/core/ingestion/languages/go/route-handler.ts @@ -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>(); + +/** + * 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; type PackageIndex = Map; const bucketKey = (dir: string, name: string): string => JSON.stringify([dir, name]); diff --git a/gitnexus/src/core/ingestion/pipeline-phases/parse-impl.ts b/gitnexus/src/core/ingestion/pipeline-phases/parse-impl.ts index 725df68f1..a93a4b0a7 100644 --- a/gitnexus/src/core/ingestion/pipeline-phases/parse-impl.ts +++ b/gitnexus/src/core/ingestion/pipeline-phases/parse-impl.ts @@ -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(); 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); diff --git a/gitnexus/src/core/ingestion/pipeline.ts b/gitnexus/src/core/ingestion/pipeline.ts index a6d51e0ad..9ec3c5981 100644 --- a/gitnexus/src/core/ingestion/pipeline.ts +++ b/gitnexus/src/core/ingestion/pipeline.ts @@ -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 ───────────────────────────────────────────────────────── diff --git a/gitnexus/src/core/ingestion/route-extractors/go-gin-echo.ts b/gitnexus/src/core/ingestion/route-extractors/go-gin-echo.ts index 125f02c72..3a94d197d 100644 --- a/gitnexus/src/core/ingestion/route-extractors/go-gin-echo.ts +++ b/gitnexus/src/core/ingestion/route-extractors/go-gin-echo.ts @@ -45,9 +45,8 @@ const VERBS: ReadonlySet = 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> = { + 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 = 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; } { + const imports = new Map(); 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(); + private readonly memo = new Map(); private readonly visiting = new Set(); constructor( private readonly bindings: GoRouteBindings, - private readonly fw: Framework, + private readonly roots: ReadonlyMap, + 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, +): { 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, +): 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, +): 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, +): 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(); - 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, +): { importPath: string; qualifier: string; typeName: string }[] { + const out = new Map(); + 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(); + const handlerHints = new Map(); + for (const { fn, registrations, bindings } of functionRegistrations(root, framework, imports)) { + const roots = new Map(); + 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(); 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; } diff --git a/gitnexus/src/core/ingestion/route-hook-config.ts b/gitnexus/src/core/ingestion/route-hook-config.ts new file mode 100644 index 000000000..716944e55 --- /dev/null +++ b/gitnexus/src/core/ingestion/route-hook-config.ts @@ -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), + ]); +} diff --git a/gitnexus/src/core/ingestion/workers/parse-worker.ts b/gitnexus/src/core/ingestion/workers/parse-worker.ts index c6024ff69..71b9b6d43 100644 --- a/gitnexus/src/core/ingestion/workers/parse-worker.ts +++ b/gitnexus/src/core/ingestion/workers/parse-worker.ts @@ -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-`. Present when the route was extracted from a diff --git a/gitnexus/src/core/rebuild-reasons.ts b/gitnexus/src/core/rebuild-reasons.ts index 44afc2f18..351c8da26 100644 --- a/gitnexus/src/core/rebuild-reasons.ts +++ b/gitnexus/src/core/rebuild-reasons.ts @@ -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', diff --git a/gitnexus/src/core/run-analyze.ts b/gitnexus/src/core/run-analyze.ts index e4a2de895..3e0f99ed9 100644 --- a/gitnexus/src/core/run-analyze.ts +++ b/gitnexus/src/core/run-analyze.ts @@ -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. diff --git a/gitnexus/src/server/analyze-launch.ts b/gitnexus/src/server/analyze-launch.ts index fa2d1a264..840d18285 100644 --- a/gitnexus/src/server/analyze-launch.ts +++ b/gitnexus/src/server/analyze-launch.ts @@ -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, }, }); }; diff --git a/gitnexus/src/storage/repo-meta.ts b/gitnexus/src/storage/repo-meta.ts index a4ca81ac4..40bc80494 100644 --- a/gitnexus/src/storage/repo-meta.ts +++ b/gitnexus/src/storage/repo-meta.ts @@ -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 diff --git a/gitnexus/test/fixtures/go-wrapped-gin-route-app/api/api.go b/gitnexus/test/fixtures/go-wrapped-gin-route-app/api/api.go new file mode 100644 index 000000000..7098e53ee --- /dev/null +++ b/gitnexus/test/fixtures/go-wrapped-gin-route-app/api/api.go @@ -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) {} diff --git a/gitnexus/test/fixtures/go-wrapped-gin-route-app/common/context.go b/gitnexus/test/fixtures/go-wrapped-gin-route-app/common/context.go new file mode 100644 index 000000000..3d4bb78c8 --- /dev/null +++ b/gitnexus/test/fixtures/go-wrapped-gin-route-app/common/context.go @@ -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) {} } diff --git a/gitnexus/test/fixtures/go-wrapped-gin-route-app/go.mod b/gitnexus/test/fixtures/go-wrapped-gin-route-app/go.mod new file mode 100644 index 000000000..b0d6e4c01 --- /dev/null +++ b/gitnexus/test/fixtures/go-wrapped-gin-route-app/go.mod @@ -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 +) diff --git a/gitnexus/test/fixtures/go-wrapped-gin-route-app/routes/routes.go b/gitnexus/test/fixtures/go-wrapped-gin-route-app/routes/routes.go new file mode 100644 index 000000000..adc33b7fc --- /dev/null +++ b/gitnexus/test/fixtures/go-wrapped-gin-route-app/routes/routes.go @@ -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) + } +} diff --git a/gitnexus/test/fixtures/go-wrapped-gin-route-app/service/jobs.go b/gitnexus/test/fixtures/go-wrapped-gin-route-app/service/jobs.go new file mode 100644 index 000000000..023e03909 --- /dev/null +++ b/gitnexus/test/fixtures/go-wrapped-gin-route-app/service/jobs.go @@ -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" } diff --git a/gitnexus/test/integration/go-wrapped-gin-route-pipeline.test.ts b/gitnexus/test/integration/go-wrapped-gin-route-pipeline.test.ts new file mode 100644 index 000000000..c71a80e64 --- /dev/null +++ b/gitnexus/test/integration/go-wrapped-gin-route-pipeline.test.ts @@ -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); +}); diff --git a/gitnexus/test/integration/server-route-hook-config.test.ts b/gitnexus/test/integration/server-route-hook-config.test.ts new file mode 100644 index 000000000..418303764 --- /dev/null +++ b/gitnexus/test/integration/server-route-hook-config.test.ts @@ -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>; + 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[] = []; + 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); +}); diff --git a/gitnexus/test/integration/watch-filesystem.test.ts b/gitnexus/test/integration/watch-filesystem.test.ts index 646d95e2a..3d3d88de6 100644 --- a/gitnexus/test/integration/watch-filesystem.test.ts +++ b/gitnexus/test/integration/watch-filesystem.test.ts @@ -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([]); + }); }); diff --git a/gitnexus/test/unit/analyze-config.test.ts b/gitnexus/test/unit/analyze-config.test.ts index a4245f6c5..96b6d474d 100644 --- a/gitnexus/test/unit/analyze-config.test.ts +++ b/gitnexus/test/unit/analyze-config.test.ts @@ -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 => { + 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 () => { diff --git a/gitnexus/test/unit/analyze-launch-collapse.test.ts b/gitnexus/test/unit/analyze-launch-collapse.test.ts index 258ebe525..4172ead9a 100644 --- a/gitnexus/test/unit/analyze-launch-collapse.test.ts +++ b/gitnexus/test/unit/analyze-launch-collapse.test.ts @@ -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('node:fs/promises'); + const os = await vi.importActual('node:os'); + rcDir = await fsp.mkdtemp(`${os.tmpdir()}/gitnexus-launch-rc-`); + }); + + afterEach(async () => { + vi.unstubAllEnvs(); + const fsp = await vi.importActual('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('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, diff --git a/gitnexus/test/unit/go-gin-echo-routes.test.ts b/gitnexus/test/unit/go-gin-echo-routes.test.ts index c527f7d2c..f2bedbe01 100644 --- a/gitnexus/test/unit/go-gin-echo-routes.test.ts +++ b/gitnexus/test/unit/go-gin-echo-routes.test.ts @@ -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({}); + }); +}); diff --git a/gitnexus/test/unit/go-route-facts.test.ts b/gitnexus/test/unit/go-route-facts.test.ts new file mode 100644 index 000000000..916a3cc0f --- /dev/null +++ b/gitnexus/test/unit/go-route-facts.test.ts @@ -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'], + ]), + ); + }); +}); diff --git a/gitnexus/test/unit/go-route-handler.test.ts b/gitnexus/test/unit/go-route-handler.test.ts index 5a8736a30..9895c5d50 100644 --- a/gitnexus/test/unit/go-route-handler.test.ts +++ b/gitnexus/test/unit/go-route-handler.test.ts @@ -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`); + }); + }); }); diff --git a/gitnexus/test/unit/incremental-orchestration.test.ts b/gitnexus/test/unit/incremental-orchestration.test.ts index cd17b01b8..0227637ce 100644 --- a/gitnexus/test/unit/incremental-orchestration.test.ts +++ b/gitnexus/test/unit/incremental-orchestration.test.ts @@ -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 { diff --git a/gitnexus/test/unit/rebuild-reasons.test.ts b/gitnexus/test/unit/rebuild-reasons.test.ts index 803c1941c..c0c14ed40 100644 --- a/gitnexus/test/unit/rebuild-reasons.test.ts +++ b/gitnexus/test/unit/rebuild-reasons.test.ts @@ -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' }, diff --git a/gitnexus/test/unit/watch-external-config.test.ts b/gitnexus/test/unit/watch-external-config.test.ts new file mode 100644 index 000000000..8a4466d20 --- /dev/null +++ b/gitnexus/test/unit/watch-external-config.test.ts @@ -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) | undefined, +})); + +vi.mock('../../src/cli/analyze-config.js', async (importActual) => { + const actual = await importActual(); + 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()), + 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 { + 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); +}); diff --git a/gitnexus/test/unit/watch-paths.test.ts b/gitnexus/test/unit/watch-paths.test.ts index 5dc5ce3f9..0a019d6bb 100644 --- a/gitnexus/test/unit/watch-paths.test.ts +++ b/gitnexus/test/unit/watch-paths.test.ts @@ -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', diff --git a/gitnexus/vitest.config.ts b/gitnexus/vitest.config.ts index b31f9c10a..c14e5cbd6 100644 --- a/gitnexus/vitest.config.ts +++ b/gitnexus/vitest.config.ts @@ -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',