From b9613ee86b4329c5ba5d69190848f0d29fc12d80 Mon Sep 17 00:00:00 2001 From: ChunxueLi <54129170+ChunxueLi@users.noreply.github.com> Date: Thu, 3 Sep 2026 03:59:45 +0800 Subject: [PATCH 1/2] feat(node): wrapped-client HTTP consumers + leading-prefix template stripping (#3111) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(http-patterns): Patch 8 - extract enterprise wrapped-client HTTP consumers Recognize the enterprise axios-wrapper call shape X.request({ url, method }) (e.g. httpClient.request from @winex-plugin/win-request) as a consumer contract source, and strip the leading gateway/service-prefix template variable so consumer paths align with backend provider routes. Effect on sr-next group: 1740 contracts / 0 cross-links -> 3540 contracts / 883 exact cross-links (14 frontend repos <-> backend/opt). Not submitted upstream yet; see CUSTOM_PATCHES.md Patch 8 for details. Co-Authored-By: Claude * feat(node): wrapped-client HTTP consumers + leading-prefix template stripping Consumer extraction for enterprise axios-wrapped clients: - X.request({ url, method }) member form (httpClient.request from win-request and friends): any .request(options) call with url/method|type string props; the url template literal is split on ${...} spans and the longest /-leading literal segment is kept. - Leading ${...} gateway/service-prefix variables are stripped as consumer-path normalization semantics in normalizeHttpPath (stripLeadingTemplatePrefix): `${client}/api/v1/x` → /api/v1/x for fetch/axios/wrapped shapes alike. Mid/tail interpolations still round-trip through {param}; a stripped remainder not starting with / is dropped (same rejection static relative urls get at scan time). - %7B/%7D unescaping for absolute-URL branches. On our 16-repo frontend monorepo this took group sync from 1740 contracts / 0 cross-links to 5111 / 2097 (exact links, 16/16 repos linked). * fix(route): preserve upstream symbol-resolution machinery; strip only Rebasing correction: an earlier iteration of this change simplified the symbol probing to a bare line-1 offset and dropped the exact-module match, which mis-attributed data-table handlers to decoy same-name symbols (6 data-route-table regressions). Restore the upstream probing (toZeroBasedLine + exact-module resolution) wholesale; the deltas this change actually needs are the pure ones: stripLeadingTemplatePrefix, normalizeConsumerPath returning null for un-reducible urls (callers drop), and the %7B/%7D brace restoration in the absolute-URL branch. * style: prettier * Address PR review feedback (#3111) Pass wrapped-client URLs through shared consumer-path normalization instead of the longest-segment reducer, emit * for present-but-non-literal methods, restore {param} via a sentinel so literal %7B segments stay encoded, and drop unused decorator helpers. Co-authored-by: Cursor * Address PR review feedback (#3111) Gate wrapped X.request({url}) on axios-proven receivers or a small wrapper allowlist so cy.request/queue.request cannot mint HTTP consumers. Co-authored-by: Cursor * Address PR review feedback (#3111) Use a private-use sentinel so literal path segments are not rewritten, trim wrapped URLs before the scan-time path gate, and treat interpolated/shorthand methods as *. Co-authored-by: Cursor * chore(autofix): apply prettier + eslint fixes via /autofix command * fix(http): honest wrapped-request methods and tighter admission (#3111) Quoted keys and object spreads were minted as GET; drop spelling-only `api`, align gateway-prefix member verbs with prefix-strip, and keep absolute URLs. Co-authored-by: Cursor --------- Co-authored-by: l.cx Co-authored-by: Claude Co-authored-by: ChunxueLi Co-authored-by: Gergő Magyar Co-authored-by: Gergo Magyar Co-authored-by: Cursor Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .../group/extractors/http-patterns/node.ts | 155 +++++++++++++- .../group/extractors/http-route-extractor.ts | 63 +++++- .../unit/group/http-route-extractor.test.ts | 111 ++++++++++ .../group/js-http-consumer-resolution.test.ts | 195 ++++++++++++++++++ 4 files changed, 511 insertions(+), 13 deletions(-) diff --git a/gitnexus/src/core/group/extractors/http-patterns/node.ts b/gitnexus/src/core/group/extractors/http-patterns/node.ts index 7a198f595..9edfd69eb 100644 --- a/gitnexus/src/core/group/extractors/http-patterns/node.ts +++ b/gitnexus/src/core/group/extractors/http-patterns/node.ts @@ -13,6 +13,7 @@ import type { HttpDetection, HttpLanguagePlugin, RepoContext } from './types.js' import { MAX_FOLD_LENGTH } from '../../../ingestion/route-extractors/constant-resolver.js'; import { DATA_ROUTE_TABLE_SOURCE, + propertyName, scanDataRouteTables, } from '../../../ingestion/route-extractors/data-route-table.js'; import { extractNestRoutes } from '../../../ingestion/route-extractors/nest.js'; @@ -152,6 +153,37 @@ const AXIOS_OBJECT_SPEC: PatternSpec> = { `, }; +// ─── Consumer: wrapped client X.request({ url, method }) ──────────── +// Enterprise wrapper shape: an axios instance (or a named request helper) +// re-exported under a local name — `httpClient.request({ url, method })` +// from `@winex-plugin/win-request`, `$http.request(...)`. Generic names +// like `api` need axios.create/import proof (`isHttpClientRef`); spelling +// alone is too common (graphql-request helpers, domain `api` objects). +// The member property is `request` (not an HTTP verb), so this cannot +// collide with the Express provider pattern (`router.get`) or the axios +// member form (`axios.get`). Option keys are resolved programmatically, +// same as the jQuery ajax / axios object forms. +// +// The query captures the receiver so scan can reject unrelated +// `.request({ url })` APIs (`cy.request`, `queue.request`). +const REQUEST_OBJECT_SPEC: PatternSpec> = { + meta: {}, + query: ` + (call_expression + function: (member_expression + object: (_) @obj + property: (property_identifier) @fn (#eq? @fn "request")) + arguments: (arguments . (object) @options)) + `, +}; + +/** + * Receivers admitted as wrapped HTTP clients without axios.create proof. + * Spelling-only: the last identifier in `obj.text` (`this.$http` → `$http`). + * Keep this set small — every extra name is a false-positive surface. + */ +const WRAPPED_REQUEST_RECEIVERS = new Set(['httpClient', '$http']); + interface NodePatternBundle { express: CompiledPatterns>; fetchNoOptions: CompiledPatterns>; @@ -160,6 +192,7 @@ interface NodePatternBundle { jqueryShorthand: CompiledPatterns>; jqueryAjax: CompiledPatterns>; axiosObject: CompiledPatterns>; + requestObject: CompiledPatterns>; } function compileBundle(language: unknown, name: string): NodePatternBundle { @@ -177,6 +210,7 @@ function compileBundle(language: unknown, name: string): NodePatternBundle { jqueryShorthand: mk(JQUERY_SHORTHAND_SPEC, 'jquery-shorthand'), jqueryAjax: mk(JQUERY_AJAX_SPEC, 'jquery-ajax'), axiosObject: mk(AXIOS_OBJECT_SPEC, 'axios-object'), + requestObject: mk(REQUEST_OBJECT_SPEC, 'request-object'), }; } @@ -190,6 +224,7 @@ const TSX_BUNDLE = compileBundle(TypeScript.tsx, 'tsx-http'); * of `keyNames`. Returns null when no matching pair is present or the * value is not a string literal. Used by the jQuery ajax / axios object * consumers to resolve `url` / `method` / `type` keys in any order. + * Keys use shared `propertyName` so quoted `"method"` matches `method`. */ function readStringProp(objectNode: Parser.SyntaxNode, keyNames: readonly string[]): string | null { for (let i = 0; i < objectNode.namedChildCount; i++) { @@ -198,7 +233,8 @@ function readStringProp(objectNode: Parser.SyntaxNode, keyNames: readonly string const keyNode = pair.childForFieldName('key'); const valueNode = pair.childForFieldName('value'); if (!keyNode || !valueNode) continue; - if (!keyNames.includes(keyNode.text)) continue; + const key = propertyName(keyNode); + if (key === null || !keyNames.includes(key)) continue; if (valueNode.type !== 'string' && valueNode.type !== 'template_string') continue; const lit = unquoteLiteral(valueNode.text); if (lit !== null) return lit; @@ -206,6 +242,80 @@ function readStringProp(objectNode: Parser.SyntaxNode, keyNames: readonly string return null; } +/** + * Verb for wrapped `X.request({ url, method|type })`. Absent key → GET + * (same default as fetch-without-options / jQuery ajax). Present but not a + * string/template, supplied only via object spread, or later overwritten by + * a duplicate key / spread → `*` so matching can still link without pinning GET. + * Later properties win, matching JavaScript object-literal evaluation. + */ +function readRequestMethod( + objectNode: Parser.SyntaxNode, + keyNames: readonly string[] = ['method', 'type'], +): string { + type Verb = { kind: 'absent' } | { kind: 'literal'; value: string } | { kind: 'unknown' }; + let last: Verb = { kind: 'absent' }; + for (let i = 0; i < objectNode.namedChildCount; i++) { + const child = objectNode.namedChild(i); + if (!child) continue; + if (child.type === 'spread_element') { + last = { kind: 'unknown' }; + continue; + } + if ( + child.type === 'shorthand_property_identifier' || + child.type === 'shorthand_property_identifier_pattern' + ) { + if (keyNames.includes(child.text)) last = { kind: 'unknown' }; + continue; + } + if (child.type !== 'pair') continue; + const keyNode = child.childForFieldName('key'); + const valueNode = child.childForFieldName('value'); + if (!keyNode) continue; + const key = propertyName(keyNode); + if (key === null || !keyNames.includes(key)) continue; + if (!valueNode || (valueNode.type !== 'string' && valueNode.type !== 'template_string')) { + last = { kind: 'unknown' }; + continue; + } + const lit = unquoteLiteral(valueNode.text); + if (lit === null || lit.includes('${')) { + last = { kind: 'unknown' }; + continue; + } + last = { kind: 'literal', value: lit }; + } + if (last.kind === 'literal') return last.value.toUpperCase(); + if (last.kind === 'unknown') return '*'; + return 'GET'; +} + +function wrappedRequestReceiverName(receiver: string): string { + const parts = receiver.split('.'); + return parts[parts.length - 1] ?? receiver; +} + +/** Axios module / axios.create instance, or a registered wrapper identifier. */ +function isAdmittedWrappedRequestReceiver( + receiver: string, + fileKey: string | undefined, + facts: JsRepoFacts | null, +): boolean { + if (WRAPPED_REQUEST_RECEIVERS.has(wrappedRequestReceiverName(receiver))) return true; + try { + const isModule = + facts === null || fileKey === undefined + ? receiver === 'axios' + : isAxiosNamespace(fileKey, receiver, facts); + if (isModule) return true; + if (!facts || fileKey === undefined) return false; + return isHttpClientRef(fileKey, receiver, facts); + } catch { + return false; + } +} + /** * Map each named import's LOCAL binding to its DECLARED export name and source * module, by walking the file's `import { x as y } from 'm'` statements. Lets @@ -390,9 +500,10 @@ function resolveFactsFor( * * `/{param}` matches every one-segment provider route in the group, and * `matching.exclude_links_param_only_paths` defaults to `false`. A path whose - * leading term is an unresolved placeholder is refused for the same reason — - * nothing pins where it starts. (`resolveJsPathExpression` already refuses those - * it folded itself; this also covers the literal fallback below.) + * leading term is an unresolved placeholder is refused unless the next + * character is `/` — that is the gateway-prefix shape + * `` `${serviceClient}/api/v1/x` `` that `stripLeadingTemplatePrefix` keeps. + * Bare `{param}` and `{param}api/x` stay rejected: nothing pins a route. */ function looksLikeHttpPath(path: string): boolean { if (path === '') return false; @@ -404,7 +515,7 @@ function looksLikeHttpPath(path: string): boolean { // unresolved term happened to contain a space. const shape = path.replace(/\$\{[^}]+\}/g, '{param}'); if (/\s/.test(shape)) return false; - if (shape.startsWith('{param}')) return false; + if (shape.startsWith('{param}') && !shape.startsWith('{param}/')) return false; // An all-digit string is a path only when it is written as one. A leading // slash is that evidence: `client.get('/123')` is a route whose segment the // consumer normalizer reads as `{param}`, while a bare `"5000"` folded out of @@ -672,8 +783,7 @@ function scanBundle( if (!optionsNode) continue; const path = readStringProp(optionsNode, ['url']); if (path === null) continue; - const rawMethod = readStringProp(optionsNode, ['method']); - const method = (rawMethod ?? 'GET').toUpperCase(); + const method = readRequestMethod(optionsNode, ['method']); out.push({ role: 'consumer', framework: 'axios', @@ -685,6 +795,37 @@ function scanBundle( }); } + // Consumer: wrapped client `X.request({ url, method })` — the shared + // enterprise axios-instance shape (`httpClient.request` from + // win-request and friends). Emit the raw url (templates intact) so + // shared `normalizeConsumerPath` can strip a leading `${…}` gateway + // prefix and fold mid/tail interpolations to `{param}`. A plugin-side + // longest-slash-segment reducer would truncate those mid-templates + // before the shared normalizer ever saw them. This scan drops only + // static relative urls (no `${`, no leading `/`, not `https?://`). + // That filter is request-wrapper-specific: fetch/axios member forms + // already admit absolute urls and leave host stripping to + // `normalizeConsumerPath`. + for (const match of runCompiledPatterns(bundle.requestObject, tree)) { + const optionsNode = match.captures.options; + const objNode = match.captures.obj; + if (!optionsNode || !objNode) continue; + if (!isAdmittedWrappedRequestReceiver(objNode.text, fileKey, facts)) continue; + const rawUrl = readStringProp(optionsNode, ['url']); + if (rawUrl === null) continue; + const url = rawUrl.trim(); + if (!url.includes('${') && !url.startsWith('/') && !/^https?:\/\//i.test(url)) continue; + out.push({ + role: 'consumer', + framework: 'request', + method: readRequestMethod(optionsNode), + path: url, + name: null, + line: optionsNode.startPosition.row + 1, + confidence: 0.65, + }); + } + for (const route of scanDataRouteTables(tree)) { const imported = route.handlerLocalName === undefined ? undefined : importMap.get(route.handlerLocalName); diff --git a/gitnexus/src/core/group/extractors/http-route-extractor.ts b/gitnexus/src/core/group/extractors/http-route-extractor.ts index 5def6a9a9..d116bc046 100644 --- a/gitnexus/src/core/group/extractors/http-route-extractor.ts +++ b/gitnexus/src/core/group/extractors/http-route-extractor.ts @@ -292,13 +292,57 @@ export function normalizeHttpPath(p: string): string { } /** - * Consumer-side normalization is more aggressive: - * - template literals (`${x}`) → `{param}` - * - strip protocol + host if the URL is absolute - * - numeric segments → `{param}` (so `/api/orders/42` → `/api/orders/{param}`) + * Strip LEADING template interpolations from a consumer url as gateway/host + * bindings — the enterprise wrapper shape `` `${serviceClient}/api/v1/x` `` + * where `${serviceClient}` selects the gateway service, not a route segment. + * This is consumer-path framework semantics (mirrors how an absolute + * `https://host/path` url keeps only its path), so it lives here rather than + * in any one language plugin: + * - `` `${c}/api/x` `` → `/api/x` (clean prefix; `${c}${d}/api/x` → `/api/x`) + * - `` `${c}/api/x/${id}` ``→ `/api/x/${id}` (mid/tail interpolations are left for the `{param}` pass) + * Returns null when the stripped remainder is not a single-slash path: + * - remainder without `/` — relative fragment (`${c}api/x`) or scheme/host + * (`${scheme}://${host}/api/x`); whether it is a path depends on + * unverifiable runtime state, so it is dropped rather than guessed at; + * - remainder starting with `//` — protocol-relative (`${proto}//host/api/x`); + * keeping it would later collapse to `/host/api/x`. + * + * The `?` in a query string cannot leak into the brace matching: `${...}` + * spans are matched by braces here (before any `{param}` replacement), and + * `normalizeHttpPath` splits on `?` only after the whole `${...}` span — + * including any `?` inside it — has been collapsed to `{param}`. So + * `` `${c}/api/x?id=${id}` `` reduces to `/api/x` on both orderings. */ -function normalizeConsumerPath(url: string): string { - const templated = url.replace(/\$\{[^}]+\}/g, '{param}').trim(); +function stripLeadingTemplatePrefix(url: string): string | null { + if (!url.startsWith('${')) return url; + const rest = url.replace(/^(?:\$\{[^}]*\})+/, ''); + return rest.startsWith('/') && !rest.startsWith('//') ? rest : null; +} + +/** + * Placeholder substituted for `${...}` before WHATWG `URL` parsing so the + * parser cannot percent-encode our own `{param}` markers. A genuine encoded + * segment like `%7Bfoo%7D` then survives as a literal, instead of being + * rewritten into braces and folded into `{param}`. + * + * Private-use U+E000 cannot appear in a real URL path, so a literal + * `__gitnexus_http_param__` segment is not rewritten into `{param}`. + */ +const CONSUMER_PARAM_SENTINEL = '\uE000'; +const CONSUMER_PARAM_SENTINEL_ENC = '%ee%80%80'; + +function restoreConsumerParamSentinel(pathOnly: string): string { + return pathOnly + .split(CONSUMER_PARAM_SENTINEL) + .join('{param}') + .replace(new RegExp(CONSUMER_PARAM_SENTINEL_ENC, 'gi'), '{param}'); +} + +/** Canonicalize a consumer URL after `stripLeadingTemplatePrefix`. */ +function normalizeConsumerPath(url: string): string | null { + const stripped = stripLeadingTemplatePrefix(url.trim()); + if (stripped === null) return null; + const templated = stripped.replace(/\$\{[^}]+\}/g, CONSUMER_PARAM_SENTINEL).trim(); let pathOnly = templated; if (/^https?:\/\//i.test(templated)) { try { @@ -307,6 +351,7 @@ function normalizeConsumerPath(url: string): string { pathOnly = templated.replace(/^https?:\/\/[^/]+/i, ''); } } + pathOnly = restoreConsumerParamSentinel(pathOnly); const normalized = normalizeHttpPath(pathOnly || '/'); const segments = normalized .split('/') @@ -999,6 +1044,12 @@ export class HttpRouteExtractor implements ContractExtractor { for (const d of detections) { if (d.role !== 'consumer') continue; const pathNorm = normalizeConsumerPath(d.path); + // A consumer url that cannot be reduced to a routable path (e.g. a + // leading template binding that is neither a clean prefix nor a + // remainder that starts with `/`) is dropped here rather than emitted + // as a never-matching contract — same treatment the plugins give + // static relative urls at scan time. + if (pathNorm === null) continue; // Resolve the function CONTAINING the fetch/axios call so the consumer // contract carries a real symbolUid (was always '' — the gap that left // cross-repo trace/impact unable to traverse HTTP links). diff --git a/gitnexus/test/unit/group/http-route-extractor.test.ts b/gitnexus/test/unit/group/http-route-extractor.test.ts index 68e7d8669..b59ca8a3b 100644 --- a/gitnexus/test/unit/group/http-route-extractor.test.ts +++ b/gitnexus/test/unit/group/http-route-extractor.test.ts @@ -2764,6 +2764,12 @@ export function updateUser(id: string, data: unknown) { export function listDefaults() { return axios({ url: '/api/defaults' }); } +export function ignoreJqueryTypeKey() { + return axios({ url: '/api/typed', type: 'POST' }); +} +export function quotedAxiosMethod() { + return axios({ url: '/api/quoted-ax', "method": 'DELETE' }); +} `, ); @@ -2773,6 +2779,111 @@ export function listDefaults() { expect(consumers.find((c) => c.contractId === 'http::POST::/api/orders')).toBeDefined(); expect(consumers.find((c) => c.contractId === 'http::PUT::/api/users/{param}')).toBeDefined(); expect(consumers.find((c) => c.contractId === 'http::GET::/api/defaults')).toBeDefined(); + expect(consumers.find((c) => c.contractId === 'http::POST::/api/typed')).toBeUndefined(); + expect(consumers.find((c) => c.contractId === 'http::GET::/api/typed')).toBeDefined(); + expect(consumers.find((c) => c.contractId === 'http::DELETE::/api/quoted-ax')).toBeDefined(); + }); + + it('extracts wrapped X.request({ url, method }) with shared prefix-strip and * verbs', async () => { + const dir = path.join(tmpDir, 'wrapped-request'); + fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src/client.ts'), + ` +import axios from 'axios'; + +export function listOrders(httpClient, serviceClient, tenant, verb) { + return httpClient.request({ url: \`\${serviceClient}/api/v1/orders\`, method: 'post' }); +} +export function getTenantOrder(httpClient, client, tenant) { + return httpClient.request({ url: \`\${client}/api/\${tenant}/orders\`, method: 'GET' }); +} +export function rootPing(httpClient, client) { + return httpClient.request({ url: \`\${client}/\`, method: 'GET' }); +} +export function dynamicVerb(httpClient) { + return httpClient.request({ url: '/api/orders', method: verb }); +} +export function missingMethod(httpClient) { + return httpClient.request({ url: '/api/defaults' }); +} +export function dropHostTemplate(httpClient, scheme, host) { + return httpClient.request({ url: \`\${scheme}://\${host}/api/x\`, method: 'GET' }); +} +export function absTemplateParam(httpClient, id) { + return httpClient.request({ url: \`https://host/api/\${id}\`, method: 'GET' }); +} +export function quotedMethod(httpClient) { + return httpClient.request({ url: '/api/quoted', "method": 'PATCH' }); +} +export function spreadMethod(httpClient, config) { + return httpClient.request({ url: '/api/spread', ...config }); +} +export function staticAbsolute(httpClient) { + return httpClient.request({ url: 'https://host/api/static', method: 'GET' }); +} +export function protocolRelative(httpClient, proto) { + return httpClient.request({ url: \`\${proto}//host/api/proto\`, method: 'GET' }); +} +export async function fetchGateway(gateway) { + return fetch(\`\${gateway}/api/users\`); +} +export function axiosGateway(gateway) { + return axios.get(\`\${gateway}/api/users\`); +} +export async function encodedBraceLiteral() { + return fetch('https://host/api/%7Bfoo%7D'); +} +export async function literalSentinelSegment() { + return fetch('https://host/api/__gitnexus_http_param__'); +} +`, + ); + + const contracts = await extractor.extract(null, dir, makeRepo(dir)); + const consumers = contracts.filter((c) => c.role === 'consumer'); + + expect(consumers.find((c) => c.contractId === 'http::POST::/api/v1/orders')).toBeDefined(); + expect( + consumers.find((c) => c.contractId === 'http::GET::/api/{param}/orders'), + ).toBeDefined(); + expect(consumers.find((c) => c.contractId === 'http::GET::/')).toBeDefined(); + expect(consumers.find((c) => c.contractId === 'http::*::/api/orders')).toBeDefined(); + expect(consumers.find((c) => c.contractId === 'http::GET::/api/defaults')).toBeDefined(); + expect(consumers.find((c) => c.contractId === 'http::GET::/api/x')).toBeUndefined(); + expect(consumers.find((c) => c.contractId === 'http::GET::/orders')).toBeUndefined(); + expect(consumers.find((c) => c.contractId === 'http::GET::/api/{param}')).toBeDefined(); + expect(consumers.find((c) => c.contractId === 'http::PATCH::/api/quoted')).toBeDefined(); + expect(consumers.find((c) => c.contractId === 'http::*::/api/spread')).toBeDefined(); + expect(consumers.find((c) => c.contractId === 'http::GET::/api/static')).toBeDefined(); + expect(consumers.find((c) => c.contractId === 'http::GET::/api/proto')).toBeUndefined(); + expect(consumers.find((c) => c.contractId === 'http::GET::/host/api/proto')).toBeUndefined(); + expect(consumers.find((c) => c.contractId === 'http::GET::/api/users')).toBeDefined(); + expect(consumers.find((c) => c.contractId === 'http::GET::/api/%7bfoo%7d')).toBeDefined(); + expect( + consumers.find((c) => c.contractId === 'http::GET::/api/__gitnexus_http_param__'), + ).toBeDefined(); + }); + + it('does not mint HTTP consumers for ungated .request({ url }) helpers', async () => { + const dir = path.join(tmpDir, 'wrapped-request-negative'); + fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src/misc.ts'), + ` +export function e2e(cy) { + return cy.request({ url: '/api/v1/orders', method: 'GET' }); +} +export function enqueue(queue) { + return queue.request({ url: '/admin', method: 'DELETE' }); +} +`, + ); + + const contracts = await extractor.extract(null, dir, makeRepo(dir)); + const consumers = contracts.filter((c) => c.role === 'consumer'); + expect(consumers.find((c) => c.contractId === 'http::GET::/api/v1/orders')).toBeUndefined(); + expect(consumers.find((c) => c.contractId === 'http::DELETE::/admin')).toBeUndefined(); }); it('does not emit consumers for unrelated object-literal calls (negative control)', async () => { diff --git a/gitnexus/test/unit/group/js-http-consumer-resolution.test.ts b/gitnexus/test/unit/group/js-http-consumer-resolution.test.ts index 8957825dc..c39dda791 100644 --- a/gitnexus/test/unit/group/js-http-consumer-resolution.test.ts +++ b/gitnexus/test/unit/group/js-http-consumer-resolution.test.ts @@ -843,3 +843,198 @@ describe('resolveJsImport', () => { ); }); }); + +describe('wrapped X.request({ url, method }) detections', () => { + it('keeps the raw template so mid-path interpolations survive to the normalizer', () => { + const detections = consumers( + JAVASCRIPT_HTTP_PLUGIN.scan( + jsParser.parse( + 'httpClient.request({ url: `${client}/api/${tenant}/orders`, method: "GET" });', + ), + ), + ); + expect(detections).toHaveLength(1); + expect(detections[0]?.framework).toBe('request'); + expect(detections[0]?.method).toBe('GET'); + expect(detections[0]?.path).toBe('${client}/api/${tenant}/orders'); + }); + + it('emits * when method is present but not a literal', () => { + const detections = consumers( + JAVASCRIPT_HTTP_PLUGIN.scan( + jsParser.parse('httpClient.request({ url: "/api/orders", method: verb });'), + ), + ); + expect(detections).toHaveLength(1); + expect(detections[0]?.method).toBe('*'); + }); + + it('uses the last duplicate method key, matching JS evaluation', () => { + const dynamicLast = consumers( + JAVASCRIPT_HTTP_PLUGIN.scan( + jsParser.parse("httpClient.request({ url: '/api/orders', method: 'GET', method: verb });"), + ), + ); + expect(dynamicLast[0]?.method).toBe('*'); + const literalLast = consumers( + JAVASCRIPT_HTTP_PLUGIN.scan( + jsParser.parse("httpClient.request({ url: '/api/orders', method: verb, method: 'POST' });"), + ), + ); + expect(literalLast[0]?.method).toBe('POST'); + }); + + it('defaults to GET only when method/type is absent', () => { + const detections = consumers( + JAVASCRIPT_HTTP_PLUGIN.scan(jsParser.parse('httpClient.request({ url: "/api/orders" });')), + ); + expect(detections).toHaveLength(1); + expect(detections[0]?.method).toBe('GET'); + }); + + it('drops a static relative url at scan time', () => { + const detections = consumers( + JAVASCRIPT_HTTP_PLUGIN.scan( + jsParser.parse("httpClient.request({ url: 'api/orders', method: 'GET' });"), + ), + ); + expect(detections).toHaveLength(0); + }); + + it('does not treat cy.request or queue.request as HTTP consumers', () => { + const detections = consumers( + JAVASCRIPT_HTTP_PLUGIN.scan( + jsParser.parse(` +cy.request({ url: '/api/v1/orders', method: 'GET' }); +queue.request({ url: '/admin', method: 'DELETE' }); +`), + ), + ); + expect(detections).toHaveLength(0); + }); + + it('ignores a config object that is not the first request argument', () => { + const detections = consumers( + JAVASCRIPT_HTTP_PLUGIN.scan( + jsParser.parse("httpClient.request('/actual', { url: '/metadata', method: 'GET' });"), + ), + ); + expect(detections).toHaveLength(0); + }); + + it('admits $http by spelling but not a bare api without axios proof', () => { + const detections = consumers( + JAVASCRIPT_HTTP_PLUGIN.scan( + jsParser.parse(` +$http.request({ url: '/api/orders', method: 'GET' }); +api.request({ url: '/api/users', method: 'POST' }); +`), + ), + ); + expect(detections).toEqual([ + expect.objectContaining({ method: 'GET', path: '/api/orders', framework: 'request' }), + ]); + }); + + it('reads quoted method/type keys and type: as the verb', () => { + const quoted = consumers( + JAVASCRIPT_HTTP_PLUGIN.scan( + jsParser.parse('httpClient.request({ url: "/api/orders", "method": "POST" });'), + ), + ); + expect(quoted[0]?.method).toBe('POST'); + const typed = consumers( + JAVASCRIPT_HTTP_PLUGIN.scan( + jsParser.parse("httpClient.request({ url: '/api/items', type: 'PUT' });"), + ), + ); + expect(typed[0]?.method).toBe('PUT'); + }); + + it('emits * when method may arrive via object spread', () => { + const detections = consumers( + JAVASCRIPT_HTTP_PLUGIN.scan( + jsParser.parse('httpClient.request({ url: "/api/orders", ...config });'), + ), + ); + expect(detections).toHaveLength(1); + expect(detections[0]?.method).toBe('*'); + }); + + it('keeps static absolute wrapped-request urls for host stripping', () => { + const detections = consumers( + JAVASCRIPT_HTTP_PLUGIN.scan( + jsParser.parse("httpClient.request({ url: 'https://host/api/x', method: 'GET' });"), + ), + ); + expect(detections).toHaveLength(1); + expect(detections[0]?.path).toBe('https://host/api/x'); + }); + + it('admits axios.create instances calling .request', () => { + const detections = consumers( + scanRepo( + { + 'src/lib/client.ts': ` + import axios from 'axios'; + export const api = axios.create({ baseURL: '/' }); + `, + 'src/api/orders.ts': ` + import { api } from '../lib/client'; + export const create = () => api.request({ url: '/api/orders', method: 'POST' }); + `, + }, + 'src/api/orders.ts', + ), + ); + expect(detections).toContainEqual( + expect.objectContaining({ role: 'consumer', method: 'POST', path: '/api/orders' }), + ); + }); + + it('admits member-verb calls with a gateway-prefixed template', () => { + const detections = consumers( + scanRepo( + { + 'src/lib/client.ts': ` + import axios from 'axios'; + export default axios.create({ baseURL: '/' }); + `, + 'src/api/users.ts': ` + import api from '../lib/client'; + export const list = (gateway: string) => api.get(\`\${gateway}/api/v1/users\`); + export const bare = (id: string) => api.get(\`\${id}\`); + export const glue = (c: string) => api.get(\`\${c}api/x\`); + `, + }, + 'src/api/users.ts', + ), + ); + expect(detections.map((d) => d.path)).toEqual(['${gateway}/api/v1/users']); + }); + + it('trims whitespace-prefixed absolute paths at scan time', () => { + const detections = consumers( + JAVASCRIPT_HTTP_PLUGIN.scan( + jsParser.parse("httpClient.request({ url: ' /api/orders', method: 'GET' });"), + ), + ); + expect(detections).toHaveLength(1); + expect(detections[0]?.path).toBe('/api/orders'); + }); + + it('emits * for interpolated method templates and shorthand method keys', () => { + const interpolated = consumers( + JAVASCRIPT_HTTP_PLUGIN.scan( + jsParser.parse('httpClient.request({ url: "/api/orders", method: `${verb}` });'), + ), + ); + expect(interpolated[0]?.method).toBe('*'); + const shorthand = consumers( + JAVASCRIPT_HTTP_PLUGIN.scan( + jsParser.parse("httpClient.request({ url: '/api/orders', method });"), + ), + ); + expect(shorthand[0]?.method).toBe('*'); + }); +}); From 131d9f93fd3e0ae970ac3bb69e0d2770003918fe Mon Sep 17 00:00:00 2001 From: ChunxueLi <54129170+ChunxueLi@users.noreply.github.com> Date: Thu, 3 Sep 2026 05:01:59 +0800 Subject: [PATCH 2/2] feat(route): resolve vendor-derived Spring mapping annotations by suffix (#2883) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(route): resolve vendor-derived Spring mapping annotations by suffix Frameworks commonly wrap Spring's built-in annotations with company-specific variants (e.g. Winning Health's @WinPostMapping wraps @PostMapping). The annotation definition lives in a binary JAR — not in source — so the meta-annotation cannot be read statically. Add resolveSpringAnnotationAlias(): resolves custom annotations by naming suffix (WinPostMapping → PostMapping → POST). This matches the universal Java convention of naming derived annotations with the base name as a suffix. Works for any vendor prefix, not just one company. The fix is in springAnnotationHttpMethods() (spring-shared.ts), which both the ingestion extractor (spring.ts) and the group extractor (java.ts) call. A single-function change propagates to both layers automatically. Zero configuration: no .gitnexusrc, no annotation allowlist. If an annotation name ends with a known Spring mapping suffix, it inherits that annotation's HTTP semantics. False-positive risk is negligible. Tests: 16 new unit tests covering resolveSpringAnnotationAlias directly, springAnnotationHttpMethods with aliased annotations, end-to-end extractSpringRoutes with vendor annotations, and ingestion/group parity. Existing route tests (260) continue to pass. * fix(route): address review findings — class-level aliases, registered prefixes P1: class-level @WinRequestMapping now gets the same prefix/constraint semantics as @RequestMapping — all five class-level exact-match sites (spring.ts phase-1 collect, typeRequestMethods, typeClassPrefixes; group http-patterns java.ts typeRequestMethods + type-level branch) route through the new shared isClassLevelMappingAnnotation predicate, and the hard-coded 'RequestMapping' argument in springAnnotationHttpMethods calls is replaced with the actual annotation name so alias resolution applies. P2: suffix-only alias matching accepted unrelated annotations (@AuditPostMapping emitted a phantom POST /audit). Alias resolution now requires a REGISTERED vendor prefix — 'Win' by default, extendable via GITNEXUS_SPRING_VENDOR_PREFIXES=Win,Acme without a rebuild. Tests: negative e2e for the unregistered-suffix phantom route, vendor class-prefix parity e2e, predicate unit matrix, env-registration test. * style: prettier * chore: drop accidental gitnexus-shared/dist worktree symlink from prettier commit Co-authored-by: Cursor * Address PR review feedback (#2883) Wire vendor Spring mapping aliases into Kotlin ingestion and group extraction, restore GITNEXUS_SPRING_VENDOR_PREFIXES after the env test, and stamp spring.route-bindings so existing indexes rebuild. Co-authored-by: Cursor * chore(autofix): apply prettier + eslint fixes via /autofix command * fix(route): honor Kotlin vendor aliases and prefix freshness Parse Kotlin RequestMapping method arrays in the shared Spring helper, bump spring.route-bindings, and rebuild when registered vendor prefixes change. Co-authored-by: Cursor --------- Co-authored-by: ChunxueLi Co-authored-by: Gergo Magyar Co-authored-by: Cursor Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Gergő Magyar --- .../src/core/analysis-feature-registry.ts | 26 ++ .../group/extractors/http-patterns/java.ts | 13 +- .../group/extractors/http-patterns/kotlin.ts | 128 ++++++-- .../frameworks/spring/analysis-features.ts | 10 + .../frameworks/spring/vendor-prefixes.ts | 27 ++ .../route-extractors/kotlin-spring.ts | 24 +- .../route-extractors/spring-shared.ts | 94 +++++- .../core/ingestion/route-extractors/spring.ts | 17 +- gitnexus/src/core/run-analyze.ts | 41 ++- gitnexus/src/storage/repo-meta.ts | 5 + gitnexus/test/unit/analysis-features.test.ts | 38 +-- .../unit/incremental-orchestration.test.ts | 37 +++ .../kotlin-spring-route-ingestion.test.ts | 157 ++++++++- .../spring-vendor-annotation-alias.test.ts | 297 ++++++++++++++++++ 14 files changed, 786 insertions(+), 128 deletions(-) create mode 100644 gitnexus/src/core/analysis-feature-registry.ts create mode 100644 gitnexus/src/core/ingestion/frameworks/spring/vendor-prefixes.ts create mode 100644 gitnexus/test/unit/spring-vendor-annotation-alias.test.ts diff --git a/gitnexus/src/core/analysis-feature-registry.ts b/gitnexus/src/core/analysis-feature-registry.ts new file mode 100644 index 000000000..474c2157d --- /dev/null +++ b/gitnexus/src/core/analysis-feature-registry.ts @@ -0,0 +1,26 @@ +import { CLASS_FRAMEWORK_ANNOTATIONS_FEATURE } from './analysis-features.js'; +import { + SPRING_AOP_FEATURE, + SPRING_BEAN_INVENTORY_FEATURE, + SPRING_CONDITIONALS_FEATURE, + SPRING_NON_HTTP_HANDLERS_FEATURE, + SPRING_ROUTE_BINDINGS_FEATURE, +} from './ingestion/frameworks/spring/analysis-features.js'; +import { + JAVA_ENUM_INTERFACE_HERITAGE_FEATURE, + JAVA_RECORD_COMPONENT_ACCESSORS_FEATURE, + SPRING_CONFIG_BINDINGS_FEATURE, +} from './ingestion/languages/java/analysis-features.js'; + +/** Production registry of independently versioned analysis capabilities. */ +export const ANALYSIS_FEATURES = [ + CLASS_FRAMEWORK_ANNOTATIONS_FEATURE, + SPRING_AOP_FEATURE, + SPRING_BEAN_INVENTORY_FEATURE, + SPRING_CONDITIONALS_FEATURE, + SPRING_NON_HTTP_HANDLERS_FEATURE, + SPRING_ROUTE_BINDINGS_FEATURE, + SPRING_CONFIG_BINDINGS_FEATURE, + JAVA_ENUM_INTERFACE_HERITAGE_FEATURE, + JAVA_RECORD_COMPONENT_ACCESSORS_FEATURE, +] as const; diff --git a/gitnexus/src/core/group/extractors/http-patterns/java.ts b/gitnexus/src/core/group/extractors/http-patterns/java.ts index 8d216c50a..8b8897a91 100644 --- a/gitnexus/src/core/group/extractors/http-patterns/java.ts +++ b/gitnexus/src/core/group/extractors/http-patterns/java.ts @@ -11,6 +11,7 @@ import { intersectSpringHttpMethods, isRouteMemberKey, findEnclosingClass, + isClassLevelMappingAnnotation, joinPath, type SharedSpringType, } from '../../../ingestion/route-extractors/spring-shared.js'; @@ -467,13 +468,15 @@ function annotationHasRouteMember(annotation: Parser.SyntaxNode): boolean { } function typeRequestMethods(typeNode: Parser.SyntaxNode): readonly string[] { - const mappings = declarationAnnotations(typeNode).filter( - (annotation) => - simpleName(annotation.childForFieldName('name')?.text ?? '') === 'RequestMapping', + const mappings = declarationAnnotations(typeNode).filter((annotation) => + isClassLevelMappingAnnotation(simpleName(annotation.childForFieldName('name')?.text ?? '')), ); if (mappings.length === 0) return ['*']; if (mappings.length !== 1) return []; - return springAnnotationHttpMethods('RequestMapping', mappings[0].text); + return springAnnotationHttpMethods( + simpleName(mappings[0].childForFieldName('name')?.text ?? 'RequestMapping'), + mappings[0].text, + ); } function hasAnnotation(node: Parser.SyntaxNode, names: string | readonly string[]): boolean { @@ -675,7 +678,7 @@ function scanRouteAnnotations(tree: Parser.Tree): RouteAnnotationScan { // Type-level (class or interface): a Spring `@RequestMapping` URL prefix, or // — on an interface — an OpenFeign `@FeignClient(path = "...")` prefix. - if (ann === 'RequestMapping') { + if (isClassLevelMappingAnnotation(ann)) { if (!isRouteMemberKey(keyNode)) continue; if (!valueNode) { // Constant-valued class prefix — see `typesWithUnfoldablePrefix`. diff --git a/gitnexus/src/core/group/extractors/http-patterns/kotlin.ts b/gitnexus/src/core/group/extractors/http-patterns/kotlin.ts index ec695a9b6..b00a3e400 100644 --- a/gitnexus/src/core/group/extractors/http-patterns/kotlin.ts +++ b/gitnexus/src/core/group/extractors/http-patterns/kotlin.ts @@ -13,9 +13,11 @@ import type { HttpScanInput, } from './types.js'; import { - METHOD_ANNOTATION_TO_HTTP, findEnclosingClass, + intersectSpringHttpMethods, + isClassLevelMappingAnnotation, joinPath, + springAnnotationHttpMethods, type SharedSpringType, } from '../../../ingestion/route-extractors/spring-shared.js'; import { @@ -448,6 +450,13 @@ function inferKotlinOkHttpMethod(urlCall: Parser.SyntaxNode): string | null { return name === null ? 'GET' : name.toUpperCase(); } +function enclosingAnnotationText(node: Parser.SyntaxNode): string { + for (let current: Parser.SyntaxNode | null = node; current; current = current.parent) { + if (current.type === 'annotation') return current.text; + } + return node.text; +} + /** * Build the plugin only if the Kotlin grammar is available. Compiling * the queries against a null grammar would throw at module load time @@ -485,7 +494,7 @@ function buildKotlinPlugin(language: unknown): HttpLanguagePlugin { (modifiers (annotation (constructor_invocation - (user_type (type_identifier) @ann (#eq? @ann "RequestMapping")) + (user_type (type_identifier) @ann (#match? @ann "RequestMapping$")) (value_arguments (value_argument . [(string_literal) @prefix (collection_literal (string_literal) @prefix)]))))) (type_identifier) @cls) @class @@ -498,7 +507,7 @@ function buildKotlinPlugin(language: unknown): HttpLanguagePlugin { (modifiers (annotation (constructor_invocation - (user_type (type_identifier) @ann (#eq? @ann "RequestMapping")) + (user_type (type_identifier) @ann (#match? @ann "RequestMapping$")) (value_arguments (value_argument (simple_identifier) @key (#match? @key "^(path|value)$") @@ -513,7 +522,7 @@ function buildKotlinPlugin(language: unknown): HttpLanguagePlugin { (modifiers (annotation (constructor_invocation - (user_type (type_identifier) @ann (#eq? @ann "RequestMapping")) + (user_type (type_identifier) @ann (#match? @ann "RequestMapping$")) (value_arguments (value_argument . ${arrayOfArg('@prefix')}))))) (type_identifier) @cls) @class @@ -526,7 +535,7 @@ function buildKotlinPlugin(language: unknown): HttpLanguagePlugin { (modifiers (annotation (constructor_invocation - (user_type (type_identifier) @ann (#eq? @ann "RequestMapping")) + (user_type (type_identifier) @ann (#match? @ann "RequestMapping$")) (value_arguments (value_argument (simple_identifier) @key (#match? @key "^(path|value)$") @@ -552,7 +561,7 @@ function buildKotlinPlugin(language: unknown): HttpLanguagePlugin { (modifiers (annotation (constructor_invocation - (user_type (type_identifier) @ann (#match? @ann "^(Get|Post|Put|Delete|Patch)Mapping$")) + (user_type (type_identifier) @ann (#match? @ann "(Request|Get|Post|Put|Delete|Patch)Mapping$")) (value_arguments (value_argument . [(string_literal) @path (collection_literal (string_literal) @path)]))))) (simple_identifier) @method_name) @method @@ -565,7 +574,7 @@ function buildKotlinPlugin(language: unknown): HttpLanguagePlugin { (modifiers (annotation (constructor_invocation - (user_type (type_identifier) @ann (#match? @ann "^(Get|Post|Put|Delete|Patch)Mapping$")) + (user_type (type_identifier) @ann (#match? @ann "(Request|Get|Post|Put|Delete|Patch)Mapping$")) (value_arguments (value_argument (simple_identifier) @key (#match? @key "^(path|value)$") @@ -580,7 +589,7 @@ function buildKotlinPlugin(language: unknown): HttpLanguagePlugin { (modifiers (annotation (constructor_invocation - (user_type (type_identifier) @ann (#match? @ann "^(Get|Post|Put|Delete|Patch)Mapping$")) + (user_type (type_identifier) @ann (#match? @ann "(Request|Get|Post|Put|Delete|Patch)Mapping$")) (value_arguments (value_argument . ${arrayOfArg('@path')}))))) (simple_identifier) @method_name) @method @@ -593,7 +602,7 @@ function buildKotlinPlugin(language: unknown): HttpLanguagePlugin { (modifiers (annotation (constructor_invocation - (user_type (type_identifier) @ann (#match? @ann "^(Get|Post|Put|Delete|Patch)Mapping$")) + (user_type (type_identifier) @ann (#match? @ann "(Request|Get|Post|Put|Delete|Patch)Mapping$")) (value_arguments (value_argument (simple_identifier) @key (#match? @key "^(path|value)$") @@ -629,7 +638,7 @@ function buildKotlinPlugin(language: unknown): HttpLanguagePlugin { (modifiers (annotation (constructor_invocation - (user_type (type_identifier) @ann (#eq? @ann "RequestMapping")) + (user_type (type_identifier) @ann (#match? @ann "RequestMapping$")) (value_arguments (value_argument) @arg)))) (type_identifier) @cls) @class `, @@ -648,7 +657,7 @@ function buildKotlinPlugin(language: unknown): HttpLanguagePlugin { (modifiers (annotation (constructor_invocation - (user_type (type_identifier) @ann (#match? @ann "^(Get|Post|Put|Delete|Patch)Mapping$")) + (user_type (type_identifier) @ann (#match? @ann "(Request|Get|Post|Put|Delete|Patch)Mapping$")) (value_arguments (value_argument) @arg)))) (simple_identifier) @method_name) @method `, @@ -713,7 +722,9 @@ function buildKotlinPlugin(language: unknown): HttpLanguagePlugin { for (const match of runCompiledPatterns(SPRING_CONST_CLASS_PREFIX_PATTERNS, tree)) { const argNode = match.captures.arg; const classNode = match.captures.class; + const annNode = match.captures.ann; if (!argNode || !classNode) continue; + if (annNode && !isClassLevelMappingAnnotation(annNode.text)) continue; if ((resolvedPrefixes.get(classNode.id) ?? []).length > 0) continue; const expr = kotlinRouteArgumentExpression(argNode); if (!expr || classifyPathArgument(expr) !== 'unresolvable') continue; @@ -1218,13 +1229,36 @@ function buildKotlinPlugin(language: unknown): HttpLanguagePlugin { const kotlinFunctionName = (fn: Parser.SyntaxNode): string | null => fn.namedChildren.find((c) => c.type === 'simple_identifier')?.text ?? null; + const kotlinTypeRequestMethods = (typeNode: Parser.SyntaxNode): readonly string[] => { + const modifiers = typeNode.namedChildren.find((child) => child.type === 'modifiers'); + const mappings = (modifiers?.namedChildren ?? []).filter((annotation) => { + if (annotation.type !== 'annotation') return false; + return isClassLevelMappingAnnotation(kotlinAnnotationName(annotation) ?? ''); + }); + if (mappings.length === 0) return ['*']; + if (mappings.length !== 1) return []; + const mapping = mappings[0]; + const mappingName = kotlinAnnotationName(mapping); + if (!mappingName) return []; + return springAnnotationHttpMethods(mappingName, mapping.text); + }; + + const kotlinClassHttpMethodsById = (tree: Parser.Tree) => + new Map( + tree.rootNode + .descendantsOfType('class_declaration') + .map((typeNode) => [typeNode.id, kotlinTypeRequestMethods(typeNode)] as const), + ); + const collectKotlinSpringTypes = (filePath: string, tree: Parser.Tree): SharedSpringType[] => { // Class-level @RequestMapping prefixes (reuse the provider class-prefix query). const prefixByClassId = new Map(); for (const match of runCompiledPatterns(SPRING_CLASS_PREFIX_PATTERNS, tree)) { const prefixNode = match.captures.prefix; const classNode = match.captures.class; + const annNode = match.captures.ann; if (!prefixNode || !classNode) continue; + if (annNode && !isClassLevelMappingAnnotation(annNode.text)) continue; // An INTERPOLATED literal (`"${ApiPaths.BASE}"`) is not a path — unquoting // its raw text would carry the source spelling into the shared type view // as a served prefix. Refusing it here is also what lets the unfoldable @@ -1242,22 +1276,32 @@ function buildKotlinPlugin(language: unknown): HttpLanguagePlugin { // noise into the shared type view, so it is left out — the same skip floor // `java.ts`'s `collectSpringTypes` keeps. const routesByMethodId = new Map>(); + const classHttpMethodsById = kotlinClassHttpMethodsById(tree); const unfoldablePrefixClassIds = collectUnfoldablePrefixClassIds(tree, prefixByClassId); for (const match of runCompiledPatterns(SPRING_METHOD_ROUTE_PATTERNS, tree)) { const annNode = match.captures.ann; const pathNode = match.captures.path; const methodNode = match.captures.method; if (!annNode || !pathNode || !methodNode) continue; - const httpMethod = METHOD_ANNOTATION_TO_HTTP[annNode.text]; - if (!httpMethod) continue; + const httpMethods = springAnnotationHttpMethods( + annNode.text, + enclosingAnnotationText(annNode), + ); + if (httpMethods.length === 0) continue; const rawPath = unquoteLiteral(pathNode.text); if (rawPath === null) continue; // A constant class prefix leaves no single prefix string for the // inheritance view to carry, so this route would be published unprefixed. const owner = findEnclosingClass(methodNode); if (owner && unfoldablePrefixClassIds.has(owner.id)) continue; + const constrainedMethods = intersectSpringHttpMethods( + owner ? (classHttpMethodsById.get(owner.id) ?? ['*']) : ['*'], + httpMethods, + ); const arr = routesByMethodId.get(methodNode.id) ?? []; - arr.push({ method: httpMethod, path: rawPath }); + for (const httpMethod of constrainedMethods) { + arr.push({ method: httpMethod, path: rawPath }); + } routesByMethodId.set(methodNode.id, arr); } @@ -1390,7 +1434,9 @@ function buildKotlinPlugin(language: unknown): HttpLanguagePlugin { for (const match of runCompiledPatterns(SPRING_CLASS_PREFIX_PATTERNS, tree)) { const prefixNode = match.captures.prefix; const classNode = match.captures.class; + const annNode = match.captures.ann; if (!prefixNode || !classNode) continue; + if (annNode && !isClassLevelMappingAnnotation(annNode.text)) continue; // An INTERPOLATED literal (`"${ApiPaths.BASE}"`) is not a path — see // `isPlainStringLiteral`. Refusing it here also lets the unfoldable // analysis below mark such a class, since that skips classes whose @@ -1438,29 +1484,38 @@ function buildKotlinPlugin(language: unknown): HttpLanguagePlugin { nameNode: Parser.SyntaxNode | undefined; methodNode: Parser.SyntaxNode; }> = []; + const classHttpMethodsById = kotlinClassHttpMethodsById(tree); for (const match of runCompiledPatterns(SPRING_METHOD_ROUTE_PATTERNS, tree)) { const annNode = match.captures.ann; const pathNode = match.captures.path; const methodNode = match.captures.method; if (!annNode || !pathNode || !methodNode) continue; - const httpMethod = METHOD_ANNOTATION_TO_HTTP[annNode.text]; - if (!httpMethod) continue; + const httpMethods = springAnnotationHttpMethods( + annNode.text, + enclosingAnnotationText(annNode), + ); + if (httpMethods.length === 0) continue; const rawPath = unquoteLiteral(pathNode.text); if (rawPath === null) continue; - methodRoutes.push({ - httpMethod, - rawPath, - nameNode: match.captures.method_name, - methodNode, - }); + for (const httpMethod of httpMethods) { + methodRoutes.push({ + httpMethod, + rawPath, + nameNode: match.captures.method_name, + methodNode, + }); + } } for (const match of runCompiledPatterns(SPRING_CONST_METHOD_ROUTE_PATTERNS, tree)) { const annNode = match.captures.ann; const argNode = match.captures.arg; const methodNode = match.captures.method; if (!annNode || !argNode || !methodNode) continue; - const httpMethod = METHOD_ANNOTATION_TO_HTTP[annNode.text]; - if (!httpMethod) continue; + const httpMethods = springAnnotationHttpMethods( + annNode.text, + enclosingAnnotationText(annNode), + ); + if (httpMethods.length === 0) continue; const expr = kotlinRouteArgumentExpression(argNode); if (!expr || !FOLDABLE_PATH_EXPRESSIONS.has(expr.type)) continue; // No repo context (context-less fallback scanning) means no constant map @@ -1481,15 +1536,26 @@ function buildKotlinPlugin(language: unknown): HttpLanguagePlugin { index, ); if (rawPath === null) continue; - methodRoutes.push({ - httpMethod, - rawPath, - nameNode: match.captures.method_name, - methodNode, - }); + for (const httpMethod of httpMethods) { + methodRoutes.push({ + httpMethod, + rawPath, + nameNode: match.captures.method_name, + methodNode, + }); + } } - for (const { httpMethod, rawPath, nameNode, methodNode } of methodRoutes) { + const constrainedMethodRoutes = methodRoutes.flatMap((route) => { + const owner = findEnclosingClass(route.methodNode); + const classMethods = owner ? (classHttpMethodsById.get(owner.id) ?? ['*']) : ['*']; + return intersectSpringHttpMethods(classMethods, [route.httpMethod]).map((httpMethod) => ({ + ...route, + httpMethod, + })); + }); + + for (const { httpMethod, rawPath, nameNode, methodNode } of constrainedMethodRoutes) { const enclosingClass = findEnclosingClass(methodNode); // A @(Get|...)Mapping inside a @FeignClient interface is an OpenFeign // consumer (a remote call), not a route this service serves. diff --git a/gitnexus/src/core/ingestion/frameworks/spring/analysis-features.ts b/gitnexus/src/core/ingestion/frameworks/spring/analysis-features.ts index ca79ed745..722c53249 100644 --- a/gitnexus/src/core/ingestion/frameworks/spring/analysis-features.ts +++ b/gitnexus/src/core/ingestion/frameworks/spring/analysis-features.ts @@ -50,3 +50,13 @@ export const SPRING_NON_HTTP_HANDLERS_FEATURE: AnalysisFeatureDescriptor = { version: 1, appliesTo: (filePaths) => filePaths.some(isJvmSourceFile), }; + +/** + * Route/handler binding extraction, including vendor `@Win*Mapping` aliases. + * Existing indexes keep a stale Route set until this version is stamped. + */ +export const SPRING_ROUTE_BINDINGS_FEATURE: AnalysisFeatureDescriptor = { + id: 'spring.route-bindings', + version: 2, + appliesTo: (filePaths) => filePaths.some(isJvmSourceFile), +}; diff --git a/gitnexus/src/core/ingestion/frameworks/spring/vendor-prefixes.ts b/gitnexus/src/core/ingestion/frameworks/spring/vendor-prefixes.ts new file mode 100644 index 000000000..8134104b4 --- /dev/null +++ b/gitnexus/src/core/ingestion/frameworks/spring/vendor-prefixes.ts @@ -0,0 +1,27 @@ +const DEFAULT_SPRING_VENDOR_PREFIXES = 'Win'; + +let cachedRawValue: string | undefined; +let cachedPrefixes: ReadonlySet | undefined; + +/** Return the configured vendor prefixes as a canonical, duplicate-free set. */ +export function springVendorPrefixes(): ReadonlySet { + const rawValue = process.env.GITNEXUS_SPRING_VENDOR_PREFIXES ?? DEFAULT_SPRING_VENDOR_PREFIXES; + if (cachedPrefixes && cachedRawValue === rawValue) return cachedPrefixes; + + cachedRawValue = rawValue; + cachedPrefixes = new Set( + rawValue + .split(',') + .map((prefix) => prefix.trim()) + .filter(Boolean), + ); + return cachedPrefixes; +} + +/** + * Stable metadata value for the route semantics controlled by the prefix list. + * Sorting makes equivalent lists independent of declaration order. + */ +export function springVendorPrefixesKey(): string { + return JSON.stringify([...springVendorPrefixes()].sort()); +} diff --git a/gitnexus/src/core/ingestion/route-extractors/kotlin-spring.ts b/gitnexus/src/core/ingestion/route-extractors/kotlin-spring.ts index 1ac21e2f9..c738ce2a4 100644 --- a/gitnexus/src/core/ingestion/route-extractors/kotlin-spring.ts +++ b/gitnexus/src/core/ingestion/route-extractors/kotlin-spring.ts @@ -9,6 +9,7 @@ import type Parser from 'tree-sitter'; import type { ExtractedDecoratorRoute } from '../workers/parse-worker.js'; import { intersectSpringHttpMethods, + isClassLevelMappingAnnotation, springAnnotationHttpMethods, unquoteSpringLiteral, } from './spring-shared.js'; @@ -140,19 +141,6 @@ function functionName(node: Parser.SyntaxNode): string | null { return identifier ? unquoteKotlinIdentifier(identifier.text) : null; } -/** - * `springAnnotationHttpMethods` parses Java `{A, B}` collections. - * Translate only Kotlin `method = [A, B]` before delegating. - */ -function kotlinSpringHttpMethods(name: string, annotation: Parser.SyntaxNode): readonly string[] { - if (name !== 'RequestMapping') return springAnnotationHttpMethods(name, annotation.text); - const normalized = annotation.text.replace( - /(\bmethod\s*=\s*)\[([^\]]*)\]/gs, - (_match, assignment: string, values: string) => `${assignment}{${values}}`, - ); - return springAnnotationHttpMethods(name, normalized); -} - function typeName(node: Parser.SyntaxNode): string | null { const identifier = node.children.find((child) => child.type === 'type_identifier'); return identifier ? unquoteKotlinIdentifier(identifier.text) : null; @@ -217,8 +205,8 @@ interface ClassMapping { * mappings, and dynamic expressions fail closed for the whole class. */ function classMapping(annotations: readonly Parser.SyntaxNode[]): ClassMapping | null { - const mappings = annotations.filter( - (annotation) => annotationName(annotation) === 'RequestMapping', + const mappings = annotations.filter((annotation) => + isClassLevelMappingAnnotation(annotationName(annotation) ?? ''), ); if (mappings.length === 0) return { prefix: '', methods: ['*'] }; if (mappings.length !== 1) return null; @@ -241,7 +229,9 @@ function classMapping(annotations: readonly Parser.SyntaxNode[]): ClassMapping | } } - const methods = kotlinSpringHttpMethods('RequestMapping', mapping); + const mappingName = annotationName(mapping); + if (!mappingName) return null; + const methods = springAnnotationHttpMethods(mappingName, mapping.text); return methods.length === 0 ? null : { prefix, methods }; } @@ -274,7 +264,7 @@ export function extractKotlinSpringRoutes( const decoratorName = annotationName(annotation); if (!decoratorName) continue; - const methodMethods = kotlinSpringHttpMethods(decoratorName, annotation); + const methodMethods = springAnnotationHttpMethods(decoratorName, annotation.text); const methods = intersectSpringHttpMethods(ownerMapping.methods, methodMethods); if (methods.length === 0) continue; diff --git a/gitnexus/src/core/ingestion/route-extractors/spring-shared.ts b/gitnexus/src/core/ingestion/route-extractors/spring-shared.ts index d417d5ff4..6224a16c9 100644 --- a/gitnexus/src/core/ingestion/route-extractors/spring-shared.ts +++ b/gitnexus/src/core/ingestion/route-extractors/spring-shared.ts @@ -18,13 +18,14 @@ import type Parser from 'tree-sitter'; import { parseSpringAnnotationArguments } from '../frameworks/spring/annotation-arguments.js'; +import { springVendorPrefixes } from '../frameworks/spring/vendor-prefixes.js'; /** * Spring shortcut method-annotation → HTTP verb. * * `@RequestMapping` is intentionally absent: on a method it carries no implicit * verb (the verb lives in its `method = RequestMethod.X` attribute), and on a - * class it is a URL prefix rather than a route. Callers handle `@RequestMapping` + * class it is a URL prefix rather than a route. Callers handle `RequestMapping` * separately. */ export const METHOD_ANNOTATION_TO_HTTP: Record = { @@ -36,7 +37,46 @@ export const METHOD_ANNOTATION_TO_HTTP: Record = { }; /** - * Parse one `RequestMethod.X` literal or a Java annotation array of literals. + * All recognised Spring mapping-annotation simple names (shortcut + base). + * Sorted longest-first so {@link resolveSpringAnnotationAlias} prefers the most + * specific suffix (e.g. `PostMapping` before any hypothetical shorter overlap). + */ +const SPRING_MAPPING_NAMES: readonly string[] = [ + ...Object.keys(METHOD_ANNOTATION_TO_HTTP), + 'RequestMapping', +].sort((a, b) => b.length - a.length); + +/** + * Resolve a REGISTERED vendor-derived Spring mapping annotation to its base. + * + * Vendor definitions often live in binary dependencies, so their Spring + * meta-annotations cannot be inspected from repository source. Resolution uses + * the conventional `` name instead. + * + * Suffix matching alone accepted unrelated annotations (`@AuditPostMapping` + * produced a phantom route — review P2). Resolution now requires the name to + * be `` with the prefix drawn from a small registry: + * `Win` by default (Winning Health), extendable via + * `GITNEXUS_SPRING_VENDOR_PREFIXES=Win,Acme,Other`. Changing the registry + * invalidates persisted JVM route evidence on the next analysis. Exact-known + * Spring annotation names return `undefined`; callers handle those directly. + */ +export function resolveSpringAnnotationAlias(annotationName: string): string | undefined { + const registeredVendorPrefixes = springVendorPrefixes(); + for (const base of SPRING_MAPPING_NAMES) { + if (annotationName.length > base.length && annotationName.endsWith(base)) { + const prefix = annotationName.slice(0, annotationName.length - base.length); + if (registeredVendorPrefixes.has(prefix)) { + return base; + } + } + } + return undefined; +} + +/** + * Parse one `RequestMethod.X` literal or a Java `{…}` / Kotlin `[…]` array of + * those literals. * An empty array is valid and means Spring's unrestricted/default method set. * Runtime expressions fail closed instead of producing a guessed route. */ @@ -60,16 +100,25 @@ function parseRequestMethodValues(value: string): readonly string[] | null { } trimmed += char; } - const hasOpeningBrace = trimmed.startsWith('{'); - const hasClosingBrace = trimmed.endsWith('}'); - if (hasOpeningBrace !== hasClosingBrace) return null; - const body = hasOpeningBrace ? trimmed.slice(1, -1).trim() : trimmed; + const wrapped = + (trimmed.startsWith('{') && trimmed.endsWith('}')) || + (trimmed.startsWith('[') && trimmed.endsWith(']')); + if ( + !wrapped && + (trimmed.startsWith('{') || + trimmed.startsWith('[') || + trimmed.endsWith('}') || + trimmed.endsWith(']')) + ) { + return null; + } + const body = wrapped ? trimmed.slice(1, -1).trim() : trimmed; if (body.length === 0) return []; - if (!hasOpeningBrace && body.includes(',')) return null; + if (!wrapped && body.includes(',')) return null; const methods: string[] = []; const parts = body.split(','); - if (hasOpeningBrace && parts[parts.length - 1].trim() === '') parts.pop(); + if (wrapped && parts[parts.length - 1].trim() === '') parts.pop(); for (const rawPart of parts) { const part = rawPart.trim(); const match = @@ -89,14 +138,28 @@ function parseRequestMethodValues(value: string): readonly string[] | null { * one or more static `RequestMethod.X` values; when its `method` member is * absent or an empty array, `'*'` preserves Spring's method-agnostic semantics. * A present but non-static method expression yields no methods (fail closed). + * + * Vendor-derived aliases (e.g. `@WinPostMapping`) are resolved by suffix to + * their base annotation before the above logic applies — see + * {@link resolveSpringAnnotationAlias}. */ export function springAnnotationHttpMethods( annotationName: string, annotationText: string, ): readonly string[] { + // Exact shortcut match (PostMapping → POST, etc.) const shortcut = METHOD_ANNOTATION_TO_HTTP[annotationName]; if (shortcut) return [shortcut]; - if (annotationName !== 'RequestMapping') return []; + + // Resolve vendor alias by suffix (WinPostMapping → PostMapping, etc.) + const base = resolveSpringAnnotationAlias(annotationName) ?? annotationName; + + // Alias of a shortcut annotation + const aliasShortcut = METHOD_ANNOTATION_TO_HTTP[base]; + if (aliasShortcut) return [aliasShortcut]; + + // Direct or aliased @RequestMapping: parse the method= attribute + if (base !== 'RequestMapping') return []; const args = parseSpringAnnotationArguments(annotationText); if (args === null) return []; @@ -109,6 +172,19 @@ export function springAnnotationHttpMethods( return methods.length > 0 ? methods : ['*']; } +/** + * True when an annotation name is a class-level request-mapping annotation — + * either Spring's own `@RequestMapping` or a registered vendor alias that + * resolves to it (`@WinRequestMapping`). Class-level handling in both the + * group extractor and the ingestion route extractor routes through this + * predicate so vendor aliases get the same prefix/constraint semantics as + * the base annotation (review P1). + */ +export function isClassLevelMappingAnnotation(annotationName: string): boolean { + if (annotationName === 'RequestMapping') return true; + return resolveSpringAnnotationAlias(annotationName) === 'RequestMapping'; +} + /** Intersect class- and method-level Spring mapping constraints. */ export function intersectSpringHttpMethods( classMethods: readonly string[], diff --git a/gitnexus/src/core/ingestion/route-extractors/spring.ts b/gitnexus/src/core/ingestion/route-extractors/spring.ts index 36828a1f5..701e7c03f 100644 --- a/gitnexus/src/core/ingestion/route-extractors/spring.ts +++ b/gitnexus/src/core/ingestion/route-extractors/spring.ts @@ -27,6 +27,7 @@ import { springAnnotationHttpMethods, isRouteMemberKey, findEnclosingType, + isClassLevelMappingAnnotation, unquoteSpringLiteral, type SharedSpringType, } from './spring-shared.js'; @@ -192,7 +193,10 @@ export function extractSpringRoutes( if (!annNode || !node || (!valueNode && !valueExprNode)) continue; const capturedAnnotationName = annNode.text.split('.').pop() ?? annNode.text; - if (node.type === 'class_declaration' && capturedAnnotationName === 'RequestMapping') { + if ( + node.type === 'class_declaration' && + isClassLevelMappingAnnotation(capturedAnnotationName) + ) { if (!isRouteMemberKey(keyNode)) continue; if (!valueNode) { classesWithUnfoldablePrefix.add(node.id); @@ -437,12 +441,14 @@ function annotationHasRouteMember(ann: Parser.SyntaxNode): boolean { /** Static class/interface-level RequestMapping method constraint, or wildcard by default. */ function typeRequestMethods(typeNode: Parser.SyntaxNode): readonly string[] { - const mappings = declarationAnnotations(typeNode).filter( - (ann) => annotationName(ann) === 'RequestMapping', + const mappings = declarationAnnotations(typeNode).filter((ann) => + isClassLevelMappingAnnotation(annotationName(ann) ?? ''), ); if (mappings.length === 0) return ['*']; if (mappings.length !== 1) return []; - return springAnnotationHttpMethods('RequestMapping', mappings[0].text); + const mappingName = annotationName(mappings[0]); + if (!mappingName) return []; + return springAnnotationHttpMethods(mappingName, mappings[0].text); } function annotationRoutePathsOrDefault(ann: Parser.SyntaxNode): string[] { @@ -455,7 +461,8 @@ function annotationRoutePathsOrDefault(ann: Parser.SyntaxNode): string[] { function typeClassPrefixes(typeNode: Parser.SyntaxNode): string[] { const prefixes: string[] = []; for (const ann of declarationAnnotations(typeNode)) { - if (annotationName(ann) === 'RequestMapping') prefixes.push(...annotationRoutePaths(ann)); + if (isClassLevelMappingAnnotation(annotationName(ann) ?? '')) + prefixes.push(...annotationRoutePaths(ann)); } return prefixes; } diff --git a/gitnexus/src/core/run-analyze.ts b/gitnexus/src/core/run-analyze.ts index bcadab3ec..be591792e 100644 --- a/gitnexus/src/core/run-analyze.ts +++ b/gitnexus/src/core/run-analyze.ts @@ -190,22 +190,13 @@ import { isSpringBeanCandidateSourceFile } from './ingestion/frameworks/spring/b import { isSpringBeanFactoryDeclaration } from './ingestion/frameworks/spring/bean-factories.js'; import { SPRING_CONFIG_UNRESOLVED_PREFIX } from './ingestion/frameworks/spring/config-bindings.js'; import { classifySpringConfigFile } from './ingestion/pipeline-phases/spring-config.js'; +import { SPRING_ROUTE_BINDINGS_FEATURE } from './ingestion/frameworks/spring/analysis-features.js'; +import { springVendorPrefixesKey } from './ingestion/frameworks/spring/vendor-prefixes.js'; import { - SPRING_AOP_FEATURE, - SPRING_BEAN_INVENTORY_FEATURE, - SPRING_CONDITIONALS_FEATURE, - SPRING_NON_HTTP_HANDLERS_FEATURE, -} from './ingestion/frameworks/spring/analysis-features.js'; -import { - JAVA_ENUM_INTERFACE_HERITAGE_FEATURE, - JAVA_RECORD_COMPONENT_ACCESSORS_FEATURE, - SPRING_CONFIG_BINDINGS_FEATURE, -} from './ingestion/languages/java/analysis-features.js'; -import { - CLASS_FRAMEWORK_ANNOTATIONS_FEATURE, findAnalysisFeatureMismatches, resolveAnalysisFeatureVersions, } from './analysis-features.js'; +import { ANALYSIS_FEATURES } from './analysis-feature-registry.js'; import { analyzerRunnerIdentitiesEqual, finalizeAnalyzerRunnerIdentity, @@ -247,17 +238,6 @@ import type { EmbeddingCheckpoint } from './embedding-checkpoint.js'; const stripControlCharacters = (msg: string): string => msg.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]/g, ''); -const ANALYSIS_FEATURES = [ - CLASS_FRAMEWORK_ANNOTATIONS_FEATURE, - SPRING_AOP_FEATURE, - SPRING_BEAN_INVENTORY_FEATURE, - SPRING_CONDITIONALS_FEATURE, - SPRING_NON_HTTP_HANDLERS_FEATURE, - SPRING_CONFIG_BINDINGS_FEATURE, - JAVA_ENUM_INTERFACE_HERITAGE_FEATURE, - JAVA_RECORD_COMPONENT_ACCESSORS_FEATURE, -] as const; - interface PersistedFrameworkAnnotationRow { readonly id?: unknown; readonly frameworkAnnotations?: unknown; @@ -1597,6 +1577,20 @@ async function runFullAnalysisInner( analysisFeatureMismatchLogged = true; } + const currentSpringVendorPrefixes = springVendorPrefixesKey(); + const persistedRouteBindings = existingMeta?.analysisFeatures?.[SPRING_ROUTE_BINDINGS_FEATURE.id]; + if ( + existingMeta && + persistedRouteBindings === SPRING_ROUTE_BINDINGS_FEATURE.version && + existingMeta.springVendorPrefixes !== currentSpringVendorPrefixes + ) { + log( + 'Spring vendor mapping prefixes changed; forcing a full rebuild so persisted Route ' + + 'evidence matches the configured aliases.', + ); + options = { ...options, force: true }; + } + // Analyzer provenance is part of freshness, not merely diagnostics. A // same-commit fast path must not preserve metadata produced by an older, // malformed, or dependency/native-different runner. Force a real rebuild so @@ -3911,6 +3905,7 @@ async function runFullAnalysisInner( ? existingMeta?.undecidedInterfaceSatisfaction : summarizeUndecidedSatisfaction(pipelineResult.undecidedSatisfaction), analysisFeatures: currentAnalysisFeatures, + springVendorPrefixes: currentSpringVendorPrefixes, // 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/storage/repo-meta.ts b/gitnexus/src/storage/repo-meta.ts index 3d6c7d376..2b8d453e6 100644 --- a/gitnexus/src/storage/repo-meta.ts +++ b/gitnexus/src/storage/repo-meta.ts @@ -202,6 +202,11 @@ export interface RepoMeta { * containing relevant source files. */ analysisFeatures?: Record; + /** + * Canonical registered-prefix list used to resolve vendor Spring mapping + * annotations. A changed value invalidates persisted JVM Route evidence. + */ + springVendorPrefixes?: 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/unit/analysis-features.test.ts b/gitnexus/test/unit/analysis-features.test.ts index 415026be1..08d4fd24c 100644 --- a/gitnexus/test/unit/analysis-features.test.ts +++ b/gitnexus/test/unit/analysis-features.test.ts @@ -5,35 +5,14 @@ import { resolveAnalysisFeatureVersions, type AnalysisFeatureDescriptor, } from '../../src/core/analysis-features.js'; -import { - SPRING_AOP_FEATURE, - SPRING_BEAN_INVENTORY_FEATURE, - SPRING_CONDITIONALS_FEATURE, - SPRING_NON_HTTP_HANDLERS_FEATURE, -} from '../../src/core/ingestion/frameworks/spring/analysis-features.js'; -import { - JAVA_ENUM_INTERFACE_HERITAGE_FEATURE, - JAVA_RECORD_COMPONENT_ACCESSORS_FEATURE, - SPRING_CONFIG_BINDINGS_FEATURE, -} from '../../src/core/ingestion/languages/java/analysis-features.js'; - -const FEATURES = [ - CLASS_FRAMEWORK_ANNOTATIONS_FEATURE, - SPRING_AOP_FEATURE, - SPRING_BEAN_INVENTORY_FEATURE, - SPRING_CONDITIONALS_FEATURE, - SPRING_NON_HTTP_HANDLERS_FEATURE, - SPRING_CONFIG_BINDINGS_FEATURE, - JAVA_ENUM_INTERFACE_HERITAGE_FEATURE, - JAVA_RECORD_COMPONENT_ACCESSORS_FEATURE, -] as const; +import { ANALYSIS_FEATURES } from '../../src/core/analysis-feature-registry.js'; describe('analysis feature versions', () => { it('separates the global Class schema capability from JVM-only Bean evidence', () => { - expect(resolveAnalysisFeatureVersions(FEATURES, ['src/app.ts'])).toEqual({ + expect(resolveAnalysisFeatureVersions(ANALYSIS_FEATURES, ['src/app.ts'])).toEqual({ 'graph.class-framework-annotations': 1, }); - expect(resolveAnalysisFeatureVersions(FEATURES, ['src/App.java'])).toEqual({ + expect(resolveAnalysisFeatureVersions(ANALYSIS_FEATURES, ['src/App.java'])).toEqual({ 'graph.class-framework-annotations': 1, 'java.heritage-captures': 1, 'java.record-component-accessors': 1, @@ -42,24 +21,27 @@ describe('analysis feature versions', () => { 'spring.conditionals-auto-configuration': 1, 'spring.config-bindings': 2, 'spring.non-http-handlers': 1, + 'spring.route-bindings': 2, }); - expect(resolveAnalysisFeatureVersions(FEATURES, ['src/App.kt'])).toEqual({ + expect(resolveAnalysisFeatureVersions(ANALYSIS_FEATURES, ['src/App.kt'])).toEqual({ 'graph.class-framework-annotations': 1, 'spring.aop-advice': 1, 'spring.bean-inventory': 2, 'spring.conditionals-auto-configuration': 1, 'spring.config-bindings': 2, 'spring.non-http-handlers': 1, + 'spring.route-bindings': 2, }); - expect(resolveAnalysisFeatureVersions(FEATURES, ['BUILD.GRADLE.KTS'])).toEqual({ + expect(resolveAnalysisFeatureVersions(ANALYSIS_FEATURES, ['BUILD.GRADLE.KTS'])).toEqual({ 'graph.class-framework-annotations': 1, 'spring.aop-advice': 1, 'spring.bean-inventory': 2, 'spring.conditionals-auto-configuration': 1, 'spring.non-http-handlers': 1, + 'spring.route-bindings': 2, }); expect( - resolveAnalysisFeatureVersions(FEATURES, [ + resolveAnalysisFeatureVersions(ANALYSIS_FEATURES, [ 'src/main/resources/application-local.yml', 'README.md', ]), @@ -68,7 +50,7 @@ describe('analysis feature versions', () => { 'spring.config-bindings': 2, }); expect( - resolveAnalysisFeatureVersions(FEATURES, [ + resolveAnalysisFeatureVersions(ANALYSIS_FEATURES, [ 'src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports', ]), ).toEqual({ diff --git a/gitnexus/test/unit/incremental-orchestration.test.ts b/gitnexus/test/unit/incremental-orchestration.test.ts index 3e7b89c07..f28992310 100644 --- a/gitnexus/test/unit/incremental-orchestration.test.ts +++ b/gitnexus/test/unit/incremental-orchestration.test.ts @@ -48,7 +48,9 @@ import { SPRING_BEAN_INVENTORY_FEATURE, SPRING_CONDITIONALS_FEATURE, SPRING_NON_HTTP_HANDLERS_FEATURE, + 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 { decodeSpringAopReason, SPRING_AOP_EVIDENCE_ID_PREFIX, @@ -799,6 +801,7 @@ describe('runFullAnalysis — incremental orchestration', () => { [SPRING_CONDITIONALS_FEATURE.id]: SPRING_CONDITIONALS_FEATURE.version, [SPRING_CONFIG_BINDINGS_FEATURE.id]: SPRING_CONFIG_BINDINGS_FEATURE.version, [SPRING_NON_HTTP_HANDLERS_FEATURE.id]: SPRING_NON_HTTP_HANDLERS_FEATURE.version, + [SPRING_ROUTE_BINDINGS_FEATURE.id]: SPRING_ROUTE_BINDINGS_FEATURE.version, }); await saveMeta(storagePath, withoutAnalysisFeature(meta!, SPRING_BEAN_INVENTORY_FEATURE.id)); @@ -819,12 +822,45 @@ describe('runFullAnalysis — incremental orchestration', () => { [SPRING_CONDITIONALS_FEATURE.id]: SPRING_CONDITIONALS_FEATURE.version, [SPRING_CONFIG_BINDINGS_FEATURE.id]: SPRING_CONFIG_BINDINGS_FEATURE.version, [SPRING_NON_HTTP_HANDLERS_FEATURE.id]: SPRING_NON_HTTP_HANDLERS_FEATURE.version, + [SPRING_ROUTE_BINDINGS_FEATURE.id]: SPRING_ROUTE_BINDINGS_FEATURE.version, }); } finally { await repo.cleanup(); } }, 300_000); + it('rebuilds a JVM index when the registered Spring vendor prefixes change', async () => { + const repo = await setupSpringBeanIncrementalRepo(); + try { + vi.stubEnv('GITNEXUS_SPRING_VENDOR_PREFIXES', 'Win'); + const { runFullAnalysis } = await import('../../src/core/run-analyze.js'); + await runFullAnalysis(repo.dbPath, { skipAgentsMd: true }, { onProgress: () => {} }); + const { storagePath } = getStoragePaths(repo.dbPath); + expect((await loadMeta(storagePath))?.springVendorPrefixes).toBe(springVendorPrefixesKey()); + + vi.stubEnv('GITNEXUS_SPRING_VENDOR_PREFIXES', 'Acme,Win'); + const logs: string[] = []; + const rebuilt = await runFullAnalysis( + repo.dbPath, + { skipAgentsMd: true }, + { onProgress: () => {}, onLog: (message) => logs.push(message) }, + ); + + expect(rebuilt.alreadyUpToDate).toBeUndefined(); + expect(logs.join('\n')).toContain('Spring vendor mapping prefixes changed'); + expect((await loadMeta(storagePath))?.springVendorPrefixes).toBe(springVendorPrefixesKey()); + + const steady = await runFullAnalysis( + repo.dbPath, + { skipAgentsMd: true }, + { onProgress: () => {} }, + ); + expect(steady.alreadyUpToDate).toBe(true); + } finally { + await repo.cleanup(); + } + }, 300_000); + it('a config-only index missing Spring config evidence rebuilds and restores the scoped stamp', async () => { const repo = await setupSpringConfigIncrementalRepo(); try { @@ -927,6 +963,7 @@ describe('runFullAnalysis — incremental orchestration', () => { [SPRING_CONDITIONALS_FEATURE.id]: SPRING_CONDITIONALS_FEATURE.version, [SPRING_CONFIG_BINDINGS_FEATURE.id]: SPRING_CONFIG_BINDINGS_FEATURE.version, [SPRING_NON_HTTP_HANDLERS_FEATURE.id]: SPRING_NON_HTTP_HANDLERS_FEATURE.version, + [SPRING_ROUTE_BINDINGS_FEATURE.id]: SPRING_ROUTE_BINDINGS_FEATURE.version, }); } finally { await repo.cleanup(); diff --git a/gitnexus/test/unit/kotlin-spring-route-ingestion.test.ts b/gitnexus/test/unit/kotlin-spring-route-ingestion.test.ts index 7ec94df5a..51c2225dd 100644 --- a/gitnexus/test/unit/kotlin-spring-route-ingestion.test.ts +++ b/gitnexus/test/unit/kotlin-spring-route-ingestion.test.ts @@ -4,7 +4,7 @@ * The Kotlin grammar is optional. Importing the extractor itself must not load * that grammar; only this guarded test setup does. */ -import { describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import Parser from 'tree-sitter'; import { requireVendoredGrammar } from '../../src/core/tree-sitter/vendored-grammars.js'; import { extractKotlinSpringRoutes } from '../../src/core/ingestion/route-extractors/kotlin-spring.js'; @@ -29,16 +29,23 @@ if (Kotlin) parser.setLanguage(Kotlin as Parser.Language); const parse = (source: string): Parser.Tree => parser.parse(source); const describeKotlin = Kotlin ? describe : describe.skip; -function constantsOf(files: Record): RepoConstants { - return new Map( - Object.entries(files).map(([filePath, source]) => [ - filePath, - extractKotlinModuleConstants(parse(source)), - ]), - ); -} - describeKotlin('extractKotlinSpringRoutes', () => { + beforeEach(() => { + vi.stubEnv('GITNEXUS_SPRING_VENDOR_PREFIXES', 'Win'); + }); + afterEach(() => { + vi.unstubAllEnvs(); + }); + + function constantsOf(files: Record): RepoConstants { + return new Map( + Object.entries(files).map(([filePath, source]) => [ + filePath, + extractKotlinModuleConstants(parse(source)), + ]), + ); + } + it('extracts direct RestController functions with independent class prefixes and handlers', () => { const routes = extractKotlinSpringRoutes( parse(` @@ -627,4 +634,134 @@ class PetsController { expect(ingestion).toEqual(group); }); + + it('resolves vendor-derived mapping aliases like Java (WinGetMapping / WinRequestMapping)', () => { + expect(KOTLIN_HTTP_PLUGIN).not.toBeNull(); + if (!KOTLIN_HTTP_PLUGIN) throw new Error('expected Kotlin HTTP plugin'); + const source = ` +@RestController +@WinRequestMapping("/vendor") +class VendorController { + @WinGetMapping("/users") + fun users(): String = "ok" +} +`; + const tree = parse(source); + const ingestion = extractKotlinSpringRoutes(tree, 'VendorController.kt'); + expect(ingestion).toHaveLength(1); + expect(ingestion[0]?.httpMethod).toBe('GET'); + expect(ingestion[0]?.prefix).toBe('/vendor'); + expect(ingestion[0]?.routePath).toBe('/users'); + + const group = KOTLIN_HTTP_PLUGIN.scan(tree, undefined, 'VendorController.kt').filter( + (detection) => detection.role === 'provider', + ); + expect(group).toEqual( + expect.arrayContaining([expect.objectContaining({ method: 'GET', path: '/vendor/users' })]), + ); + }); + + it('normalizes Kotlin method arrays for aliased RequestMapping annotations', () => { + expect(KOTLIN_HTTP_PLUGIN).not.toBeNull(); + if (!KOTLIN_HTTP_PLUGIN) throw new Error('expected Kotlin HTTP plugin'); + const source = ` +@RestController +@WinRequestMapping("/vendor") +class VendorController { + @WinRequestMapping(path = "/inspect", method = [RequestMethod.GET, RequestMethod.HEAD]) + fun inspect(): String = "ok" +} +`; + const tree = parse(source); + const ingestion = new Set( + extractKotlinSpringRoutes(tree, 'VendorController.kt').map( + (route) => `${route.httpMethod} ${joinPath(route.prefix ?? '', route.routePath)}`, + ), + ); + const group = new Set( + KOTLIN_HTTP_PLUGIN.scan(tree, undefined, 'VendorController.kt') + .filter((detection) => detection.role === 'provider') + .map((detection) => `${detection.method} ${detection.path}`), + ); + + expect(ingestion).toEqual(new Set(['GET /vendor/inspect', 'HEAD /vendor/inspect'])); + expect(group).toEqual(ingestion); + }); + + it('applies aliased class-level Kotlin method arrays to handler routes', () => { + expect(KOTLIN_HTTP_PLUGIN).not.toBeNull(); + if (!KOTLIN_HTTP_PLUGIN) throw new Error('expected Kotlin HTTP plugin'); + const tree = parse(` +@RestController +@WinRequestMapping(path = "/vendor", method = [RequestMethod.GET, RequestMethod.HEAD]) +class VendorController { + @WinRequestMapping("/inspect") + fun inspect(): String = "ok" +} +`); + const ingestion = new Set( + extractKotlinSpringRoutes(tree, 'VendorController.kt').map( + (route) => `${route.httpMethod} ${joinPath(route.prefix ?? '', route.routePath)}`, + ), + ); + const group = new Set( + KOTLIN_HTTP_PLUGIN.scan(tree, undefined, 'VendorController.kt') + .filter((detection) => detection.role === 'provider') + .map((detection) => `${detection.method} ${detection.path}`), + ); + + expect(ingestion).toEqual(new Set(['GET /vendor/inspect', 'HEAD /vendor/inspect'])); + expect(group).toEqual(ingestion); + }); + + it('keeps aliased class method constraints in inherited group contracts', () => { + expect(KOTLIN_HTTP_PLUGIN?.scanProject).toBeDefined(); + if (!KOTLIN_HTTP_PLUGIN?.scanProject) throw new Error('expected Kotlin project scanner'); + const tree = parse(` +@WinRequestMapping(path = "/contract", method = [RequestMethod.GET]) +interface Contract { + @WinRequestMapping(path = "/items", method = [RequestMethod.GET, RequestMethod.POST]) + fun inspect(): String +} + +@RestController +@WinRequestMapping("/impl") +class VendorController : Contract { + override fun inspect(): String = "ok" +} +`); + + const detections = KOTLIN_HTTP_PLUGIN.scanProject([ + { filePath: 'VendorController.kt', tree }, + ]).flatMap((file) => file.detections); + + expect(detections).toEqual([ + expect.objectContaining({ + role: 'provider', + method: 'GET', + path: '/impl/contract/items', + }), + ]); + }); + + it('does not treat unregistered suffix annotations as Kotlin routes', () => { + const source = ` +@RestController +class AuditController { + @AuditPostMapping("/audit") + fun audit(): String = "x" + + @AuditRequestMapping(path = "/request", method = [RequestMethod.POST]) + fun request(): String = "x" +} +`; + const tree = parse(source); + expect(extractKotlinSpringRoutes(tree, 'AuditController.kt')).toHaveLength(0); + expect(KOTLIN_HTTP_PLUGIN).not.toBeNull(); + expect( + KOTLIN_HTTP_PLUGIN?.scan(tree, undefined, 'AuditController.kt').filter( + (detection) => detection.role === 'provider', + ), + ).toEqual([]); + }); }); diff --git a/gitnexus/test/unit/spring-vendor-annotation-alias.test.ts b/gitnexus/test/unit/spring-vendor-annotation-alias.test.ts new file mode 100644 index 000000000..317b0beb6 --- /dev/null +++ b/gitnexus/test/unit/spring-vendor-annotation-alias.test.ts @@ -0,0 +1,297 @@ +/** + * Unit test: vendor-derived Spring mapping annotation alias resolution. + * + * Frameworks wrap Spring's built-in annotations with company-specific variants + * (e.g. Winning Health's `@WinPostMapping`). The tree-sitter query captures + * these annotations like any other, but `springAnnotationHttpMethods` must + * resolve them to the correct HTTP verb via suffix matching. + * + * These tests cover: + * 1. `resolveSpringAnnotationAlias` directly (unit) + * 2. `springAnnotationHttpMethods` with aliased annotations (unit) + * 3. End-to-end `extractSpringRoutes` with a fixture using vendor annotations + * 4. Parity: both ingestion and group extractors surface the same routes + */ +import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'; +import Parser from 'tree-sitter'; +import Java from 'tree-sitter-java'; +import { + resolveSpringAnnotationAlias, + springAnnotationHttpMethods, +} from '../../src/core/ingestion/route-extractors/spring-shared.js'; +import { extractSpringRoutes } from '../../src/core/ingestion/route-extractors/spring.js'; +import { JAVA_HTTP_PLUGIN } from '../../src/core/group/extractors/http-patterns/java.js'; +import { normalizeExtractedRoutePath } from '../../src/core/ingestion/route-extractors/route-path.js'; +import { springVendorPrefixesKey } from '../../src/core/ingestion/frameworks/spring/vendor-prefixes.js'; + +function parse(code: string): Parser.Tree { + const parser = new Parser(); + parser.setLanguage(Java); + return parser.parse(code); +} + +beforeEach(() => { + vi.stubEnv('GITNEXUS_SPRING_VENDOR_PREFIXES', 'Win'); +}); +afterEach(() => { + vi.unstubAllEnvs(); +}); + +describe('resolveSpringAnnotationAlias', () => { + it('returns undefined for exact built-in shortcut annotations', () => { + expect(resolveSpringAnnotationAlias('PostMapping')).toBeUndefined(); + expect(resolveSpringAnnotationAlias('GetMapping')).toBeUndefined(); + expect(resolveSpringAnnotationAlias('PutMapping')).toBeUndefined(); + expect(resolveSpringAnnotationAlias('DeleteMapping')).toBeUndefined(); + expect(resolveSpringAnnotationAlias('PatchMapping')).toBeUndefined(); + }); + + it('returns undefined for exact RequestMapping', () => { + expect(resolveSpringAnnotationAlias('RequestMapping')).toBeUndefined(); + }); + + it('returns undefined for unrelated annotations', () => { + expect(resolveSpringAnnotationAlias('Override')).toBeUndefined(); + expect(resolveSpringAnnotationAlias('Autowired')).toBeUndefined(); + expect(resolveSpringAnnotationAlias('Component')).toBeUndefined(); + expect(resolveSpringAnnotationAlias('Data')).toBeUndefined(); + }); + + it('resolves vendor shortcut annotations by suffix', () => { + expect(resolveSpringAnnotationAlias('WinPostMapping')).toBe('PostMapping'); + expect(resolveSpringAnnotationAlias('WinGetMapping')).toBe('GetMapping'); + expect(resolveSpringAnnotationAlias('WinPutMapping')).toBe('PutMapping'); + expect(resolveSpringAnnotationAlias('WinDeleteMapping')).toBe('DeleteMapping'); + expect(resolveSpringAnnotationAlias('WinPatchMapping')).toBe('PatchMapping'); + }); + + it('resolves vendor RequestMapping variants', () => { + expect(resolveSpringAnnotationAlias('WinRequestMapping')).toBe('RequestMapping'); + }); + + it('ignores unregistered vendor prefixes (review: suffix-only accepted @AuditPostMapping)', () => { + // Suffix matching alone produced phantom routes from unrelated + // annotations like @AuditPostMapping — resolution now requires a + // registered prefix (Win by default). + expect(resolveSpringAnnotationAlias('AuditPostMapping')).toBeUndefined(); + expect(resolveSpringAnnotationAlias('CompanyPostMapping')).toBeUndefined(); + expect(resolveSpringAnnotationAlias('XyzGetMapping')).toBeUndefined(); + }); + + it('does not match annotations that merely contain a mapping name', () => { + expect(resolveSpringAnnotationAlias('PostMappingHelper')).toBeUndefined(); + expect(resolveSpringAnnotationAlias('GetMappingInfo')).toBeUndefined(); + expect(resolveSpringAnnotationAlias('PreMapping')).toBeUndefined(); + }); +}); + +describe('Spring vendor prefix freshness', () => { + it('canonicalizes equivalent lists regardless of order and duplicates', () => { + vi.stubEnv('GITNEXUS_SPRING_VENDOR_PREFIXES', ' Win,Acme,Win '); + const first = springVendorPrefixesKey(); + vi.stubEnv('GITNEXUS_SPRING_VENDOR_PREFIXES', 'Acme,Win'); + const second = springVendorPrefixesKey(); + + expect(first).toBe('["Acme","Win"]'); + expect(second).toBe(first); + expect(first).not.toBe('["Win"]'); + }); +}); + +describe('springAnnotationHttpMethods with vendor aliases', () => { + it('resolves WinPostMapping to POST', () => { + expect(springAnnotationHttpMethods('WinPostMapping', '@WinPostMapping("/api")')).toEqual([ + 'POST', + ]); + }); + + it('resolves WinGetMapping to GET', () => { + expect(springAnnotationHttpMethods('WinGetMapping', '@WinGetMapping("/api")')).toEqual(['GET']); + }); + + it('resolves WinDeleteMapping to DELETE', () => { + expect(springAnnotationHttpMethods('WinDeleteMapping', '@WinDeleteMapping("/api")')).toEqual([ + 'DELETE', + ]); + }); + + it('resolves WinRequestMapping without method attribute to wildcard', () => { + expect(springAnnotationHttpMethods('WinRequestMapping', '@WinRequestMapping("/api")')).toEqual([ + '*', + ]); + }); + + it('resolves WinRequestMapping with method attribute', () => { + const text = '@WinRequestMapping(value = "/api", method = RequestMethod.POST)'; + expect(springAnnotationHttpMethods('WinRequestMapping', text)).toEqual(['POST']); + }); + + it('accepts Kotlin collection syntax for RequestMapping method arrays', () => { + const text = + '@WinRequestMapping(value = "/api", method = [RequestMethod.GET, RequestMethod.HEAD])'; + expect(springAnnotationHttpMethods('WinRequestMapping', text)).toEqual(['GET', 'HEAD']); + }); + + it('fail-closes mismatched RequestMapping method collection delimiters', () => { + const text = '@WinRequestMapping(method = {RequestMethod.GET])'; + expect(springAnnotationHttpMethods('WinRequestMapping', text)).toEqual([]); + }); + + it('returns empty for unrelated annotations', () => { + expect(springAnnotationHttpMethods('Component', '@Component')).toEqual([]); + expect(springAnnotationHttpMethods('Override', '@Override')).toEqual([]); + }); +}); + +describe('extractSpringRoutes with vendor annotations', () => { + it('extracts routes from a controller using @Win annotations', () => { + const tree = parse(` +package com.winning.opt.controller; + +@RestController +@RequestMapping("/api/opt") +public class OrderController { + @WinPostMapping("/create") + public String create() { return "{}"; } + + @WinGetMapping("/query") + public String query() { return "[]"; } + + @WinPostMapping(value = "/update") + public String update() { return "{}"; } +} +`); + + const routes = extractSpringRoutes(tree, 'OrderController.java'); + expect(routes).toHaveLength(3); + + const postRoutes = routes.filter((r) => r.httpMethod === 'POST'); + expect(postRoutes).toHaveLength(2); + const postPaths = postRoutes.map((r) => r.routePath).sort(); + expect(postPaths).toEqual(['/create', '/update']); + for (const r of postRoutes) { + expect(r.prefix).toBe('/api/opt'); + } + + const getRoute = routes.find((r) => r.httpMethod === 'GET')!; + expect(getRoute.routePath).toBe('/query'); + expect(getRoute.prefix).toBe('/api/opt'); + }); + + it('extracts routes when vendor and standard annotations are mixed', () => { + const tree = parse(` +@RestController +@RequestMapping("/api/mix") +public class MixedController { + @WinPostMapping("/win-create") + public String winCreate() { return "{}"; } + + @PostMapping("/std-create") + public String stdCreate() { return "{}"; } + + @GetMapping("/std-get") + public String stdGet() { return "[]"; } +} +`); + + const routes = extractSpringRoutes(tree, 'MixedController.java'); + expect(routes).toHaveLength(3); + + const paths = routes.map((r) => r.routePath).sort(); + expect(paths).toEqual(['/std-create', '/std-get', '/win-create']); + }); + + it('ingestion and group extractors agree on vendor annotation routes', () => { + const tree = parse(` +@RestController +@RequestMapping("/api/parity") +public class ParityController { + @WinPostMapping("/create") + public String create() { return "{}"; } + + @WinGetMapping("/query") + public String query() { return "[]"; } +} +`); + + const ingestionRoutes = new Set( + extractSpringRoutes(tree, 'ParityController.java').map( + (r) => `${r.httpMethod} ${normalizeExtractedRoutePath(r.routePath, r.prefix ?? null)}`, + ), + ); + + const groupRoutes = new Set( + JAVA_HTTP_PLUGIN.scan(tree) + .filter((d) => d.role === 'provider') + .map((d) => `${d.method} ${normalizeExtractedRoutePath(d.path, null)}`), + ); + + expect([...ingestionRoutes].sort()).toEqual([...groupRoutes].sort()); + expect([...ingestionRoutes].sort()).toEqual([ + 'GET /api/parity/query', + 'POST /api/parity/create', + ]); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// Review regressions (magyargergo, 2026-08-29) +// ═══════════════════════════════════════════════════════════════════════════ + +describe('review regressions: class-level aliases', () => { + it('P1: isClassLevelMappingAnnotation accepts @WinRequestMapping like @RequestMapping', async () => { + const { isClassLevelMappingAnnotation } = + await import('../../src/core/ingestion/route-extractors/spring-shared.js'); + expect(isClassLevelMappingAnnotation('RequestMapping')).toBe(true); + expect(isClassLevelMappingAnnotation('WinRequestMapping')).toBe(true); + expect(isClassLevelMappingAnnotation('WinPostMapping')).toBe(false); + expect(isClassLevelMappingAnnotation('AuditRequestMapping')).toBe(false); + expect(isClassLevelMappingAnnotation('GetMapping')).toBe(false); + }); + + it('P1: vendor class prefix flows into route paths (@WinRequestMapping + @WinGetMapping)', () => { + const tree = parse(` +@WinRequestMapping("/vendor") +public class VendorController { + @WinGetMapping("/users") + public String list() { return "ok"; } +} +`); + const routes = extractSpringRoutes(tree, 'VendorController.java'); + expect(routes).toHaveLength(1); + // Class prefix /vendor comes from the aliased @WinRequestMapping — the + // exact path the old exact-match-only class handling missed (review P1). + expect(routes[0].prefix).toBe('/vendor'); + expect(routes[0].routePath).toBe('/users'); + expect(routes[0].httpMethod).toBe('GET'); + expect( + JAVA_HTTP_PLUGIN.scan(tree) + .filter((detection) => detection.role === 'provider') + .map((detection) => `${detection.method} ${detection.path}`), + ).toEqual(['GET /vendor/users']); + }); + + it('P2: unregistered suffix no longer emits a phantom route (end-to-end)', () => { + const tree = parse(` +public class AuditController { + @AuditPostMapping("/audit") + public String audit() { return "x"; } +} +`); + const routes = extractSpringRoutes(tree, 'AuditController.java'); + expect(routes).toHaveLength(0); + expect( + JAVA_HTTP_PLUGIN.scan(tree).filter((detection) => detection.role === 'provider'), + ).toEqual([]); + }); + + it('P2: extra vendor prefixes can be registered via env', () => { + vi.stubEnv('GITNEXUS_SPRING_VENDOR_PREFIXES', 'Win,Acme'); + try { + expect(resolveSpringAnnotationAlias('AcmePostMapping')).toBe('PostMapping'); + expect(resolveSpringAnnotationAlias('OtherPostMapping')).toBeUndefined(); + } finally { + vi.unstubAllEnvs(); + } + }); +});