Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
129 changes: 129 additions & 0 deletions gitnexus/src/core/group/extractors/http-patterns/node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -103,13 +106,58 @@ const AXIOS_SPEC: PatternSpec<Record<string, never>> = {
`,
};

// ─── 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<Record<string, never>> = {
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<Record<string, never>> = {
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<Record<string, never>> = {
meta: {},
query: `
(call_expression
function: (identifier) @fn (#eq? @fn "axios")
arguments: (arguments (object) @options))
`,
};

interface NodePatternBundle {
controller: CompiledPatterns<Record<string, never>>;
methodDecorator: CompiledPatterns<Record<string, never>>;
express: CompiledPatterns<Record<string, never>>;
fetchNoOptions: CompiledPatterns<Record<string, never>>;
fetchWithOptions: CompiledPatterns<Record<string, never>>;
axios: CompiledPatterns<Record<string, never>>;
jqueryShorthand: CompiledPatterns<Record<string, never>>;
jqueryAjax: CompiledPatterns<Record<string, never>>;
axiosObject: CompiledPatterns<Record<string, never>>;
}

function compileBundle(language: unknown, name: string): NodePatternBundle {
Expand All @@ -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'),
};
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.<verb>(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;
}

Expand Down
118 changes: 118 additions & 0 deletions gitnexus/test/unit/group/http-route-extractor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,124 @@ 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' });

function reloadOrder(id) {
return $.ajax({ url: \`/api/orders/\${id}\`, method: 'GET' });
}
`,
);

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();
// 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 () => {
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 });
Expand Down
Loading