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] 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('*'); + }); +});