From 3cef822416e3cd8a1522f9a1f05b3f8fdd2860df Mon Sep 17 00:00:00 2001 From: azizur1992 Date: Thu, 16 Apr 2026 16:56:06 +0100 Subject: [PATCH 1/2] feat(extractors): detect jQuery $.ajax/$.get/$.post and axios object-form as HTTP consumers The JS/TS HTTP consumer extractor currently recognises fetch() and axios.() but misses three patterns extremely common in Laravel and legacy frontends: - jQuery shorthand: $.get(url), $.post(url, data) - jQuery ajax form: $.ajax({ url, method }) / $.ajax({ url, type }) - axios object form: axios({ method, url }) Missing them means the frontend->backend cross-link disappears from `group sync`, breaking impact analysis for whole classes of repos. Implementation (node.ts): - 3 new PatternSpecs alongside the existing FETCH_/AXIOS_ specs - NodePatternBundle extended with jqueryShorthand / jqueryAjax / axiosObject slots, compiled for JS / TS / TSX grammars - readStringProp() helper walks object-literal `pair` children and resolves `url` / `method` / `type` keys independent of order, sidestepping the positional S-expression constraint on the query form proposed in the issue - 3 new scan loops in scanBundle() emit HttpDetection with framework 'jquery' (new) or 'axios' (existing), confidence 0.7 to match the existing source-scan consumers, defaulting method to GET when absent (matches both jQuery and axios runtime) Tests (http-route-extractor.test.ts): 4 new cases -- 3 positive (shorthand, ajax with method:/type: and default GET, object-form with swapped key order and default GET) plus 1 negative control that asserts unrelated \$.fn.extend / \$.each / non-axios helper calls with {url, method} literals produce zero consumer contracts. Closes #828 --- .../group/extractors/http-patterns/node.ts | 129 ++++++++++++++++++ .../unit/group/http-route-extractor.test.ts | 108 +++++++++++++++ 2 files changed, 237 insertions(+) diff --git a/gitnexus/src/core/group/extractors/http-patterns/node.ts b/gitnexus/src/core/group/extractors/http-patterns/node.ts index 587f48e8c7..fbf9886651 100644 --- a/gitnexus/src/core/group/extractors/http-patterns/node.ts +++ b/gitnexus/src/core/group/extractors/http-patterns/node.ts @@ -17,6 +17,9 @@ import type { HttpDetection, HttpLanguagePlugin } from './types.js'; * - Express `router.get(...)` / `app.post(...)` providers * - `fetch(url)` / `fetch(url, { method: 'POST' })` consumers * - `axios.get(url)` / `axios.delete(url)` consumers + * - `axios({ method, url })` object-form consumers + * - jQuery `$.get(url)` / `$.post(url, ...)` shorthand consumers + * - jQuery `$.ajax({ url, method | type })` consumers * * Because the JavaScript and TypeScript tree-sitter grammars share * node type names for every construct we query, pattern sources are @@ -103,6 +106,48 @@ const AXIOS_SPEC: PatternSpec> = { `, }; +// ─── Consumer: jQuery shorthand $.get(url) / $.post(url, ...) ──────── +// `$` is a valid JS identifier, so tree-sitter parses `$.get(...)` as a +// call_expression whose function is a member_expression on identifier `$`. +const JQUERY_SHORTHAND_SPEC: PatternSpec> = { + meta: {}, + query: ` + (call_expression + function: (member_expression + object: (identifier) @obj (#eq? @obj "$") + property: (property_identifier) @http_method (#match? @http_method "^(get|post)$")) + arguments: (arguments . [(string) (template_string)] @path)) + `, +}; + +// ─── Consumer: jQuery $.ajax({ url, method|type }) ─────────────────── +// The query captures the options object only; key/value pairs are read +// programmatically via `readStringProp` below, which tolerates any key +// order and accepts either `method:` or `type:` (jQuery supports both). +const JQUERY_AJAX_SPEC: PatternSpec> = { + meta: {}, + query: ` + (call_expression + function: (member_expression + object: (identifier) @obj (#eq? @obj "$") + property: (property_identifier) @fn (#eq? @fn "ajax")) + arguments: (arguments (object) @options)) + `, +}; + +// ─── Consumer: axios({ method, url }) object form ──────────────────── +// Distinct from AXIOS_SPEC above because the call target is an identifier +// (`axios`) rather than a member expression (`axios.get`). As with the +// jQuery ajax form, option keys are resolved programmatically. +const AXIOS_OBJECT_SPEC: PatternSpec> = { + meta: {}, + query: ` + (call_expression + function: (identifier) @fn (#eq? @fn "axios") + arguments: (arguments (object) @options)) + `, +}; + interface NodePatternBundle { controller: CompiledPatterns>; methodDecorator: CompiledPatterns>; @@ -110,6 +155,9 @@ interface NodePatternBundle { fetchNoOptions: CompiledPatterns>; fetchWithOptions: CompiledPatterns>; axios: CompiledPatterns>; + jqueryShorthand: CompiledPatterns>; + jqueryAjax: CompiledPatterns>; + axiosObject: CompiledPatterns>; } function compileBundle(language: unknown, name: string): NodePatternBundle { @@ -126,6 +174,9 @@ function compileBundle(language: unknown, name: string): NodePatternBundle { fetchNoOptions: mk(FETCH_NO_OPTIONS_SPEC, 'fetch-no-options'), fetchWithOptions: mk(FETCH_WITH_OPTIONS_SPEC, 'fetch-with-options'), axios: mk(AXIOS_SPEC, 'axios'), + jqueryShorthand: mk(JQUERY_SHORTHAND_SPEC, 'jquery-shorthand'), + jqueryAjax: mk(JQUERY_AJAX_SPEC, 'jquery-ajax'), + axiosObject: mk(AXIOS_OBJECT_SPEC, 'axios-object'), }; } @@ -160,6 +211,28 @@ function joinPath(prefix: string, sub: string): string { return `/${cleanPrefix}/${cleanSub}`; } +/** + * Walk `pair` children of an `object` literal and return the unquoted + * string/template_string value for the first pair whose key matches one + * 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. + */ +function readStringProp(objectNode: Parser.SyntaxNode, keyNames: readonly string[]): string | null { + for (let i = 0; i < objectNode.namedChildCount; i++) { + const pair = objectNode.namedChild(i); + if (!pair || pair.type !== 'pair') continue; + const keyNode = pair.childForFieldName('key'); + const valueNode = pair.childForFieldName('value'); + if (!keyNode || !valueNode) continue; + if (!keyNames.includes(keyNode.text)) continue; + if (valueNode.type !== 'string' && valueNode.type !== 'template_string') continue; + const lit = unquoteLiteral(valueNode.text); + if (lit !== null) return lit; + } + return null; +} + /** * For a standalone `decorator` node (child of class_body / program), * find the related `class_declaration` node that it decorates. In @@ -351,6 +424,62 @@ function scanBundle(bundle: NodePatternBundle, tree: Parser.Tree): HttpDetection }); } + // Consumer: jQuery shorthand $.get(url) / $.post(url, ...) + for (const match of runCompiledPatterns(bundle.jqueryShorthand, tree)) { + const methodNode = match.captures.http_method; + const pathNode = match.captures.path; + if (!methodNode || !pathNode) continue; + const path = unquoteLiteral(pathNode.text); + if (path === null) continue; + out.push({ + role: 'consumer', + framework: 'jquery', + method: methodNode.text.toUpperCase(), + path, + name: null, + confidence: 0.7, + }); + } + + // Consumer: jQuery $.ajax({ url, method|type }). jQuery accepts either + // `method:` or `type:`; both default to GET when absent. + for (const match of runCompiledPatterns(bundle.jqueryAjax, tree)) { + const optionsNode = match.captures.options; + if (!optionsNode) continue; + const path = readStringProp(optionsNode, ['url']); + if (path === null) continue; + const rawMethod = readStringProp(optionsNode, ['method', 'type']); + const method = (rawMethod ?? 'GET').toUpperCase(); + out.push({ + role: 'consumer', + framework: 'jquery', + method, + path, + name: null, + confidence: 0.7, + }); + } + + // Consumer: axios({ method, url }) object form. Structurally distinct + // from axios.(url) (identifier vs member_expression call), so no + // dedup against the member-form loop above is required. + for (const match of runCompiledPatterns(bundle.axiosObject, tree)) { + const optionsNode = match.captures.options; + if (!optionsNode) continue; + const path = readStringProp(optionsNode, ['url']); + if (path === null) continue; + const rawMethod = readStringProp(optionsNode, ['method']); + const method = (rawMethod ?? 'GET').toUpperCase(); + out.push({ + role: 'consumer', + framework: 'axios', + method, + path, + name: null, + confidence: 0.7, + }); + } + return out; } diff --git a/gitnexus/test/unit/group/http-route-extractor.test.ts b/gitnexus/test/unit/group/http-route-extractor.test.ts index 653b4952c4..f5473d95fd 100644 --- a/gitnexus/test/unit/group/http-route-extractor.test.ts +++ b/gitnexus/test/unit/group/http-route-extractor.test.ts @@ -290,6 +290,114 @@ export const deleteUser = (id: string) => axios.delete(\`/api/users/\${id}\`); ).toBeDefined(); }); + it('extracts jQuery $.get and $.post shorthand', async () => { + const dir = path.join(tmpDir, 'jquery-shorthand'); + fs.mkdirSync(path.join(dir, 'public/js'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'public/js/users.js'), + ` +function loadUsers() { + $.get('/api/users', function (data) { console.log(data); }); +} + +function createUser(payload) { + $.post('/api/users', payload); +} +`, + ); + + const contracts = await extractor.extract(null, dir, makeRepo(dir)); + const consumers = contracts.filter((c) => c.role === 'consumer'); + + const getRoute = consumers.find((c) => c.contractId === 'http::GET::/api/users'); + expect(getRoute).toBeDefined(); + expect(getRoute?.meta.framework).toBe('jquery'); + + const postRoute = consumers.find((c) => c.contractId === 'http::POST::/api/users'); + expect(postRoute).toBeDefined(); + expect(postRoute?.meta.framework).toBe('jquery'); + }); + + it('extracts jQuery $.ajax with method: and type: keys and default GET', async () => { + const dir = path.join(tmpDir, 'jquery-ajax'); + fs.mkdirSync(path.join(dir, 'public/js'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'public/js/orders.js'), + ` +$.ajax({ url: '/api/orders', method: 'PUT', data: {} }); +$.ajax({ url: '/api/items', type: 'DELETE' }); +$.ajax({ url: '/api/default' }); +`, + ); + + const contracts = await extractor.extract(null, dir, makeRepo(dir)); + const consumers = contracts.filter((c) => c.role === 'consumer'); + + expect(consumers.find((c) => c.contractId === 'http::PUT::/api/orders')).toBeDefined(); + expect(consumers.find((c) => c.contractId === 'http::DELETE::/api/items')).toBeDefined(); + expect(consumers.find((c) => c.contractId === 'http::GET::/api/default')).toBeDefined(); + }); + + it('extracts axios({ method, url }) object form regardless of key order', async () => { + const dir = path.join(tmpDir, 'axios-object'); + fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src/orders.ts'), + ` +import axios from 'axios'; + +export function createOrder(data: unknown) { + return axios({ method: 'POST', url: '/api/orders', data }); +} + +export function updateUser(id: string, data: unknown) { + return axios({ url: \`/api/users/\${id}\`, method: 'PUT', data }); +} + +export function listDefaults() { + return axios({ url: '/api/defaults' }); +} +`, + ); + + 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/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(); + }); + + it('does not emit consumers for unrelated object-literal calls (negative control)', async () => { + const dir = path.join(tmpDir, 'jquery-axios-negative'); + fs.mkdirSync(path.join(dir, 'public/js'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'public/js/misc.js'), + ` +// jQuery but not an ajax/get/post call +$.fn.extend({ url: '/nope', method: 'POST' }); +$.each([1, 2, 3], function (i, v) { return v; }); + +// Not axios and not $ — unrelated helper that happens to take { url, method } +function myHelper(opts) { return opts; } +myHelper({ url: '/nope', method: 'POST' }); + +// Bare object literal, not a call argument at all +const cfg = { url: '/nope', method: 'POST' }; +console.log(cfg); +`, + ); + + const contracts = await extractor.extract(null, dir, makeRepo(dir)); + const consumers = contracts.filter((c) => c.role === 'consumer'); + + // None of the above should have produced any HTTP consumer contracts. + const nopeConsumers = consumers.filter( + (c) => typeof c.meta.path === 'string' && c.meta.path.includes('/nope'), + ); + expect(nopeConsumers).toHaveLength(0); + }); + it('extracts Python requests calls', async () => { const dir = path.join(tmpDir, 'python-consumer'); fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); From 968a7f44c79ee7a77cabdce86436245671657d2b Mon Sep 17 00:00:00 2001 From: azizur1992 Date: Thu, 16 Apr 2026 18:08:58 +0100 Subject: [PATCH 2/2] test(extractors): cover jQuery $.ajax with template-literal URL Extend the existing $.ajax fixture with `url: \`/api/orders/\${id}\`` and assert the consumer is emitted as http::GET::/api/orders/{param}. This makes jQuery + template-URL explicit rather than implicit via the axios object test (readStringProp already accepts template_string for both; this is coverage, not new behaviour). Addresses the single non-blocking finding on PR #887. --- gitnexus/test/unit/group/http-route-extractor.test.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/gitnexus/test/unit/group/http-route-extractor.test.ts b/gitnexus/test/unit/group/http-route-extractor.test.ts index f5473d95fd..8ff914fcc9 100644 --- a/gitnexus/test/unit/group/http-route-extractor.test.ts +++ b/gitnexus/test/unit/group/http-route-extractor.test.ts @@ -327,6 +327,10 @@ function createUser(payload) { $.ajax({ url: '/api/orders', method: 'PUT', data: {} }); $.ajax({ url: '/api/items', type: 'DELETE' }); $.ajax({ url: '/api/default' }); + +function reloadOrder(id) { + return $.ajax({ url: \`/api/orders/\${id}\`, method: 'GET' }); +} `, ); @@ -336,6 +340,12 @@ $.ajax({ url: '/api/default' }); expect(consumers.find((c) => c.contractId === 'http::PUT::/api/orders')).toBeDefined(); expect(consumers.find((c) => c.contractId === 'http::DELETE::/api/items')).toBeDefined(); expect(consumers.find((c) => c.contractId === 'http::GET::/api/default')).toBeDefined(); + // Template-literal URL inside $.ajax is normalized to {param} the same + // way the fetch/axios paths do — confirms readStringProp accepts + // template_string values for jQuery ajax, not just for axios object form. + expect( + consumers.find((c) => c.contractId === 'http::GET::/api/orders/{param}'), + ).toBeDefined(); }); it('extracts axios({ method, url }) object form regardless of key order', async () => {