From 68bd835198a8c62076edafd3029873cdc82b3f99 Mon Sep 17 00:00:00 2001 From: juyua9 Date: Thu, 7 May 2026 19:05:22 +0800 Subject: [PATCH 1/5] fix(group): detect httpx async consumers --- .../group/extractors/http-patterns/python.ts | 133 ++++++++++++++++++ .../unit/group/http-route-extractor.test.ts | 35 +++++ 2 files changed, 168 insertions(+) diff --git a/gitnexus/src/core/group/extractors/http-patterns/python.ts b/gitnexus/src/core/group/extractors/http-patterns/python.ts index 27ddf6633c..ccb7790b34 100644 --- a/gitnexus/src/core/group/extractors/http-patterns/python.ts +++ b/gitnexus/src/core/group/extractors/http-patterns/python.ts @@ -1,3 +1,4 @@ +import type Parser from 'tree-sitter'; import Python from 'tree-sitter-python'; import { compilePatterns, @@ -12,6 +13,7 @@ import type { HttpDetection, HttpLanguagePlugin } from './types.js'; * - FastAPI `@app.get("/path")` provider decorators * - `requests.get/post/...("url")` consumer calls * - Generic `requests.request("METHOD", "url")` consumer calls + * - `httpx.AsyncClient` instances calling `.get/.post/...("url")` */ const FASTAPI_VERBS: Record = { @@ -77,11 +79,103 @@ const REQUESTS_GENERIC_PATTERNS = compilePatterns({ ], } satisfies LanguagePatterns>); + +// ─── Consumer: httpx.AsyncClient assignments ──────────────────────── +const HTTPX_ASYNC_CLIENT_ASSIGN_PATTERNS = compilePatterns({ + name: 'python-httpx-async-client-assign', + language: Python, + patterns: [ + { + meta: {}, + query: ` + (assignment + left: (_) @client + right: (call + function: (attribute + object: (identifier) @module (#eq? @module "httpx") + attribute: (identifier) @client_class (#eq? @client_class "AsyncClient")))) + `, + }, + ], +} satisfies LanguagePatterns>); + +// ─── Consumer: async with httpx.AsyncClient() as client ────────────── +const HTTPX_ASYNC_CLIENT_WITH_ALIAS_PATTERNS = compilePatterns({ + name: 'python-httpx-async-client-with-alias', + language: Python, + patterns: [ + { + meta: {}, + query: ` + (as_pattern + (call + function: (attribute + object: (identifier) @module (#eq? @module "httpx") + attribute: (identifier) @client_class (#eq? @client_class "AsyncClient"))) + (as_pattern_target (identifier) @client)) + `, + }, + ], +} satisfies LanguagePatterns>); + +// ─── Consumer: httpx AsyncClient .get/.post/...("url") ────────────── +const HTTPX_ASYNC_CLIENT_VERB_PATTERNS = compilePatterns({ + name: 'python-httpx-async-client-verb', + language: Python, + patterns: [ + { + meta: {}, + query: ` + (call + function: (attribute + object: (_) @client + attribute: (identifier) @method (#match? @method "^(get|post|put|delete|patch)$")) + arguments: (argument_list . (string) @path)) + `, + }, + ], +} satisfies LanguagePatterns>); + +// ─── Consumer: httpx AsyncClient .request("METHOD", "url") ───────── +const HTTPX_ASYNC_CLIENT_GENERIC_PATTERNS = compilePatterns({ + name: 'python-httpx-async-client-generic', + language: Python, + patterns: [ + { + meta: {}, + query: ` + (call + function: (attribute + object: (_) @client + attribute: (identifier) @method (#eq? @method "request")) + arguments: (argument_list . (string) @http_method (string) @path)) + `, + }, + ], +} satisfies LanguagePatterns>); + +function collectHttpxAsyncClients(tree: Parser.Tree): Set { + const clients = new Set(); + + for (const match of runCompiledPatterns(HTTPX_ASYNC_CLIENT_ASSIGN_PATTERNS, tree)) { + const clientNode = match.captures.client; + if (clientNode) clients.add(clientNode.text); + } + + for (const match of runCompiledPatterns(HTTPX_ASYNC_CLIENT_WITH_ALIAS_PATTERNS, tree)) { + const clientNode = match.captures.client; + if (clientNode) clients.add(clientNode.text); + } + + return clients; +} + export const PYTHON_HTTP_PLUGIN: HttpLanguagePlugin = { name: 'python-http', language: Python, scan(tree) { const out: HttpDetection[] = []; + const httpxAsyncClients = collectHttpxAsyncClients(tree); // Providers: FastAPI for (const match of runCompiledPatterns(FASTAPI_PATTERNS, tree)) { @@ -137,6 +231,45 @@ export const PYTHON_HTTP_PLUGIN: HttpLanguagePlugin = { }); } + // Consumers: httpx.AsyncClient.("url") + for (const match of runCompiledPatterns(HTTPX_ASYNC_CLIENT_VERB_PATTERNS, tree)) { + const clientNode = match.captures.client; + const methodNode = match.captures.method; + const pathNode = match.captures.path; + if (!clientNode || !methodNode || !pathNode) continue; + if (!httpxAsyncClients.has(clientNode.text)) continue; + const path = unquoteLiteral(pathNode.text); + if (path === null) continue; + out.push({ + role: 'consumer', + framework: 'python-httpx', + method: methodNode.text.toUpperCase(), + path, + name: null, + confidence: 0.7, + }); + } + + // Consumers: httpx.AsyncClient.request("METHOD", "url") + for (const match of runCompiledPatterns(HTTPX_ASYNC_CLIENT_GENERIC_PATTERNS, tree)) { + const clientNode = match.captures.client; + const methodNode = match.captures.http_method; + const pathNode = match.captures.path; + if (!clientNode || !methodNode || !pathNode) continue; + if (!httpxAsyncClients.has(clientNode.text)) continue; + const methodRaw = unquoteLiteral(methodNode.text); + const path = unquoteLiteral(pathNode.text); + if (methodRaw === null || path === null) continue; + out.push({ + role: 'consumer', + framework: 'python-httpx', + method: methodRaw.toUpperCase(), + 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 69d3da2fab..063d4768ae 100644 --- a/gitnexus/test/unit/group/http-route-extractor.test.ts +++ b/gitnexus/test/unit/group/http-route-extractor.test.ts @@ -428,6 +428,41 @@ def create_order(): consumers.find((c) => c.contractId === 'http::POST::/api/orders/{param}'), ).toBeDefined(); }); + it('extracts Python httpx.AsyncClient calls assigned to attributes or aliases', async () => { + const dir = path.join(tmpDir, 'python-httpx-consumer'); + fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src', 'client.py'), + ` +import httpx + +class TopicClient: + def __init__(self): + self._client = httpx.AsyncClient(base_url="https://svc.local") + + async def list_topics(self): + return await self._client.get("/topic") + + async def publish(self): + return await self._client.request("POST", "/questions/import") + +async def check_duplicate(): + async with httpx.AsyncClient() as client: + return await client.post("https://svc.local/questions/duplicate-check") +`, + ); + + 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::/topic')).toBeDefined(); + expect(consumers.find((c) => c.contractId === 'http::POST::/questions/import')).toBeDefined(); + expect( + consumers.find((c) => c.contractId === 'http::POST::/questions/duplicate-check'), + ).toBeDefined(); + expect(consumers.every((c) => c.meta.framework === 'python-httpx')).toBe(true); + }); + it('extracts Java RestTemplate, WebClient and OkHttp calls', async () => { const dir = path.join(tmpDir, 'java-consumer'); From 522a6b2a2f0cdae532d03c6f6a9056d924f9ed2c Mon Sep 17 00:00:00 2001 From: juyua9 Date: Thu, 7 May 2026 19:54:12 +0800 Subject: [PATCH 2/5] test(group): tighten httpx consumer coverage --- .../group/extractors/http-patterns/python.ts | 4 ++- .../unit/group/http-route-extractor.test.ts | 28 ++++++++++++++----- 2 files changed, 24 insertions(+), 8 deletions(-) diff --git a/gitnexus/src/core/group/extractors/http-patterns/python.ts b/gitnexus/src/core/group/extractors/http-patterns/python.ts index ccb7790b34..31361f8fc8 100644 --- a/gitnexus/src/core/group/extractors/http-patterns/python.ts +++ b/gitnexus/src/core/group/extractors/http-patterns/python.ts @@ -79,8 +79,10 @@ const REQUESTS_GENERIC_PATTERNS = compilePatterns({ ], } satisfies LanguagePatterns>); - // ─── Consumer: httpx.AsyncClient assignments ──────────────────────── +// NOTE: This targeted detector only tracks explicit `httpx.AsyncClient(...)` +// construction. Direct imports (`from httpx import AsyncClient`) and module +// aliases (`import httpx as hx`) are intentionally left for a follow-up. const HTTPX_ASYNC_CLIENT_ASSIGN_PATTERNS = compilePatterns({ name: 'python-httpx-async-client-assign', language: Python, diff --git a/gitnexus/test/unit/group/http-route-extractor.test.ts b/gitnexus/test/unit/group/http-route-extractor.test.ts index 063d4768ae..a575451b98 100644 --- a/gitnexus/test/unit/group/http-route-extractor.test.ts +++ b/gitnexus/test/unit/group/http-route-extractor.test.ts @@ -446,8 +446,14 @@ class TopicClient: async def publish(self): return await self._client.request("POST", "/questions/import") + async def delete_topic(self): + return await self._client.delete("/topic") + async def check_duplicate(): async with httpx.AsyncClient() as client: + data = {} + data.get("/nope") + service.request("POST", "/nope") return await client.post("https://svc.local/questions/duplicate-check") `, ); @@ -455,15 +461,23 @@ async def check_duplicate(): 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::/topic')).toBeDefined(); - expect(consumers.find((c) => c.contractId === 'http::POST::/questions/import')).toBeDefined(); - expect( - consumers.find((c) => c.contractId === 'http::POST::/questions/duplicate-check'), - ).toBeDefined(); - expect(consumers.every((c) => c.meta.framework === 'python-httpx')).toBe(true); + const expected = [ + 'http::GET::/topic', + 'http::POST::/questions/import', + 'http::DELETE::/topic', + 'http::POST::/questions/duplicate-check', + ]; + + for (const contractId of expected) { + const consumer = consumers.find((c) => c.contractId === contractId); + expect(consumer).toBeDefined(); + expect(consumer?.meta.framework).toBe('python-httpx'); + } + + expect(consumers.find((c) => c.contractId === 'http::GET::/nope')).toBeUndefined(); + expect(consumers.find((c) => c.contractId === 'http::POST::/nope')).toBeUndefined(); }); - it('extracts Java RestTemplate, WebClient and OkHttp calls', async () => { const dir = path.join(tmpDir, 'java-consumer'); fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); From feac264216f095528895260eb4554c4bed334ade Mon Sep 17 00:00:00 2001 From: juyua9 Date: Fri, 8 May 2026 21:53:13 +0800 Subject: [PATCH 3/5] test(group): create extractor temp dirs safely --- gitnexus/test/unit/group/http-route-extractor.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/gitnexus/test/unit/group/http-route-extractor.test.ts b/gitnexus/test/unit/group/http-route-extractor.test.ts index a575451b98..c41fb52ca9 100644 --- a/gitnexus/test/unit/group/http-route-extractor.test.ts +++ b/gitnexus/test/unit/group/http-route-extractor.test.ts @@ -6,12 +6,12 @@ import { HttpRouteExtractor } from '../../../src/core/group/extractors/http-rout import type { RepoHandle } from '../../../src/core/group/types.js'; describe('HttpRouteExtractor', () => { - const tmpDir = path.join(os.tmpdir(), `gitnexus-http-extract-${Date.now()}`); + let tmpDir: string; let extractor: HttpRouteExtractor; beforeEach(() => { extractor = new HttpRouteExtractor(); - fs.mkdirSync(tmpDir, { recursive: true }); + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-http-extract-')); }); afterEach(() => { From 4b4bee8bad17321b5d71547e448d114647cd6d9e Mon Sep 17 00:00:00 2001 From: juyua9 Date: Sun, 10 May 2026 20:20:36 +0800 Subject: [PATCH 4/5] fix(group): scope httpx async client tracking --- .../group/extractors/http-patterns/python.ts | 99 +++++++++++++++---- .../unit/group/http-route-extractor.test.ts | 7 ++ 2 files changed, 88 insertions(+), 18 deletions(-) diff --git a/gitnexus/src/core/group/extractors/http-patterns/python.ts b/gitnexus/src/core/group/extractors/http-patterns/python.ts index 31361f8fc8..077ce31e77 100644 --- a/gitnexus/src/core/group/extractors/http-patterns/python.ts +++ b/gitnexus/src/core/group/extractors/http-patterns/python.ts @@ -120,6 +120,85 @@ const HTTPX_ASYNC_CLIENT_WITH_ALIAS_PATTERNS = compilePatterns({ ], } satisfies LanguagePatterns>); +function getScopeKey(node: Parser.SyntaxNode | null, preferClass = false): string { + if (preferClass) { + let current: Parser.SyntaxNode | null = node; + while (current) { + if (current.type === 'class_definition') { + return `class:${current.startIndex}:${current.endIndex}`; + } + current = current.parent; + } + } + + let current: Parser.SyntaxNode | null = node; + while (current) { + if (current.type === 'function_definition') { + return `function:${current.startIndex}:${current.endIndex}`; + } + current = current.parent; + } + + return 'module'; +} + +function trackedClientScopeKey(clientNode: Parser.SyntaxNode): string { + return getScopeKey(clientNode.parent, clientNode.text.includes('.')); +} + +function callScopeKeys(clientNode: Parser.SyntaxNode): string[] { + const keys = new Set(); + const preferClass = clientNode.text.includes('.'); + const nearestScope = getScopeKey(clientNode.parent, preferClass); + + if (nearestScope !== 'module') { + keys.add(nearestScope); + } + + if (!preferClass) { + const functionScope = getScopeKey(clientNode.parent, false); + if (functionScope !== 'module') { + keys.add(functionScope); + } + } + + keys.add('module'); + return [...keys]; +} + +function collectHttpxAsyncClients(tree: Parser.Tree): Map> { + const clients = new Map>(); + + const addClient = (clientNode: Parser.SyntaxNode | undefined) => { + if (!clientNode) return; + const scopeKey = trackedClientScopeKey(clientNode); + const clientText = clientNode.text; + const scopes = clients.get(clientText) ?? new Set(); + scopes.add(scopeKey); + clients.set(clientText, scopes); + }; + + for (const match of runCompiledPatterns(HTTPX_ASYNC_CLIENT_ASSIGN_PATTERNS, tree)) { + addClient(match.captures.client); + } + + for (const match of runCompiledPatterns(HTTPX_ASYNC_CLIENT_WITH_ALIAS_PATTERNS, tree)) { + addClient(match.captures.client); + } + + return clients; +} + +function hasTrackedHttpxAsyncClient( + clients: Map>, + clientNode: Parser.SyntaxNode, +): boolean { + const scopes = clients.get(clientNode.text); + if (!scopes) return false; + + return callScopeKeys(clientNode).some((scopeKey) => scopes.has(scopeKey)); +} + // ─── Consumer: httpx AsyncClient .get/.post/...("url") ────────────── const HTTPX_ASYNC_CLIENT_VERB_PATTERNS = compilePatterns({ name: 'python-httpx-async-client-verb', @@ -156,22 +235,6 @@ const HTTPX_ASYNC_CLIENT_GENERIC_PATTERNS = compilePatterns({ ], } satisfies LanguagePatterns>); -function collectHttpxAsyncClients(tree: Parser.Tree): Set { - const clients = new Set(); - - for (const match of runCompiledPatterns(HTTPX_ASYNC_CLIENT_ASSIGN_PATTERNS, tree)) { - const clientNode = match.captures.client; - if (clientNode) clients.add(clientNode.text); - } - - for (const match of runCompiledPatterns(HTTPX_ASYNC_CLIENT_WITH_ALIAS_PATTERNS, tree)) { - const clientNode = match.captures.client; - if (clientNode) clients.add(clientNode.text); - } - - return clients; -} - export const PYTHON_HTTP_PLUGIN: HttpLanguagePlugin = { name: 'python-http', language: Python, @@ -239,7 +302,7 @@ export const PYTHON_HTTP_PLUGIN: HttpLanguagePlugin = { const methodNode = match.captures.method; const pathNode = match.captures.path; if (!clientNode || !methodNode || !pathNode) continue; - if (!httpxAsyncClients.has(clientNode.text)) continue; + if (!hasTrackedHttpxAsyncClient(httpxAsyncClients, clientNode)) continue; const path = unquoteLiteral(pathNode.text); if (path === null) continue; out.push({ @@ -258,7 +321,7 @@ export const PYTHON_HTTP_PLUGIN: HttpLanguagePlugin = { const methodNode = match.captures.http_method; const pathNode = match.captures.path; if (!clientNode || !methodNode || !pathNode) continue; - if (!httpxAsyncClients.has(clientNode.text)) continue; + if (!hasTrackedHttpxAsyncClient(httpxAsyncClients, clientNode)) continue; const methodRaw = unquoteLiteral(methodNode.text); const path = unquoteLiteral(pathNode.text); if (methodRaw === null || path === null) continue; diff --git a/gitnexus/test/unit/group/http-route-extractor.test.ts b/gitnexus/test/unit/group/http-route-extractor.test.ts index c41fb52ca9..0297f22e8c 100644 --- a/gitnexus/test/unit/group/http-route-extractor.test.ts +++ b/gitnexus/test/unit/group/http-route-extractor.test.ts @@ -455,6 +455,10 @@ async def check_duplicate(): data.get("/nope") service.request("POST", "/nope") return await client.post("https://svc.local/questions/duplicate-check") + +def unrelated_scope_collision(): + client = acquire_cache_client() + return client.get("/ignored-same-name") `, ); @@ -476,6 +480,9 @@ async def check_duplicate(): expect(consumers.find((c) => c.contractId === 'http::GET::/nope')).toBeUndefined(); expect(consumers.find((c) => c.contractId === 'http::POST::/nope')).toBeUndefined(); + expect( + consumers.find((c) => c.contractId === 'http::GET::/ignored-same-name'), + ).toBeUndefined(); }); it('extracts Java RestTemplate, WebClient and OkHttp calls', async () => { From 7b28c9327298cf1c0f6401603854a12a3cdc91b9 Mon Sep 17 00:00:00 2001 From: juyua9 Date: Mon, 11 May 2026 17:38:33 +0800 Subject: [PATCH 5/5] fix(group): tighten httpx module-scope tracking Prevent module-scope httpx.AsyncClient tracking from matching same-name local variables inside functions. Also documents the intentionally unsupported direct-import, alias, and typed-assignment forms, and extends the httpx extractor regression fixture to cover module-scope shadowing while keeping module-scope calls detected. --- .../group/extractors/http-patterns/python.ts | 17 +++++------------ .../unit/group/http-route-extractor.test.ts | 12 ++++++++++++ 2 files changed, 17 insertions(+), 12 deletions(-) diff --git a/gitnexus/src/core/group/extractors/http-patterns/python.ts b/gitnexus/src/core/group/extractors/http-patterns/python.ts index 077ce31e77..0d33852470 100644 --- a/gitnexus/src/core/group/extractors/http-patterns/python.ts +++ b/gitnexus/src/core/group/extractors/http-patterns/python.ts @@ -82,7 +82,10 @@ const REQUESTS_GENERIC_PATTERNS = compilePatterns({ // ─── Consumer: httpx.AsyncClient assignments ──────────────────────── // NOTE: This targeted detector only tracks explicit `httpx.AsyncClient(...)` // construction. Direct imports (`from httpx import AsyncClient`) and module -// aliases (`import httpx as hx`) are intentionally left for a follow-up. +// aliases (`import httpx as hx`) and annotated assignments (`client: httpx.AsyncClient = ...`) +// are intentionally left for a follow-up. Module-scope clients are only matched +// at module scope; calls inside functions require a function/class-local tracked +// client to avoid false positives from same-name local variables. const HTTPX_ASYNC_CLIENT_ASSIGN_PATTERNS = compilePatterns({ name: 'python-httpx-async-client-assign', language: Python, @@ -151,18 +154,8 @@ function callScopeKeys(clientNode: Parser.SyntaxNode): string[] { const preferClass = clientNode.text.includes('.'); const nearestScope = getScopeKey(clientNode.parent, preferClass); - if (nearestScope !== 'module') { - keys.add(nearestScope); - } - - if (!preferClass) { - const functionScope = getScopeKey(clientNode.parent, false); - if (functionScope !== 'module') { - keys.add(functionScope); - } - } + keys.add(nearestScope); - keys.add('module'); return [...keys]; } diff --git a/gitnexus/test/unit/group/http-route-extractor.test.ts b/gitnexus/test/unit/group/http-route-extractor.test.ts index 28f458db3b..aa648a71d5 100644 --- a/gitnexus/test/unit/group/http-route-extractor.test.ts +++ b/gitnexus/test/unit/group/http-route-extractor.test.ts @@ -444,6 +444,8 @@ def create_order(): ` import httpx +module_client = httpx.AsyncClient(base_url="https://svc.local") + class TopicClient: def __init__(self): self._client = httpx.AsyncClient(base_url="https://svc.local") @@ -467,6 +469,12 @@ async def check_duplicate(): def unrelated_scope_collision(): client = acquire_cache_client() return client.get("/ignored-same-name") + +def module_scope_shadow_collision(): + client = acquire_cache_client() + return client.get("/ignored-module-same-name") + +module_client.get("/module-topic") `, ); @@ -478,6 +486,7 @@ def unrelated_scope_collision(): 'http::POST::/questions/import', 'http::DELETE::/topic', 'http::POST::/questions/duplicate-check', + 'http::GET::/module-topic', ]; for (const contractId of expected) { @@ -491,6 +500,9 @@ def unrelated_scope_collision(): expect( consumers.find((c) => c.contractId === 'http::GET::/ignored-same-name'), ).toBeUndefined(); + expect( + consumers.find((c) => c.contractId === 'http::GET::/ignored-module-same-name'), + ).toBeUndefined(); }); it('extracts Java RestTemplate, WebClient and OkHttp calls', async () => {