GitNexus/gitnexus/test/unit/group/http-route-extractor.test.ts
ChunxueLi b9613ee86b
feat(node): wrapped-client HTTP consumers + leading-prefix template stripping (#3111)
* 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 <noreply@anthropic.com>

* 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 <cursoragent@cursor.com>

* 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 <cursoragent@cursor.com>

* 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 <cursoragent@cursor.com>

* 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 <cursoragent@cursor.com>

---------

Co-authored-by: l.cx <l.cx@winning.com.cn>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: ChunxueLi <mecoloud@users.noreply.gitee.com>
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-09-02 19:59:45 +00:00

7595 lines
278 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
const { parseSourceSafeSpy } = vi.hoisted(() => ({ parseSourceSafeSpy: vi.fn() }));
vi.mock('../../../src/core/tree-sitter/safe-parse.js', async () => {
const { buildSafeParseMock } = await import('../../helpers/parse-source-safe-mock.js');
return buildSafeParseMock(parseSourceSafeSpy);
});
import {
HttpRouteExtractor,
normalizeRepoRelPath,
} from '../../../src/core/group/extractors/http-route-extractor.js';
import { getPluginForFile } from '../../../src/core/group/extractors/http-patterns/index.js';
import type { RepoHandle } from '../../../src/core/group/types.js';
describe('HttpRouteExtractor', () => {
let tmpDir: string;
let extractor: HttpRouteExtractor;
beforeEach(() => {
extractor = new HttpRouteExtractor();
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-http-extract-'));
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
const makeRepo = (repoPath: string): RepoHandle => ({
id: 'test-repo',
path: 'test/backend',
repoPath,
storagePath: path.join(repoPath, '.gitnexus'),
});
describe('plugin selection', () => {
it('does not route Blade templates through the PHP source-scan plugin', () => {
expect(getPluginForFile('resources/views/welcome.blade.php')).toBeUndefined();
expect(getPluginForFile('routes/web.php')).toBeDefined();
});
});
const toPosixPath = (filePath: string): string => filePath.replace(/\\/g, '/');
describe('repo-relative path normalization', () => {
it('normalizes Windows source-scan paths before symbol lookup', () => {
expect(normalizeRepoRelPath('src\\api\\users.ts')).toBe('src/api/users.ts');
expect(normalizeRepoRelPath('.\\src\\api\\users.ts')).toBe('src/api/users.ts');
expect(normalizeRepoRelPath('./src/api/users.ts')).toBe('src/api/users.ts');
});
});
describe('symbolUid resolution via containment', () => {
it('resolves a source-scan consumer to the function CONTAINING the fetch', async () => {
const dir = path.join(tmpDir, 'consumer-containment');
fs.mkdirSync(path.join(dir, 'src/api'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'src/api/users.ts'),
`export async function fetchUsers() {
const r = await fetch('/api/users');
return r.json();
}
`,
);
// The DEFINES query (CONTAINING_QUERY) returns the function span; every
// other query (HANDLES_ROUTE / FETCHES / CONTAINS) is empty, so only the
// source-scan + line-span containment path resolves the symbol.
const mockDbExecutor = async (
query: string,
params?: Record<string, unknown>,
): Promise<Record<string, unknown>[]> => {
if (query.includes('UNION ALL') && String(params?.filePath ?? '').includes('users.ts')) {
return [
{
uid: 'fn-fetchUsers',
name: 'fetchUsers',
filePath: 'src/api/users.ts',
startLine: 1,
endLine: 4,
labels: ['Function'],
},
];
}
return [];
};
const contracts = await extractor.extract(mockDbExecutor, dir, makeRepo(dir));
const consumer = contracts.find((c) => c.role === 'consumer');
expect(consumer).toMatchObject({
symbolUid: 'fn-fetchUsers',
symbolName: 'fetchUsers',
meta: { extractionStrategy: 'source_scan_resolved' },
});
});
it('does NOT resolve an anonymous express handler to a sibling fn named "handler"', async () => {
const dir = path.join(tmpDir, 'anon-handler-no-false-name');
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
// An unrelated function literally named `handler`, plus an ANONYMOUS route
// handler. The anonymous handler must not be mis-attached to `handler`.
fs.writeFileSync(
path.join(dir, 'src/routes.ts'),
`import { Router } from 'express';
const router = Router();
function handler() { return 1; }
router.get('/api/x', (req, res) => { res.json([]); });
export default router;
`,
);
const mockDbExecutor = async (
query: string,
params?: Record<string, unknown>,
): Promise<Record<string, unknown>[]> => {
if (query.includes('UNION ALL') && String(params?.filePath ?? '').includes('routes.ts')) {
// `function handler` is on source line 3 → 0-based span [2,2]. The
// anonymous route handler is on line 4, so it is NOT inside this span.
return [
{
uid: 'uid-unrelated-handler',
name: 'handler',
filePath: 'src/routes.ts',
startLine: 2,
endLine: 2,
labels: ['Function'],
},
];
}
return [];
};
const contracts = await extractor.extract(mockDbExecutor, dir, makeRepo(dir));
const provider = contracts.find(
(c) => c.role === 'provider' && c.contractId === 'http::GET::/api/x',
);
expect(provider).toBeDefined();
// The anonymous arrow (line ~4) is NOT inside `handler`'s span [3,3], so it
// must not borrow that uid — name resolution is skipped for anonymous.
expect(provider?.symbolUid).not.toBe('uid-unrelated-handler');
});
it('resolves an express provider to its named handler symbol', async () => {
const dir = path.join(tmpDir, 'provider-named-handler');
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'src/routes.ts'),
`import { Router } from 'express';
const router = Router();
export function listUsers(req, res) { res.json([]); }
router.get('/api/users', listUsers);
export default router;
`,
);
const mockDbExecutor = async (
query: string,
params?: Record<string, unknown>,
): Promise<Record<string, unknown>[]> => {
if (query.includes('UNION ALL') && String(params?.filePath ?? '').includes('routes.ts')) {
return [
{
uid: 'fn-listUsers',
name: 'listUsers',
filePath: 'src/routes.ts',
startLine: 3,
endLine: 3,
labels: ['Function'],
},
];
}
return [];
};
const contracts = await extractor.extract(mockDbExecutor, dir, makeRepo(dir));
const provider = contracts.find(
(c) => c.role === 'provider' && c.contractId === 'http::GET::/api/users',
);
expect(provider).toMatchObject({
symbolUid: 'fn-listUsers',
symbolName: 'listUsers',
});
});
// A handler defined in a different file than its route registration: the
// registration file's symbols do not contain it, so resolution falls through
// to the unique repo-wide name lookup (#2275).
const crossFileRoutes = `import { Router } from 'express';
import { listUsers } from './handlers/users';
const router = Router();
router.get('/api/users', listUsers);
export default router;
`;
const routesFileSyms = [
{
uid: 'const-router',
name: 'router',
filePath: 'src/routes.ts',
startLine: 3,
endLine: 3,
labels: ['Const'],
},
];
const writeCrossFile = (sub: string) => {
const dir = path.join(tmpDir, sub);
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
fs.writeFileSync(path.join(dir, 'src/routes.ts'), crossFileRoutes);
return dir;
};
const providerOf = (contracts: Awaited<ReturnType<typeof extractor.extract>>) =>
contracts.find((c) => c.role === 'provider' && c.contractId === 'http::GET::/api/users');
it('resolves a cross-file named handler via the unique repo-wide lookup', async () => {
const dir = writeCrossFile('xfile-unique');
const mockDbExecutor = async (
query: string,
params?: Record<string, unknown>,
): Promise<Record<string, unknown>[]> => {
if (query.includes('UNION ALL'))
return String(params?.filePath ?? '').includes('routes.ts') ? routesFileSyms : [];
if (query.includes('filePath IN $filePaths')) return [];
if (query.includes('n.name = $name'))
return params?.name === 'listUsers'
? [{ uid: 'fn-listUsers-xfile', name: 'listUsers', filePath: 'src/handlers/users.ts' }]
: [];
return [];
};
const provider = providerOf(await extractor.extract(mockDbExecutor, dir, makeRepo(dir)));
expect(provider).toMatchObject({ symbolUid: 'fn-listUsers-xfile', symbolName: 'listUsers' });
});
it('retains unique-name fallback for a Flask handler imported from a bare module', async () => {
const dir = path.join(tmpDir, 'py-flask-bare-import');
fs.mkdirSync(path.join(dir, 'app'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'app/routes.py'),
`from flask import Flask
from shared_handlers import list_users
app = Flask(__name__)
app.add_url_rule('/api/users', view_func=list_users)
`,
);
const mockDbExecutor = async (
query: string,
params?: Record<string, unknown>,
): Promise<Record<string, unknown>[]> => {
if (query.includes('UNION ALL') || query.includes('filePath IN $filePaths')) return [];
if (query.includes('n.name = $name') && params?.name === 'list_users') {
return [{ uid: 'fn-list-users', name: 'list_users', filePath: 'shared/handlers.py' }];
}
return [];
};
const contracts = await extractor.extract(mockDbExecutor, dir, makeRepo(dir));
const provider = contracts.find(
(contract) =>
contract.role === 'provider' && contract.contractId === 'http::GET::/api/users',
);
expect(provider).toMatchObject({ symbolUid: 'fn-list-users', symbolName: 'list_users' });
});
it('leaves symbolUid empty when the repo-wide name is AMBIGUOUS (multiple matches)', async () => {
const dir = writeCrossFile('xfile-ambiguous');
const mockDbExecutor = async (
query: string,
params?: Record<string, unknown>,
): Promise<Record<string, unknown>[]> => {
if (query.includes('UNION ALL'))
return String(params?.filePath ?? '').includes('routes.ts') ? routesFileSyms : [];
if (query.includes('n.name = $name'))
return [
{ uid: 'fn-a', name: 'listUsers', filePath: 'src/a.ts' },
{ uid: 'fn-b', name: 'listUsers', filePath: 'src/b.ts' },
];
return [];
};
const provider = providerOf(await extractor.extract(mockDbExecutor, dir, makeRepo(dir)));
expect(provider?.symbolUid).toBe('');
});
it('leaves symbolUid empty when no repo-wide name matches', async () => {
const dir = writeCrossFile('xfile-zero');
const mockDbExecutor = async (
query: string,
params?: Record<string, unknown>,
): Promise<Record<string, unknown>[]> => {
if (query.includes('UNION ALL'))
return String(params?.filePath ?? '').includes('routes.ts') ? routesFileSyms : [];
return [];
};
const provider = providerOf(await extractor.extract(mockDbExecutor, dir, makeRepo(dir)));
expect(provider?.symbolUid).toBe('');
});
it('prefers a LOCALLY-DEFINED handler and never consults the repo-wide lookup', async () => {
// Handler defined in the registration file itself (not imported) → no
// handlerImport → file-scoped resolution wins; the global / module lookups
// are never consulted.
const dir = path.join(tmpDir, 'local-handler-wins');
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'src/routes.ts'),
`import { Router } from 'express';
const router = Router();
function listUsers(req, res) {
res.json([]);
}
router.get('/api/users', listUsers);
export default router;
`,
);
let globalQueries = 0;
const mockDbExecutor = async (
query: string,
params?: Record<string, unknown>,
): Promise<Record<string, unknown>[]> => {
if (query.includes('UNION ALL'))
return String(params?.filePath ?? '').includes('routes.ts')
? [
{
uid: 'fn-samefile',
name: 'listUsers',
filePath: 'src/routes.ts',
startLine: 3,
endLine: 5,
labels: ['Function'],
},
]
: [];
if (query.includes('n.name = $name')) {
globalQueries += 1;
return [{ uid: 'fn-global', name: 'listUsers', filePath: 'src/elsewhere.ts' }];
}
return [];
};
const provider = providerOf(await extractor.extract(mockDbExecutor, dir, makeRepo(dir)));
expect(provider?.symbolUid).toBe('fn-samefile');
expect(globalQueries).toBe(0);
});
it('leaves symbolUid empty (no exception) when the repo-wide query throws', async () => {
const dir = writeCrossFile('xfile-throws');
const mockDbExecutor = async (
query: string,
params?: Record<string, unknown>,
): Promise<Record<string, unknown>[]> => {
if (query.includes('UNION ALL'))
return String(params?.filePath ?? '').includes('routes.ts') ? routesFileSyms : [];
if (query.includes('n.name = $name')) throw new Error('DB locked');
return [];
};
const provider = providerOf(await extractor.extract(mockDbExecutor, dir, makeRepo(dir)));
expect(provider?.symbolUid).toBe('');
});
it('caches the repo-wide lookup by name across detections in one extract()', async () => {
// Two routes in the same file referencing the SAME cross-file handler must
// issue the by-name query at most once (memoized by name).
const dir = path.join(tmpDir, 'xfile-cache');
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'src/routes.ts'),
`import { Router } from 'express';
import { listUsers } from './handlers/users';
const router = Router();
router.get('/api/users', listUsers);
router.post('/api/users', listUsers);
export default router;
`,
);
let globalQueries = 0;
const mockDbExecutor = async (
query: string,
params?: Record<string, unknown>,
): Promise<Record<string, unknown>[]> => {
if (query.includes('UNION ALL'))
return String(params?.filePath ?? '').includes('routes.ts') ? routesFileSyms : [];
if (query.includes('n.name = $name')) {
globalQueries += 1;
return [{ uid: 'fn-listUsers-x', name: 'listUsers', filePath: 'src/handlers/users.ts' }];
}
return [];
};
await extractor.extract(mockDbExecutor, dir, makeRepo(dir));
expect(globalQueries).toBe(1);
});
it('does NOT consult the repo-wide lookup for consumers', async () => {
// A consumer (the function making a fetch) resolves by containment in its
// own file; the provider-only repo-wide lookup must never fire for it.
const dir = path.join(tmpDir, 'xfile-consumer-gate');
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'src/api.ts'),
`export async function fetchUsers() {
const r = await fetch('/api/users');
return r.json();
}
`,
);
let globalQueries = 0;
const mockDbExecutor = async (
query: string,
params?: Record<string, unknown>,
): Promise<Record<string, unknown>[]> => {
if (query.includes('UNION ALL'))
return String(params?.filePath ?? '').includes('api.ts')
? [
{
uid: 'fn-fetchUsers',
name: 'fetchUsers',
filePath: 'src/api.ts',
startLine: 1,
endLine: 4,
labels: ['Function'],
},
]
: [];
if (query.includes('n.name = $name')) {
globalQueries += 1;
return [{ uid: 'should-not-be-used', name: 'fetchUsers', filePath: 'src/x.ts' }];
}
return [];
};
const contracts = await extractor.extract(mockDbExecutor, dir, makeRepo(dir));
const consumer = contracts.find((c) => c.role === 'consumer');
expect(consumer?.symbolName).toBe('fetchUsers');
expect(globalQueries).toBe(0);
});
it('does NOT attach a named provider to its registrar when the name is unresolvable', async () => {
// router.get(...) is registered INSIDE setupRoutes(); the handler
// `listUsers` is ambiguous repo-wide (2 matches) so name resolution fails.
// The route must NOT fall through to line-span containment and attach to
// the enclosing `setupRoutes` wrapper — it stays empty (file fallback).
const dir = path.join(tmpDir, 'xfile-wrapper-no-attach');
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'src/routes.ts'),
`import { Router } from 'express';
import { listUsers } from './handlers/users';
const router = Router();
export function setupRoutes() {
router.get('/api/users', listUsers);
}
export default router;
`,
);
const mockDbExecutor = async (
query: string,
params?: Record<string, unknown>,
): Promise<Record<string, unknown>[]> => {
if (query.includes('UNION ALL'))
return String(params?.filePath ?? '').includes('routes.ts')
? [
{
uid: 'fn-setupRoutes',
name: 'setupRoutes',
filePath: 'src/routes.ts',
startLine: 1,
endLine: 99,
labels: ['Function'],
},
]
: [];
if (query.includes('n.name = $name'))
return [
{ uid: 'fn-a', name: 'listUsers', filePath: 'src/a.ts' },
{ uid: 'fn-b', name: 'listUsers', filePath: 'src/b.ts' },
];
return [];
};
const provider = providerOf(await extractor.extract(mockDbExecutor, dir, makeRepo(dir)));
expect(provider?.symbolUid).toBe('');
});
it('rejects a unique repo-wide match that carries no real file (synthetic node)', async () => {
// A handler name colliding with an ORM model node (orm.ts emits
// filePath: '') yields a single match with no file. It must be rejected,
// not attached as an edge-less cross-trace anchor.
const dir = writeCrossFile('xfile-empty-filepath');
const mockDbExecutor = async (
query: string,
params?: Record<string, unknown>,
): Promise<Record<string, unknown>[]> => {
if (query.includes('UNION ALL'))
return String(params?.filePath ?? '').includes('routes.ts') ? routesFileSyms : [];
if (query.includes('n.name = $name'))
return [{ uid: 'orm-listUsers', name: 'listUsers', filePath: '' }];
return [];
};
const provider = providerOf(await extractor.extract(mockDbExecutor, dir, makeRepo(dir)));
expect(provider?.symbolUid).toBe('');
});
it('resolves via the repo-wide lookup when the registration file has NO indexed symbols', async () => {
// Pins the reordered early-return: CONTAINING_QUERY returns [] for the
// registration file (no in-file symbols at all), yet the unique repo-wide
// match still resolves the cross-file handler. Before the reorder, the
// `syms.length === 0` guard short-circuited above the provider name branch.
const dir = writeCrossFile('xfile-empty-regfile');
const mockDbExecutor = async (
query: string,
params?: Record<string, unknown>,
): Promise<Record<string, unknown>[]> => {
if (query.includes('UNION ALL')) return [];
if (query.includes('n.name = $name'))
return params?.name === 'listUsers'
? [{ uid: 'fn-listUsers-xfile', name: 'listUsers', filePath: 'src/handlers/users.ts' }]
: [];
return [];
};
const provider = providerOf(await extractor.extract(mockDbExecutor, dir, makeRepo(dir)));
expect(provider).toMatchObject({ symbolUid: 'fn-listUsers-xfile', symbolName: 'listUsers' });
});
it('resolves an ALIASED import to its declared name in the target module (not the alias)', async () => {
// import { listUsers as handleUsers } from './handlers/users';
// router.get('/api/users', handleUsers); + an UNRELATED function handleUsers
// elsewhere. The route must resolve to the imported `listUsers`, and the
// local alias `handleUsers` must NEVER be looked up.
const dir = path.join(tmpDir, 'xfile-alias');
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'src/routes.ts'),
`import { Router } from 'express';
import { listUsers as handleUsers } from './handlers/users';
const router = Router();
router.get('/api/users', handleUsers);
export default router;
`,
);
const queriedNames: string[] = [];
const mockDbExecutor = async (
query: string,
params?: Record<string, unknown>,
): Promise<Record<string, unknown>[]> => {
if (query.includes('STARTS WITH')) {
queriedNames.push(`module:${String(params?.name)}`);
return params?.name === 'listUsers' &&
String(params?.fileDot ?? '').startsWith('src/handlers/users')
? [{ uid: 'fn-listUsers', name: 'listUsers', filePath: 'src/handlers/users.ts' }]
: [];
}
if (query.includes('n.name = $name')) {
queriedNames.push(`global:${String(params?.name)}`);
return params?.name === 'handleUsers'
? [{ uid: 'fn-unrelated', name: 'handleUsers', filePath: 'src/other.ts' }]
: [];
}
return [];
};
const provider = providerOf(await extractor.extract(mockDbExecutor, dir, makeRepo(dir)));
expect(provider).toMatchObject({ symbolUid: 'fn-listUsers', symbolName: 'listUsers' });
expect(queriedNames).not.toContain('module:handleUsers');
expect(queriedNames).not.toContain('global:handleUsers');
});
it('pins an imported handler to its module, resolving a name that is ambiguous repo-wide', async () => {
// `listUsers` exists in two files; the import pins to ./handlers/users, so
// the module-scoped query returns exactly one even though a repo-wide
// name lookup would be ambiguous (and would decline).
const dir = writeCrossFile('xfile-module-pin');
const mockDbExecutor = async (
query: string,
params?: Record<string, unknown>,
): Promise<Record<string, unknown>[]> => {
if (query.includes('STARTS WITH'))
return String(params?.fileDot ?? '').startsWith('src/handlers/users')
? [{ uid: 'fn-the-right-one', name: 'listUsers', filePath: 'src/handlers/users.ts' }]
: [];
if (query.includes('n.name = $name'))
return [
{ uid: 'fn-a', name: 'listUsers', filePath: 'src/handlers/users.ts' },
{ uid: 'fn-b', name: 'listUsers', filePath: 'src/admin/users.ts' },
];
return [];
};
const provider = providerOf(await extractor.extract(mockDbExecutor, dir, makeRepo(dir)));
expect(provider?.symbolUid).toBe('fn-the-right-one');
});
it('resolves a Python Flask add_url_rule ALIASED view through the import (relative module)', async () => {
// from .handlers.users import list_users as handle_users
// app.add_url_rule('/api/users', view_func=handle_users)
// resolves to the declared `list_users` in app/handlers/users.py — the
// relative dotted module `.handlers.users` is pinned, the alias never used.
const dir = path.join(tmpDir, 'py-flask-alias');
fs.mkdirSync(path.join(dir, 'app', 'handlers'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'app/routes.py'),
`from flask import Flask
from .handlers.users import list_users as handle_users
app = Flask(__name__)
app.add_url_rule('/api/users', view_func=handle_users)
`,
);
const queriedNames: string[] = [];
const mockDbExecutor = async (
query: string,
params?: Record<string, unknown>,
): Promise<Record<string, unknown>[]> => {
if (query.includes('STARTS WITH')) {
queriedNames.push(`module:${String(params?.name)}`);
return params?.name === 'list_users' &&
String(params?.fileDot ?? '').startsWith('app/handlers/users')
? [{ uid: 'fn-list_users', name: 'list_users', filePath: 'app/handlers/users.py' }]
: [];
}
if (query.includes('n.name = $name')) {
queriedNames.push(`global:${String(params?.name)}`);
return [];
}
return [];
};
const contracts = await extractor.extract(mockDbExecutor, dir, makeRepo(dir));
const provider = contracts.find(
(c) => c.role === 'provider' && c.contractId === 'http::GET::/api/users',
);
expect(provider).toMatchObject({ symbolUid: 'fn-list_users', symbolName: 'list_users' });
expect(queriedNames).not.toContain('module:handle_users');
});
it('retains legacy package-prefix resolution for a Python re-export', async () => {
const dir = path.join(tmpDir, 'py-flask-reexport');
fs.mkdirSync(path.join(dir, 'app', 'handlers'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'app/routes.py'),
`from flask import Flask
from .handlers import list_users
app = Flask(__name__)
app.add_url_rule('/api/users', view_func=list_users)
`,
);
fs.writeFileSync(
path.join(dir, 'app', 'handlers', '__init__.py'),
`from .users import list_users\n`,
);
fs.writeFileSync(
path.join(dir, 'app', 'handlers', 'users.py'),
`def list_users(): return []\n`,
);
const mockDbExecutor = async (
query: string,
params?: Record<string, unknown>,
): Promise<Record<string, unknown>[]> => {
if (query.includes('STARTS WITH') && String(params?.fileSlash) === 'app/handlers/') {
return [
{
uid: 'fn-list-users',
name: 'list_users',
filePath: 'app/handlers/users.py',
},
];
}
return [];
};
const contracts = await extractor.extract(mockDbExecutor, dir, makeRepo(dir));
const provider = contracts.find(
(contract) =>
contract.role === 'provider' && contract.contractId === 'http::GET::/api/users',
);
expect(provider).toMatchObject({ symbolUid: 'fn-list-users', symbolName: 'list_users' });
});
// ── Inline / closure provider handlers (#2276) ──────────────────────
// An inline provider handler has no name, so it must resolve by line-span
// containment to the symbol it lives in — exactly like a consumer. Mirrors
// the Node/Express inline-arrow behavior for the non-Node plugins.
it('resolves a Go inline http.HandleFunc closure to its containing function (#2276)', async () => {
const dir = path.join(tmpDir, 'go-inline-handlefunc');
fs.mkdirSync(path.join(dir, 'cmd'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'cmd/server.go'),
`package main
func main() {
http.HandleFunc("/api/inline", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("ok"))
})
}
`,
);
// main() spans source lines 3-7 → 0-based [2,6]; the HandleFunc
// registration and its anonymous func are on line 4 (row 3), inside span.
const mockDbExecutor = async (
query: string,
params?: Record<string, unknown>,
): Promise<Record<string, unknown>[]> => {
if (query.includes('UNION ALL') && String(params?.filePath ?? '').includes('server.go')) {
return [
{
uid: 'fn-main',
name: 'main',
filePath: 'cmd/server.go',
startLine: 2,
endLine: 6,
labels: ['Function'],
},
];
}
return [];
};
const contracts = await extractor.extract(mockDbExecutor, dir, makeRepo(dir));
const provider = contracts.find(
(c) => c.role === 'provider' && c.contractId === 'http::GET::/api/inline',
);
expect(provider).toMatchObject({
symbolUid: 'fn-main',
symbolName: 'main',
meta: { extractionStrategy: 'source_scan_resolved' },
});
});
it('resolves a Go inline gin framework-route closure to its containing function (#2276)', async () => {
const dir = path.join(tmpDir, 'go-inline-gin');
fs.mkdirSync(path.join(dir, 'cmd'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'cmd/server.go'),
`package main
func registerRoutes(r *gin.Engine) {
r.GET("/api/ping", func(c *gin.Context) {
c.String(200, "pong")
})
}
`,
);
const mockDbExecutor = async (
query: string,
params?: Record<string, unknown>,
): Promise<Record<string, unknown>[]> => {
if (query.includes('UNION ALL') && String(params?.filePath ?? '').includes('server.go')) {
return [
{
uid: 'fn-registerRoutes',
name: 'registerRoutes',
filePath: 'cmd/server.go',
startLine: 2,
endLine: 6,
labels: ['Function'],
},
];
}
return [];
};
const contracts = await extractor.extract(mockDbExecutor, dir, makeRepo(dir));
const provider = contracts.find(
(c) => c.role === 'provider' && c.contractId === 'http::GET::/api/ping',
);
expect(provider).toMatchObject({
symbolUid: 'fn-registerRoutes',
symbolName: 'registerRoutes',
});
});
it('keeps Go NAMED HandleFunc handler resolving by name, not containment (#2276)', async () => {
const dir = path.join(tmpDir, 'go-named-handlefunc');
fs.mkdirSync(path.join(dir, 'cmd'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'cmd/server.go'),
`package main
func healthHandler(w http.ResponseWriter, r *http.Request) {}
func main() {
http.HandleFunc("/api/health", healthHandler)
}
`,
);
// Both the named handler and the registrar main() are indexed. A named
// provider must resolve to the HANDLER by name, never to main by line.
const mockDbExecutor = async (
query: string,
params?: Record<string, unknown>,
): Promise<Record<string, unknown>[]> => {
if (query.includes('UNION ALL') && String(params?.filePath ?? '').includes('server.go')) {
return [
{
uid: 'fn-healthHandler',
name: 'healthHandler',
filePath: 'cmd/server.go',
startLine: 2,
endLine: 2,
labels: ['Function'],
},
{
uid: 'fn-main',
name: 'main',
filePath: 'cmd/server.go',
startLine: 4,
endLine: 6,
labels: ['Function'],
},
];
}
return [];
};
const contracts = await extractor.extract(mockDbExecutor, dir, makeRepo(dir));
const provider = contracts.find(
(c) => c.role === 'provider' && c.contractId === 'http::GET::/api/health',
);
expect(provider).toMatchObject({
symbolUid: 'fn-healthHandler',
symbolName: 'healthHandler',
});
});
it('keeps a NAMED gin framework-route handler resolving by name, not its registrar (#2276)', async () => {
// The framework-route query was also widened to accept func literals; this
// pins that a NAMED identifier handler still resolves by name even though
// a `line` is now emitted and the enclosing registrar span covers it.
const dir = path.join(tmpDir, 'go-named-gin');
fs.mkdirSync(path.join(dir, 'cmd'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'cmd/server.go'),
`package main
func listOrders(c *gin.Context) {}
func registerRoutes(r *gin.Engine) {
r.GET("/api/orders", listOrders)
}
`,
);
// listOrders spans [2,2]; registerRoutes spans [4,6] and its span CONTAINS
// the r.GET line (row 5). A named provider must resolve to listOrders by
// name — never to registerRoutes by line-span containment.
const mockDbExecutor = async (
query: string,
params?: Record<string, unknown>,
): Promise<Record<string, unknown>[]> => {
if (query.includes('UNION ALL') && String(params?.filePath ?? '').includes('server.go')) {
return [
{
uid: 'fn-listOrders',
name: 'listOrders',
filePath: 'cmd/server.go',
startLine: 2,
endLine: 2,
labels: ['Function'],
},
{
uid: 'fn-registerRoutes',
name: 'registerRoutes',
filePath: 'cmd/server.go',
startLine: 4,
endLine: 6,
labels: ['Function'],
},
];
}
return [];
};
const contracts = await extractor.extract(mockDbExecutor, dir, makeRepo(dir));
const provider = contracts.find(
(c) => c.role === 'provider' && c.contractId === 'http::GET::/api/orders',
);
expect(provider).toMatchObject({
symbolUid: 'fn-listOrders',
symbolName: 'listOrders',
});
});
it('binds the LAST arg as handler for a middleware + inline route, not the middleware (#2276)', async () => {
// `r.GET(path, mw, func(){})` — gin/echo variadic middleware before an
// inline handler. The trailing-anchor on @handler must select the closure
// (→ containment to the enclosing fn), NOT the middleware identifier. With
// the prior unanchored capture this emitted a second detection for `mw`
// that won the contractId merge and mis-attributed the route to it.
const dir = path.join(tmpDir, 'go-mw-inline-gin');
fs.mkdirSync(path.join(dir, 'cmd'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'cmd/server.go'),
`package main
func authMiddleware(c *gin.Context) {}
func registerRoutes(r *gin.Engine) {
r.GET("/api/guarded", authMiddleware, func(c *gin.Context) {
c.String(200, "ok")
})
}
`,
);
// Both the middleware and the enclosing registrar are indexed. The closure
// sits at line 6, contained by registerRoutes [5,9]; authMiddleware [3,3]
// does not contain it. The route must resolve to registerRoutes.
const mockDbExecutor = async (
query: string,
params?: Record<string, unknown>,
): Promise<Record<string, unknown>[]> => {
if (query.includes('UNION ALL') && String(params?.filePath ?? '').includes('server.go')) {
return [
{
uid: 'fn-authMiddleware',
name: 'authMiddleware',
filePath: 'cmd/server.go',
startLine: 3,
endLine: 3,
labels: ['Function'],
},
{
uid: 'fn-registerRoutes',
name: 'registerRoutes',
filePath: 'cmd/server.go',
startLine: 5,
endLine: 9,
labels: ['Function'],
},
];
}
return [];
};
const contracts = await extractor.extract(mockDbExecutor, dir, makeRepo(dir));
const providers = contracts.filter(
(c) => c.role === 'provider' && c.contractId === 'http::GET::/api/guarded',
);
// Exactly one provider (no middleware over-match), resolved by containment.
expect(providers).toHaveLength(1);
expect(providers[0]).toMatchObject({
symbolUid: 'fn-registerRoutes',
symbolName: 'registerRoutes',
});
});
it('binds the LAST arg as handler for a middleware + NAMED route, not the middleware (#2276)', async () => {
// `r.GET(path, mw, namedHandler)` — the named handler is the last arg and
// must resolve by name; the leading middleware identifier must not win.
const dir = path.join(tmpDir, 'go-mw-named-gin');
fs.mkdirSync(path.join(dir, 'cmd'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'cmd/server.go'),
`package main
func authMiddleware(c *gin.Context) {}
func listOrders(c *gin.Context) {}
func registerRoutes(r *gin.Engine) {
r.GET("/api/orders", authMiddleware, listOrders)
}
`,
);
const mockDbExecutor = async (
query: string,
params?: Record<string, unknown>,
): Promise<Record<string, unknown>[]> => {
if (query.includes('UNION ALL') && String(params?.filePath ?? '').includes('server.go')) {
return [
{
uid: 'fn-authMiddleware',
name: 'authMiddleware',
filePath: 'cmd/server.go',
startLine: 3,
endLine: 3,
labels: ['Function'],
},
{
uid: 'fn-listOrders',
name: 'listOrders',
filePath: 'cmd/server.go',
startLine: 5,
endLine: 5,
labels: ['Function'],
},
{
uid: 'fn-registerRoutes',
name: 'registerRoutes',
filePath: 'cmd/server.go',
startLine: 7,
endLine: 9,
labels: ['Function'],
},
];
}
return [];
};
const contracts = await extractor.extract(mockDbExecutor, dir, makeRepo(dir));
const providers = contracts.filter(
(c) => c.role === 'provider' && c.contractId === 'http::GET::/api/orders',
);
expect(providers).toHaveLength(1);
expect(providers[0]).toMatchObject({
symbolUid: 'fn-listOrders',
symbolName: 'listOrders',
});
});
it('resolves a Laravel closure route nested in a method to that method (#2276)', async () => {
const dir = path.join(tmpDir, 'php-closure-in-method');
fs.mkdirSync(path.join(dir, 'app'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'app/RouteServiceProvider.php'),
`<?php
class RouteServiceProvider {
public function boot() {
Route::get('/api/closure', function () {
return 1;
});
}
}
`,
);
// boot() spans source lines 3-7 → 0-based [2,6]; the closure registration
// is on line 4 (row 3), inside boot's span.
const mockDbExecutor = async (
query: string,
params?: Record<string, unknown>,
): Promise<Record<string, unknown>[]> => {
if (
query.includes('UNION ALL') &&
String(params?.filePath ?? '').includes('RouteServiceProvider.php')
) {
return [
{
uid: 'method-boot',
name: 'boot',
filePath: 'app/RouteServiceProvider.php',
startLine: 2,
endLine: 6,
labels: ['Method'],
},
];
}
return [];
};
const contracts = await extractor.extract(mockDbExecutor, dir, makeRepo(dir));
const provider = contracts.find(
(c) => c.role === 'provider' && c.contractId === 'http::GET::/api/closure',
);
expect(provider).toMatchObject({
symbolUid: 'method-boot',
symbolName: 'boot',
meta: { extractionStrategy: 'source_scan_resolved' },
});
});
it('resolves a Laravel arrow-fn closure route by containment (#2276)', async () => {
const dir = path.join(tmpDir, 'php-arrow-in-method');
fs.mkdirSync(path.join(dir, 'app'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'app/RouteServiceProvider.php'),
`<?php
class RouteServiceProvider {
public function boot() {
Route::post('/api/arrow', fn() => response());
}
}
`,
);
const mockDbExecutor = async (
query: string,
params?: Record<string, unknown>,
): Promise<Record<string, unknown>[]> => {
if (
query.includes('UNION ALL') &&
String(params?.filePath ?? '').includes('RouteServiceProvider.php')
) {
return [
{
uid: 'method-boot',
name: 'boot',
filePath: 'app/RouteServiceProvider.php',
startLine: 2,
endLine: 4,
labels: ['Method'],
},
];
}
return [];
};
const contracts = await extractor.extract(mockDbExecutor, dir, makeRepo(dir));
const provider = contracts.find(
(c) => c.role === 'provider' && c.contractId === 'http::POST::/api/arrow',
);
expect(provider).toMatchObject({ symbolUid: 'method-boot', symbolName: 'boot' });
});
it('leaves a Laravel NAMED-controller route at the prior behavior (no closure path) (#2276)', async () => {
const dir = path.join(tmpDir, 'php-named-controller');
fs.mkdirSync(path.join(dir, 'routes'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'routes/web.php'),
`<?php
Route::put('/api/named', [UserController::class, 'update']);
`,
);
// No closure → name stays 'route' (not null); the 'route' label resolves
// to no symbol, so symbolUid stays empty (file-level). Behavior unchanged.
const mockDbExecutor = async (): Promise<Record<string, unknown>[]> => [];
const contracts = await extractor.extract(mockDbExecutor, dir, makeRepo(dir));
const provider = contracts.find(
(c) => c.role === 'provider' && c.contractId === 'http::PUT::/api/named',
);
expect(provider).toBeDefined();
expect(provider?.symbolUid).toBe('');
});
it('resolves a FastAPI @app provider to its decorated function (source-scan fallback) (#2276)', async () => {
const dir = path.join(tmpDir, 'py-fastapi-app');
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(
path.join(dir, 'main.py'),
`from fastapi import FastAPI
app = FastAPI()
@app.get("/api/items")
def list_items():
return []
`,
);
// The decorator is on line 4 (row 3); `def list_items` is on line 5
// (row 4). tree-sitter records the function_definition span from `def`,
// so list_items spans 0-based [4,5]. The detection line is the decorator
// row + 1 = 5, and the resolver's direct `line` probe (row 4) lands in
// the def span. (Graph routes are authoritative; this is the fallback.)
const mockDbExecutor = async (
query: string,
params?: Record<string, unknown>,
): Promise<Record<string, unknown>[]> => {
if (query.includes('UNION ALL') && String(params?.filePath ?? '').includes('main.py')) {
return [
{
uid: 'fn-list_items',
name: 'list_items',
filePath: 'main.py',
startLine: 4,
endLine: 5,
labels: ['Function'],
},
];
}
return [];
};
const contracts = await extractor.extract(mockDbExecutor, dir, makeRepo(dir));
const provider = contracts.find(
(c) => c.role === 'provider' && c.contractId === 'http::GET::/api/items',
);
expect(provider).toMatchObject({
symbolUid: 'fn-list_items',
symbolName: 'list_items',
meta: { extractionStrategy: 'source_scan_resolved' },
});
});
// Documented limitations pinned by tests (#2276): a closure with no
// enclosing function symbol, and a multi-decorator FastAPI handler whose
// detection line falls above the def-span, both degrade to file-level
// rather than mis-attributing. These lock the comments in php.ts/python.ts.
it('leaves a FILE-scope Laravel closure at file-level (no enclosing symbol) (#2276)', async () => {
const dir = path.join(tmpDir, 'php-closure-file-scope');
fs.mkdirSync(path.join(dir, 'routes'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'routes/web.php'),
`<?php
Route::get('/api/home', function () {
return view('home');
});
`,
);
// No enclosing function/method at file scope, and PHP closures are not
// indexed as symbols → containment finds nothing → symbolUid stays empty.
const mockDbExecutor = async (): Promise<Record<string, unknown>[]> => [];
const contracts = await extractor.extract(mockDbExecutor, dir, makeRepo(dir));
const provider = contracts.find(
(c) => c.role === 'provider' && c.contractId === 'http::GET::/api/home',
);
expect(provider).toBeDefined();
expect(provider?.symbolUid).toBe('');
});
it('leaves a multi-decorator FastAPI handler at file-level (line above def-span) (#2276)', async () => {
const dir = path.join(tmpDir, 'py-fastapi-multidecorator');
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(
path.join(dir, 'main.py'),
`from fastapi import FastAPI
app = FastAPI()
@app.get("/api/items")
@require_auth
def list_items():
return []
`,
);
// The path literal is on line 4 (row 3) → detection line = row 3 + 1 = 4.
// With a second decorator the `def` is on line 6 (row 5), so list_items
// spans 0-based [5,6]. The resolver probes row 3 (line-1) then row 4
// (line); both fall ABOVE the [5,6] span → no containment → file-level.
// (Single-decorator resolves because there the def sits at the line probe.)
const mockDbExecutor = async (
query: string,
params?: Record<string, unknown>,
): Promise<Record<string, unknown>[]> => {
if (query.includes('UNION ALL') && String(params?.filePath ?? '').includes('main.py')) {
return [
{
uid: 'fn-list_items',
name: 'list_items',
filePath: 'main.py',
startLine: 5,
endLine: 6,
labels: ['Function'],
},
];
}
return [];
};
const contracts = await extractor.extract(mockDbExecutor, dir, makeRepo(dir));
const provider = contracts.find(
(c) => c.role === 'provider' && c.contractId === 'http::GET::/api/items',
);
expect(provider).toBeDefined();
expect(provider?.symbolUid).toBe('');
});
// The @router/APIRouter provider emit also carries `line` (#2276), but only
// @app was covered above. These pin the @router containment path: a
// single-decorator router handler resolves to its function, and a
// multi-decorator one degrades to file-level — same as @app.
it('resolves a FastAPI @router provider to its decorated function (source-scan fallback) (#2276)', async () => {
const dir = path.join(tmpDir, 'py-fastapi-router');
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(
path.join(dir, 'main.py'),
`from fastapi import APIRouter
router = APIRouter()
@router.get("/api/items")
def list_items():
return []
`,
);
// Same shape as the @app case: @router.get is on line 4 (row 3), `def`
// on line 5 → list_items spans 0-based [4,5]; the `line` probe lands in
// the def span and resolves via source_scan_resolved. No include_router
// prefix in scope, so the unprefixed path is emitted.
const mockDbExecutor = async (
query: string,
params?: Record<string, unknown>,
): Promise<Record<string, unknown>[]> => {
if (query.includes('UNION ALL') && String(params?.filePath ?? '').includes('main.py')) {
return [
{
uid: 'fn-list_items',
name: 'list_items',
filePath: 'main.py',
startLine: 4,
endLine: 5,
labels: ['Function'],
},
];
}
return [];
};
const contracts = await extractor.extract(mockDbExecutor, dir, makeRepo(dir));
const provider = contracts.find(
(c) => c.role === 'provider' && c.contractId === 'http::GET::/api/items',
);
expect(provider).toMatchObject({
symbolUid: 'fn-list_items',
symbolName: 'list_items',
meta: { extractionStrategy: 'source_scan_resolved' },
});
});
it('leaves a module-scope multi-decorator @router handler at file-level (#2276)', async () => {
const dir = path.join(tmpDir, 'py-fastapi-router-multidecorator');
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(
path.join(dir, 'main.py'),
`from fastapi import APIRouter
router = APIRouter()
@router.post("/api/items")
@require_auth
def create_item():
return {}
`,
);
// With a second decorator the `def` is on line 6 → create_item spans
// 0-based [5,6]; the path-line probe falls ABOVE that span → no
// containment → file-level (symbolUid stays empty), matching @app.
const mockDbExecutor = async (
query: string,
params?: Record<string, unknown>,
): Promise<Record<string, unknown>[]> => {
if (query.includes('UNION ALL') && String(params?.filePath ?? '').includes('main.py')) {
return [
{
uid: 'fn-create_item',
name: 'create_item',
filePath: 'main.py',
startLine: 5,
endLine: 6,
labels: ['Function'],
},
];
}
return [];
};
const contracts = await extractor.extract(mockDbExecutor, dir, makeRepo(dir));
const provider = contracts.find(
(c) => c.role === 'provider' && c.contractId === 'http::POST::/api/items',
);
expect(provider).toBeDefined();
expect(provider?.symbolUid).toBe('');
});
});
describe('provider extraction — graph-first (Strategy A)', () => {
it('extracts routes from Route/HANDLES_ROUTE graph + source scan for method', async () => {
const dir = path.join(tmpDir, 'graph-first');
fs.mkdirSync(path.join(dir, 'src/controller'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'src/controller/UserController.java'),
`
@RestController
@RequestMapping("/api/v2")
public class UserController {
@GetMapping("/users")
public List<User> list() { return service.findAll(); }
@PostMapping("/users")
public User create(@RequestBody User user) { return service.save(user); }
}
`,
);
const mockDbExecutor = async (query: string) => {
if (query.includes('HANDLES_ROUTE')) {
return [
{
fileId: 'file-uid-ctrl',
filePath: 'src/controller/UserController.java',
routePath: '/api/v2/users',
routeId: 'route-uid-users',
responseKeys: null,
routeSource: 'decorator-GetMapping',
},
];
}
if (query.includes('UNION ALL')) {
return [
{
uid: 'uid-ctrl-list',
name: 'list',
filePath: 'src/controller/UserController.java',
labels: ['Method'],
},
{
uid: 'uid-ctrl-create',
name: 'create',
filePath: 'src/controller/UserController.java',
labels: ['Method'],
},
];
}
return [];
};
const contracts = await extractor.extract(mockDbExecutor, dir, makeRepo(dir));
const providers = contracts.filter((c) => c.role === 'provider');
const getRoute = providers.find((c) => c.contractId === 'http::GET::/api/v2/users');
expect(getRoute).toBeDefined();
expect(getRoute!.confidence).toBe(0.9);
expect(getRoute!.symbolUid).not.toBe('file-uid-ctrl');
});
it('supplements graph providers with source-scan providers from other files', async () => {
const dir = path.join(tmpDir, 'graph-source-provider-union');
fs.mkdirSync(path.join(dir, 'src/controller'), { recursive: true });
fs.mkdirSync(path.join(dir, 'cmd'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'src/controller/UserController.java'),
`
@RestController
@RequestMapping("/api/v2")
public class UserController {
@GetMapping("/users")
public List<User> list() { return service.findAll(); }
}
`,
);
fs.writeFileSync(
path.join(dir, 'cmd/server.go'),
`
package main
func healthHandler(w http.ResponseWriter, r *http.Request) {}
func main() {
http.HandleFunc("/api/health", healthHandler)
}
`,
);
const mockDbExecutor = async (query: string) => {
if (query.includes('HANDLES_ROUTE')) {
return [
{
fileId: 'file-uid-ctrl',
filePath: 'src/controller/UserController.java',
routePath: '/api/v2/users',
routeId: 'route-uid-users',
responseKeys: null,
routeSource: 'decorator-GetMapping',
},
];
}
if (query.includes('FETCHES')) return [];
if (query.includes('UNION ALL')) {
return [
{
uid: 'uid-ctrl-list',
name: 'list',
filePath: 'src/controller/UserController.java',
labels: ['Method'],
},
];
}
return [];
};
const contracts = await extractor.extract(mockDbExecutor, dir, makeRepo(dir));
const providers = contracts.filter((c) => c.role === 'provider');
const graphRouteMatches = providers.filter(
(c) => c.contractId === 'http::GET::/api/v2/users',
);
expect(graphRouteMatches).toHaveLength(1);
expect(graphRouteMatches[0].symbolUid).toBe('uid-ctrl-list');
expect(graphRouteMatches[0].meta.extractionStrategy).toBe('graph_assisted');
const sourceRoute = providers.find((c) => c.contractId === 'http::GET::/api/health');
expect(sourceRoute).toBeDefined();
expect(sourceRoute?.symbolName).toBe('healthHandler');
expect(sourceRoute?.meta.extractionStrategy).toBe('source_scan');
});
});
describe('provider extraction — source-scan fallback (Strategy B)', () => {
it('extracts Spring @GetMapping annotation', async () => {
const dir = path.join(tmpDir, 'spring');
fs.mkdirSync(path.join(dir, 'src/controller'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'src/controller/UserController.java'),
`
package com.example;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/v2")
public class UserController {
@GetMapping("/users")
public List<User> list() { return service.findAll(); }
@PostMapping("/users")
public User create(@RequestBody User user) { return service.save(user); }
@GetMapping("/users/{id}")
public User getById(@PathVariable Long id) { return service.findById(id); }
}
`,
);
const contracts = await extractor.extract(null, dir, makeRepo(dir));
const providers = contracts.filter((c) => c.role === 'provider');
expect(providers.length).toBeGreaterThanOrEqual(3);
const listRoute = providers.find((c) => c.contractId === 'http::GET::/api/v2/users');
expect(listRoute).toBeDefined();
expect(listRoute!.meta.method).toBe('GET');
expect(listRoute!.meta.path).toBe('/api/v2/users');
const createRoute = providers.find((c) => c.contractId === 'http::POST::/api/v2/users');
expect(createRoute).toBeDefined();
const getByIdRoute = providers.find(
(c) => c.contractId === 'http::GET::/api/v2/users/{param}',
);
expect(getByIdRoute).toBeDefined();
});
// ─── #1834 — Spring named annotation arguments ──────────────────
// Spring annotations accept both positional shorthand
// (`@GetMapping("/users")`) and named arguments
// (`@GetMapping(value = "/users")` or `@GetMapping(path = "/users")`).
// The two AST shapes produced by tree-sitter-java differ:
// @GetMapping("/users") → annotation_argument_list > string_literal
// @GetMapping(value = "/users") → annotation_argument_list > element_value_pair
// The named-arg pattern in `http-patterns/java.ts` MUST constrain
// the `key` field to `path`/`value`; without that constraint the
// query also captures other string-valued attributes such as
// `produces`, `consumes`, `headers`, `name`, `params` (see PR #1834
// review). The tests below pin both the positive cases and the
// negative anti-regression cases.
it('extracts Spring class-level @RequestMapping(path = "/api")', async () => {
const dir = path.join(tmpDir, 'spring-class-named-path');
fs.mkdirSync(path.join(dir, 'src/controller'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'src/controller/UserController.java'),
`
package com.example;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping(path = "/api/v3")
public class UserController {
@GetMapping("/users")
public List<User> list() { return service.findAll(); }
}
`,
);
const contracts = await extractor.extract(null, dir, makeRepo(dir));
const providers = contracts.filter((c) => c.role === 'provider');
const route = providers.find((c) => c.contractId === 'http::GET::/api/v3/users');
expect(route).toBeDefined();
expect(route!.meta.path).toBe('/api/v3/users');
});
it('extracts Spring class-level @RequestMapping(value = "/api")', async () => {
const dir = path.join(tmpDir, 'spring-class-named-value');
fs.mkdirSync(path.join(dir, 'src/controller'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'src/controller/OrderController.java'),
`
package com.example;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping(value = "/orders")
public class OrderController {
@GetMapping("/list")
public List<Order> list() { return service.findAll(); }
}
`,
);
const contracts = await extractor.extract(null, dir, makeRepo(dir));
const providers = contracts.filter((c) => c.role === 'provider');
const route = providers.find((c) => c.contractId === 'http::GET::/orders/list');
expect(route).toBeDefined();
});
it('extracts Spring method-level @GetMapping(value = "/users") (named value)', async () => {
const dir = path.join(tmpDir, 'spring-method-named-value');
fs.mkdirSync(path.join(dir, 'src/controller'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'src/controller/UserController.java'),
`
package com.example;
import org.springframework.web.bind.annotation.*;
@RestController
public class UserController {
@GetMapping(value = "/users")
public List<User> list() { return service.findAll(); }
}
`,
);
const contracts = await extractor.extract(null, dir, makeRepo(dir));
const providers = contracts.filter((c) => c.role === 'provider');
const route = providers.find((c) => c.contractId === 'http::GET::/users');
expect(route).toBeDefined();
expect(route!.symbolName).toBe('list');
});
it('extracts Spring method-level @GetMapping(path = "/users") (named path)', async () => {
const dir = path.join(tmpDir, 'spring-method-named-path-get');
fs.mkdirSync(path.join(dir, 'src/controller'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'src/controller/UserController.java'),
`
package com.example;
import org.springframework.web.bind.annotation.*;
@RestController
public class UserController {
@GetMapping(path = "/users")
public List<User> list() { return service.findAll(); }
}
`,
);
const contracts = await extractor.extract(null, dir, makeRepo(dir));
const providers = contracts.filter((c) => c.role === 'provider');
const route = providers.find((c) => c.contractId === 'http::GET::/users');
expect(route).toBeDefined();
expect(route!.symbolName).toBe('list');
});
it('extracts Spring method-level @PostMapping(path = "/users") (named path)', async () => {
const dir = path.join(tmpDir, 'spring-method-named-path-post');
fs.mkdirSync(path.join(dir, 'src/controller'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'src/controller/UserController.java'),
`
package com.example;
import org.springframework.web.bind.annotation.*;
@RestController
public class UserController {
@PostMapping(path = "/users")
public User create(@RequestBody User user) { return service.save(user); }
}
`,
);
const contracts = await extractor.extract(null, dir, makeRepo(dir));
const providers = contracts.filter((c) => c.role === 'provider');
const route = providers.find((c) => c.contractId === 'http::POST::/users');
expect(route).toBeDefined();
expect(route!.symbolName).toBe('create');
});
it('combines class named-arg prefix with method positional path', async () => {
const dir = path.join(tmpDir, 'spring-mixed-class-named-method-pos');
fs.mkdirSync(path.join(dir, 'src/controller'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'src/controller/UserController.java'),
`
package com.example;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping(path = "/api")
public class UserController {
@GetMapping("/users")
public List<User> list() { return service.findAll(); }
}
`,
);
const contracts = await extractor.extract(null, dir, makeRepo(dir));
const providers = contracts.filter((c) => c.role === 'provider');
const route = providers.find((c) => c.contractId === 'http::GET::/api/users');
expect(route).toBeDefined();
});
it('combines class positional prefix with method named-arg path', async () => {
const dir = path.join(tmpDir, 'spring-mixed-class-pos-method-named');
fs.mkdirSync(path.join(dir, 'src/controller'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'src/controller/UserController.java'),
`
package com.example;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api")
public class UserController {
@GetMapping(value = "/users")
public List<User> list() { return service.findAll(); }
}
`,
);
const contracts = await extractor.extract(null, dir, makeRepo(dir));
const providers = contracts.filter((c) => c.role === 'provider');
const route = providers.find((c) => c.contractId === 'http::GET::/api/users');
expect(route).toBeDefined();
});
it('emits a root provider for pathless @GetMapping without leaking produces', async () => {
// A pathless mapping is valid, but produces metadata must never become a path.
const dir = path.join(tmpDir, 'spring-produces-only');
fs.mkdirSync(path.join(dir, 'src/controller'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'src/controller/MisleadingController.java'),
`
package com.example;
import org.springframework.web.bind.annotation.*;
@RestController
public class MisleadingController {
@GetMapping(produces = "application/json")
public List<User> list() { return service.findAll(); }
}
`,
);
const contracts = await extractor.extract(null, dir, makeRepo(dir));
const providers = contracts.filter((c) => c.role === 'provider');
// The non-route string must not become an /application/json provider.
expect(
providers.find((c) => c.contractId === 'http::GET::/application/json'),
).toBeUndefined();
// The mapping itself still contributes one pathless root provider.
const fromThisFile = providers.filter((c) =>
c.symbolRef.filePath.endsWith('MisleadingController.java'),
);
expect(fromThisFile).toHaveLength(1);
expect(fromThisFile[0].contractId).toBe('http::GET::/');
});
it('emits exactly one provider for @GetMapping(name = "...", value = "/users")', async () => {
// Anti-regression: without the `key:` constraint, the named-arg
// query would capture both string literals and emit two
// contracts (`/listUsers` + `/users`). With the constraint, only
// `/users` is emitted.
const dir = path.join(tmpDir, 'spring-name-and-value');
fs.mkdirSync(path.join(dir, 'src/controller'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'src/controller/UserController.java'),
`
package com.example;
import org.springframework.web.bind.annotation.*;
@RestController
public class UserController {
@GetMapping(name = "listUsers", value = "/users")
public List<User> list() { return service.findAll(); }
}
`,
);
const contracts = await extractor.extract(null, dir, makeRepo(dir));
const providers = contracts.filter((c) => c.role === 'provider');
const usersRoute = providers.find((c) => c.contractId === 'http::GET::/users');
expect(usersRoute).toBeDefined();
expect(usersRoute!.symbolName).toBe('list');
// The non-route `name` attribute must NOT produce a route.
expect(providers.find((c) => c.contractId === 'http::GET::/listUsers')).toBeUndefined();
const fromThisFile = providers.filter((c) =>
c.symbolRef.filePath.endsWith('UserController.java'),
);
expect(fromThisFile).toHaveLength(1);
});
it('uses `path` (not non-route key) as class prefix when both appear', async () => {
// Anti-regression: without the `key:` constraint, the LAST
// element_value_pair in the annotation wins because
// prefixByClassId.set is called per match, in document order. So
// `@RequestMapping(path = "/api", name = "myApi")` would mistakenly
// set the prefix to `myApi`. With the constraint, only the
// `path`/`value` pair is captured and the prefix stays `/api`.
const dir = path.join(tmpDir, 'spring-class-prefix-last-wins');
fs.mkdirSync(path.join(dir, 'src/controller'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'src/controller/UserController.java'),
`
package com.example;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping(path = "/api", name = "myApi")
public class UserController {
@GetMapping("/users")
public List<User> list() { return service.findAll(); }
}
`,
);
const contracts = await extractor.extract(null, dir, makeRepo(dir));
const providers = contracts.filter((c) => c.role === 'provider');
const route = providers.find((c) => c.contractId === 'http::GET::/api/users');
expect(route).toBeDefined();
// Must NOT have used `myApi` as the class prefix.
expect(providers.find((c) => c.contractId === 'http::GET::/myApi/users')).toBeUndefined();
});
// ─── #1834 follow-up — Spring on Kotlin ──────────────────────────
// The same positional / named-argument distinction applies to
// Kotlin Spring Boot controllers. The Kotlin tree-sitter grammar
// (fwcd/tree-sitter-kotlin) produces a different AST shape than
// tree-sitter-java — both forms share `value_argument`, with the
// optional leading `simple_identifier "="` distinguishing named
// from positional. The plugin in `http-patterns/kotlin.ts` mirrors
// the safety bar from java.ts: positional uses `.` to anchor the
// string_literal as the first named child of `value_argument`,
// and the named pattern restricts the `simple_identifier` key to
// `^(path|value)$` to avoid capturing `produces`, `consumes`,
// `headers`, `name`, `params`, etc.
//
// tree-sitter-kotlin is an optionalDependency. If the binding is
// unavailable in the current test environment, `getPluginForFile`
// returns undefined for `.kt` files and we skip the suite.
const kotlinAvailable = getPluginForFile('Probe.kt') !== undefined;
const itKotlin = kotlinAvailable ? it : it.skip;
itKotlin('extracts Kotlin @RequestMapping("/api/v1") (positional class prefix)', async () => {
const dir = path.join(tmpDir, 'kotlin-spring-class-positional');
fs.mkdirSync(path.join(dir, 'src/controller'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'src/controller/UserController.kt'),
`package com.example
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
@RestController
@RequestMapping("/api/v1")
class UserController {
@GetMapping("/users") fun list() {}
}
`,
);
const contracts = await extractor.extract(null, dir, makeRepo(dir));
const providers = contracts.filter((c) => c.role === 'provider');
const route = providers.find((c) => c.contractId === 'http::GET::/api/v1/users');
expect(route).toBeDefined();
expect(route!.symbolName).toBe('list');
expect(route!.meta.framework).toBe('spring');
});
itKotlin('extracts Kotlin @RequestMapping(path = "/api/v2") (named class prefix)', async () => {
const dir = path.join(tmpDir, 'kotlin-spring-class-named-path');
fs.mkdirSync(path.join(dir, 'src/controller'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'src/controller/UserController.kt'),
`package com.example
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
@RestController
@RequestMapping(path = "/api/v2")
class UserController {
@GetMapping("/users") fun list() {}
}
`,
);
const contracts = await extractor.extract(null, dir, makeRepo(dir));
const providers = contracts.filter((c) => c.role === 'provider');
const route = providers.find((c) => c.contractId === 'http::GET::/api/v2/users');
expect(route).toBeDefined();
});
itKotlin(
'extracts Kotlin @RequestMapping(value = "/orders") (named class prefix)',
async () => {
const dir = path.join(tmpDir, 'kotlin-spring-class-named-value');
fs.mkdirSync(path.join(dir, 'src/controller'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'src/controller/OrderController.kt'),
`package com.example
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
@RestController
@RequestMapping(value = "/orders")
class OrderController {
@GetMapping("/list") fun list() {}
}
`,
);
const contracts = await extractor.extract(null, dir, makeRepo(dir));
const providers = contracts.filter((c) => c.role === 'provider');
expect(providers.find((c) => c.contractId === 'http::GET::/orders/list')).toBeDefined();
},
);
itKotlin('extracts Kotlin method-level @GetMapping(value = "/users")', async () => {
const dir = path.join(tmpDir, 'kotlin-spring-method-named-value');
fs.mkdirSync(path.join(dir, 'src/controller'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'src/controller/UserController.kt'),
`package com.example
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RestController
@RestController
class UserController {
@GetMapping(value = "/users") fun list() {}
}
`,
);
const contracts = await extractor.extract(null, dir, makeRepo(dir));
const providers = contracts.filter((c) => c.role === 'provider');
const route = providers.find((c) => c.contractId === 'http::GET::/users');
expect(route).toBeDefined();
expect(route!.symbolName).toBe('list');
});
itKotlin('extracts Kotlin method-level @GetMapping(path = "/users")', async () => {
const dir = path.join(tmpDir, 'kotlin-spring-method-named-path-get');
fs.mkdirSync(path.join(dir, 'src/controller'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'src/controller/UserController.kt'),
`package com.example
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RestController
@RestController
class UserController {
@GetMapping(path = "/users") fun list() {}
}
`,
);
const contracts = await extractor.extract(null, dir, makeRepo(dir));
const providers = contracts.filter((c) => c.role === 'provider');
expect(providers.find((c) => c.contractId === 'http::GET::/users')).toBeDefined();
});
itKotlin('extracts Kotlin method-level @PostMapping(path = "/users")', async () => {
const dir = path.join(tmpDir, 'kotlin-spring-method-named-path-post');
fs.mkdirSync(path.join(dir, 'src/controller'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'src/controller/UserController.kt'),
`package com.example
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RestController
@RestController
class UserController {
@PostMapping(path = "/users") fun create() {}
}
`,
);
const contracts = await extractor.extract(null, dir, makeRepo(dir));
const providers = contracts.filter((c) => c.role === 'provider');
const route = providers.find((c) => c.contractId === 'http::POST::/users');
expect(route).toBeDefined();
expect(route!.symbolName).toBe('create');
});
itKotlin('combines Kotlin class named-arg prefix with method positional path', async () => {
const dir = path.join(tmpDir, 'kotlin-spring-mixed-class-named-method-pos');
fs.mkdirSync(path.join(dir, 'src/controller'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'src/controller/UserController.kt'),
`package com.example
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
@RestController
@RequestMapping(path = "/api")
class UserController {
@GetMapping("/users") fun list() {}
}
`,
);
const contracts = await extractor.extract(null, dir, makeRepo(dir));
const providers = contracts.filter((c) => c.role === 'provider');
expect(providers.find((c) => c.contractId === 'http::GET::/api/users')).toBeDefined();
});
itKotlin('combines Kotlin class positional prefix with method named-arg path', async () => {
const dir = path.join(tmpDir, 'kotlin-spring-mixed-class-pos-method-named');
fs.mkdirSync(path.join(dir, 'src/controller'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'src/controller/UserController.kt'),
`package com.example
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
@RestController
@RequestMapping("/api")
class UserController {
@GetMapping(value = "/users") fun list() {}
}
`,
);
const contracts = await extractor.extract(null, dir, makeRepo(dir));
const providers = contracts.filter((c) => c.role === 'provider');
expect(providers.find((c) => c.contractId === 'http::GET::/api/users')).toBeDefined();
});
itKotlin(
'does NOT emit a Kotlin provider for @GetMapping(produces = ...) without path/value',
async () => {
// Anti-regression: without the `simple_identifier` key
// constraint, the named-arg query would capture
// `produces = "application/json"` and emit a bogus
// `http::GET::/application/json` contract.
const dir = path.join(tmpDir, 'kotlin-spring-produces-only');
fs.mkdirSync(path.join(dir, 'src/controller'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'src/controller/MisleadingController.kt'),
`package com.example
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RestController
@RestController
class MisleadingController {
@GetMapping(produces = "application/json") fun list() {}
}
`,
);
const contracts = await extractor.extract(null, dir, makeRepo(dir));
const providers = contracts.filter((c) => c.role === 'provider');
expect(
providers.find((c) => c.contractId === 'http::GET::/application/json'),
).toBeUndefined();
const fromThisFile = providers.filter((c) =>
c.symbolRef.filePath.endsWith('MisleadingController.kt'),
);
expect(fromThisFile).toHaveLength(0);
},
);
itKotlin(
'emits exactly one Kotlin provider for @GetMapping(name = "...", value = "/users")',
async () => {
// Anti-regression: without the key constraint, both string
// literals would be captured as method paths, emitting two
// contracts (`/listUsers` + `/users`).
const dir = path.join(tmpDir, 'kotlin-spring-name-and-value');
fs.mkdirSync(path.join(dir, 'src/controller'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'src/controller/UserController.kt'),
`package com.example
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RestController
@RestController
class UserController {
@GetMapping(name = "listUsers", value = "/users") fun list() {}
}
`,
);
const contracts = await extractor.extract(null, dir, makeRepo(dir));
const providers = contracts.filter((c) => c.role === 'provider');
const usersRoute = providers.find((c) => c.contractId === 'http::GET::/users');
expect(usersRoute).toBeDefined();
expect(usersRoute!.symbolName).toBe('list');
expect(providers.find((c) => c.contractId === 'http::GET::/listUsers')).toBeUndefined();
const fromThisFile = providers.filter((c) =>
c.symbolRef.filePath.endsWith('UserController.kt'),
);
expect(fromThisFile).toHaveLength(1);
},
);
itKotlin(
'uses Kotlin `path` (not non-route key) as class prefix when both appear',
async () => {
// Anti-regression: without the key constraint, the LAST captured
// value_argument would win in the prefix map. Here `name = "myApi"`
// appears after `path = "/api"` — the prefix must remain `/api`.
const dir = path.join(tmpDir, 'kotlin-spring-class-prefix-key-wins');
fs.mkdirSync(path.join(dir, 'src/controller'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'src/controller/UserController.kt'),
`package com.example
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
@RestController
@RequestMapping(path = "/api", name = "myApi")
class UserController {
@GetMapping("/users") fun list() {}
}
`,
);
const contracts = await extractor.extract(null, dir, makeRepo(dir));
const providers = contracts.filter((c) => c.role === 'provider');
expect(providers.find((c) => c.contractId === 'http::GET::/api/users')).toBeDefined();
expect(providers.find((c) => c.contractId === 'http::GET::/myApi/users')).toBeUndefined();
},
);
it('does not emit annotated Java interfaces as concrete Spring provider routes', async () => {
const dir = path.join(tmpDir, 'spring-interface-only');
fs.mkdirSync(path.join(dir, 'src/rest'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'src/rest/DepartmentApi.java'),
`
package com.example.rest;
import org.springframework.web.bind.annotation.*;
@RequestMapping("/departments")
public interface DepartmentApi {
@GetMapping("")
Object list();
@GetMapping("/{name}")
Object getByName(@PathVariable String name);
}
`,
);
const contracts = await extractor.extract(null, dir, makeRepo(dir));
const providers = contracts.filter((c) => c.role === 'provider');
expect(providers).toHaveLength(0);
});
it('inherits Spring interface route mappings when controller methods omit annotations', async () => {
const dir = path.join(tmpDir, 'spring-interface-inherited-methods');
fs.mkdirSync(path.join(dir, 'src/rest'), { recursive: true });
fs.mkdirSync(path.join(dir, 'src/controller'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'src/rest/StatusApi.java'),
`
package com.example.rest;
import org.springframework.web.bind.annotation.*;
@RequestMapping("/status")
public interface StatusApi {
@GetMapping("")
Object getStatus();
}
`,
);
fs.writeFileSync(
path.join(dir, 'src/controller/StatusController.java'),
`
package com.example.controller;
import com.example.rest.StatusApi;
import org.springframework.web.bind.annotation.*;
@RestController
public class StatusController implements StatusApi {
@Override
public Object getStatus() { return null; }
}
`,
);
const contracts = await extractor.extract(null, dir, makeRepo(dir));
const providers = contracts.filter((c) => c.role === 'provider');
const statusRoute = providers.find((c) => c.contractId === 'http::GET::/status');
expect(statusRoute).toBeDefined();
expect(toPosixPath(statusRoute!.symbolRef.filePath)).toBe(
'src/controller/StatusController.java',
);
expect(statusRoute!.symbolName).toBe('getStatus');
expect(providers.filter((c) => c.symbolRef.filePath.includes('StatusApi.java'))).toHaveLength(
0,
);
});
it('combines controller class mapping with inherited interface method mapping', async () => {
const dir = path.join(tmpDir, 'spring-interface-controller-prefix');
fs.mkdirSync(path.join(dir, 'src/rest'), { recursive: true });
fs.mkdirSync(path.join(dir, 'src/controller'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'src/rest/UserApi.java'),
`
package com.example.rest;
import org.springframework.web.bind.annotation.*;
public interface UserApi {
@GetMapping("/users")
Object listUsers();
}
`,
);
fs.writeFileSync(
path.join(dir, 'src/controller/UserController.java'),
`
package com.example.controller;
import com.example.rest.UserApi;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api")
public class UserController implements UserApi {
@Override
public Object listUsers() { return null; }
}
`,
);
const contracts = await extractor.extract(null, dir, makeRepo(dir));
const providers = contracts.filter((c) => c.role === 'provider');
const usersRoute = providers.find((c) => c.contractId === 'http::GET::/api/users');
expect(usersRoute).toBeDefined();
expect(toPosixPath(usersRoute!.symbolRef.filePath)).toBe(
'src/controller/UserController.java',
);
expect(usersRoute!.meta.framework).toBe('spring');
expect(usersRoute!.confidence).toBe(0.8);
});
it('does not duplicate inherited Spring prefixes already present on the controller', async () => {
const dir = path.join(tmpDir, 'spring-inherited-prefix-dedup');
fs.mkdirSync(path.join(dir, 'src/rest'), { recursive: true });
fs.mkdirSync(path.join(dir, 'src/controller'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'src/rest/DataReleaseFacade.java'),
`
package com.example.rest;
import org.springframework.web.bind.annotation.*;
@RequestMapping("/open/ai")
public interface DataReleaseFacade {
@GetMapping("/query")
Object query();
}
`,
);
fs.writeFileSync(
path.join(dir, 'src/controller/BaseFacadeService.java'),
`
package com.example.controller;
import org.springframework.web.bind.annotation.*;
@RequestMapping("/open/ai")
public abstract class BaseFacadeService {
}
`,
);
fs.writeFileSync(
path.join(dir, 'src/controller/DataReleaseFacadeImpl.java'),
`
package com.example.controller;
import com.example.rest.DataReleaseFacade;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/open/ai")
public class DataReleaseFacadeImpl extends BaseFacadeService implements DataReleaseFacade {
@Override
public Object query() { return null; }
}
`,
);
const contracts = await extractor.extract(null, dir, makeRepo(dir));
const providers = contracts.filter((c) => c.role === 'provider');
const queryRoute = providers.find((c) => c.contractId === 'http::GET::/open/ai/query');
expect(queryRoute).toBeDefined();
expect(toPosixPath(queryRoute!.symbolRef.filePath)).toBe(
'src/controller/DataReleaseFacadeImpl.java',
);
expect(providers.find((c) => c.contractId === 'http::GET::/open/ai/open/ai/query')).toBe(
undefined,
);
});
it('still combines distinct inherited Spring prefixes that share a leading segment', async () => {
const dir = path.join(tmpDir, 'spring-interface-shared-leading-prefix');
fs.mkdirSync(path.join(dir, 'src/rest'), { recursive: true });
fs.mkdirSync(path.join(dir, 'src/controller'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'src/rest/DataReleaseApi.java'),
`
package com.example.rest;
import org.springframework.web.bind.annotation.*;
@RequestMapping("/open/ai")
public interface DataReleaseApi {
@GetMapping("/query")
Object query();
}
`,
);
fs.writeFileSync(
path.join(dir, 'src/controller/DataReleaseFacadeImpl.java'),
`
package com.example.controller;
import com.example.rest.DataReleaseApi;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/open")
public class DataReleaseFacadeImpl implements DataReleaseApi {
@Override
public Object query() { return null; }
}
`,
);
const contracts = await extractor.extract(null, dir, makeRepo(dir));
const providers = contracts.filter((c) => c.role === 'provider');
expect(
providers.find((c) => c.contractId === 'http::GET::/open/open/ai/query'),
).toBeDefined();
expect(providers.find((c) => c.contractId === 'http::GET::/open/ai/query')).toBeUndefined();
});
it('keeps a controller prefix when a prefix-less interface method starts with the same path', async () => {
const dir = path.join(tmpDir, 'spring-interface-method-prefix-overlap');
fs.mkdirSync(path.join(dir, 'src/rest'), { recursive: true });
fs.mkdirSync(path.join(dir, 'src/controller'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'src/rest/UserApi.java'),
`
package com.example.rest;
import org.springframework.web.bind.annotation.*;
public interface UserApi {
@GetMapping("/users/{id}")
Object getUser();
}
`,
);
fs.writeFileSync(
path.join(dir, 'src/controller/UserController.java'),
`
package com.example.controller;
import com.example.rest.UserApi;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/users")
public class UserController implements UserApi {
@Override
public Object getUser() { return null; }
}
`,
);
const contracts = await extractor.extract(null, dir, makeRepo(dir));
const providers = contracts.filter((c) => c.role === 'provider');
expect(
providers.find((c) => c.contractId === 'http::GET::/users/users/{param}'),
).toBeDefined();
expect(providers.find((c) => c.contractId === 'http::GET::/users/{param}')).toBeUndefined();
});
it('skips ambiguous inherited routes when interfaces share a simple name', async () => {
const dir = path.join(tmpDir, 'spring-interface-simple-name-collision');
fs.mkdirSync(path.join(dir, 'src/a'), { recursive: true });
fs.mkdirSync(path.join(dir, 'src/b'), { recursive: true });
fs.mkdirSync(path.join(dir, 'src/controller'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'src/a/StatusApi.java'),
`
package com.example.a;
import org.springframework.web.bind.annotation.*;
public interface StatusApi {
@GetMapping("/a/status")
Object getStatus();
}
`,
);
fs.writeFileSync(
path.join(dir, 'src/b/StatusApi.java'),
`
package com.example.b;
import org.springframework.web.bind.annotation.*;
public interface StatusApi {
@GetMapping("/b/status")
Object getStatus();
}
`,
);
fs.writeFileSync(
path.join(dir, 'src/controller/StatusController.java'),
`
package com.example.controller;
import com.example.a.StatusApi;
import org.springframework.web.bind.annotation.*;
@RestController
public class StatusController implements StatusApi {
@Override
public Object getStatus() { return null; }
}
`,
);
const contracts = await extractor.extract(null, dir, makeRepo(dir));
const providers = contracts.filter((c) => c.role === 'provider');
expect(providers.find((c) => c.contractId === 'http::GET::/b/status')).toBeUndefined();
expect(providers.find((c) => c.contractId === 'http::GET::/a/status')).toBeUndefined();
expect(
providers.filter((c) => c.symbolRef.filePath.includes('StatusController.java')),
).toHaveLength(0);
});
it('extracts fully-qualified Java route annotations (#2254 FQN follow-through)', async () => {
// JAVA_ROUTE_ANNOTATION_PATTERNS now binds `name: [(identifier)
// (scoped_identifier)]`; a deep FQN route annotation
// (`@org.springframework…GetMapping`) is matched and the for-loop
// normalizes the name to its trailing segment (`simpleName`). The
// controller was already recognised (hasAnnotation trailing-segment match);
// now the route string is extracted too — parity with the Kotlin plugin.
const dir = path.join(tmpDir, 'java-fqn-route-annotation');
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'src', 'FqnController.java'),
`
@org.springframework.web.bind.annotation.RestController
@org.springframework.web.bind.annotation.RequestMapping("/api")
class FqnController {
@org.springframework.web.bind.annotation.GetMapping("/users")
Object users() { return null; }
}
`,
);
const contracts = await extractor.extract(null, dir, makeRepo(dir));
const providers = contracts.filter((c) => c.role === 'provider');
expect(
providers.find(
(c) =>
c.contractId === 'http::GET::/api/users' &&
c.meta.framework === 'spring' &&
c.confidence === 0.8,
),
).toBeDefined();
});
it('extracts FQN OpenFeign consumers + two-segment FQN, with anti-overreach', async () => {
const dir = path.join(tmpDir, 'java-fqn-feign');
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'src', 'QualifiedClient.java'),
`
@org.springframework.cloud.openfeign.FeignClient(name = "order-service", path = "/api")
interface QualifiedClient {
@org.springframework.web.bind.annotation.GetMapping("/orders/{id}")
OrderDto getOrder(String id);
}
@foo.RestController
@foo.RequestMapping("/v2")
class ShortFqnController {
@foo.GetMapping("/items")
Object items() { return null; }
}
@com.example.NotARoute("/should-not-extract")
class Unrelated {
@com.example.AlsoNotARoute("/nope")
Object noop() { return null; }
}
`,
);
const contracts = await extractor.extract(null, dir, makeRepo(dir));
const fromFile = contracts.filter((c) =>
c.symbolRef.filePath.endsWith('QualifiedClient.java'),
);
// Exactly two contracts: the FQN @FeignClient(path)+@GetMapping consumer and
// the two-segment-FQN provider. The `Unrelated` class's non-route FQN
// annotations contribute nothing (anti-overreach — simpleName misses them).
expect(new Set(fromFile.map((c) => `${c.role} ${c.contractId} ${c.meta.framework}`))).toEqual(
new Set([
'consumer http::GET::/api/orders/{param} openfeign',
'provider http::GET::/v2/items spring',
]),
);
});
it('extracts Express router.get patterns', async () => {
const dir = path.join(tmpDir, 'express');
fs.mkdirSync(path.join(dir, 'src/routes'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'src/routes/users.ts'),
`
import { Router } from 'express';
const router = Router();
router.get('/api/users', async (req, res) => { res.json([]); });
router.post('/api/users', async (req, res) => { res.json({}); });
router.delete('/api/users/:id', async (req, res) => { res.sendStatus(204); });
export default router;
`,
);
const contracts = await extractor.extract(null, dir, makeRepo(dir));
const providers = contracts.filter((c) => c.role === 'provider');
expect(providers.length).toBeGreaterThanOrEqual(3);
expect(providers.find((c) => c.contractId === 'http::GET::/api/users')).toBeDefined();
expect(providers.find((c) => c.contractId === 'http::POST::/api/users')).toBeDefined();
expect(
providers.find((c) => c.contractId === 'http::DELETE::/api/users/{param}'),
).toBeDefined();
});
it('dedupes source-only providers by contract id', async () => {
const dir = path.join(tmpDir, 'source-only-same-contract-id');
fs.mkdirSync(path.join(dir, 'src/routes'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'src/routes/health-a.ts'),
`
router.get('/api/health', healthA);
`,
);
fs.writeFileSync(
path.join(dir, 'src/routes/health-b.ts'),
`
router.get('/api/health', healthB);
`,
);
const contracts = await extractor.extract(null, dir, makeRepo(dir));
const providers = contracts.filter((c) => c.contractId === 'http::GET::/api/health');
expect(providers).toHaveLength(1);
expect(providers[0].role).toBe('provider');
expect(providers[0].meta.extractionStrategy).toBe('source_scan');
});
it('extracts Go Gin and Echo route registrations', async () => {
const dir = path.join(tmpDir, 'go-frameworks');
fs.mkdirSync(path.join(dir, 'cmd'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'cmd', 'server.go'),
`
package main
func createOrder(c *gin.Context) {}
func listOrders(c echo.Context) error { return nil }
func main() {
r := gin.Default()
r.POST("/api/orders/:id", createOrder)
e := echo.New()
e.GET("/api/orders", listOrders)
}
`,
);
const contracts = await extractor.extract(null, dir, makeRepo(dir));
const providers = contracts.filter((c) => c.role === 'provider');
const ginRoute = providers.find((c) => c.contractId === 'http::POST::/api/orders/{param}');
expect(ginRoute).toBeDefined();
expect(ginRoute?.symbolName).toBe('createOrder');
const echoRoute = providers.find((c) => c.contractId === 'http::GET::/api/orders');
expect(echoRoute).toBeDefined();
expect(echoRoute?.symbolName).toBe('listOrders');
});
it('extracts stdlib HandleFunc providers', async () => {
const dir = path.join(tmpDir, 'go-stdlib-provider');
fs.mkdirSync(path.join(dir, 'cmd'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'cmd', 'server.go'),
`
package main
func healthHandler(w http.ResponseWriter, r *http.Request) {}
func main() {
http.HandleFunc("/api/health", healthHandler)
}
`,
);
const contracts = await extractor.extract(null, dir, makeRepo(dir));
const providers = contracts.filter((c) => c.role === 'provider');
const healthRoute = providers.find((c) => c.contractId === 'http::GET::/api/health');
expect(healthRoute).toBeDefined();
expect(healthRoute?.symbolName).toBe('healthHandler');
});
it('extracts NestJS controller decorators', async () => {
const dir = path.join(tmpDir, 'nestjs');
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'src', 'orders.controller.ts'),
`
import { Controller, Patch } from '@nestjs/common';
@Controller('orders')
export class OrdersController {
@Patch(':id')
updateOrder() {
return {};
}
}
`,
);
const contracts = await extractor.extract(null, dir, makeRepo(dir));
const providers = contracts.filter((c) => c.role === 'provider');
const patchRoute = providers.find((c) => c.contractId === 'http::PATCH::/orders/{param}');
expect(patchRoute).toBeDefined();
expect(patchRoute?.symbolName).toBe('updateOrder');
});
});
describe('consumer extraction — fetch patterns', () => {
it('extracts fetch() calls', async () => {
const dir = path.join(tmpDir, 'frontend');
fs.mkdirSync(path.join(dir, 'src/api'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'src/api/users.ts'),
`
export async function fetchUsers() {
const res = await fetch('/api/users');
return res.json();
}
export async function createUser(data: any) {
const res = await fetch('/api/users', { method: 'POST', body: JSON.stringify(data) });
return res.json();
}
`,
);
const contracts = await extractor.extract(null, dir, makeRepo(dir));
const consumers = contracts.filter((c) => c.role === 'consumer');
expect(consumers.length).toBeGreaterThanOrEqual(2);
expect(consumers.find((c) => c.contractId === 'http::GET::/api/users')).toBeDefined();
expect(consumers.find((c) => c.contractId === 'http::POST::/api/users')).toBeDefined();
});
it('extracts axios calls', async () => {
const dir = path.join(tmpDir, 'axios-fe');
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'src/api.ts'),
`
import axios from 'axios';
export const getUsers = () => axios.get('/api/users');
export const deleteUser = (id: string) => axios.delete(\`/api/users/\${id}\`);
`,
);
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/users')).toBeDefined();
expect(
consumers.find((c) => c.contractId === 'http::DELETE::/api/users/{param}'),
).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' });
}
export function ignoreJqueryTypeKey() {
return axios({ url: '/api/typed', type: 'POST' });
}
export function quotedAxiosMethod() {
return axios({ url: '/api/quoted-ax', "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::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 () => {
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 });
fs.writeFileSync(
path.join(dir, 'src', 'client.py'),
`
import requests
def create_order():
return requests.post("https://svc.local/api/orders/42", json={"id": 42})
`,
);
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/{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
import httpx as hx
from httpx import AsyncClient
from httpx import AsyncClient as HttpxAsyncClient
# Dotted-package look-alikes — must NOT be detected as httpx.
import my_pkg.httpx as evil_mod
from my_pkg.httpx import AsyncClient as evil_async
# Longer dotted path — must also NOT be detected.
import a.b.c.httpx as deep_evil
from a.b.c.httpx import AsyncClient as deep_evil_async
# Relative import — module_name is a relative_import node, not dotted_name, so
# it must not produce a contract either.
from .httpx import AsyncClient as rel_evil_async
module_client = httpx.AsyncClient(base_url="https://svc.local")
module_alias_client = hx.AsyncClient(base_url="https://svc.local")
module_direct_client = AsyncClient(base_url="https://svc.local")
module_renamed_client = HttpxAsyncClient(base_url="https://svc.local")
evil_mod_client = evil_mod.AsyncClient(base_url="https://svc.local")
evil_direct_client = evil_async(base_url="https://svc.local")
deep_evil_mod_client = deep_evil.AsyncClient(base_url="https://svc.local")
deep_evil_direct_client = deep_evil_async(base_url="https://svc.local")
rel_evil_direct_client = rel_evil_async(base_url="https://svc.local")
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 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")
async def import_aliases():
local_alias_client = hx.AsyncClient(base_url="https://svc.local")
local_direct_client = AsyncClient(base_url="https://svc.local")
local_renamed_client = HttpxAsyncClient(base_url="https://svc.local")
await local_alias_client.get("/alias-topic")
await local_direct_client.patch("/direct-topic")
await local_renamed_client.request("PUT", "/renamed-topic")
async with hx.AsyncClient() as alias_context:
await alias_context.delete("/alias-context")
async with AsyncClient() as direct_context:
return await direct_context.post("/direct-context")
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")
def shadow_direct_alias():
AsyncClient = lambda: FakeClient()
client = AsyncClient()
return client.get("/shadow-direct-fp")
def shadow_module_alias():
hx = FakeMod()
client = hx.AsyncClient()
return client.get("/shadow-module-fp")
async def shadow_direct_context():
AsyncClient = lambda: FakeClient()
async with AsyncClient() as client:
return await client.get("/shadow-direct-context-fp")
def shadow_tuple_destructure():
AsyncClient, _other = (lambda: FakeClient()), 42
client = AsyncClient()
return client.get("/shadow-tuple-fp")
# Class-body assignment of an imported alias is a class attribute under Python
# LEGB rules — methods inside still see the module binding. The detector must
# NOT poison the methods, so the legitimate httpx call below should still emit.
class ClassBodyRebindHolder:
AsyncClient = lambda: FakeClient()
def __init__(self):
self._client = httpx.AsyncClient(base_url="https://svc.local")
async def fetch(self):
return await self._client.get("/class-body-rebind-ok")
module_client.get("/module-topic")
module_alias_client.get("/module-alias-topic")
module_direct_client.get("/module-direct-topic")
module_renamed_client.get("/module-renamed-topic")
evil_mod_client.get("/evil-module-dotted-fp")
evil_direct_client.get("/evil-direct-dotted-fp")
deep_evil_mod_client.get("/deep-evil-module-dotted-fp")
deep_evil_direct_client.get("/deep-evil-direct-dotted-fp")
rel_evil_direct_client.get("/rel-evil-direct-fp")
`,
);
// Isolated file for module-level rebind: shadowing applies file-wide, so
// it must not affect the assertions in client.py above.
fs.writeFileSync(
path.join(dir, 'src', 'module_rebind.py'),
`
from httpx import AsyncClient
# Module-level rebind: the rest of this file's bare AsyncClient calls must NOT
# emit httpx consumer contracts.
AsyncClient = lambda: FakeClient()
shadowed_module_client = AsyncClient(base_url="https://svc.local")
shadowed_module_client.get("/module-level-rebind-fp")
`,
);
const contracts = await extractor.extract(null, dir, makeRepo(dir));
const consumers = contracts.filter((c) => c.role === 'consumer');
const expected = [
'http::GET::/topic',
'http::POST::/questions/import',
'http::DELETE::/topic',
'http::POST::/questions/duplicate-check',
'http::GET::/alias-topic',
'http::PATCH::/direct-topic',
'http::PUT::/renamed-topic',
'http::DELETE::/alias-context',
'http::POST::/direct-context',
'http::GET::/module-topic',
'http::GET::/module-alias-topic',
'http::GET::/module-direct-topic',
'http::GET::/module-renamed-topic',
// Class-body rebind of `AsyncClient` is a class attribute, not a
// method-scope shadow — the legitimate httpx.AsyncClient call inside
// the class must still emit.
'http::GET::/class-body-rebind-ok',
];
for (const contractId of expected) {
const consumer = consumers.find((c) => c.contractId === contractId);
expect(consumer).toBeDefined();
expect(consumer?.meta.framework).toBe('python-httpx');
}
// Positive control: the legitimate `module_direct_client = AsyncClient(...)`
// path was actually exercised, so the negative dotted-package assertions
// below are not passing vacuously.
expect(
consumers.find((c) => c.contractId === 'http::GET::/module-direct-topic'),
).toBeDefined();
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();
expect(
consumers.find((c) => c.contractId === 'http::GET::/ignored-module-same-name'),
).toBeUndefined();
// Finding 1: dotted-package look-alikes (`my_pkg.httpx`, three-segment
// `a.b.c.httpx`, and relative `.httpx`) must not be detected.
expect(
consumers.find((c) => c.contractId === 'http::GET::/evil-module-dotted-fp'),
).toBeUndefined();
expect(
consumers.find((c) => c.contractId === 'http::GET::/evil-direct-dotted-fp'),
).toBeUndefined();
expect(
consumers.find((c) => c.contractId === 'http::GET::/deep-evil-module-dotted-fp'),
).toBeUndefined();
expect(
consumers.find((c) => c.contractId === 'http::GET::/deep-evil-direct-dotted-fp'),
).toBeUndefined();
expect(
consumers.find((c) => c.contractId === 'http::GET::/rel-evil-direct-fp'),
).toBeUndefined();
// Finding 2: locally rebound imported aliases must not be detected.
expect(
consumers.find((c) => c.contractId === 'http::GET::/shadow-direct-fp'),
).toBeUndefined();
expect(
consumers.find((c) => c.contractId === 'http::GET::/shadow-module-fp'),
).toBeUndefined();
expect(
consumers.find((c) => c.contractId === 'http::GET::/shadow-direct-context-fp'),
).toBeUndefined();
// Tuple/list destructuring rebinds must also shadow the alias.
expect(consumers.find((c) => c.contractId === 'http::GET::/shadow-tuple-fp')).toBeUndefined();
// Module-level rebind in a separate file must shadow the whole file.
expect(
consumers.find((c) => c.contractId === 'http::GET::/module-level-rebind-fp'),
).toBeUndefined();
});
it('extracts Java Spring RestTemplate, WebClient and OkHttp literal calls', async () => {
const dir = path.join(tmpDir, 'java-consumer');
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'src', 'ApiClient.java'),
`
import org.springframework.http.HttpMethod;
import org.springframework.web.client.RestTemplate;
import org.springframework.web.reactive.function.client.WebClient;
import okhttp3.Request;
class ApiClient {
void run(RestTemplate restTemplate, WebClient webClient) {
restTemplate.getForObject("/api/users/{id}", String.class, 42);
restTemplate.exchange("/api/users/{id}/details", HttpMethod.GET, null, String.class);
webClient.post().uri("/api/users");
new Request.Builder().url("/api/orders/42").build();
}
}
`,
);
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/users/{param}')).toBeDefined();
expect(
consumers.find((c) => c.contractId === 'http::GET::/api/users/{param}/details'),
).toBeDefined();
expect(
consumers.find((c) => c.contractId === 'http::GET::/api/orders/{param}'),
).toBeDefined();
expect(
consumers.find(
(c) =>
c.contractId === 'http::GET::/api/users/{param}/details' &&
c.meta.framework === 'spring-rest-template' &&
c.confidence === 0.7,
),
).toBeDefined();
expect(
consumers.find(
(c) =>
c.contractId === 'http::POST::/api/users' &&
c.meta.framework === 'spring-web-client' &&
c.confidence === 0.7,
),
).toBeDefined();
});
it('extracts Java RestTemplate URI.create(...) static paths', async () => {
const dir = path.join(tmpDir, 'java-rest-template-uri-create');
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'src', 'UriClient.java'),
`
import java.net.URI;
import org.springframework.http.HttpMethod;
import org.springframework.web.client.RestTemplate;
class UriClient {
void run(RestTemplate restTemplate) {
String dynamicPath = "/api/dynamic-users/99";
restTemplate.getForEntity(URI.create("/api/uri-users/42"), String.class);
restTemplate.exchange(URI.create("/api/uri-users/42/details"), HttpMethod.POST, null, String.class);
restTemplate.getForObject(dynamicPath, String.class);
}
}
`,
);
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/uri-users/{param}' &&
c.meta.framework === 'spring-rest-template' &&
c.confidence === 0.7,
),
).toBeDefined();
expect(
consumers.find((c) => c.contractId === 'http::POST::/api/uri-users/{param}/details'),
).toBeDefined();
// Anti-overreach: a variable-bound path is not statically resolvable, so
// the widened `(_) @path` capture must NOT emit a consumer for it.
expect(
consumers.find((c) => c.contractId === 'http::GET::/api/dynamic-users/{param}'),
).toBeUndefined();
});
it('extracts Java RestTemplate UriComponentsBuilder fluent-chain paths', async () => {
const dir = path.join(tmpDir, 'java-rest-template-builder');
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'src', 'BuilderClient.java'),
`
import org.springframework.web.client.RestTemplate;
import org.springframework.web.util.UriComponentsBuilder;
class BuilderClient {
void run(RestTemplate restTemplate, String idVar) {
restTemplate.getForObject(
UriComponentsBuilder.fromPath("/api").path("/builder-users").pathSegment("42").build().toUriString(),
String.class);
restTemplate.getForObject(
UriComponentsBuilder.fromUriString("/base").path("/sub").queryParam("page", "1").build().toUriString(),
String.class);
restTemplate.getForObject(
UriComponentsBuilder.fromHttpUrl("https://example.com/api").path("/external-users").query("page=1").build().toUriString(),
String.class);
restTemplate.getForObject(
UriComponentsBuilder.fromPath("/api").pathSegment(idVar).build().toUriString(),
String.class);
}
}
`,
);
const contracts = await extractor.extract(null, dir, makeRepo(dir));
const consumers = contracts.filter((c) => c.role === 'consumer');
// fromPath + path + numeric pathSegment → joined, numeric → {param}.
expect(
consumers.find((c) => c.contractId === 'http::GET::/api/builder-users/{param}'),
).toBeDefined();
// fromUriString seed + path; the queryParam attribute does not alter the path.
expect(consumers.find((c) => c.contractId === 'http::GET::/base/sub')).toBeDefined();
// fromHttpUrl host seed: helper keeps the host; normalizeConsumerPath strips it.
expect(
consumers.find((c) => c.contractId === 'http::GET::/api/external-users'),
).toBeDefined();
// Anti-overreach: the non-literal `pathSegment(idVar)` call defeats static
// resolution, so exactly the three resolvable chains emit — not a fourth.
expect(
consumers.filter((c) => c.symbolRef.filePath.endsWith('BuilderClient.java')),
).toHaveLength(3);
});
it('strips a query string baked into a UriComponentsBuilder seed (#2268)', async () => {
const dir = path.join(tmpDir, 'java-rest-template-builder-query-seed');
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'src', 'QuerySeedClient.java'),
`
import org.springframework.web.client.RestTemplate;
import org.springframework.web.util.UriComponentsBuilder;
class QuerySeedClient {
void run(RestTemplate restTemplate) {
restTemplate.getForObject(
UriComponentsBuilder.fromUriString("/base?x=1").path("/sub").build().toUriString(),
String.class);
restTemplate.getForObject(
UriComponentsBuilder.fromHttpUrl("https://example.com/api?x=1").path("/y").build().toUriString(),
String.class);
}
}
`,
);
const contracts = await extractor.extract(null, dir, makeRepo(dir));
const consumers = contracts.filter((c) => c.role === 'consumer');
// Query in the seed must NOT swallow the later .path() segment:
// fromUriString("/base?x=1").path("/sub") → /base/sub (was /base before the fix).
expect(consumers.find((c) => c.contractId === 'http::GET::/base/sub')).toBeDefined();
// Host + query seed: query stripped at the seed, host stripped downstream.
expect(consumers.find((c) => c.contractId === 'http::GET::/api/y')).toBeDefined();
// Count guard: exactly the two resolvable chains. A regression that
// double-emitted (e.g. both /base and /base/sub) would slip past the two
// `find` assertions above without this.
expect(
consumers.filter((c) => c.symbolRef.filePath.endsWith('QuerySeedClient.java')),
).toHaveLength(2);
});
it('resolves a UriComponentsBuilder argument passed to restTemplate.exchange (#2268)', async () => {
// The exchange() path capture was widened to `(_) @path` alongside the
// plain RestTemplate loop, but was only covered with URI.create. Pin that a
// UriComponentsBuilder chain through exchange() also resolves end-to-end.
const dir = path.join(tmpDir, 'java-rest-template-exchange-builder');
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'src', 'ExchangeBuilderClient.java'),
`
import org.springframework.http.HttpMethod;
import org.springframework.web.client.RestTemplate;
import org.springframework.web.util.UriComponentsBuilder;
class ExchangeBuilderClient {
void run(RestTemplate restTemplate) {
restTemplate.exchange(
UriComponentsBuilder.fromPath("/api").path("/exchange-users").build().toUriString(),
HttpMethod.PUT, null, String.class);
}
}
`,
);
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/exchange-users'),
).toBeDefined();
});
it('appends UriComponentsBuilder .path() verbatim per Spring semantics (#2268)', async () => {
// Spring `.path(p)` appends `p` as-is (no slash inserted) then collapses
// duplicate slashes — unlike `.pathSegment`, which slash-joins. So a
// no-leading-slash arg is NOT given a phantom slash, a leading-slash arg
// joins cleanly, a trailing-slash base collapses, and a host seed keeps its
// `://` until the downstream normalizer strips the host.
const dir = path.join(tmpDir, 'java-rest-template-builder-path');
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'src', 'PathClient.java'),
`
import org.springframework.web.client.RestTemplate;
import org.springframework.web.util.UriComponentsBuilder;
class PathClient {
void run(RestTemplate restTemplate) {
restTemplate.getForObject(UriComponentsBuilder.fromPath("/api").path("noslash").build().toUriString(), String.class);
restTemplate.getForObject(UriComponentsBuilder.fromPath("/svc").path("/withslash").build().toUriString(), String.class);
restTemplate.getForObject(UriComponentsBuilder.fromPath("/trail/").path("/seg").build().toUriString(), String.class);
restTemplate.getForObject(UriComponentsBuilder.fromPath("/first-").path("value/").path("/end").build().toUriString(), String.class);
restTemplate.getForObject(UriComponentsBuilder.fromHttpUrl("https://example.com/api").path("/ext").build().toUriString(), String.class);
restTemplate.getForObject(UriComponentsBuilder.fromPath("/empty").path("").build().toUriString(), String.class);
}
}
`,
);
const contracts = await extractor.extract(null, dir, makeRepo(dir));
const fromFile = contracts.filter(
(c) => c.role === 'consumer' && c.symbolRef.filePath.endsWith('PathClient.java'),
);
// Exactly these six — no phantom slash on the no-leading-slash arg, no
// double slash from the trailing-slash base, no `://` corruption.
expect(new Set(fromFile.map((c) => c.contractId))).toEqual(
new Set([
'http::GET::/apinoslash', // fromPath("/api").path("noslash") — verbatim, no slash
'http::GET::/svc/withslash', // leading-slash arg joins cleanly
'http::GET::/trail/seg', // trailing-slash base collapses
'http::GET::/first-value/end', // value/ + /end → one slash
'http::GET::/api/ext', // host seed: `://` kept, host stripped downstream
'http::GET::/empty', // empty .path("") is a no-op
]),
);
});
it('does not overflow on a pathological UriComponentsBuilder chain (#2268)', async () => {
const dir = path.join(tmpDir, 'java-rest-template-builder-deep');
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
// A chain far deeper than the recursion cap — exercises the depth guard.
const deepChain = `UriComponentsBuilder.fromPath("/r")${'.path("/x")'.repeat(200)}.build().toUriString()`;
fs.writeFileSync(
path.join(dir, 'src', 'DeepClient.java'),
`
import org.springframework.web.client.RestTemplate;
import org.springframework.web.util.UriComponentsBuilder;
class DeepClient {
void run(RestTemplate restTemplate) {
restTemplate.getForObject(${deepChain}, String.class);
}
}
`,
);
// Must not throw (the depth guard caps recursion). A chain past the cap
// resolves to null, so no consumer is emitted for it (without the guard it
// would either overflow or resolve to a bogus deep path).
const contracts = await extractor.extract(null, dir, makeRepo(dir));
expect(
contracts.filter(
(c) => c.role === 'consumer' && c.symbolRef.filePath.endsWith('DeepClient.java'),
),
).toHaveLength(0);
});
it('infers Java OkHttp verbs from sibling Request.Builder calls', async () => {
const dir = path.join(tmpDir, 'java-okhttp-verbs');
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'src', 'OkHttpVerbs.java'),
`
import okhttp3.Request;
import okhttp3.RequestBody;
class OkHttpVerbs {
void run(RequestBody body, String verb) {
new Request.Builder().url("/api/orders/0").get().build();
new Request.Builder().url("/api/orders/head").head().build();
new Request.Builder().url("/api/orders").post(body).build();
new Request.Builder().url("/api/orders/1").put(body).build();
new Request.Builder().url("/api/orders/2").delete().build();
new Request.Builder().url("/api/orders/3").method("PATCH", body).build();
new Request.Builder().url("/api/bare-build").build();
new Request.Builder().url("/api/dyn-verb").method(verb, body).build();
}
}
`,
);
const contracts = await extractor.extract(null, dir, makeRepo(dir));
const okhttp = contracts.filter(
(c) =>
c.role === 'consumer' &&
c.meta.framework === 'okhttp' &&
c.symbolRef.filePath.endsWith('OkHttpVerbs.java'),
);
// The bare `.build()` with no verb call gets its OWN path (`/api/bare-build`)
// so the default-GET branch of inferOkHttpMethod is pinned independently.
// An explicit `.method(verb, …)` with a *variable* verb (`/api/dyn-verb`) is
// unresolvable and emits NOTHING — not a guessed GET (parity with WebClient
// long-form). Explicit verbs resolve; numeric segments normalize to {param}.
expect(okhttp.find((c) => c.contractId === 'http::GET::/api/bare-build')).toBeDefined();
// Variable-bound `.method(verb)` produces no contract at all (any verb).
expect(okhttp.find((c) => c.contractId.includes('/api/dyn-verb'))).toBeUndefined();
expect(new Set(okhttp.map((c) => c.contractId))).toEqual(
new Set([
'http::GET::/api/bare-build',
'http::GET::/api/orders/{param}',
'http::POST::/api/orders',
'http::PUT::/api/orders/{param}',
'http::DELETE::/api/orders/{param}',
'http::PATCH::/api/orders/{param}',
'http::HEAD::/api/orders/head',
]),
);
expect(okhttp.every((c) => c.confidence === 0.7)).toBe(true);
});
it('does not emit a contract for an empty-string verb literal (#2268)', async () => {
// `.method("", body)` is an explicit-but-unresolvable verb — `unquoteLiteral`
// returns "" (not null), so the falsiness guard must skip it rather than
// emit a malformed `http::::/path` contract or a guessed GET. Covers both
// the OkHttp and Java-HttpClient verb-walks (shared `inferBuilderVerb`).
const dir = path.join(tmpDir, 'java-empty-verb');
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'src', 'EmptyVerb.java'),
`
import java.net.URI;
import java.net.http.HttpRequest;
import okhttp3.Request;
import okhttp3.RequestBody;
class EmptyVerb {
void run(RequestBody body) throws Exception {
new Request.Builder().url("/api/okhttp-empty").method("", body).build();
HttpRequest hc = HttpRequest.newBuilder().uri(URI.create("/api/hc-empty")).method("", body).build();
}
}
`,
);
const contracts = await extractor.extract(null, dir, makeRepo(dir));
const fromFile = contracts.filter(
(c) => c.role === 'consumer' && c.symbolRef.filePath.endsWith('EmptyVerb.java'),
);
// No contract at all — neither a malformed empty-method id nor a guessed GET.
expect(fromFile.map((c) => c.contractId)).toEqual([]);
});
it('extracts OkHttp chains with a builder call before .url(), with anti-overreach (#2268)', async () => {
// A builder call BEFORE .url() (`new Request.Builder().addHeader(...).url(...)`)
// no longer drops the contract — the chain is matched as long as it roots at
// `new Request.Builder()`. The verb-walk scans the whole chain, so a verb set
// before .url() also resolves. A `.url(...)` on an unrelated object does NOT
// emit (the root gate rejects it).
const dir = path.join(tmpDir, 'java-okhttp-pre-url');
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'src', 'OkHttpPreUrl.java'),
`
import okhttp3.Request;
import okhttp3.RequestBody;
class OkHttpPreUrl {
void run(RequestBody body, SomeClient other) {
new Request.Builder().addHeader("A", "b").url("/api/pre-url").build();
new Request.Builder().post(body).url("/api/verb-first").build();
other.url("/api/not-okhttp").build();
}
}
`,
);
const contracts = await extractor.extract(null, dir, makeRepo(dir));
const okhttp = contracts.filter(
(c) =>
c.role === 'consumer' &&
c.meta.framework === 'okhttp' &&
c.symbolRef.filePath.endsWith('OkHttpPreUrl.java'),
);
// The header-before-url chain (default GET) and the verb-before-url chain
// (POST) both emit; `other.url(...)` does not (not a Request.Builder chain).
expect(new Set(okhttp.map((c) => c.contractId))).toEqual(
new Set(['http::GET::/api/pre-url', 'http::POST::/api/verb-first']),
);
});
it('extracts Java WebClient long-form method(HttpMethod.X).uri(...) — #2254 parity', async () => {
// Parity with the Kotlin plugin: a single structural query matches the
// verb (HttpMethod.X field access) and path. Previously deferred on the
// Java side; PR #2254 lifts it so .java and .kt detect it identically.
const dir = path.join(tmpDir, 'java-web-client-long-form');
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'src', 'LongFormClient.java'),
`
import org.springframework.http.HttpMethod;
import org.springframework.web.reactive.function.client.WebClient;
class LongFormClient {
void run(WebClient webClient) {
webClient.method(HttpMethod.GET).uri("/api/get").retrieve();
webClient.method(HttpMethod.POST).uri("/api/post").retrieve();
webClient.method(HttpMethod.PUT).uri("/api/put").retrieve();
webClient.method(HttpMethod.DELETE).uri("/api/delete").retrieve();
webClient.method(HttpMethod.PATCH).uri("/api/users/42").retrieve();
}
}
`,
);
const contracts = await extractor.extract(null, dir, makeRepo(dir));
const consumers = contracts.filter((c) => c.role === 'consumer');
for (const [verb, p] of [
['GET', '/api/get'],
['POST', '/api/post'],
['PUT', '/api/put'],
['DELETE', '/api/delete'],
['PATCH', '/api/users/{param}'],
]) {
expect(
consumers.find(
(c) =>
c.contractId === `http::${verb}::${p}` &&
c.meta.framework === 'spring-web-client' &&
c.confidence === 0.7,
),
).toBeDefined();
}
// No double-emit: the short-form query cannot also fire on the long form.
expect(consumers.filter((c) => c.contractId === 'http::GET::/api/get')).toHaveLength(1);
});
it('does NOT match Java WebClient long-form with a variable-bound verb', async () => {
// The value carries a bare identifier, not a HttpMethod.X field access —
// source-scan can't follow the binding (anti-overreach, parity with Kotlin).
const dir = path.join(tmpDir, 'java-web-client-long-form-var');
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'src', 'VarVerbClient.java'),
`
import org.springframework.http.HttpMethod;
import org.springframework.web.reactive.function.client.WebClient;
class VarVerbClient {
void run(WebClient webClient, HttpMethod verb) {
webClient.method(verb).uri("/api/users/42").retrieve();
}
}
`,
);
const contracts = await extractor.extract(null, dir, makeRepo(dir));
const consumers = contracts.filter((c) => c.role === 'consumer');
expect(
consumers.find(
(c) => c.contractId.startsWith('http::') && c.contractId.includes('/api/users'),
),
).toBeUndefined();
});
it('handles Java array-form annotation paths ({"/x"}, key = {"/x"})', async () => {
const dir = path.join(tmpDir, 'java-array-paths');
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
// Provider: array class prefix + array method path.
fs.writeFileSync(
path.join(dir, 'src', 'ProductController.java'),
`
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.GetMapping;
@RestController
@RequestMapping({"/api/products"})
class ProductController {
@GetMapping(path = {"/{id}"})
Product get(Integer id) { return null; }
}
`,
);
// OpenFeign consumer with array positional path.
fs.writeFileSync(
path.join(dir, 'src', 'OrdersClient.java'),
`
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.PostMapping;
@FeignClient(name = "orders")
interface OrdersClient {
@PostMapping({"/orders/search"})
Object search();
}
`,
);
const contracts = await extractor.extract(null, dir, makeRepo(dir));
const providers = contracts.filter((c) => c.role === 'provider');
const consumers = contracts.filter((c) => c.role === 'consumer');
// Array class prefix + array method path → provider.
expect(
providers.find((c) => c.contractId === 'http::GET::/api/products/{param}'),
).toBeDefined();
// @FeignClient with array positional path → consumer.
expect(
consumers.find(
(c) => c.contractId === 'http::POST::/orders/search' && c.meta.framework === 'openfeign',
),
).toBeDefined();
});
it('extracts OpenFeign clients as consumers, not providers', async () => {
const dir = path.join(tmpDir, 'java-openfeign-consumer');
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'src', 'OrderClient.java'),
`
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PathVariable;
@FeignClient(name = "order-service", url = "\${order.service.url}", path = "/api")
interface OrderClient {
@GetMapping("/orders/{id}")
OrderDto getOrder(@PathVariable("id") String id);
@PostMapping(path = "/orders")
OrderDto createOrder(OrderDto body);
}
`,
);
const contracts = await extractor.extract(null, dir, makeRepo(dir));
const consumers = contracts.filter((c) => c.role === 'consumer');
const providers = contracts.filter((c) => c.role === 'provider');
expect(
consumers.find((c) => c.contractId === 'http::GET::/api/orders/{param}'),
).toBeDefined();
expect(
consumers.find(
(c) =>
c.contractId === 'http::POST::/api/orders' &&
c.meta.framework === 'openfeign' &&
c.confidence === 0.7,
),
).toBeDefined();
expect(
providers.find((c) => c.symbolRef.filePath.endsWith('OrderClient.java')),
).toBeUndefined();
});
it('extracts Spring HTTP Interface @(Get|...)Exchange clients as consumers', async () => {
const dir = path.join(tmpDir, 'java-http-exchange-consumer');
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'src', 'InventoryApi.java'),
`
import org.springframework.web.service.annotation.HttpExchange;
import org.springframework.web.service.annotation.GetExchange;
import org.springframework.web.service.annotation.PostExchange;
@HttpExchange(url = "/items")
interface InventoryApi {
@GetExchange(url = "/{id}")
Item getItem(Integer id);
@PostExchange("/search")
Page<Item> search(ItemFilter query);
}
`,
);
const contracts = await extractor.extract(null, dir, makeRepo(dir));
const consumers = contracts.filter((c) => c.role === 'consumer');
const providers = contracts.filter((c) => c.role === 'provider');
expect(
consumers.find(
(c) =>
c.contractId === 'http::GET::/items/{param}' &&
c.meta.framework === 'spring-http-interface' &&
c.confidence === 0.75,
),
).toBeDefined();
expect(consumers.find((c) => c.contractId === 'http::POST::/items/search')).toBeDefined();
// Declarative HTTP-interface methods are consumers, never providers.
expect(
providers.find((c) => c.symbolRef.filePath.endsWith('InventoryApi.java')),
).toBeUndefined();
});
it('extracts OpenFeign clients without an interface path prefix', async () => {
const dir = path.join(tmpDir, 'java-openfeign-no-prefix');
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'src', 'HealthClient.java'),
`
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
@FeignClient(name = "health-service")
interface HealthClient {
@GetMapping("/health")
String health();
}
`,
);
const contracts = await extractor.extract(null, dir, makeRepo(dir));
const consumers = contracts.filter((c) => c.role === 'consumer');
const providers = contracts.filter((c) => c.role === 'provider');
expect(
consumers.find(
(c) =>
c.contractId === 'http::GET::/health' &&
c.meta.framework === 'openfeign' &&
c.confidence === 0.7,
),
).toBeDefined();
expect(
providers.find((c) => c.symbolRef.filePath.endsWith('HealthClient.java')),
).toBeUndefined();
});
it('does not treat @FeignClient text in an interface body as a Feign annotation', async () => {
const dir = path.join(tmpDir, 'java-non-feign-interface-text');
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'src', 'NotFeignClient.java'),
`
import org.springframework.web.bind.annotation.GetMapping;
interface NotFeignClient {
String MARKER = "@FeignClient";
@GetMapping("/not-feign")
String call();
}
`,
);
const contracts = await extractor.extract(null, dir, makeRepo(dir));
const consumers = contracts.filter((c) => c.role === 'consumer');
const providers = contracts.filter((c) => c.role === 'provider');
expect(consumers.find((c) => c.contractId === 'http::GET::/not-feign')).toBeUndefined();
expect(providers.find((c) => c.contractId === 'http::GET::/not-feign')).toBeUndefined();
});
it('extracts OpenFeign clients with @RequestMapping interface prefixes', async () => {
const dir = path.join(tmpDir, 'java-openfeign-request-mapping-prefix');
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'src', 'InventoryClient.java'),
`
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
@FeignClient(name = "inventory-service")
@RequestMapping(path = "/api")
interface InventoryClient {
@GetMapping("/inventory/{id}")
InventoryDto getInventory(String id);
}
`,
);
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/inventory/{param}' &&
c.meta.framework === 'openfeign',
),
).toBeDefined();
});
it('prefers @FeignClient(path=...) over @RequestMapping prefixes on OpenFeign clients', async () => {
const dir = path.join(tmpDir, 'java-openfeign-prefix-precedence');
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'src', 'PrecedenceClient.java'),
`
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
@FeignClient(name = "order-service", path = "/feign-path")
@RequestMapping("/rm-path")
interface PrecedenceClient {
@GetMapping("/orders")
OrderDto getOrders();
}
`,
);
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::/feign-path/orders')).toBeDefined();
expect(consumers.find((c) => c.contractId === 'http::GET::/rm-path/orders')).toBeUndefined();
});
it('extracts native @RequestLine consumers on @FeignClient interfaces', async () => {
const dir = path.join(tmpDir, 'java-feign-request-line-basic');
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'src', 'AiClient.java'),
`
import org.springframework.cloud.openfeign.FeignClient;
import feign.RequestLine;
@FeignClient(name = "ai-backend")
interface AiClient {
@RequestLine("POST /ai/summarize")
String summarize();
@RequestLine("GET /ai/health")
String health();
}
`,
);
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::/ai/summarize' &&
c.meta.framework === 'openfeign' &&
c.confidence === 0.75,
),
).toBeDefined();
expect(
consumers.find(
(c) =>
c.contractId === 'http::GET::/ai/health' &&
c.meta.framework === 'openfeign' &&
c.confidence === 0.75,
),
).toBeDefined();
});
it('joins @FeignClient(path=...) prefix with @RequestLine paths', async () => {
const dir = path.join(tmpDir, 'java-feign-request-line-prefix');
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'src', 'OrderClient.java'),
`
import org.springframework.cloud.openfeign.FeignClient;
import feign.RequestLine;
@FeignClient(name = "order-service", path = "/api")
interface OrderClient {
@RequestLine("GET /orders/{id}")
OrderDto get(Long id);
@RequestLine("DELETE /orders/{id}")
void delete(Long id);
}
`,
);
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/orders/{param}'),
).toBeDefined();
expect(
consumers.find((c) => c.contractId === 'http::DELETE::/api/orders/{param}'),
).toBeDefined();
});
it('strips query strings from @RequestLine values when forming contract IDs', async () => {
const dir = path.join(tmpDir, 'java-feign-request-line-query');
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'src', 'SearchClient.java'),
`
import org.springframework.cloud.openfeign.FeignClient;
import feign.RequestLine;
@FeignClient(name = "search-service")
interface SearchClient {
@RequestLine("GET /search?q={query}&limit={limit}")
SearchResult search();
}
`,
);
const contracts = await extractor.extract(null, dir, makeRepo(dir));
const consumers = contracts.filter((c) => c.role === 'consumer');
// Query string is dropped — contract ID is method+path only.
expect(consumers.find((c) => c.contractId === 'http::GET::/search')).toBeDefined();
expect(
consumers.find((c) => c.contractId.includes('?') || c.contractId.includes('limit')),
).toBeUndefined();
});
it('extracts native @RequestLine on a plain interface without @FeignClient (Feign.builder())', async () => {
// The canonical core-Feign usage: a plain interface with `@RequestLine`,
// wired up via `Feign.builder()`. There is NO `@FeignClient` annotation
// (that is the Spring Cloud variant, which uses Spring MVC annotations and
// is mutually exclusive with `@RequestLine`). This is the shape used by
// real client-jar consumers, so it must be recognized.
const dir = path.join(tmpDir, 'java-request-line-no-feign');
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'src', 'BigModelClient.java'),
`
import feign.Headers;
import feign.RequestLine;
import feign.Response;
public interface BigModelClient {
@RequestLine("POST /ai/summarization")
@Headers("Content-Type: application/json")
Response summarize();
@RequestLine("GET /ai/concurrent")
Response concurrent();
}
`,
);
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::/ai/summarization' &&
c.meta.framework === 'openfeign' &&
c.confidence === 0.75,
),
).toBeDefined();
expect(
consumers.find(
(c) => c.contractId === 'http::GET::/ai/concurrent' && c.meta.framework === 'openfeign',
),
).toBeDefined();
});
it('mixes @RequestLine and @GetMapping methods on the same @FeignClient interface', async () => {
const dir = path.join(tmpDir, 'java-feign-mixed-annotations');
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'src', 'MixedClient.java'),
`
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import feign.RequestLine;
@FeignClient(name = "mixed-service", path = "/api")
interface MixedClient {
@GetMapping("/spring-style")
String springStyle();
@RequestLine("GET /native-style")
String nativeStyle();
}
`,
);
const contracts = await extractor.extract(null, dir, makeRepo(dir));
const consumers = contracts.filter((c) => c.role === 'consumer');
// Both annotation styles produce contracts — they don't conflict.
expect(
consumers.find(
(c) =>
c.contractId === 'http::GET::/api/spring-style' &&
c.meta.framework === 'openfeign' &&
c.confidence === 0.7,
),
).toBeDefined();
expect(
consumers.find(
(c) =>
c.contractId === 'http::GET::/api/native-style' &&
c.meta.framework === 'openfeign' &&
c.confidence === 0.75,
),
).toBeDefined();
});
it('extracts @RequestLine values written with the named "value" argument', async () => {
const dir = path.join(tmpDir, 'java-feign-request-line-named');
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'src', 'NamedArgClient.java'),
`
import org.springframework.cloud.openfeign.FeignClient;
import feign.RequestLine;
@FeignClient(name = "named-arg-service")
interface NamedArgClient {
@RequestLine(value = "POST /create")
String create();
}
`,
);
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::/create' && c.meta.framework === 'openfeign',
),
).toBeDefined();
});
it('ignores @RequestLine whose named argument is not "value"', async () => {
// The consolidated query matches every named annotation argument; the
// scanRouteAnnotations loop drops a @RequestLine whose key is not `value`.
const dir = path.join(tmpDir, 'java-feign-request-line-wrong-key');
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'src', 'WrongKeyClient.java'),
`
import org.springframework.cloud.openfeign.FeignClient;
import feign.RequestLine;
@FeignClient(name = "wrong-key-service")
interface WrongKeyClient {
@RequestLine(name = "GET /should-not-extract")
String shouldNotBeExtracted();
}
`,
);
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::/should-not-extract'),
).toBeUndefined();
});
it('ignores @RequestLine values that are not a "VERB /path" line', async () => {
// `parseRequestLine` only accepts a recognized HTTP verb followed by a
// path starting with `/`. Malformed values (no verb, no leading-slash
// path, or unknown verb) must be dropped — this guards the relaxed
// (no-@FeignClient) matcher from turning arbitrary `@RequestLine` string
// literals into bogus contracts.
const dir = path.join(tmpDir, 'java-request-line-malformed');
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'src', 'MalformedClient.java'),
`
import feign.RequestLine;
interface MalformedClient {
@RequestLine("not a request line at all")
String noVerb();
@RequestLine("GET relative/no/leading/slash")
String noLeadingSlash();
@RequestLine("FETCH /unknown-verb")
String unknownVerb();
}
`,
);
const contracts = await extractor.extract(null, dir, makeRepo(dir));
const consumers = contracts.filter((c) => c.role === 'consumer');
// None of the three malformed values should yield a contract.
expect(
consumers.filter((c) => c.symbolRef.filePath.endsWith('MalformedClient.java')),
).toHaveLength(0);
});
it('ignores @RequestLine on a class method (Feign proxies are interfaces only)', async () => {
// The relaxed matcher still requires an enclosing interface: Feign builds
// its proxy from an interface, so a `@RequestLine` on a concrete class
// method is not a Feign call and must not be emitted as a consumer.
const dir = path.join(tmpDir, 'java-request-line-on-class');
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'src', 'NotAProxy.java'),
`
import feign.RequestLine;
class NotAProxy {
@RequestLine("GET /should-not-extract")
String call() { return null; }
}
`,
);
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::/should-not-extract'),
).toBeUndefined();
});
it('prefers @FeignClient(path=...) over @RequestMapping when @RequestMapping appears first', async () => {
// Reverse-order companion to the precedence test above: @FeignClient(path)
// must win even when @RequestMapping is the first annotation in source,
// exercising the deferred interfaceRequestMappingPrefixes apply.
const dir = path.join(tmpDir, 'java-openfeign-prefix-precedence-reversed');
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'src', 'ReversedPrecedenceClient.java'),
`
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
@RequestMapping("/rm-path")
@FeignClient(name = "order-service", path = "/feign-path")
interface ReversedPrecedenceClient {
@GetMapping("/orders")
OrderDto getOrders();
}
`,
);
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::/feign-path/orders')).toBeDefined();
expect(consumers.find((c) => c.contractId === 'http::GET::/rm-path/orders')).toBeUndefined();
});
it('a @FeignClient API interface implemented by a controller yields both a consumer and a provider (Java)', async () => {
// Java twin of the Kotlin dual-role case: an `api` module publishes a
// @FeignClient contract (consumer) that the service's @RestController
// implements (provider).
const dir = path.join(tmpDir, 'java-feign-api-implemented');
fs.mkdirSync(path.join(dir, 'src/rest'), { recursive: true });
fs.mkdirSync(path.join(dir, 'src/controller'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'src/rest/WarehouseApi.java'),
`
package com.example.rest;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.*;
@FeignClient(name = "catalog-service")
@RequestMapping("/warehouses")
public interface WarehouseApi {
@GetMapping("/{id}/stock")
Object listStock();
}
`,
);
fs.writeFileSync(
path.join(dir, 'src/controller/WarehouseController.java'),
`
package com.example.controller;
import com.example.rest.WarehouseApi;
import org.springframework.web.bind.annotation.*;
@RestController
public class WarehouseController implements WarehouseApi {
@Override
public Object listStock() { return null; }
}
`,
);
const contracts = await extractor.extract(null, dir, makeRepo(dir));
const providers = contracts.filter((c) => c.role === 'provider');
const consumers = contracts.filter((c) => c.role === 'consumer');
expect(
consumers.find(
(c) =>
c.contractId === 'http::GET::/warehouses/{param}/stock' &&
c.meta.framework === 'openfeign',
),
).toBeDefined();
expect(
providers.find(
(c) =>
c.contractId === 'http::GET::/warehouses/{param}/stock' &&
c.symbolRef.filePath.endsWith('WarehouseController.java') &&
c.confidence === 0.8,
),
).toBeDefined();
});
it('extracts Java and Apache HttpClient literal request construction', async () => {
const dir = path.join(tmpDir, 'java-http-client-consumer');
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'src', 'HttpClients.java'),
`
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import org.apache.http.client.methods.HttpGet;
import org.apache.http.client.methods.HttpPost;
import org.apache.http.client.methods.HttpPut;
import org.apache.http.client.methods.HttpDelete;
import org.apache.http.client.methods.HttpPatch;
class HttpClients {
void run(HttpClient client) throws Exception {
HttpRequest get = HttpRequest.newBuilder()
.uri(URI.create("/api/users/1"))
.GET()
.build();
HttpRequest post = HttpRequest.newBuilder()
.uri(URI.create("/api/users"))
.POST(HttpRequest.BodyPublishers.ofString("{}"))
.build();
new HttpGet("/api/orders/2");
new HttpPost("/api/orders");
new HttpPut("/api/orders/3");
new HttpDelete("/api/orders/4");
new HttpPatch("/api/orders/5");
}
}
`,
);
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/users/{param}')).toBeDefined();
expect(
consumers.find(
(c) =>
c.contractId === 'http::POST::/api/users' &&
c.meta.framework === 'java-http-client' &&
c.confidence === 0.65,
),
).toBeDefined();
expect(
consumers.find((c) => c.contractId === 'http::GET::/api/orders/{param}'),
).toBeDefined();
expect(
consumers.find(
(c) =>
c.contractId === 'http::POST::/api/orders' &&
c.meta.framework === 'apache-http-client' &&
c.confidence === 0.65,
),
).toBeDefined();
expect(
consumers.find((c) => c.contractId === 'http::PUT::/api/orders/{param}'),
).toBeDefined();
expect(
consumers.find((c) => c.contractId === 'http::DELETE::/api/orders/{param}'),
).toBeDefined();
expect(
consumers.find((c) => c.contractId === 'http::PATCH::/api/orders/{param}'),
).toBeDefined();
});
it('extracts Java HttpClient HEAD, .method(), and default-GET forms', async () => {
const dir = path.join(tmpDir, 'java-http-client-verbs');
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'src', 'HttpClientVerbs.java'),
`
import java.net.URI;
import java.net.http.HttpRequest;
class HttpClientVerbs {
void run(String verb) throws Exception {
HttpRequest head = HttpRequest.newBuilder().uri(URI.create("/api/users/head")).HEAD().build();
HttpRequest patch = HttpRequest.newBuilder().uri(URI.create("/api/users/2")).method("PATCH", HttpRequest.BodyPublishers.ofString("{}")).build();
HttpRequest def = HttpRequest.newBuilder().uri(URI.create("/api/default-users/3")).build();
HttpRequest dyn = HttpRequest.newBuilder().uri(URI.create("/api/dyn/4")).method(verb, HttpRequest.BodyPublishers.noBody()).build();
}
}
`,
);
const contracts = await extractor.extract(null, dir, makeRepo(dir));
const hc = contracts.filter(
(c) =>
c.role === 'consumer' &&
c.meta.framework === 'java-http-client' &&
c.symbolRef.filePath.endsWith('HttpClientVerbs.java'),
);
// HEAD via verb helper, PATCH via `.method("X")`, default GET via bare
// `.build()`. The variable-bound `.method(verb, …)` is NOT resolved
// (string literal only) → no contract. Exact set-equality also pins that
// no chain double-emits (e.g. HEAD would never also yield a GET).
expect(new Set(hc.map((c) => c.contractId))).toEqual(
new Set([
'http::HEAD::/api/users/head',
'http::PATCH::/api/users/{param}',
'http::GET::/api/default-users/{param}',
]),
);
expect(hc.every((c) => c.confidence === 0.65)).toBe(true);
});
it('extracts Java HttpClient verbs across intervening builder calls (#2268)', async () => {
// The verb-walk is transparent to neutral calls AFTER `.uri(...)`, so a
// `.header()`/`.timeout()` hop before the terminal no longer drops the
// contract. Each verb-producing branch gets a DISTINCT non-numeric path so
// the set-equality assertion cannot mask a branch. A call BEFORE `.uri()`,
// a constructor-arg `newBuilder(uri)`, and a non-literal `.uri()` arg are
// documented misses (must NOT extract).
const dir = path.join(tmpDir, 'java-http-client-intervening');
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'src', 'HttpClientIntervening.java'),
`
import java.net.URI;
import java.net.http.HttpRequest;
import java.net.http.HttpClient;
import java.time.Duration;
class HttpClientIntervening {
void run(URI uriVar, Duration dur, HttpClient.Version ver, HttpRequest.BodyPublisher body) throws Exception {
// Intervening calls AFTER .uri() — header/timeout transparent to the verb-walk.
HttpRequest a = HttpRequest.newBuilder().uri(URI.create("/api/hdr")).header("Accept", "application/json").build();
HttpRequest b = HttpRequest.newBuilder().uri(URI.create("/api/tmo")).timeout(dur).method("PUT", body).build();
HttpRequest c = HttpRequest.newBuilder().uri(URI.create("/api/hdr-verb")).header("X", "y").DELETE().build();
// Verb helper BEFORE an intervening call (walk does not stop at the first non-verb).
HttpRequest d = HttpRequest.newBuilder().uri(URI.create("/api/verb-then-hdr")).POST(body).header("X", "y").build();
// Unbuilt .uri() — no .build(); over-match emits the default GET (mirrors OkHttp).
HttpRequest.Builder e = HttpRequest.newBuilder().uri(URI.create("/api/unbuilt"));
// Call BEFORE .uri() and the constructor-arg form are now captured too (the
// chain roots at HttpRequest.newBuilder); only a non-literal .uri() is a miss.
HttpRequest f = HttpRequest.newBuilder().version(ver).uri(URI.create("/api/pre-uri")).build(); // call before .uri()
HttpRequest g = HttpRequest.newBuilder(URI.create("/api/ctor")).build(); // constructor-arg, no .uri()
HttpRequest h = HttpRequest.newBuilder().uri(uriVar).build(); // non-literal .uri() arg → miss
}
}
`,
);
const contracts = await extractor.extract(null, dir, makeRepo(dir));
const hc = contracts.filter(
(c) =>
c.role === 'consumer' &&
c.meta.framework === 'java-http-client' &&
c.symbolRef.filePath.endsWith('HttpClientIntervening.java'),
);
// The seven resolvable chains (incl. the pre-`.uri()` and constructor-arg
// forms); only the non-literal `.uri(uriVar)` contributes nothing.
expect(new Set(hc.map((c) => c.contractId))).toEqual(
new Set([
'http::GET::/api/hdr',
'http::PUT::/api/tmo',
'http::DELETE::/api/hdr-verb',
'http::POST::/api/verb-then-hdr',
'http::GET::/api/unbuilt',
'http::GET::/api/pre-uri',
'http::GET::/api/ctor',
]),
);
expect(hc.every((c) => c.confidence === 0.65)).toBe(true);
});
it('passes through a non-standard HttpClient .method("VERB") verb (#2268)', async () => {
// A custom verb literal passes through (uppercased), matching the OkHttp
// .method() precedent — the verb-walk does not restrict to known verbs.
const dir = path.join(tmpDir, 'java-http-client-custom-verb');
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'src', 'CustomVerb.java'),
`
import java.net.URI;
import java.net.http.HttpRequest;
class CustomVerb {
void run(HttpRequest.BodyPublisher body) throws Exception {
HttpRequest r = HttpRequest.newBuilder().uri(URI.create("/api/report")).method("REPORT", body).build();
}
}
`,
);
const contracts = await extractor.extract(null, dir, makeRepo(dir));
const hc = contracts.filter(
(c) =>
c.role === 'consumer' &&
c.meta.framework === 'java-http-client' &&
c.symbolRef.filePath.endsWith('CustomVerb.java'),
);
expect(new Set(hc.map((c) => c.contractId))).toEqual(new Set(['http::REPORT::/api/report']));
});
it('handles HttpClient constructor-URI override and rejects non-newBuilder .uri (#2268)', async () => {
// A constructor URI overridden by a later `.uri(...)` emits ONLY the override
// (not both), and a `.uri(URI.create(...))` on a chain that does not root at
// HttpRequest.newBuilder (e.g. WebClient) is NOT a java-http-client consumer.
const dir = path.join(tmpDir, 'java-http-client-ctor-override');
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'src', 'CtorOverride.java'),
`
import java.net.URI;
import java.net.http.HttpRequest;
import org.springframework.web.reactive.function.client.WebClient;
class CtorOverride {
void run(WebClient webClient) {
HttpRequest a = HttpRequest.newBuilder(URI.create("/api/ctor-overridden")).uri(URI.create("/api/override-wins")).build();
webClient.get().uri(URI.create("/api/webclient-not-hc")).retrieve();
}
}
`,
);
const contracts = await extractor.extract(null, dir, makeRepo(dir));
const hc = contracts.filter(
(c) =>
c.role === 'consumer' &&
c.meta.framework === 'java-http-client' &&
c.symbolRef.filePath.endsWith('CtorOverride.java'),
);
// Only the override URI, exactly once; the overridden constructor URI and the
// WebClient `.uri()` are not java-http-client contracts.
expect(new Set(hc.map((c) => c.contractId))).toEqual(
new Set(['http::GET::/api/override-wins']),
);
});
it('resolves the last verb when a chain sets two (runtime last-wins) (#2268)', async () => {
// Each verb-setter overwrites the previous at runtime, so a chain that sets
// two verbs resolves to the one nearest the terminal — not the first found.
const dir = path.join(tmpDir, 'java-http-two-verb');
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'src', 'TwoVerb.java'),
`
import java.net.URI;
import java.net.http.HttpRequest;
import okhttp3.Request;
import okhttp3.RequestBody;
class TwoVerb {
void run(RequestBody body, HttpRequest.BodyPublisher pub) throws Exception {
HttpRequest hc = HttpRequest.newBuilder().GET().uri(URI.create("/api/hc-two")).POST(pub).build();
new Request.Builder().get().url("/api/ok-two").post(body).build();
}
}
`,
);
const contracts = await extractor.extract(null, dir, makeRepo(dir));
const fromFile = contracts.filter(
(c) => c.role === 'consumer' && c.symbolRef.filePath.endsWith('TwoVerb.java'),
);
// Both resolve to the LAST verb (POST), not the first (GET).
expect(new Set(fromFile.map((c) => `${c.meta.framework} ${c.contractId}`))).toEqual(
new Set(['java-http-client http::POST::/api/hc-two', 'okhttp http::POST::/api/ok-two']),
);
});
// ─── Kotlin consumers (RestTemplate / WebClient short+long / OkHttp) ──
// Same shape as the Java consumer test above, but parsed by the
// tree-sitter-kotlin grammar via `KOTLIN_HTTP_PLUGIN`. Four
// consumer flavors covered here: RestTemplate (#1855), WebClient
// short form (#1855), OkHttp (#1855), and WebClient long form
// (`webClient.method(HttpMethod.X).uri(...)`, this PR / #1884) —
// see kotlin.ts file header for the full list.
//
// tree-sitter-kotlin is an optionalDependency. If the binding is
// unavailable, `getPluginForFile` returns undefined for `.kt` and
// we skip the suite (matches the gating on the Provider tests).
const kotlinConsumerAvailable = getPluginForFile('Probe.kt') !== undefined;
const itKotlinConsumer = kotlinConsumerAvailable ? it : it.skip;
itKotlinConsumer('extracts Kotlin RestTemplate verbs', async () => {
const dir = path.join(tmpDir, 'kotlin-rest-template');
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'src', 'ApiClient.kt'),
`package com.example
import org.springframework.web.client.RestTemplate
class ApiClient(private val restTemplate: RestTemplate) {
fun run() {
restTemplate.getForObject("/api/users/1", User::class.java)
restTemplate.getForEntity("/api/users/2", User::class.java)
restTemplate.postForObject("/api/users", body, User::class.java)
restTemplate.postForEntity("/api/users", body, User::class.java)
restTemplate.put("/api/users/3", body)
restTemplate.delete("/api/users/4")
restTemplate.patchForObject("/api/users/5", body, User::class.java)
}
}
`,
);
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/users/{param}')).toBeDefined();
expect(consumers.find((c) => c.contractId === 'http::POST::/api/users')).toBeDefined();
expect(consumers.find((c) => c.contractId === 'http::PUT::/api/users/{param}')).toBeDefined();
expect(
consumers.find((c) => c.contractId === 'http::DELETE::/api/users/{param}'),
).toBeDefined();
expect(
consumers.find((c) => c.contractId === 'http::PATCH::/api/users/{param}'),
).toBeDefined();
// Framework label must be the same `spring-rest-template` used
// by the Java plugin so polyglot repos coalesce on a single key.
const restConsumers = consumers.filter((c) => c.meta.framework === 'spring-rest-template');
expect(restConsumers.length).toBeGreaterThanOrEqual(5);
});
itKotlinConsumer('extracts Kotlin WebClient short-form verbs', async () => {
const dir = path.join(tmpDir, 'kotlin-web-client-short');
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'src', 'OrderClient.kt'),
`package com.example
import org.springframework.web.reactive.function.client.WebClient
import org.springframework.web.reactive.function.client.awaitBody
import org.springframework.web.reactive.function.client.awaitBodilessEntity
class OrderClient(private val webClient: WebClient) {
suspend fun run() {
val r1 = webClient.get().uri("/api/orders/1").retrieve().awaitBody<Order>()
val r2 = webClient.post().uri("/api/orders").retrieve().awaitBody<Order>()
val r3 = webClient.put().uri("/api/orders/2").retrieve().awaitBody<Order>()
val r4 = webClient.delete().uri("/api/orders/3").retrieve().awaitBodilessEntity()
val r5 = webClient.patch().uri("/api/orders/4").retrieve().awaitBody<Order>()
}
}
`,
);
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/orders/{param}'),
).toBeDefined();
expect(consumers.find((c) => c.contractId === 'http::POST::/api/orders')).toBeDefined();
expect(
consumers.find((c) => c.contractId === 'http::PUT::/api/orders/{param}'),
).toBeDefined();
expect(
consumers.find((c) => c.contractId === 'http::DELETE::/api/orders/{param}'),
).toBeDefined();
expect(
consumers.find((c) => c.contractId === 'http::PATCH::/api/orders/{param}'),
).toBeDefined();
const wcConsumers = consumers.filter((c) => c.meta.framework === 'spring-web-client');
expect(wcConsumers.length).toBeGreaterThanOrEqual(5);
});
itKotlinConsumer('extracts Kotlin OkHttp Request.Builder().url(...)', async () => {
const dir = path.join(tmpDir, 'kotlin-okhttp');
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'src', 'OkClient.kt'),
`package com.example
import okhttp3.OkHttpClient
import okhttp3.Request
class OkClient(private val client: OkHttpClient) {
fun fetch() {
val req = Request.Builder().url("/api/items").build()
val resp = client.newCall(req).execute()
}
}
`,
);
const contracts = await extractor.extract(null, dir, makeRepo(dir));
const consumers = contracts.filter((c) => c.role === 'consumer');
const okConsumer = consumers.find((c) => c.contractId === 'http::GET::/api/items');
expect(okConsumer).toBeDefined();
expect(okConsumer!.meta.framework).toBe('okhttp');
});
itKotlinConsumer(
'Kotlin OkHttp .url("/x").post(body) infers POST — verb-walk parity with Java (#2268)',
async () => {
// OkHttp encodes the HTTP verb on a sibling call (`.post(body)` / `.delete()`
// / `.method("X")`), not on `.url(...)`. The Kotlin plugin now WALKS the
// builder chain (`inferKotlinOkHttpMethod`) to recover it — the mirror of
// the Java side's `inferOkHttpMethod`. So `Request.Builder().url("/api/users")`
// `.post(body).build()` emits `http::POST::/api/users` (not GET) on `.kt`,
// identical to `.java` (pinned by the Java↔Kotlin parity harness below).
const dir = path.join(tmpDir, 'kotlin-okhttp-post-chain');
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'src', 'OkPostClient.kt'),
`package com.example
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody
class OkPostClient(private val client: OkHttpClient, private val body: RequestBody) {
fun create() {
val req = Request.Builder().url("/api/users").post(body).build()
client.newCall(req).execute()
}
}
`,
);
const contracts = await extractor.extract(null, dir, makeRepo(dir));
const fromThisFile = contracts.filter(
(c) => c.role === 'consumer' && c.symbolRef.filePath.endsWith('OkPostClient.kt'),
);
// The sibling `.post(body)` is recovered: exactly one POST consumer, no GET.
expect(fromThisFile).toHaveLength(1);
expect(fromThisFile[0].contractId).toBe('http::POST::/api/users');
expect(fromThisFile[0].meta.method).toBe('POST');
expect(fromThisFile.find((c) => c.contractId === 'http::GET::/api/users')).toBeUndefined();
},
);
itKotlinConsumer(
'Kotlin OkHttp verb-walk: helpers, .method("X"), default GET, variable skip (#2268)',
async () => {
// Parity with the Java OkHttp verb cases: a verb helper resolves, a literal
// `.method("X")` resolves, a bare `.build()` defaults to GET, and a
// variable-bound `.method(verb)` is unresolvable → emits nothing.
const dir = path.join(tmpDir, 'kotlin-okhttp-verbs');
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'src', 'OkVerbs.kt'),
`package com.example
import okhttp3.Request
import okhttp3.RequestBody
class OkVerbs(private val body: RequestBody, private val verb: String) {
fun run() {
Request.Builder().url("/api/k-get").build()
Request.Builder().url("/api/k-delete").delete().build()
Request.Builder().url("/api/k-patch").method("PATCH", body).build()
Request.Builder().url("/api/k-named").method(method = "REPORT", body = body).build()
Request.Builder().url("/api/k-dyn").method(verb, body).build()
}
}
`,
);
const contracts = await extractor.extract(null, dir, makeRepo(dir));
const okhttp = contracts.filter(
(c) =>
c.role === 'consumer' &&
c.meta.framework === 'okhttp' &&
c.symbolRef.filePath.endsWith('OkVerbs.kt'),
);
// The variable-bound `.method(verb)` (`/api/k-dyn`) emits nothing; the
// named-argument `.method(method = "REPORT")` resolves its literal verb.
expect(new Set(okhttp.map((c) => c.contractId))).toEqual(
new Set([
'http::GET::/api/k-get',
'http::DELETE::/api/k-delete',
'http::PATCH::/api/k-patch',
'http::REPORT::/api/k-named',
]),
);
},
);
itKotlinConsumer(
'Kotlin OkHttp: builder call before .url(), with anti-overreach (#2268)',
async () => {
// Parity with the Java OkHttp pre-`.url()` support: a builder call BEFORE
// `.url()` is captured (the chain roots at Request.Builder), the verb-walk
// scans the whole chain so a verb before `.url()` resolves, and a `.url(...)`
// on an unrelated object does NOT emit.
const dir = path.join(tmpDir, 'kotlin-okhttp-pre-url');
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'src', 'OkPreUrl.kt'),
`package com.example
import okhttp3.Request
import okhttp3.RequestBody
class OkPreUrl(private val body: RequestBody, private val other: SomeClient) {
fun run() {
Request.Builder().addHeader("A", "b").url("/api/k-pre-url").build()
Request.Builder().post(body).url("/api/k-verb-first").build()
other.url("/api/k-not-okhttp").build()
}
}
`,
);
const contracts = await extractor.extract(null, dir, makeRepo(dir));
const okhttp = contracts.filter(
(c) =>
c.role === 'consumer' &&
c.meta.framework === 'okhttp' &&
c.symbolRef.filePath.endsWith('OkPreUrl.kt'),
);
// header-before-url → GET, verb-before-url → POST; `other.url(...)` emits nothing.
expect(new Set(okhttp.map((c) => c.contractId))).toEqual(
new Set(['http::GET::/api/k-pre-url', 'http::POST::/api/k-verb-first']),
);
},
);
itKotlinConsumer('extracts Kotlin WebClient long form GET', async () => {
const dir = path.join(tmpDir, 'kotlin-web-client-long-get');
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'src', 'LongGetClient.kt'),
`package com.example
import org.springframework.http.HttpMethod
import org.springframework.web.reactive.function.client.WebClient
import org.springframework.web.reactive.function.client.awaitBody
class LongGetClient(private val webClient: WebClient) {
suspend fun run() {
val r = webClient.method(HttpMethod.GET).uri("/api/users").retrieve().awaitBody<User>()
}
}
`,
);
const contracts = await extractor.extract(null, dir, makeRepo(dir));
const consumers = contracts.filter((c) => c.role === 'consumer');
const route = consumers.find((c) => c.contractId === 'http::GET::/api/users');
expect(route).toBeDefined();
expect(route!.meta.framework).toBe('spring-web-client');
});
itKotlinConsumer('extracts Kotlin WebClient long form POST/PUT/DELETE/PATCH', async () => {
const dir = path.join(tmpDir, 'kotlin-web-client-long-verbs');
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'src', 'LongVerbClient.kt'),
`package com.example
import org.springframework.http.HttpMethod
import org.springframework.web.reactive.function.client.WebClient
import org.springframework.web.reactive.function.client.awaitBody
import org.springframework.web.reactive.function.client.awaitBodilessEntity
class LongVerbClient(private val webClient: WebClient) {
suspend fun run() {
webClient.method(HttpMethod.POST).uri("/api/orders").retrieve().awaitBody<Order>()
webClient.method(HttpMethod.PUT).uri("/api/orders/1").retrieve().awaitBody<Order>()
webClient.method(HttpMethod.DELETE).uri("/api/orders/2").retrieve().awaitBodilessEntity()
webClient.method(HttpMethod.PATCH).uri("/api/orders/3").retrieve().awaitBody<Order>()
}
}
`,
);
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/orders/{param}'),
).toBeDefined();
expect(
consumers.find((c) => c.contractId === 'http::DELETE::/api/orders/{param}'),
).toBeDefined();
expect(
consumers.find((c) => c.contractId === 'http::PATCH::/api/orders/{param}'),
).toBeDefined();
// All four should be tagged as `spring-web-client` so polyglot
// repos coalesce on the same framework key as the short form.
// The fixture is fully deterministic — exactly 4 long-form calls,
// no short-form / RestTemplate / OkHttp calls mixed in — so an
// exact count is meaningful (DoD §2.7). If a future change
// accidentally emits a 5th consumer (e.g. duplicate query firing,
// or a regressed receiver constraint matching unrelated calls),
// this assertion catches it.
const wcConsumers = consumers.filter((c) => c.meta.framework === 'spring-web-client');
expect(wcConsumers).toHaveLength(4);
});
itKotlinConsumer(
'short-form query does NOT also fire on Kotlin WebClient long form (no double-emit)',
async () => {
// The long-form query handles `webClient.method(HttpMethod.X).uri(...)`,
// and the short-form query handles `webClient.get().uri(...)`. Both
// queries carry sibling `(navigation_suffix (simple_identifier) @verb)`
// constraints — short form requires the verb name itself
// (`get`/`post`/...), long form requires the literal name
// `method`. The two are disjoint.
//
// This test pins that disjointness: a single `.method(HttpMethod.GET)`
// call must emit ONE consumer, not two (one from each query).
const dir = path.join(tmpDir, 'kotlin-web-client-long-no-double');
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'src', 'NoDoubleClient.kt'),
`package com.example
import org.springframework.http.HttpMethod
import org.springframework.web.reactive.function.client.WebClient
import org.springframework.web.reactive.function.client.awaitBody
class NoDoubleClient(private val webClient: WebClient) {
suspend fun run() {
webClient.method(HttpMethod.GET).uri("/api/single").retrieve().awaitBody<String>()
}
}
`,
);
const contracts = await extractor.extract(null, dir, makeRepo(dir));
const consumers = contracts.filter((c) => c.role === 'consumer');
const fromThisFile = consumers.filter((c) =>
c.symbolRef.filePath.endsWith('NoDoubleClient.kt'),
);
expect(fromThisFile).toHaveLength(1);
expect(fromThisFile[0].contractId).toBe('http::GET::/api/single');
},
);
itKotlinConsumer(
'does NOT match Kotlin WebClient long form with variable-bound verb',
async () => {
// Anti-overreach: source-scan can't follow `val verb = HttpMethod.X`
// back to the literal — that's a graph-aware concern. The long-form
// query requires `(navigation_expression HttpMethod . verb)` as the
// `value_argument` shape, so a bare `simple_identifier` (the
// variable name) fails to match. Pin this so a future relaxation
// of the value_argument shape cannot silently start guessing the
// verb from arbitrary identifiers.
const dir = path.join(tmpDir, 'kotlin-web-client-long-var-verb');
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'src', 'VariableVerbClient.kt'),
`package com.example
import org.springframework.http.HttpMethod
import org.springframework.web.reactive.function.client.WebClient
import org.springframework.web.reactive.function.client.awaitBody
class VariableVerbClient(private val webClient: WebClient) {
suspend fun run() {
val verb = HttpMethod.PATCH
val r = webClient.method(verb).uri("/api/dynamic").retrieve().awaitBody<String>()
}
}
`,
);
const contracts = await extractor.extract(null, dir, makeRepo(dir));
const consumers = contracts.filter((c) => c.role === 'consumer');
const fromThisFile = consumers.filter((c) =>
c.symbolRef.filePath.endsWith('VariableVerbClient.kt'),
);
expect(fromThisFile).toHaveLength(0);
},
);
itKotlinConsumer(
'does NOT pick up unrelated string-literal calls on a non-restTemplate receiver',
async () => {
// Anti-regression: the RestTemplate receiver constraint
// (#eq? @obj "restTemplate") must hold. A field with a
// different conventional name (e.g. `cacheClient`) calling
// `.getForObject("/x", ...)` should NOT produce a route.
const dir = path.join(tmpDir, 'kotlin-rest-template-other-receiver');
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'src', 'CacheClient.kt'),
`package com.example
class CacheClient(private val cacheClient: SomeCache) {
fun run() {
cacheClient.getForObject("/cache/key", String::class.java)
}
}
`,
);
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::/cache/key')).toBeUndefined();
const fromCache = consumers.filter((c) => c.symbolRef.filePath.endsWith('CacheClient.kt'));
expect(fromCache).toHaveLength(0);
},
);
// ─── Kotlin OpenFeign + Spring HTTP Interface consumers ──────────────
// `@FeignClient` interfaces (Spring MVC `@*Mapping` methods) and Spring 6
// declarative HTTP Interfaces (`@(Get|...)Exchange`) are the dominant
// outbound-call patterns in Kotlin+Spring services. In tree-sitter-kotlin
// an `interface` is a `class_declaration`, so without a `@FeignClient`
// gate the `@*Mapping` methods would mis-classify as providers.
itKotlinConsumer(
'extracts Kotlin @FeignClient methods as consumers, not providers',
async () => {
const dir = path.join(tmpDir, 'kotlin-feign-consumer');
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'src', 'InventoryClient.kt'),
`package com.example
import org.springframework.cloud.openfeign.FeignClient
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PostMapping
@FeignClient(name = "inventory-service", configuration = [InventoryFeignClientConfig::class])
interface InventoryClient {
@GetMapping("items/{itemId}", consumes = [MediaType.APPLICATION_JSON_VALUE], produces = [MediaType.APPLICATION_JSON_VALUE])
fun getItem(@PathVariable("itemId") itemId: Int): ItemDto
@PostMapping("items/search", consumes = [MediaType.APPLICATION_JSON_VALUE])
fun getItems(@RequestBody query: ItemFilter): Page<ItemDto>
}
`,
);
const contracts = await extractor.extract(null, dir, makeRepo(dir));
const consumers = contracts.filter((c) => c.role === 'consumer');
const providers = contracts.filter((c) => c.role === 'provider');
expect(
consumers.find(
(c) =>
c.contractId === 'http::GET::/items/{param}' &&
c.meta.framework === 'openfeign' &&
c.confidence === 0.7,
),
).toBeDefined();
expect(consumers.find((c) => c.contractId === 'http::POST::/items/search')).toBeDefined();
// The Feign interface methods must NOT leak into providers.
expect(
providers.find((c) => c.symbolRef.filePath.endsWith('InventoryClient.kt')),
).toBeUndefined();
},
);
itKotlinConsumer('applies @FeignClient(path) and @RequestMapping prefixes', async () => {
// One interface per file — the real-world layout (e.g. InventoryClient.kt).
const dir = path.join(tmpDir, 'kotlin-feign-prefix');
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'src', 'PrecedenceClient.kt'),
`package com.example
import org.springframework.cloud.openfeign.FeignClient
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RequestMapping
@FeignClient(name = "a", path = "/feign-path")
@RequestMapping("/rm-path")
interface PrecedenceClient {
@GetMapping("/orders")
fun getOrders(): Any
}
`,
);
fs.writeFileSync(
path.join(dir, 'src', 'InventoryClient.kt'),
`package com.example
import org.springframework.cloud.openfeign.FeignClient
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RequestMapping
@FeignClient(name = "b")
@RequestMapping(path = "/api")
interface InventoryClient {
@GetMapping("/inventory/{id}")
fun getInventory(id: String): Any
}
`,
);
const contracts = await extractor.extract(null, dir, makeRepo(dir));
const consumers = contracts.filter((c) => c.role === 'consumer');
// @FeignClient(path) wins over @RequestMapping.
expect(consumers.find((c) => c.contractId === 'http::GET::/feign-path/orders')).toBeDefined();
expect(consumers.find((c) => c.contractId === 'http::GET::/rm-path/orders')).toBeUndefined();
// @RequestMapping is the fallback prefix when there is no @FeignClient(path).
expect(
consumers.find((c) => c.contractId === 'http::GET::/api/inventory/{param}'),
).toBeDefined();
});
itKotlinConsumer(
'extracts Kotlin Spring HTTP Interface @(Get|...)Exchange consumers',
async () => {
const dir = path.join(tmpDir, 'kotlin-http-exchange');
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'src', 'InventoryApi.kt'),
`package com.example
import org.springframework.web.service.annotation.GetExchange
import org.springframework.web.service.annotation.PostExchange
import org.springframework.web.service.annotation.PutExchange
import org.springframework.web.service.annotation.PatchExchange
import org.springframework.web.service.annotation.DeleteExchange
interface InventoryApi {
@GetExchange(url = "/items/{itemId}", accept = [MediaType.APPLICATION_JSON_VALUE])
fun obtainItem(@PathVariable itemId: Int): Any
@PostExchange(url = "/items/search")
fun search(): Any
@PutExchange(url = "/items")
fun create(): Any
@PatchExchange(url = "/items/update/{itemId}")
fun update(@PathVariable itemId: Int): Any
@DeleteExchange(url = "/items/{itemId}")
fun remove(@PathVariable itemId: Int): Any
}
`,
);
const contracts = await extractor.extract(null, dir, makeRepo(dir));
const consumers = contracts.filter((c) => c.role === 'consumer');
const providers = contracts.filter((c) => c.role === 'provider');
expect(
consumers.find(
(c) =>
c.contractId === 'http::GET::/items/{param}' &&
c.meta.framework === 'spring-http-interface' &&
c.confidence === 0.75,
),
).toBeDefined();
expect(consumers.find((c) => c.contractId === 'http::POST::/items/search')).toBeDefined();
expect(consumers.find((c) => c.contractId === 'http::PUT::/items')).toBeDefined();
expect(
consumers.find((c) => c.contractId === 'http::PATCH::/items/update/{param}'),
).toBeDefined();
expect(
consumers.find((c) => c.contractId === 'http::DELETE::/items/{param}'),
).toBeDefined();
// Declarative HTTP-interface methods are consumers, never providers.
expect(
providers.find((c) => c.symbolRef.filePath.endsWith('InventoryApi.kt')),
).toBeUndefined();
},
);
itKotlinConsumer(
'applies class-level @HttpExchange(url) prefix and a positional @GetExchange',
async () => {
const dir = path.join(tmpDir, 'kotlin-http-exchange-prefix');
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'src', 'ProductApi.kt'),
`package com.example
import org.springframework.web.service.annotation.HttpExchange
import org.springframework.web.service.annotation.GetExchange
@HttpExchange(url = "/products")
interface ProductApi {
@GetExchange("/{id}")
fun get(@PathVariable id: Int): Any
}
`,
);
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::/products/{param}' &&
c.meta.framework === 'spring-http-interface',
),
).toBeDefined();
},
);
itKotlinConsumer('extracts Kotlin OpenFeign native @RequestLine consumers', async () => {
const dir = path.join(tmpDir, 'kotlin-feign-request-line');
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'src', 'AiClient.kt'),
`package com.example
import org.springframework.cloud.openfeign.FeignClient
import feign.RequestLine
@FeignClient(name = "ai-backend")
interface AiClient {
@RequestLine("POST /ai/summarize")
fun summarize(): String
@RequestLine("GET /ai/health")
fun health(): String
}
`,
);
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::/ai/summarize' &&
c.meta.framework === 'openfeign' &&
c.confidence === 0.75,
),
).toBeDefined();
expect(consumers.find((c) => c.contractId === 'http::GET::/ai/health')).toBeDefined();
});
itKotlinConsumer(
'applies the @RequestMapping interface prefix to @RequestLine consumers (no @FeignClient path) — #2254 P2 parity',
async () => {
// Parity with java.ts, which merges the @RequestMapping prefix into
// feignPrefixByInterfaceId: an interface with @RequestMapping("/orders")
// and a @RequestLine method (no @FeignClient(path)) must apply the prefix.
// Kotlin previously dropped it (PR #2254 tri-review, kotlin.ts:978).
const dir = path.join(tmpDir, 'kotlin-request-line-rm-prefix');
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'src', 'OrderClient.kt'),
`package com.example
import org.springframework.cloud.openfeign.FeignClient
import org.springframework.web.bind.annotation.RequestMapping
import feign.RequestLine
@FeignClient(name = "order-service")
@RequestMapping("/orders")
interface OrderClient {
@RequestLine("GET /{id}")
fun get(id: String): Any
}
`,
);
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::/orders/{param}' &&
c.meta.framework === 'openfeign' &&
c.confidence === 0.75,
),
).toBeDefined();
// The un-prefixed form must NOT be emitted (the prefix was applied).
expect(consumers.find((c) => c.contractId === 'http::GET::/{param}')).toBeUndefined();
},
);
itKotlinConsumer(
'prefers @FeignClient(path) over @RequestMapping for @RequestLine consumers',
async () => {
const dir = path.join(tmpDir, 'kotlin-request-line-feign-path-wins');
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'src', 'OrderClient.kt'),
`package com.example
import org.springframework.cloud.openfeign.FeignClient
import org.springframework.web.bind.annotation.RequestMapping
import feign.RequestLine
@FeignClient(name = "order-service", path = "/feign-path")
@RequestMapping("/rm-path")
interface OrderClient {
@RequestLine("GET /orders/{id}")
fun get(id: String): Any
}
`,
);
const contracts = await extractor.extract(null, dir, makeRepo(dir));
const consumers = contracts.filter((c) => c.role === 'consumer');
// @FeignClient(path) wins over @RequestMapping (parity with the @GetMapping path).
expect(
consumers.find((c) => c.contractId === 'http::GET::/feign-path/orders/{param}'),
).toBeDefined();
expect(
consumers.find((c) => c.contractId === 'http::GET::/rm-path/orders/{param}'),
).toBeUndefined();
},
);
itKotlinConsumer(
'extracts Kotlin @RequestLine written with the named "value" argument (#2254 P2)',
async () => {
const dir = path.join(tmpDir, 'kotlin-request-line-named-value');
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'src', 'AiClient.kt'),
`package com.example
import org.springframework.cloud.openfeign.FeignClient
import feign.RequestLine
@FeignClient(name = "ai-backend")
interface AiClient {
@RequestLine(value = "POST /create")
fun create(): String
}
`,
);
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::/create' &&
c.meta.framework === 'openfeign' &&
c.confidence === 0.75,
),
).toBeDefined();
},
);
itKotlinConsumer(
'ignores Kotlin @RequestLine whose named argument is not "value"',
async () => {
const dir = path.join(tmpDir, 'kotlin-request-line-non-value-key');
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'src', 'AiClient.kt'),
`package com.example
import org.springframework.cloud.openfeign.FeignClient
import feign.RequestLine
@FeignClient(name = "ai-backend")
interface AiClient {
@RequestLine(name = "GET /should-not-extract")
fun nope(): String
}
`,
);
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::/should-not-extract'),
).toBeUndefined();
},
);
itKotlinConsumer(
'strips query strings from Kotlin @RequestLine values when forming contract IDs',
async () => {
const dir = path.join(tmpDir, 'kotlin-request-line-query');
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'src', 'SearchClient.kt'),
`package com.example
import org.springframework.cloud.openfeign.FeignClient
import feign.RequestLine
@FeignClient(name = "search-service")
interface SearchClient {
@RequestLine("GET /search?q={query}&limit={limit}")
fun search(): Any
}
`,
);
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::/search')).toBeDefined();
expect(
consumers.find((c) => c.contractId.includes('?') || c.contractId.includes('limit')),
).toBeUndefined();
},
);
itKotlinConsumer(
'mixes Kotlin @RequestLine and @GetMapping methods on the same @FeignClient interface',
async () => {
const dir = path.join(tmpDir, 'kotlin-feign-mixed-annotations');
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'src', 'MixedClient.kt'),
`package com.example
import org.springframework.cloud.openfeign.FeignClient
import org.springframework.web.bind.annotation.GetMapping
import feign.RequestLine
@FeignClient(name = "mixed-service", path = "/api")
interface MixedClient {
@GetMapping("/spring-style")
fun springStyle(): String
@RequestLine("GET /native-style")
fun nativeStyle(): String
}
`,
);
const contracts = await extractor.extract(null, dir, makeRepo(dir));
const consumers = contracts.filter((c) => c.role === 'consumer');
// @GetMapping → @FeignClient(path) prefix; confidence 0.7.
expect(
consumers.find(
(c) =>
c.contractId === 'http::GET::/api/spring-style' &&
c.meta.framework === 'openfeign' &&
c.confidence === 0.7,
),
).toBeDefined();
// @RequestLine → @FeignClient(path) prefix; confidence 0.75.
expect(
consumers.find(
(c) =>
c.contractId === 'http::GET::/api/native-style' &&
c.meta.framework === 'openfeign' &&
c.confidence === 0.75,
),
).toBeDefined();
},
);
itKotlinConsumer(
'ignores Kotlin @RequestLine values that are not a "VERB /path" line',
async () => {
const dir = path.join(tmpDir, 'kotlin-request-line-malformed');
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'src', 'MalformedClient.kt'),
`package com.example
import feign.RequestLine
interface MalformedClient {
@RequestLine("not a request line at all")
fun noVerb(): String
@RequestLine("GET relative/no/leading/slash")
fun noLeadingSlash(): String
@RequestLine("FETCH /unknown-verb")
fun unknownVerb(): String
}
`,
);
const contracts = await extractor.extract(null, dir, makeRepo(dir));
const consumers = contracts.filter((c) => c.role === 'consumer');
expect(
consumers.filter((c) => c.symbolRef.filePath.endsWith('MalformedClient.kt')),
).toHaveLength(0);
},
);
itKotlinConsumer(
'prefers @FeignClient(path) over @RequestMapping when @RequestMapping appears first (Kotlin)',
async () => {
// Source-order independence twin: @FeignClient(path) wins even when
// @RequestMapping is the first annotation.
const dir = path.join(tmpDir, 'kotlin-feign-prefix-precedence-reversed');
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'src', 'ReversedClient.kt'),
`package com.example
import org.springframework.cloud.openfeign.FeignClient
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RequestMapping
@RequestMapping("/rm-path")
@FeignClient(name = "order-service", path = "/feign-path")
interface ReversedClient {
@GetMapping("/orders")
fun getOrders(): Any
}
`,
);
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::/feign-path/orders'),
).toBeDefined();
expect(
consumers.find((c) => c.contractId === 'http::GET::/rm-path/orders'),
).toBeUndefined();
},
);
itKotlinConsumer(
'classifies a @RestController class as provider and a @FeignClient interface as consumer',
async () => {
const dir = path.join(tmpDir, 'kotlin-controller-vs-feign');
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'src', 'Mixed.kt'),
`package com.example
import org.springframework.web.bind.annotation.RestController
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.cloud.openfeign.FeignClient
@RestController
class OrdersController {
@GetMapping("/orders/{id}")
fun getOrder(@PathVariable id: Int): Any = TODO()
}
@FeignClient(name = "pricing")
interface PricingClient {
@GetMapping("/prices/{id}")
fun getPrice(id: Int): Any
}
`,
);
const contracts = await extractor.extract(null, dir, makeRepo(dir));
const providers = contracts.filter((c) => c.role === 'provider');
const consumers = contracts.filter((c) => c.role === 'consumer');
// Controller method → provider (not a consumer).
expect(
providers.find(
(c) => c.contractId === 'http::GET::/orders/{param}' && c.meta.framework === 'spring',
),
).toBeDefined();
expect(
consumers.find((c) => c.contractId === 'http::GET::/orders/{param}'),
).toBeUndefined();
// Feign interface method → consumer (not a provider).
expect(
consumers.find(
(c) =>
c.contractId === 'http::GET::/prices/{param}' && c.meta.framework === 'openfeign',
),
).toBeDefined();
expect(
providers.find((c) => c.contractId === 'http::GET::/prices/{param}'),
).toBeUndefined();
},
);
itKotlinConsumer(
'emits a provider for a class implementing a route interface (interface-based controller)',
async () => {
// One interface per file (real layout). Routes live on the interface; the
// @RestController override carries none → inherited via scanProject.
const dir = path.join(tmpDir, 'kotlin-interface-based-controller');
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'src', 'WarehouseApi.kt'),
`package com.example
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.GetMapping
@RequestMapping("/warehouses")
interface WarehouseApi {
@GetMapping("/{id}/stock")
fun listStock(id: String): Any
}
`,
);
fs.writeFileSync(
path.join(dir, 'src', 'WarehouseController.kt'),
`package com.example
import org.springframework.web.bind.annotation.RestController
@RestController
class WarehouseController(private val svc: Svc) : WarehouseApi {
override fun listStock(id: String): Any = TODO()
}
`,
);
const contracts = await extractor.extract(null, dir, makeRepo(dir));
const providers = contracts.filter((c) => c.role === 'provider');
// The controller inherits the route declared on WarehouseApi → provider.
expect(
providers.find(
(c) =>
c.contractId === 'http::GET::/warehouses/{param}/stock' &&
c.symbolRef.filePath.endsWith('WarehouseController.kt') &&
c.meta.framework === 'spring' &&
c.confidence === 0.8,
),
).toBeDefined();
},
);
itKotlinConsumer(
'recognises a fully-qualified @org…RestController as a controller (#2254 FQN parity)',
async () => {
// A FQN annotation parses to a user_type with one type_identifier per
// segment; the controller gate must read the trailing segment, not "org".
const dir = path.join(tmpDir, 'kotlin-fqn-controller');
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'src', 'WarehouseApi.kt'),
`package com.example
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.GetMapping
@RequestMapping("/warehouses")
interface WarehouseApi {
@GetMapping("/{id}/stock")
fun listStock(id: String): Any
}
`,
);
fs.writeFileSync(
path.join(dir, 'src', 'WarehouseController.kt'),
`package com.example
@org.springframework.web.bind.annotation.RestController
class WarehouseController(private val svc: Svc) : WarehouseApi {
override fun listStock(id: String): Any = TODO()
}
`,
);
const contracts = await extractor.extract(null, dir, makeRepo(dir));
const providers = contracts.filter((c) => c.role === 'provider');
expect(
providers.find(
(c) =>
c.contractId === 'http::GET::/warehouses/{param}/stock' &&
c.symbolRef.filePath.endsWith('WarehouseController.kt'),
),
).toBeDefined();
},
);
itKotlinConsumer(
'resolves a fully-qualified supertype to its trailing segment for interface inheritance',
async () => {
// `: com.example.WarehouseApi` must resolve to "WarehouseApi" (trailing
// segment), not "com", so the inherited interface route is matched.
const dir = path.join(tmpDir, 'kotlin-fqn-supertype');
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'src', 'WarehouseApi.kt'),
`package com.example
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.GetMapping
@RequestMapping("/warehouses")
interface WarehouseApi {
@GetMapping("/{id}/stock")
fun listStock(id: String): Any
}
`,
);
fs.writeFileSync(
path.join(dir, 'src', 'WarehouseController.kt'),
`package com.example
import org.springframework.web.bind.annotation.RestController
@RestController
class WarehouseController(private val svc: Svc) : com.example.WarehouseApi {
override fun listStock(id: String): Any = TODO()
}
`,
);
const contracts = await extractor.extract(null, dir, makeRepo(dir));
const providers = contracts.filter((c) => c.role === 'provider');
expect(
providers.find(
(c) =>
c.contractId === 'http::GET::/warehouses/{param}/stock' &&
c.symbolRef.filePath.endsWith('WarehouseController.kt'),
),
).toBeDefined();
},
);
itKotlinConsumer(
'a @FeignClient API interface implemented by a controller yields both a consumer and a provider',
async () => {
// catalog-service pattern: an `api` module publishes a @FeignClient contract that
// the service's own @RestController implements. The interface is the
// client SDK (consumer); the implementing controller is the provider.
const dir = path.join(tmpDir, 'kotlin-feign-api-implemented');
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'src', 'WarehouseApi.kt'),
`package com.example
import org.springframework.cloud.openfeign.FeignClient
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.GetMapping
@FeignClient(name = "catalog-service")
@RequestMapping("/warehouses")
interface WarehouseApi {
@GetMapping("/{id}/stock")
fun listStock(id: String): Any
}
`,
);
fs.writeFileSync(
path.join(dir, 'src', 'WarehouseController.kt'),
`package com.example
import org.springframework.web.bind.annotation.RestController
@RestController
class WarehouseController(private val svc: Svc) : WarehouseApi {
override fun listStock(id: String): Any = TODO()
}
`,
);
const contracts = await extractor.extract(null, dir, makeRepo(dir));
const providers = contracts.filter((c) => c.role === 'provider');
const consumers = contracts.filter((c) => c.role === 'consumer');
// Interface (published @FeignClient client SDK) → consumer.
expect(
consumers.find(
(c) =>
c.contractId === 'http::GET::/warehouses/{param}/stock' &&
c.meta.framework === 'openfeign',
),
).toBeDefined();
// Implementing @RestController → provider (route inherited from the interface).
expect(
providers.find(
(c) =>
c.contractId === 'http::GET::/warehouses/{param}/stock' &&
c.symbolRef.filePath.endsWith('WarehouseController.kt'),
),
).toBeDefined();
},
);
itKotlinConsumer(
'handles Kotlin array-form paths (["/x"], value = ["/x"]) for providers and consumers',
async () => {
// Spring path/value attributes are Array<String>; the array literal form
// is common in Kotlin. Each route-bearing annotation must accept both a
// bare string and a single-element array (collection_literal).
const dir = path.join(tmpDir, 'kotlin-array-paths');
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
// Provider: array class prefix + array method path.
fs.writeFileSync(
path.join(dir, 'src', 'ProductsController.kt'),
`package com.example
import org.springframework.web.bind.annotation.RestController
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.GetMapping
@RestController
@RequestMapping(["/api/products"])
class ProductsController {
@GetMapping(value = ["/{id}"])
fun get(id: Int): Any = TODO()
}
`,
);
// OpenFeign consumer: positional array path.
fs.writeFileSync(
path.join(dir, 'src', 'OrdersClient.kt'),
`package com.example
import org.springframework.cloud.openfeign.FeignClient
import org.springframework.web.bind.annotation.PostMapping
@FeignClient(name = "orders")
interface OrdersClient {
@PostMapping(["/orders/search"])
fun search(): Any
}
`,
);
// Spring HTTP Interface consumer: named array url.
fs.writeFileSync(
path.join(dir, 'src', 'PricingApi.kt'),
`package com.example
import org.springframework.web.service.annotation.GetExchange
interface PricingApi {
@GetExchange(url = ["/pricing/{id}"])
fun price(id: Int): Any
}
`,
);
const contracts = await extractor.extract(null, dir, makeRepo(dir));
const providers = contracts.filter((c) => c.role === 'provider');
const consumers = contracts.filter((c) => c.role === 'consumer');
// Array class prefix + array method path → provider.
expect(
providers.find((c) => c.contractId === 'http::GET::/api/products/{param}'),
).toBeDefined();
// @FeignClient positional array path → consumer.
expect(
consumers.find(
(c) =>
c.contractId === 'http::POST::/orders/search' && c.meta.framework === 'openfeign',
),
).toBeDefined();
// @GetExchange(url = [...]) array → consumer.
expect(
consumers.find(
(c) =>
c.contractId === 'http::GET::/pricing/{param}' &&
c.meta.framework === 'spring-http-interface',
),
).toBeDefined();
},
);
itKotlinConsumer(
'handles Kotlin arrayOf("/x") annotation arrays across families (#2254 P3)',
async () => {
// arrayOf(...) is the explicit (older) form of a Kotlin String[] arg,
// distinct from the ["/x"] collection_literal. Each route-bearing
// annotation must accept it, positional and named, in all families.
const dir = path.join(tmpDir, 'kotlin-array-of');
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
// Provider: positional arrayOf class prefix + named arrayOf method path.
fs.writeFileSync(
path.join(dir, 'src', 'ProductsController.kt'),
`package com.example
import org.springframework.web.bind.annotation.RestController
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.GetMapping
@RestController
@RequestMapping(arrayOf("/api/products"))
class ProductsController {
@GetMapping(value = arrayOf("/{id}"))
fun get(id: Int): Any = TODO()
}
`,
);
// OpenFeign consumer: named arrayOf path prefix.
fs.writeFileSync(
path.join(dir, 'src', 'OrdersClient.kt'),
`package com.example
import org.springframework.cloud.openfeign.FeignClient
import org.springframework.web.bind.annotation.PostMapping
@FeignClient(name = "orders", path = arrayOf("/feign"))
interface OrdersClient {
@PostMapping(arrayOf("/orders/search"))
fun search(): Any
}
`,
);
// Spring HTTP Interface consumer: positional arrayOf class prefix + named arrayOf url.
fs.writeFileSync(
path.join(dir, 'src', 'PricingApi.kt'),
`package com.example
import org.springframework.web.service.annotation.HttpExchange
import org.springframework.web.service.annotation.GetExchange
@HttpExchange(arrayOf("/pricing"))
interface PricingApi {
@GetExchange(url = arrayOf("/{id}"))
fun price(id: Int): Any
}
`,
);
const contracts = await extractor.extract(null, dir, makeRepo(dir));
const providers = contracts.filter((c) => c.role === 'provider');
const consumers = contracts.filter((c) => c.role === 'consumer');
// class @RequestMapping(arrayOf) + method @GetMapping(value = arrayOf) → provider.
expect(
providers.find((c) => c.contractId === 'http::GET::/api/products/{param}'),
).toBeDefined();
// @FeignClient(path = arrayOf) + @PostMapping(arrayOf) → consumer (path applied).
expect(
consumers.find(
(c) =>
c.contractId === 'http::POST::/feign/orders/search' &&
c.meta.framework === 'openfeign',
),
).toBeDefined();
// @HttpExchange(arrayOf) + @GetExchange(url = arrayOf) → consumer (prefix applied).
expect(
consumers.find(
(c) =>
c.contractId === 'http::GET::/pricing/{param}' &&
c.meta.framework === 'spring-http-interface',
),
).toBeDefined();
},
);
itKotlinConsumer(
'registers a multi-element arrayOf("/a","/b") under every element (cross-product)',
async () => {
const dir = path.join(tmpDir, 'kotlin-array-of-multi');
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'src', 'MultiController.kt'),
`package com.example
import org.springframework.web.bind.annotation.RestController
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.GetMapping
@RestController
@RequestMapping(arrayOf("/a", "/b"))
class MultiController {
@GetMapping(arrayOf("/x", "/y"))
fun get(): Any = TODO()
}
`,
);
const contracts = await extractor.extract(null, dir, makeRepo(dir));
const providers = contracts.filter((c) => c.role === 'provider');
// 2 prefixes × 2 method paths → 4 contract IDs.
for (const id of [
'http::GET::/a/x',
'http::GET::/a/y',
'http::GET::/b/x',
'http::GET::/b/y',
]) {
expect(providers.find((c) => c.contractId === id)).toBeDefined();
}
},
);
itKotlinConsumer(
'mixes arrayOf and collection-literal arrays without cannibalising either',
async () => {
// The dedicated arrayOf pattern must not drop the sibling ["/x"]
// collection_literal match (the tree-sitter 0.21.x predicate-bucket hazard).
const dir = path.join(tmpDir, 'kotlin-array-of-mixed');
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'src', 'ArrayOfController.kt'),
`package com.example
import org.springframework.web.bind.annotation.RestController
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.GetMapping
@RestController
@RequestMapping(arrayOf("/aof"))
class ArrayOfController {
@GetMapping(arrayOf("/x"))
fun get(): Any = TODO()
}
`,
);
fs.writeFileSync(
path.join(dir, 'src', 'LiteralController.kt'),
`package com.example
import org.springframework.web.bind.annotation.RestController
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.GetMapping
@RestController
@RequestMapping(["/lit"])
class LiteralController {
@GetMapping(["/y"])
fun get(): Any = TODO()
}
`,
);
const contracts = await extractor.extract(null, dir, makeRepo(dir));
const providers = contracts.filter((c) => c.role === 'provider');
expect(providers.find((c) => c.contractId === 'http::GET::/aof/x')).toBeDefined();
expect(providers.find((c) => c.contractId === 'http::GET::/lit/y')).toBeDefined();
},
);
itKotlinConsumer(
'does not treat a non-arrayOf call or a non-route arrayOf key as a route (anti-overreach)',
async () => {
const dir = path.join(tmpDir, 'kotlin-array-of-negative');
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
// buildPath(...) is a call_expression but not arrayOf → no prefix.
// produces = arrayOf(...) is a non-route key → no route.
// arrayOf() is empty → no phantom route.
fs.writeFileSync(
path.join(dir, 'src', 'NegController.kt'),
`package com.example
import org.springframework.web.bind.annotation.RestController
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.GetMapping
@RestController
@RequestMapping(buildPath("/built"))
class NegController {
@GetMapping(produces = arrayOf("application/json"))
fun a(): Any = TODO()
@GetMapping(arrayOf())
fun b(): Any = TODO()
}
`,
);
const contracts = await extractor.extract(null, dir, makeRepo(dir));
const providers = contracts.filter((c) =>
c.symbolRef.filePath.endsWith('NegController.kt'),
);
// No route should be produced from any of the three anti-overreach forms.
expect(providers).toHaveLength(0);
},
);
itKotlinConsumer(
'does not extract @RequestLine on a Kotlin class method (Feign proxies are interfaces only)',
async () => {
// Feign builds its proxy from an interface; a @RequestLine on a concrete
// class is not a client call. Anti-overreach guard, parity with java.ts.
const dir = path.join(tmpDir, 'kotlin-request-line-class');
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'src', 'AiClientImpl.kt'),
`package com.example
import feign.RequestLine
class AiClientImpl {
@RequestLine("GET /should-not-extract")
fun health(): String = TODO()
}
`,
);
const contracts = await extractor.extract(null, dir, makeRepo(dir));
expect(
contracts.find((c) => c.contractId === 'http::GET::/should-not-extract'),
).toBeUndefined();
},
);
itKotlinConsumer(
'extracts Kotlin @RequestLine on a plain interface without @FeignClient (Feign.builder())',
async () => {
// Core-Feign usage: a plain interface with @RequestLine wired via
// Feign.builder() — no @FeignClient. The structural interface check
// (not a @FeignClient gate) admits it, matching the Java plugin.
const dir = path.join(tmpDir, 'kotlin-request-line-plain-interface');
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'src', 'AiClient.kt'),
`package com.example
import feign.RequestLine
interface AiClient {
@RequestLine("POST /ai/summarize")
fun summarize(): String
@RequestLine("GET /ai/health")
fun health(): String
}
`,
);
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::/ai/summarize' &&
c.meta.framework === 'openfeign' &&
c.confidence === 0.75,
),
).toBeDefined();
expect(
consumers.find(
(c) => c.contractId === 'http::GET::/ai/health' && c.meta.framework === 'openfeign',
),
).toBeDefined();
},
);
itKotlinConsumer(
'does not emit a provider for a non-controller class implementing a route interface',
async () => {
// Only a @RestController/@Controller implementer serves the interface's
// routes. A plain service/adapter implementing the same interface must
// NOT emit phantom providers (parity with Java's isController gate).
const dir = path.join(tmpDir, 'kotlin-noncontroller-impl');
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'src', 'WarehouseApi.kt'),
`package com.example
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.GetMapping
@RequestMapping("/warehouses")
interface WarehouseApi {
@GetMapping("/{id}/stock")
fun listStock(id: String): Any
}
`,
);
fs.writeFileSync(
path.join(dir, 'src', 'WarehouseServiceImpl.kt'),
`package com.example
class WarehouseServiceImpl(private val svc: Svc) : WarehouseApi {
override fun listStock(id: String): Any = TODO()
}
`,
);
const contracts = await extractor.extract(null, dir, makeRepo(dir));
const providers = contracts.filter((c) => c.role === 'provider');
expect(
providers.find((c) => c.contractId === 'http::GET::/warehouses/{param}/stock'),
).toBeUndefined();
},
);
itKotlinConsumer(
'detects a controller using the arg-form @RestController("bean")',
async () => {
// The arg-form @RestController("bean") attaches under the class
// `modifiers` as an `annotation` whose child is a `constructor_invocation`
// (NOT a detached sibling). The controller gate reads its trailing name so
// the inherited route is still emitted.
const dir = path.join(tmpDir, 'kotlin-argform-restcontroller');
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'src', 'WarehouseApi.kt'),
`package com.example
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.GetMapping
@RequestMapping("/warehouses")
interface WarehouseApi {
@GetMapping("/{id}/stock")
fun listStock(id: String): Any
}
`,
);
fs.writeFileSync(
path.join(dir, 'src', 'WarehouseController.kt'),
`package com.example
import org.springframework.web.bind.annotation.RestController
@RestController("warehouseController")
class WarehouseController(private val svc: Svc) : WarehouseApi {
override fun listStock(id: String): Any = TODO()
}
`,
);
const contracts = await extractor.extract(null, dir, makeRepo(dir));
const providers = contracts.filter((c) => c.role === 'provider');
expect(
providers.find(
(c) =>
c.contractId === 'http::GET::/warehouses/{param}/stock' &&
c.symbolRef.filePath.endsWith('WarehouseController.kt'),
),
).toBeDefined();
},
);
itKotlinConsumer(
'treats @(Get|...)Exchange as a consumer even on a concrete class (parity with Java)',
async () => {
// @(Get|...)Exchange is definitionally a client (HttpServiceProxyFactory)
// annotation. Like java.ts, the extractor classifies it as a consumer
// regardless of the enclosing type — so even a (mis-)use on a concrete
// class yields a consumer, never a provider. Pins the accepted behavior.
const dir = path.join(tmpDir, 'kotlin-exchange-on-class');
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'src', 'ReportClient.kt'),
`package com.example
import org.springframework.stereotype.Component
import org.springframework.web.service.annotation.GetExchange
@Component
class ReportClient {
@GetExchange("/reports/{id}")
fun report(id: Int): Any = TODO()
}
`,
);
const contracts = await extractor.extract(null, dir, makeRepo(dir));
expect(
contracts.find(
(c) =>
c.role === 'consumer' &&
c.contractId === 'http::GET::/reports/{param}' &&
c.meta.framework === 'spring-http-interface',
),
).toBeDefined();
// Never a provider.
expect(
contracts.find(
(c) => c.role === 'provider' && c.contractId === 'http::GET::/reports/{param}',
),
).toBeUndefined();
},
);
itKotlinConsumer(
'emits one contract per element of a multi-element method-level path array',
async () => {
// Spring registers `@GetMapping(["/a", "/b"])` under BOTH paths, so the
// extractor must emit N contracts (one per array element), not just one.
const dir = path.join(tmpDir, 'kotlin-multi-method-array');
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'src', 'AliasController.kt'),
`package com.example
import org.springframework.web.bind.annotation.RestController
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.GetMapping
@RestController
@RequestMapping("/api")
class AliasController {
@GetMapping(value = ["/primary", "/alias"])
fun get(): Any = TODO()
}
`,
);
const contracts = await extractor.extract(null, dir, makeRepo(dir));
const providers = contracts.filter((c) => c.role === 'provider');
expect(providers.find((c) => c.contractId === 'http::GET::/api/primary')).toBeDefined();
expect(providers.find((c) => c.contractId === 'http::GET::/api/alias')).toBeDefined();
},
);
itKotlinConsumer(
'emits one contract per element of a multi-element class-level prefix array',
async () => {
// Spring registers a method under EVERY class-level prefix, so a
// `@RequestMapping(["/api/v1", "/api/v2"])` controller must yield a
// contract per (prefix × method-path) combination, not just the last prefix.
const dir = path.join(tmpDir, 'kotlin-multi-prefix-array');
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'src', 'VersionedController.kt'),
`package com.example
import org.springframework.web.bind.annotation.RestController
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.GetMapping
@RestController
@RequestMapping(["/base/one", "/base/two"])
class VersionedController {
@GetMapping("/items")
fun get(): Any = TODO()
}
`,
);
const contracts = await extractor.extract(null, dir, makeRepo(dir));
const providers = contracts.filter((c) => c.role === 'provider');
expect(providers.find((c) => c.contractId === 'http::GET::/base/one/items')).toBeDefined();
expect(providers.find((c) => c.contractId === 'http::GET::/base/two/items')).toBeDefined();
},
);
it('emits one contract per element of a multi-element Java path array', async () => {
// Java parity: @GetMapping({"/a", "/b"}) method array and a multi-element
// class-level @RequestMapping must both expand to N contracts.
const dir = path.join(tmpDir, 'java-multi-array');
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'src', 'AliasController.java'),
`package com.example;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.GetMapping;
@RestController
@RequestMapping({"/base/one", "/base/two"})
public class AliasController {
@GetMapping({"/primary", "/alias"})
public Object get() { return null; }
}
`,
);
const contracts = await extractor.extract(null, dir, makeRepo(dir));
const providers = contracts.filter((c) => c.role === 'provider');
for (const id of [
'http::GET::/base/one/primary',
'http::GET::/base/one/alias',
'http::GET::/base/two/primary',
'http::GET::/base/two/alias',
]) {
expect(providers.find((c) => c.contractId === id)).toBeDefined();
}
});
itKotlinConsumer(
'combines a Kotlin controller class prefix with an inherited interface prefix',
async () => {
// Interface-based controller where BOTH the controller and the interface
// carry a class-level @RequestMapping: the inherited route must be prefixed
// by the controller prefix too (parity with java.ts joinInheritedSpringPath).
const dir = path.join(tmpDir, 'kotlin-controller-prefix-inherit');
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'src', 'WidgetApi.kt'),
`package com.example
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.GetMapping
@RequestMapping("/v1")
interface WidgetApi {
@GetMapping("/{id}")
fun fetch(id: String): Any
}
`,
);
fs.writeFileSync(
path.join(dir, 'src', 'WidgetController.kt'),
`package com.example
import org.springframework.web.bind.annotation.RestController
import org.springframework.web.bind.annotation.RequestMapping
@RestController
@RequestMapping("/api")
class WidgetController : WidgetApi {
override fun fetch(id: String): Any = TODO()
}
`,
);
const contracts = await extractor.extract(null, dir, makeRepo(dir));
const providers = contracts.filter((c) => c.role === 'provider');
expect(
providers.find(
(c) =>
c.contractId === 'http::GET::/api/v1/{param}' &&
c.symbolRef.filePath.endsWith('WidgetController.kt'),
),
).toBeDefined();
},
);
itKotlinConsumer(
'does not double a shared prefix when a Kotlin controller repeats the interface prefix',
async () => {
// #2057 parity: controller prefix == interface prefix must not be prepended
// twice (no /shared/shared/...).
const dir = path.join(tmpDir, 'kotlin-controller-prefix-dedup');
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'src', 'LedgerApi.kt'),
`package com.example
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.GetMapping
@RequestMapping("/shared")
interface LedgerApi {
@GetMapping("/entries")
fun entries(): Any
}
`,
);
fs.writeFileSync(
path.join(dir, 'src', 'LedgerController.kt'),
`package com.example
import org.springframework.web.bind.annotation.RestController
import org.springframework.web.bind.annotation.RequestMapping
@RestController
@RequestMapping("/shared")
class LedgerController : LedgerApi {
override fun entries(): Any = TODO()
}
`,
);
const contracts = await extractor.extract(null, dir, makeRepo(dir));
const providers = contracts.filter((c) => c.role === 'provider');
expect(providers.find((c) => c.contractId === 'http::GET::/shared/entries')).toBeDefined();
expect(
providers.find((c) => c.contractId === 'http::GET::/shared/shared/entries'),
).toBeUndefined();
},
);
itKotlinConsumer(
'still combines distinct inherited Kotlin prefixes that share a leading segment',
async () => {
// Twin of the Java 'shared leading segment' case: controller @RequestMapping("/open")
// + interface @RequestMapping("/open/ai") must combine to /open/open/ai/query, NOT
// dedup to /open/ai/query (the dedup only fires on an exact prefix match).
const dir = path.join(tmpDir, 'kotlin-shared-leading-prefix');
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'src', 'DataReleaseApi.kt'),
`package com.example
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.GetMapping
@RequestMapping("/open/ai")
interface DataReleaseApi {
@GetMapping("/query")
fun query(): Any
}
`,
);
fs.writeFileSync(
path.join(dir, 'src', 'DataReleaseController.kt'),
`package com.example
import org.springframework.web.bind.annotation.RestController
import org.springframework.web.bind.annotation.RequestMapping
@RestController
@RequestMapping("/open")
class DataReleaseController : DataReleaseApi {
override fun query(): Any = TODO()
}
`,
);
const contracts = await extractor.extract(null, dir, makeRepo(dir));
const providers = contracts.filter((c) => c.role === 'provider');
expect(
providers.find((c) => c.contractId === 'http::GET::/open/open/ai/query'),
).toBeDefined();
expect(providers.find((c) => c.contractId === 'http::GET::/open/ai/query')).toBeUndefined();
},
);
itKotlinConsumer(
'keeps a Kotlin controller prefix when a prefix-less interface method starts with the same path',
async () => {
// Twin of the Java prefix-overlap case: controller @RequestMapping("/users")
// + interface @GetMapping("/users/{id}") (no interface prefix) →
// /users/users/{param}, not deduped to /users/{param}.
const dir = path.join(tmpDir, 'kotlin-method-prefix-overlap');
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'src', 'UserApi.kt'),
`package com.example
import org.springframework.web.bind.annotation.GetMapping
interface UserApi {
@GetMapping("/users/{id}")
fun getUser(id: String): Any
}
`,
);
fs.writeFileSync(
path.join(dir, 'src', 'UserController.kt'),
`package com.example
import org.springframework.web.bind.annotation.RestController
import org.springframework.web.bind.annotation.RequestMapping
@RestController
@RequestMapping("/users")
class UserController : UserApi {
override fun getUser(id: String): Any = TODO()
}
`,
);
const contracts = await extractor.extract(null, dir, makeRepo(dir));
const providers = contracts.filter((c) => c.role === 'provider');
expect(
providers.find((c) => c.contractId === 'http::GET::/users/users/{param}'),
).toBeDefined();
expect(providers.find((c) => c.contractId === 'http::GET::/users/{param}')).toBeUndefined();
},
);
itKotlinConsumer(
'skips ambiguous inherited Kotlin routes when interfaces share a simple name',
async () => {
// Twin of the Java simple-name-collision case: two distinct interfaces both
// named StatusApi → ambiguous, so the implementing controller emits nothing.
const dir = path.join(tmpDir, 'kotlin-iface-name-collision');
fs.mkdirSync(path.join(dir, 'src', 'a'), { recursive: true });
fs.mkdirSync(path.join(dir, 'src', 'b'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'src', 'a', 'StatusApi.kt'),
`package com.example.a
import org.springframework.web.bind.annotation.GetMapping
interface StatusApi {
@GetMapping("/a/status")
fun getStatus(): Any
}
`,
);
fs.writeFileSync(
path.join(dir, 'src', 'b', 'StatusApi.kt'),
`package com.example.b
import org.springframework.web.bind.annotation.GetMapping
interface StatusApi {
@GetMapping("/b/status")
fun getStatus(): Any
}
`,
);
fs.writeFileSync(
path.join(dir, 'src', 'StatusController.kt'),
`package com.example
import org.springframework.web.bind.annotation.RestController
@RestController
class StatusController : StatusApi {
override fun getStatus(): Any = TODO()
}
`,
);
const contracts = await extractor.extract(null, dir, makeRepo(dir));
const providers = contracts.filter((c) => c.role === 'provider');
expect(providers.find((c) => c.contractId === 'http::GET::/a/status')).toBeUndefined();
expect(providers.find((c) => c.contractId === 'http::GET::/b/status')).toBeUndefined();
expect(
providers.filter((c) => c.symbolRef.filePath.endsWith('StatusController.kt')),
).toHaveLength(0);
},
);
itKotlinConsumer(
'emits routes from every distinctly-named interface a Kotlin controller implements',
async () => {
// Positive multi-interface case (untested in both languages before #2254):
// a controller implementing two route interfaces emits both their routes.
const dir = path.join(tmpDir, 'kotlin-multi-iface');
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'src', 'Apis.kt'),
`package com.example
import org.springframework.web.bind.annotation.GetMapping
interface OrdersApi {
@GetMapping("/orders")
fun orders(): Any
}
interface UsersApi {
@GetMapping("/users")
fun users(): Any
}
`,
);
fs.writeFileSync(
path.join(dir, 'src', 'GatewayController.kt'),
`package com.example
import org.springframework.web.bind.annotation.RestController
@RestController
class GatewayController : OrdersApi, UsersApi {
override fun orders(): Any = TODO()
override fun users(): Any = TODO()
}
`,
);
const contracts = await extractor.extract(null, dir, makeRepo(dir));
const providers = contracts.filter((c) => c.role === 'provider');
expect(providers.find((c) => c.contractId === 'http::GET::/orders')).toBeDefined();
expect(providers.find((c) => c.contractId === 'http::GET::/users')).toBeDefined();
},
);
// ─── Byte-identical Java↔Kotlin contract parity (set-equality harness) ─────
// Independent per-side twin tests can both pass while the emitted contract
// SETS differ (an extra contract on one side, or a confidence/framework
// drift). This harness feeds matched .java/.kt fixtures through both plugins
// and asserts the full projected contract set is equal across languages AND
// equal to the expected set — the only check that actually verifies the
// "byte-identical contract IDs" goal. Per-scenario twins above stay for
// readability and language-specific cases; this covers the parity-critical
// families. Drift in any covered family fails here directly.
describe('Java↔Kotlin contract parity (set-equality)', () => {
interface ParityFile {
name: string;
java: string;
kotlin: string;
}
interface ParityContract {
role: string;
contractId: string;
framework: unknown;
confidence: number;
}
interface ParityRow {
name: string;
files: ParityFile[];
expected: ParityContract[];
}
const sortContracts = (contracts: ParityContract[]): ParityContract[] =>
[...contracts].sort((a, b) =>
`${a.role} ${a.contractId}`.localeCompare(`${b.role} ${b.contractId}`),
);
const projectContracts = (
contracts: Awaited<ReturnType<typeof extractor.extract>>,
): ParityContract[] =>
sortContracts(
contracts.map((c) => ({
role: c.role,
contractId: c.contractId,
framework: c.meta.framework,
confidence: c.confidence,
})),
);
const rows: ParityRow[] = [
{
name: 'OkHttp builder verb inference',
files: [
{
name: 'OkClient',
java: `
import okhttp3.Request;
import okhttp3.RequestBody;
class OkClient {
void run(RequestBody body) {
new Request.Builder().url("/api/things").post(body).build();
}
}
`,
kotlin: `package com.example
import okhttp3.Request
import okhttp3.RequestBody
class OkClient(private val body: RequestBody) {
fun run() {
Request.Builder().url("/api/things").post(body).build()
}
}
`,
},
],
expected: [
{
role: 'consumer',
contractId: 'http::POST::/api/things',
framework: 'okhttp',
confidence: 0.7,
},
],
},
{
name: 'OkHttp verb + builder call before .url()',
files: [
{
name: 'OkPre',
java: `
import okhttp3.Request;
import okhttp3.RequestBody;
class OkPre {
void run(RequestBody body) {
new Request.Builder().post(body).url("/api/pre").build();
}
}
`,
kotlin: `package com.example
import okhttp3.Request
import okhttp3.RequestBody
class OkPre(private val body: RequestBody) {
fun run() {
Request.Builder().post(body).url("/api/pre").build()
}
}
`,
},
],
expected: [
{
role: 'consumer',
contractId: 'http::POST::/api/pre',
framework: 'okhttp',
confidence: 0.7,
},
],
},
{
name: '@RequestLine with @RequestMapping prefix fallback',
files: [
{
name: 'OrderClient',
java: `
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.RequestMapping;
import feign.RequestLine;
@FeignClient(name = "order-service")
@RequestMapping("/orders")
interface OrderClient {
@RequestLine("GET /{id}")
Object get();
}
`,
kotlin: `package com.example
import org.springframework.cloud.openfeign.FeignClient
import org.springframework.web.bind.annotation.RequestMapping
import feign.RequestLine
@FeignClient(name = "order-service")
@RequestMapping("/orders")
interface OrderClient {
@RequestLine("GET /{id}")
fun get(): Any
}
`,
},
],
expected: [
{
role: 'consumer',
contractId: 'http::GET::/orders/{param}',
framework: 'openfeign',
confidence: 0.75,
},
],
},
{
name: 'named @RequestLine(value=...)',
files: [
{
name: 'CreateClient',
java: `
import org.springframework.cloud.openfeign.FeignClient;
import feign.RequestLine;
@FeignClient(name = "create-service")
interface CreateClient {
@RequestLine(value = "POST /create")
Object create();
}
`,
kotlin: `package com.example
import org.springframework.cloud.openfeign.FeignClient
import feign.RequestLine
@FeignClient(name = "create-service")
interface CreateClient {
@RequestLine(value = "POST /create")
fun create(): Any
}
`,
},
],
expected: [
{
role: 'consumer',
contractId: 'http::POST::/create',
framework: 'openfeign',
confidence: 0.75,
},
],
},
{
name: '@FeignClient(path) + @GetMapping',
files: [
{
name: 'UsersClient',
java: `
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
@FeignClient(name = "users-service", path = "/api")
interface UsersClient {
@GetMapping("/users")
Object users();
}
`,
kotlin: `package com.example
import org.springframework.cloud.openfeign.FeignClient
import org.springframework.web.bind.annotation.GetMapping
@FeignClient(name = "users-service", path = "/api")
interface UsersClient {
@GetMapping("/users")
fun users(): Any
}
`,
},
],
expected: [
{
role: 'consumer',
contractId: 'http::GET::/api/users',
framework: 'openfeign',
confidence: 0.7,
},
],
},
{
name: '@HttpExchange(url) prefix + @GetExchange',
files: [
{
name: 'ProductApi',
java: `
import org.springframework.web.service.annotation.HttpExchange;
import org.springframework.web.service.annotation.GetExchange;
@HttpExchange(url = "/products")
interface ProductApi {
@GetExchange("/{id}")
Object get();
}
`,
kotlin: `package com.example
import org.springframework.web.service.annotation.HttpExchange
import org.springframework.web.service.annotation.GetExchange
@HttpExchange(url = "/products")
interface ProductApi {
@GetExchange("/{id}")
fun get(): Any
}
`,
},
],
expected: [
{
role: 'consumer',
contractId: 'http::GET::/products/{param}',
framework: 'spring-http-interface',
confidence: 0.75,
},
],
},
{
name: 'WebClient long-form method(HttpMethod.X).uri(...)',
files: [
{
name: 'LongFormClient',
java: `
import org.springframework.http.HttpMethod;
import org.springframework.web.reactive.function.client.WebClient;
class LongFormClient {
void run(WebClient webClient) {
webClient.method(HttpMethod.GET).uri("/api/items").retrieve();
}
}
`,
kotlin: `package com.example
import org.springframework.http.HttpMethod
import org.springframework.web.reactive.function.client.WebClient
class LongFormClient {
fun run(webClient: WebClient) {
webClient.method(HttpMethod.GET).uri("/api/items").retrieve()
}
}
`,
},
],
expected: [
{
role: 'consumer',
contractId: 'http::GET::/api/items',
framework: 'spring-web-client',
confidence: 0.7,
},
],
},
{
name: 'interface-based controller inheritance',
files: [
{
name: 'WarehouseApi',
java: `
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.GetMapping;
@RequestMapping("/warehouses")
interface WarehouseApi {
@GetMapping("/{id}/stock")
Object listStock();
}
`,
kotlin: `package com.example
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.GetMapping
@RequestMapping("/warehouses")
interface WarehouseApi {
@GetMapping("/{id}/stock")
fun listStock(): Any
}
`,
},
{
name: 'WarehouseController',
java: `
import org.springframework.web.bind.annotation.RestController;
@RestController
class WarehouseController implements WarehouseApi {
@Override
public Object listStock() { return null; }
}
`,
kotlin: `package com.example
import org.springframework.web.bind.annotation.RestController
@RestController
class WarehouseController : WarehouseApi {
override fun listStock(): Any = TODO()
}
`,
},
],
expected: [
{
role: 'provider',
contractId: 'http::GET::/warehouses/{param}/stock',
framework: 'spring',
confidence: 0.8,
},
],
},
];
rows.forEach((row) => {
itKotlinConsumer(`emits identical contracts for ${row.name}`, async () => {
const base = path.join(tmpDir, `parity-${row.name.replace(/[^a-z0-9]+/gi, '-')}`);
const javaDir = path.join(base, 'java');
const kotlinDir = path.join(base, 'kotlin');
fs.mkdirSync(path.join(javaDir, 'src'), { recursive: true });
fs.mkdirSync(path.join(kotlinDir, 'src'), { recursive: true });
for (const file of row.files) {
fs.writeFileSync(path.join(javaDir, 'src', `${file.name}.java`), file.java);
fs.writeFileSync(path.join(kotlinDir, 'src', `${file.name}.kt`), file.kotlin);
}
const javaContracts = projectContracts(
await extractor.extract(null, javaDir, makeRepo(javaDir)),
);
const kotlinContracts = projectContracts(
await extractor.extract(null, kotlinDir, makeRepo(kotlinDir)),
);
const expected = sortContracts(row.expected);
// The two languages emit the same contract set...
expect(kotlinContracts).toEqual(javaContracts);
// ...and it is exactly the expected set (no extra/missing contracts).
expect(javaContracts).toEqual(expected);
});
});
});
it('extracts Go stdlib and resty calls', async () => {
const dir = path.join(tmpDir, 'go-consumer');
fs.mkdirSync(path.join(dir, 'cmd'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'cmd', 'client.go'),
`
package main
import (
"net/http"
"github.com/go-resty/resty/v2"
)
func main() {
http.Get("/api/health")
client := resty.New()
client.R().Delete("/api/orders/42")
}
`,
);
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/health')).toBeDefined();
expect(
consumers.find((c) => c.contractId === 'http::DELETE::/api/orders/{param}'),
).toBeDefined();
});
});
describe('provider extraction — Laravel', () => {
it('extracts Laravel Route::get patterns', async () => {
const dir = path.join(tmpDir, 'laravel');
fs.mkdirSync(path.join(dir, 'routes'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'routes/api.php'),
`<?php
Route::get('/users', [UserController::class, 'index']);
Route::post('/users', [UserController::class, 'store']);
Route::delete('/users/{id}', [UserController::class, 'destroy']);
`,
);
const contracts = await extractor.extract(null, dir, makeRepo(dir));
const providers = contracts.filter((c) => c.role === 'provider');
expect(providers.length).toBeGreaterThanOrEqual(3);
expect(providers.find((c) => c.contractId === 'http::GET::/users')).toBeDefined();
expect(providers.find((c) => c.contractId === 'http::POST::/users')).toBeDefined();
expect(providers.find((c) => c.contractId === 'http::DELETE::/users/{param}')).toBeDefined();
});
});
describe('consumer extraction — PHP', () => {
it('extracts Laravel Http facade calls', async () => {
const dir = path.join(tmpDir, 'php-http-facade');
fs.mkdirSync(path.join(dir, 'app'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'app/Client.php'),
`<?php
use Illuminate\\Support\\Facades\\Http;
class Client {
public function run() {
Http::get('/api/users');
Http::post('/api/orders/42');
Http::delete('/api/users/7');
}
}
`,
);
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/users')).toBeDefined();
expect(
consumers.find((c) => c.contractId === 'http::POST::/api/orders/{param}'),
).toBeDefined();
expect(
consumers.find((c) => c.contractId === 'http::DELETE::/api/users/{param}'),
).toBeDefined();
});
it('extracts Guzzle $client->method() calls', async () => {
const dir = path.join(tmpDir, 'php-guzzle');
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'src/ApiClient.php'),
`<?php
use GuzzleHttp\\Client;
class ApiClient {
public function run(Client $client) {
$client->get('/api/health');
$client->post('/api/orders/42');
}
}
`,
);
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/health')).toBeDefined();
expect(
consumers.find((c) => c.contractId === 'http::POST::/api/orders/{param}'),
).toBeDefined();
});
it('extracts file_get_contents HTTP calls', async () => {
const dir = path.join(tmpDir, 'php-fgc');
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'src/fetch.php'),
`<?php
function fetchRemote() {
$data = file_get_contents('https://example.test/api/items/1');
$local = file_get_contents('/tmp/local-file.txt');
return $data;
}
`,
);
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/items/{param}')).toBeDefined();
// file paths and stream wrappers must not emit consumer contracts
expect(consumers.find((c) => c.meta.path === '/tmp/local-file.txt')).toBeUndefined();
});
});
describe('provider extraction — FastAPI', () => {
it('extracts FastAPI @app.get decorator patterns', async () => {
const dir = path.join(tmpDir, 'fastapi');
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'src/main.py'),
`from fastapi import FastAPI
app = FastAPI()
@app.get("/users")
async def list_users():
return []
@app.post("/users")
async def create_user(user: UserCreate):
return user
`,
);
const contracts = await extractor.extract(null, dir, makeRepo(dir));
const providers = contracts.filter((c) => c.role === 'provider');
expect(providers.length).toBeGreaterThanOrEqual(2);
expect(providers.find((c) => c.contractId === 'http::GET::/users')).toBeDefined();
expect(providers.find((c) => c.contractId === 'http::POST::/users')).toBeDefined();
});
it('joins FastAPI @router.<verb> path with include_router(prefix=...) from main.py (attribute shape)', async () => {
const dir = path.join(tmpDir, 'fastapi-router-attr');
fs.mkdirSync(path.join(dir, 'api'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'main.py'),
`from fastapi import FastAPI
from api import assistant
app = FastAPI()
app.include_router(assistant.router, prefix='/ai', tags=['ai'])
`,
);
fs.writeFileSync(
path.join(dir, 'api/assistant.py'),
`from fastapi import APIRouter
router = APIRouter()
@router.post("/assistant")
async def assistant(req):
return {}
`,
);
const contracts = await extractor.extract(null, dir, makeRepo(dir));
const providers = contracts.filter((c) => c.role === 'provider');
expect(providers.find((c) => c.contractId === 'http::POST::/ai/assistant')).toBeDefined();
// bare unprefixed form should not be emitted when a prefix mapping exists
expect(providers.find((c) => c.contractId === 'http::POST::/assistant')).toBeUndefined();
});
it('joins FastAPI @router.<verb> path with include_router(prefix=...) (named-import shape)', async () => {
const dir = path.join(tmpDir, 'fastapi-router-named');
fs.mkdirSync(path.join(dir, 'api'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'main.py'),
`from fastapi import FastAPI
from api.predict import router as predict_router
app = FastAPI()
app.include_router(predict_router, prefix='/ai')
`,
);
fs.writeFileSync(
path.join(dir, 'api/predict.py'),
`from fastapi import APIRouter
router = APIRouter()
@router.get("/concurrent")
async def concurrent():
return {}
`,
);
const contracts = await extractor.extract(null, dir, makeRepo(dir));
const providers = contracts.filter((c) => c.role === 'provider');
expect(providers.find((c) => c.contractId === 'http::GET::/ai/concurrent')).toBeDefined();
});
it('joins FastAPI @router.<verb> path with APIRouter(prefix=...) in the same file', async () => {
const dir = path.join(tmpDir, 'fastapi-router-constructor-prefix');
fs.mkdirSync(path.join(dir, 'api'), { recursive: true });
fs.writeFileSync(path.join(dir, 'main.py'), `app = None\n`);
fs.writeFileSync(
path.join(dir, 'api/items.py'),
`from fastapi import APIRouter
router = APIRouter(prefix="/api/items", tags=["items"])
@router.get("")
async def list_items():
return []
@router.post("/{item_id}")
async def update_item(item_id: str):
return {}
`,
);
const contracts = await extractor.extract(null, dir, makeRepo(dir));
const providers = contracts.filter((c) => c.role === 'provider');
expect(providers.find((c) => c.contractId === 'http::GET::/api/items')).toBeDefined();
expect(
providers.find((c) => c.contractId === 'http::POST::/api/items/{param}'),
).toBeDefined();
expect(providers.find((c) => c.contractId === 'http::GET::/')).toBeUndefined();
expect(providers.find((c) => c.contractId === 'http::POST::/{param}')).toBeUndefined();
});
it('treats an empty APIRouter(prefix="") as no prefix', async () => {
const dir = path.join(tmpDir, 'fastapi-router-empty-prefix');
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, 'main.py'), `app = None\n`);
fs.writeFileSync(
path.join(dir, 'items.py'),
`from fastapi import APIRouter
router = APIRouter(prefix="")
@router.get("/list")
async def list_items():
return []
`,
);
const contracts = await extractor.extract(null, dir, makeRepo(dir));
const providers = contracts.filter((c) => c.role === 'provider');
// Empty prefix is a clean no-op — the route keeps its bare decorator path.
expect(providers.find((c) => c.contractId === 'http::GET::/list')).toBeDefined();
});
it('does not bleed root APIRouter(prefix=...) onto nested same-stem files', async () => {
const dir = path.join(tmpDir, 'fastapi-router-constructor-prefix-no-bleed');
fs.mkdirSync(path.join(dir, 'admin'), { recursive: true });
fs.writeFileSync(path.join(dir, 'main.py'), `app = None\n`);
fs.writeFileSync(
path.join(dir, 'users.py'),
`from fastapi import APIRouter
router = APIRouter(prefix="/root-users")
@router.get("/landing")
async def root_users_landing():
return {}
`,
);
fs.writeFileSync(
path.join(dir, 'admin/users.py'),
`from fastapi import APIRouter
router = APIRouter()
@router.get("/audit")
async def audit():
return {}
`,
);
const contracts = await extractor.extract(null, dir, makeRepo(dir));
const providers = contracts.filter((c) => c.role === 'provider');
expect(
providers.find((c) => c.contractId === 'http::GET::/root-users/landing'),
).toBeDefined();
expect(providers.find((c) => c.contractId === 'http::GET::/audit')).toBeDefined();
expect(
providers.find((c) => c.contractId === 'http::GET::/root-users/audit'),
).toBeUndefined();
});
it('stacks FastAPI APIRouter(prefix=...) with include_router(prefix=...)', async () => {
const dir = path.join(tmpDir, 'fastapi-router-stacked-prefix');
fs.mkdirSync(path.join(dir, 'api'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'main.py'),
`from fastapi import FastAPI
from api import items
app = FastAPI()
app.include_router(items.router, prefix="/v1")
`,
);
fs.writeFileSync(
path.join(dir, 'api/items.py'),
`from fastapi import APIRouter
router = APIRouter(prefix="/items")
@router.get("/{item_id}")
async def get_item(item_id: str):
return {}
`,
);
const contracts = await extractor.extract(null, dir, makeRepo(dir));
const providers = contracts.filter((c) => c.role === 'provider');
expect(providers.find((c) => c.contractId === 'http::GET::/v1/items/{param}')).toBeDefined();
expect(providers.find((c) => c.contractId === 'http::GET::/items/{param}')).toBeUndefined();
});
it('emits @router.<verb> path unmodified when no include_router prefix is configured', async () => {
const dir = path.join(tmpDir, 'fastapi-router-no-prefix');
fs.mkdirSync(path.join(dir, 'api'), { recursive: true });
fs.writeFileSync(path.join(dir, 'main.py'), `app = None\n`);
fs.writeFileSync(
path.join(dir, 'api/loose.py'),
`from fastapi import APIRouter
router = APIRouter()
@router.get("/standalone")
async def standalone():
return {}
`,
);
const contracts = await extractor.extract(null, dir, makeRepo(dir));
const providers = contracts.filter((c) => c.role === 'provider');
expect(providers.find((c) => c.contractId === 'http::GET::/standalone')).toBeDefined();
});
});
describe('consumer extraction — graph-first (Strategy A)', () => {
it('extracts consumers from FETCHES graph edges, resolved to the containing fn', async () => {
const dir = path.join(tmpDir, 'graph-consumers');
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
// A real fetch so the plugin produces a consumer detection with a line;
// the graph path then resolves it to the CONTAINING function by line-span.
fs.writeFileSync(
path.join(dir, 'src/api.ts'),
`export async function fetchUsers() {
const r = await fetch('/api/users');
return r.json();
}
`,
);
const mockDbExecutor = async (query: string) => {
if (query.includes('HANDLES_ROUTE')) return [];
if (query.includes('FETCHES')) {
return [
{
fileId: 'file-uid-api',
filePath: 'src/api.ts',
routePath: '/api/users',
routeId: 'route-uid-users',
fetchReason: 'fetch-url-match',
},
];
}
if (query.includes('UNION ALL') && String(query).includes('filePath')) {
return [
{
uid: 'uid-fn-fetch',
name: 'fetchUsers',
filePath: 'src/api.ts',
startLine: 1,
endLine: 4,
labels: ['Function'],
},
];
}
return [];
};
const contracts = await extractor.extract(mockDbExecutor, dir, makeRepo(dir));
const consumers = contracts.filter((c) => c.role === 'consumer');
expect(consumers.length).toBeGreaterThanOrEqual(1);
expect(consumers[0].confidence).toBe(0.9);
expect(consumers[0].symbolName).toBe('fetchUsers');
expect(consumers[0].symbolUid).toBe('uid-fn-fetch');
});
it('supplements graph consumers with source-scan consumers from other files', async () => {
const dir = path.join(tmpDir, 'graph-source-consumer-union');
fs.mkdirSync(path.join(dir, 'src/api'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'src/api/graph.ts'),
`export async function fetchUsers() {
const r = await fetch('/api/users');
return r.json();
}
`,
);
fs.writeFileSync(
path.join(dir, 'src/api/health.ts'),
`
export async function fetchHealth() {
const res = await fetch('/api/health');
return res.json();
}
`,
);
const mockDbExecutor = async (query: string, params?: Record<string, unknown>) => {
if (query.includes('HANDLES_ROUTE')) return [];
if (query.includes('FETCHES')) {
return [
{
fileId: 'file-uid-api',
filePath: 'src/api/graph.ts',
routePath: '/api/users',
routeId: 'route-uid-users',
fetchReason: 'fetch-url-match',
},
];
}
if (query.includes('UNION ALL')) {
const fp = String(params?.filePath ?? '');
const row = fp.includes('graph.ts')
? { uid: 'uid-fn-fetch', name: 'fetchUsers', filePath: 'src/api/graph.ts' }
: fp.includes('health.ts')
? { uid: 'uid-fn-health', name: 'fetchHealth', filePath: 'src/api/health.ts' }
: null;
return row ? [{ ...row, startLine: 1, endLine: 4, labels: ['Function'] }] : [];
}
return [];
};
const contracts = await extractor.extract(mockDbExecutor, dir, makeRepo(dir));
const consumers = contracts.filter((c) => c.role === 'consumer');
const graphConsumer = consumers.find((c) => c.contractId === 'http::GET::/api/users');
expect(graphConsumer).toBeDefined();
expect(graphConsumer?.symbolUid).toBe('uid-fn-fetch');
expect(graphConsumer?.meta.extractionStrategy).toBe('graph_assisted');
const sourceConsumer = consumers.find((c) => c.contractId === 'http::GET::/api/health');
expect(sourceConsumer).toBeDefined();
expect(sourceConsumer?.meta.extractionStrategy).toBe('source_scan_resolved');
});
});
describe('edge cases', () => {
it('returns empty for repo with no matching files', async () => {
const dir = path.join(tmpDir, 'empty-repo');
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, 'README.md'), '# Hello');
const contracts = await extractor.extract(null, dir, makeRepo(dir));
expect(contracts).toHaveLength(0);
});
it('handles graph queries that throw gracefully', async () => {
const dir = path.join(tmpDir, 'graph-error');
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
fs.writeFileSync(path.join(dir, 'src/routes.ts'), `router.get('/api/health', handler);`);
const throwingExecutor = async () => {
throw new Error('DB unavailable');
};
const contracts = await extractor.extract(throwingExecutor, dir, makeRepo(dir));
// Should fall back to source scan
const providers = contracts.filter((c) => c.role === 'provider');
expect(providers.length).toBeGreaterThanOrEqual(1);
});
});
describe('path normalization', () => {
it('strips trailing slash', async () => {
const dir = path.join(tmpDir, 'trailing');
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'src/router.ts'),
`
router.get('/api/users/', handler);
`,
);
const contracts = await extractor.extract(null, dir, makeRepo(dir));
const provider = contracts.find((c) => c.role === 'provider');
expect(provider?.meta.path).toBe('/api/users');
});
it('normalizes path params from multiple syntaxes', async () => {
const dir = path.join(tmpDir, 'params');
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'src/router.ts'),
`
router.get('/api/users/:id', handler1);
router.get('/api/posts/{postId}', handler2);
`,
);
const contracts = await extractor.extract(null, dir, makeRepo(dir));
contracts.forEach((c) => {
expect(c.meta.path).not.toContain(':id');
expect(c.meta.path).not.toContain('{postId}');
if (typeof c.meta.path === 'string' && c.meta.path.includes('users/')) {
expect(c.meta.path).toContain('{param}');
}
});
});
});
// ─── #1185: contract extractors must honour .gitnexusignore ─────────
//
// Pre-#1185 the source-scan path used a hardcoded
// `[node_modules, .git, dist, build, vendor]` glob ignore array, so a
// user's `.gitnexusignore` pattern (e.g. a Python venv `mentor_env/`,
// a generated stubs dir, a noisy fixture tree) was silently scanned
// anyway. Since #1185 the source-scan path consumes the shared
// `IgnoreService` (mirrors `filesystem-walker.ts`), so any pattern in
// `.gitnexusignore` (or `.gitignore`) prunes the glob.
describe('respects .gitnexusignore (#1185)', () => {
it('source-scan glob skips files matched by .gitnexusignore', async () => {
const dir = path.join(tmpDir, 'gitnexusignore-honoured');
fs.mkdirSync(path.join(dir, 'src/routes'), { recursive: true });
fs.mkdirSync(path.join(dir, 'mentor_env/lib'), { recursive: true });
// Control: a normal route file that SHOULD be discovered.
fs.writeFileSync(
path.join(dir, 'src/routes/users.ts'),
`import { Router } from 'express';
const router = Router();
router.get('/api/users', (req, res) => res.json([]));
export default router;
`,
);
// Vendored source under a venv-style dir: the same Express
// pattern, but inside a directory the user wants excluded.
fs.writeFileSync(
path.join(dir, 'mentor_env/lib/leaked.ts'),
`import { Router } from 'express';
const r = Router();
r.get('/api/leaked', (req, res) => res.json([]));
export default r;
`,
);
fs.writeFileSync(path.join(dir, '.gitnexusignore'), 'mentor_env/\n');
const contracts = await extractor.extract(null, dir, makeRepo(dir));
const providers = contracts.filter((c) => c.role === 'provider');
// Control survives.
expect(providers.find((c) => c.contractId === 'http::GET::/api/users')).toBeDefined();
// Excluded path is pruned at the glob level — nothing emitted.
expect(providers.find((c) => c.contractId === 'http::GET::/api/leaked')).toBeUndefined();
// Defence-in-depth: no contract whose symbolRef is under mentor_env/.
expect(contracts.some((c) => c.symbolRef?.filePath?.startsWith('mentor_env/'))).toBe(false);
});
// Pinned by the @claude review on PR #1247: above, only `.gitnexusignore`
// is exercised. `createIgnoreFilter` reads `.gitignore` too via
// `loadIgnoreRules`, but that integration is only proven at the
// `IgnoreService` level — no extractor-level test for the
// `.gitignore`-only code path. Adding one minimal extractor-level
// assertion here closes the gap (one shared test is sufficient
// because all three extractors consume the same filter object).
it('source-scan glob also skips files matched by `.gitignore` (no `.gitnexusignore`)', async () => {
const dir = path.join(tmpDir, 'gitignore-honoured');
fs.mkdirSync(path.join(dir, 'src/routes'), { recursive: true });
fs.mkdirSync(path.join(dir, 'mentor_env/lib'), { recursive: true });
// Same Express pattern as above so detection logic is identical.
fs.writeFileSync(
path.join(dir, 'src/routes/users.ts'),
`import { Router } from 'express';
const router = Router();
router.get('/api/users', (req, res) => res.json([]));
export default router;
`,
);
fs.writeFileSync(
path.join(dir, 'mentor_env/lib/leaked.ts'),
`import { Router } from 'express';
const r = Router();
r.get('/api/leaked', (req, res) => res.json([]));
export default r;
`,
);
// Note: NO .gitnexusignore — only `.gitignore`. This proves the
// `.gitignore` code path inside `createIgnoreFilter` is wired to
// the extractors' globs.
fs.writeFileSync(path.join(dir, '.gitignore'), 'mentor_env/\n');
const contracts = await extractor.extract(null, dir, makeRepo(dir));
const providers = contracts.filter((c) => c.role === 'provider');
expect(providers.find((c) => c.contractId === 'http::GET::/api/users')).toBeDefined();
expect(providers.find((c) => c.contractId === 'http::GET::/api/leaked')).toBeUndefined();
expect(contracts.some((c) => c.symbolRef?.filePath?.startsWith('mentor_env/'))).toBe(false);
});
});
describe('Windows SIGSEGV regression — large input must route through parseSourceSafe', () => {
it('routes >32 767-char source file through parseSourceSafe (not direct parser.parse)', async () => {
parseSourceSafeSpy.mockClear();
// >40 000-char Java controller file. Direct parser.parse(content) on
// an input this size SIGSEGVs the process on Windows. The spy assertion
// is what catches the regression — a "no throw" assertion alone is
// satisfied by the bypass on Linux/macOS where parser.parse(40 000 chars)
// succeeds.
const padding = Array.from(
{ length: 600 },
(_, i) => ` public String helper${i}() { return "padding-${i}-aaaaaaaaaaaaaaaaaaa"; }\n`,
).join('');
const largeJava = `package com.example;\n\n@RestController\npublic class BigController {\n${padding}}\n`;
expect(largeJava.length).toBeGreaterThan(40_000);
// Use mkdtempSync rather than a fixed subdir name: satisfies CodeQL's
// js/insecure-temporary-file rule by generating a unique random suffix
// instead of relying on the parent tmpDir's predictable Date.now() name.
const dir = fs.mkdtempSync(path.join(tmpDir, 'large-input-'));
fs.mkdirSync(path.join(dir, 'src/controller'), { recursive: true });
fs.writeFileSync(path.join(dir, 'src/controller/BigController.java'), largeJava);
const mockDbExecutor = async (_query: string) => [];
await extractor.extract(mockDbExecutor, dir, makeRepo(dir));
expect(parseSourceSafeSpy).toHaveBeenCalled();
});
});
});