mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-11 22:53:04 +00:00
* 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>
1040 lines
36 KiB
TypeScript
1040 lines
36 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
import Parser from 'tree-sitter';
|
|
import JavaScript from 'tree-sitter-javascript';
|
|
import TypeScript from 'tree-sitter-typescript';
|
|
import {
|
|
TYPESCRIPT_HTTP_PLUGIN,
|
|
JAVASCRIPT_HTTP_PLUGIN,
|
|
} from '../../../src/core/group/extractors/http-patterns/node.js';
|
|
import { resolveJsImport } from '../../../src/core/ingestion/route-extractors/js-const-resolver.js';
|
|
import type { HttpDetection } from '../../../src/core/group/extractors/http-patterns/types.js';
|
|
|
|
const tsParser = new Parser();
|
|
tsParser.setLanguage(TypeScript.typescript);
|
|
|
|
// Compiled tree-sitter queries are grammar-bound, so a plugin must be driven
|
|
// with a tree parsed by ITS grammar.
|
|
const jsParser = new Parser();
|
|
jsParser.setLanguage(JavaScript);
|
|
|
|
/**
|
|
* Drive the plugin the way the orchestrator does: a `prepareRepo` pre-pass over
|
|
* a virtual repo, then a per-file `scan` with the resulting context.
|
|
*/
|
|
function scanRepo(files: Record<string, string>, target: string): HttpDetection[] {
|
|
const paths = Object.keys(files);
|
|
const repoContext = TYPESCRIPT_HTTP_PLUGIN.prepareRepo?.({
|
|
repoPath: '/repo',
|
|
files: paths,
|
|
parser: tsParser,
|
|
readFile: (rel) => files[rel] ?? null,
|
|
parseSource: (parser, src) => parser.parse(src),
|
|
});
|
|
return TYPESCRIPT_HTTP_PLUGIN.scan(tsParser.parse(files[target]), repoContext, target);
|
|
}
|
|
|
|
const consumers = (detections: HttpDetection[]) => detections.filter((d) => d.role === 'consumer');
|
|
|
|
/** `scanRepo`, but the pre-pass may fail on chosen files. */
|
|
function scanRepoWithParse(
|
|
files: Record<string, string>,
|
|
target: string,
|
|
parseSource: (parser: Parser, src: string) => Parser.Tree | null,
|
|
): HttpDetection[] {
|
|
const repoContext = TYPESCRIPT_HTTP_PLUGIN.prepareRepo?.({
|
|
repoPath: '/repo',
|
|
files: Object.keys(files),
|
|
parser: tsParser,
|
|
readFile: (rel) => files[rel] ?? null,
|
|
parseSource,
|
|
});
|
|
return TYPESCRIPT_HTTP_PLUGIN.scan(tsParser.parse(files[target]), repoContext, target);
|
|
}
|
|
|
|
// The shape the finding was reported against: a configured client in one file,
|
|
// a frozen route table in another, and call sites that reference both by name.
|
|
const AXIOS_CONFIG = `
|
|
import axios from 'axios';
|
|
const axiosInstance = axios.create({ baseURL: process.env.API_URL });
|
|
const routeApiClient = axiosInstance;
|
|
export default routeApiClient;
|
|
`;
|
|
|
|
const API_ROUTES = `
|
|
export const API_ROUTE_PATH = {
|
|
LINKS: "/links",
|
|
EVENTS: "/events",
|
|
CURATOR_LISTS: "/curator-lists",
|
|
} as const;
|
|
`;
|
|
|
|
describe('JS/TS HTTP consumer resolution', () => {
|
|
it('resolves a configured client and a table path imported from other files', () => {
|
|
const detections = scanRepo(
|
|
{
|
|
'src/lib/axios.config.ts': AXIOS_CONFIG,
|
|
'src/api-modules/shared/api-routes.ts': API_ROUTES,
|
|
'src/api-modules/curators/curators.api.ts': `
|
|
import routeApiClient from '@/lib/axios.config';
|
|
import { API_ROUTE_PATH } from '@/api-modules/shared/api-routes';
|
|
export async function getLists() {
|
|
return routeApiClient.get(API_ROUTE_PATH.CURATOR_LISTS, {});
|
|
}
|
|
`,
|
|
},
|
|
'src/api-modules/curators/curators.api.ts',
|
|
);
|
|
|
|
expect(consumers(detections)).toContainEqual(
|
|
expect.objectContaining({ role: 'consumer', method: 'GET', path: '/curator-lists' }),
|
|
);
|
|
});
|
|
|
|
it('resolves relative imports for the client and the route table', () => {
|
|
const detections = scanRepo(
|
|
{
|
|
'src/lib/axios.config.ts': AXIOS_CONFIG,
|
|
'src/api/routes.ts': API_ROUTES,
|
|
'src/api/links.api.ts': `
|
|
import client from '../lib/axios.config';
|
|
import { API_ROUTE_PATH } from './routes';
|
|
export const load = () => client.post(API_ROUTE_PATH.LINKS);
|
|
`,
|
|
},
|
|
'src/api/links.api.ts',
|
|
);
|
|
|
|
expect(consumers(detections)).toContainEqual(
|
|
expect.objectContaining({ method: 'POST', path: '/links' }),
|
|
);
|
|
});
|
|
|
|
it('folds a template partially, keeping the resolved prefix', () => {
|
|
const detections = scanRepo(
|
|
{
|
|
'src/lib/axios.config.ts': AXIOS_CONFIG,
|
|
'src/api/routes.ts': API_ROUTES,
|
|
'src/api/curators.api.ts': `
|
|
import client from '../lib/axios.config';
|
|
import { API_ROUTE_PATH } from './routes';
|
|
export const add = (eventId: string) =>
|
|
client.post(\`\${API_ROUTE_PATH.CURATOR_LISTS}/\${eventId}/add-to-list\`);
|
|
`,
|
|
},
|
|
'src/api/curators.api.ts',
|
|
);
|
|
|
|
// The unresolvable `${eventId}` stays a placeholder for consumer-side
|
|
// normalization to read as {param}; the known prefix is no longer lost.
|
|
expect(consumers(detections)).toContainEqual(
|
|
expect.objectContaining({ path: '/curator-lists/${eventId}/add-to-list' }),
|
|
);
|
|
});
|
|
|
|
it('resolves a `+` concatenation against an imported base constant', () => {
|
|
const detections = scanRepo(
|
|
{
|
|
'src/lib/axios.config.ts': AXIOS_CONFIG,
|
|
'src/api/base.ts': `export const BASE = "/api/v1";`,
|
|
'src/api/users.api.ts': `
|
|
import client from '../lib/axios.config';
|
|
import { BASE } from './base';
|
|
export const list = () => client.get(BASE + "/users");
|
|
`,
|
|
},
|
|
'src/api/users.api.ts',
|
|
);
|
|
|
|
expect(consumers(detections)).toContainEqual(
|
|
expect.objectContaining({ method: 'GET', path: '/api/v1/users' }),
|
|
);
|
|
});
|
|
|
|
it('follows a barrel re-export to the defining module', () => {
|
|
const detections = scanRepo(
|
|
{
|
|
'src/lib/axios.config.ts': AXIOS_CONFIG,
|
|
'src/api/routes.ts': API_ROUTES,
|
|
'src/api/index.ts': `export { API_ROUTE_PATH } from './routes';`,
|
|
'src/api/events.api.ts': `
|
|
import client from '../lib/axios.config';
|
|
import { API_ROUTE_PATH } from './index';
|
|
export const list = () => client.get(API_ROUTE_PATH.EVENTS);
|
|
`,
|
|
},
|
|
'src/api/events.api.ts',
|
|
);
|
|
|
|
expect(consumers(detections)).toContainEqual(
|
|
expect.objectContaining({ method: 'GET', path: '/events' }),
|
|
);
|
|
});
|
|
|
|
// ─── Shapes real applications actually ship ────────────────────────
|
|
|
|
it('proves a client built by a factory wrapper, not just a bare axios.create', () => {
|
|
const detections = scanRepo(
|
|
{
|
|
// The shape Sourcerer-fe ships: the instance is an argument to a
|
|
// decorator that returns the configured client.
|
|
'src/lib/axios.config.ts': `
|
|
import axios from 'axios';
|
|
const routeApiClient = setupClientInterceptors({
|
|
axiosInstance: axios.create({ baseURL: API_URL }),
|
|
onError: (e) => e,
|
|
});
|
|
export default routeApiClient;
|
|
`,
|
|
'src/api/routes.ts': API_ROUTES,
|
|
'src/api/links.api.ts': `
|
|
import routeApiClient from '@/lib/axios.config';
|
|
import { API_ROUTE_PATH } from '@/api/routes';
|
|
export const load = () => routeApiClient.get(API_ROUTE_PATH.LINKS);
|
|
`,
|
|
},
|
|
'src/api/links.api.ts',
|
|
);
|
|
|
|
expect(consumers(detections)).toContainEqual(
|
|
expect.objectContaining({ method: 'GET', path: '/links' }),
|
|
);
|
|
});
|
|
|
|
it('follows `export *` through a directory barrel', () => {
|
|
const detections = scanRepo(
|
|
{
|
|
'src/lib/axios.config.ts': AXIOS_CONFIG,
|
|
'src/api-modules/shared/api-routes.ts': API_ROUTES,
|
|
'src/api-modules/shared/index.ts': `
|
|
export * from "./api-routes";
|
|
export * from "./query-keys";
|
|
`,
|
|
'src/api-modules/shared/query-keys.ts': `export const QUERY_KEYS = { A: "a" };`,
|
|
'src/api-modules/curators/curators.api.ts': `
|
|
import client from '@/lib/axios.config';
|
|
import { API_ROUTE_PATH } from '@/api-modules/shared';
|
|
export const get = () => client.get(API_ROUTE_PATH.CURATOR_LISTS);
|
|
`,
|
|
},
|
|
'src/api-modules/curators/curators.api.ts',
|
|
);
|
|
|
|
expect(consumers(detections)).toContainEqual(
|
|
expect.objectContaining({ method: 'GET', path: '/curator-lists' }),
|
|
);
|
|
});
|
|
|
|
it('recognizes an aliased axios import', () => {
|
|
const detections = scanRepo(
|
|
{
|
|
'src/lib/client.ts': `
|
|
import ax from 'axios';
|
|
export default ax.create({ baseURL: '/' });
|
|
`,
|
|
'src/api/routes.ts': API_ROUTES,
|
|
'src/api/events.api.ts': `
|
|
import client from '../lib/client';
|
|
import { API_ROUTE_PATH } from './routes';
|
|
export const list = () => client.get(API_ROUTE_PATH.EVENTS);
|
|
`,
|
|
},
|
|
'src/api/events.api.ts',
|
|
);
|
|
|
|
expect(consumers(detections)).toContainEqual(
|
|
expect.objectContaining({ method: 'GET', path: '/events' }),
|
|
);
|
|
});
|
|
|
|
it('keeps literal segments of a template nested inside a substitution', () => {
|
|
const detections = scanRepo(
|
|
{
|
|
'src/lib/axios.config.ts': AXIOS_CONFIG,
|
|
'src/api/routes.ts': API_ROUTES,
|
|
'src/api/events.api.ts': `
|
|
import client from '../lib/axios.config';
|
|
import { API_ROUTE_PATH } from './routes';
|
|
export const unlike = (id: string) =>
|
|
client.delete(\`\${API_ROUTE_PATH.EVENTS}\${\`/\${id}/unlike\`}\`);
|
|
`,
|
|
},
|
|
'src/api/events.api.ts',
|
|
);
|
|
|
|
expect(consumers(detections)).toContainEqual(
|
|
expect.objectContaining({ path: '/events/${id}/unlike' }),
|
|
);
|
|
});
|
|
|
|
it('does not let a client built inside a callback vouch for the outer name', () => {
|
|
const detections = scanRepo(
|
|
{
|
|
'src/thing.ts': `
|
|
const thing = configure(() => axios.create({ baseURL: '/' }));
|
|
export const read = () => thing.get('/users');
|
|
`,
|
|
},
|
|
'src/thing.ts',
|
|
);
|
|
|
|
expect(consumers(detections)).toEqual([]);
|
|
});
|
|
|
|
// ─── Precision guards ──────────────────────────────────────────────
|
|
|
|
it('does NOT emit an Express provider route as a consumer of itself', () => {
|
|
const detections = scanRepo(
|
|
{
|
|
'src/server.ts': `
|
|
import express from 'express';
|
|
const router = express.Router();
|
|
router.get('/users', listUsers);
|
|
app.post('/orders', createOrder);
|
|
`,
|
|
},
|
|
'src/server.ts',
|
|
);
|
|
|
|
expect(consumers(detections)).toEqual([]);
|
|
// …while still being seen as providers.
|
|
expect(detections.filter((d) => d.role === 'provider').map((d) => d.path)).toEqual(
|
|
expect.arrayContaining(['/users', '/orders']),
|
|
);
|
|
});
|
|
|
|
it('does NOT claim an unproven receiver that merely has a .get method', () => {
|
|
const detections = scanRepo(
|
|
{
|
|
'src/cache.ts': `
|
|
const cache = new Map<string, string>();
|
|
const store = { get: (k: string) => k };
|
|
export const read = () => cache.get('/users') ?? store.get('/orders');
|
|
`,
|
|
},
|
|
'src/cache.ts',
|
|
);
|
|
|
|
expect(consumers(detections)).toEqual([]);
|
|
});
|
|
|
|
it('refuses to resolve an import whose specifier matches two files', () => {
|
|
const detections = scanRepo(
|
|
{
|
|
'src/lib/axios.config.ts': AXIOS_CONFIG,
|
|
'a/shared/routes.ts': API_ROUTES,
|
|
'b/shared/routes.ts': `export const API_ROUTE_PATH = { LINKS: "/other-links" } as const;`,
|
|
'src/api/links.api.ts': `
|
|
import client from '../lib/axios.config';
|
|
import { API_ROUTE_PATH } from 'shared/routes';
|
|
export const load = () => client.get(API_ROUTE_PATH.LINKS);
|
|
`,
|
|
},
|
|
'src/api/links.api.ts',
|
|
);
|
|
|
|
// Two candidates for `shared/routes` — an unresolved path is correct here;
|
|
// guessing either one would invent a cross-repo link.
|
|
expect(consumers(detections)).toEqual([]);
|
|
});
|
|
|
|
// ─── Backward compatibility ────────────────────────────────────────
|
|
|
|
it('still detects a bare axios call with a literal path and no repo context', () => {
|
|
const detections = JAVASCRIPT_HTTP_PLUGIN.scan(
|
|
jsParser.parse(`axios.get('/legacy'); axios.post('/legacy', body);`),
|
|
);
|
|
|
|
expect(consumers(detections)).toEqual([
|
|
expect.objectContaining({ method: 'GET', path: '/legacy' }),
|
|
expect.objectContaining({ method: 'POST', path: '/legacy' }),
|
|
]);
|
|
});
|
|
|
|
it('preserves the raw template when there is no repo context to fold against', () => {
|
|
const detections = JAVASCRIPT_HTTP_PLUGIN.scan(jsParser.parse('axios.get(`/users/${id}`);'));
|
|
|
|
expect(consumers(detections)).toContainEqual(expect.objectContaining({ path: '/users/${id}' }));
|
|
});
|
|
|
|
it('drops a non-literal path it cannot resolve rather than emitting its text', () => {
|
|
const detections = JAVASCRIPT_HTTP_PLUGIN.scan(
|
|
jsParser.parse(`axios.get(API_ROUTE_PATH.LINKS);`),
|
|
);
|
|
|
|
expect(consumers(detections)).toEqual([]);
|
|
});
|
|
|
|
// ─── Review findings: precision, termination and keying ────────────
|
|
|
|
it('keys the fact map the same way on a platform that hands it backslashes', () => {
|
|
// glob v13 is called without `posix: true` and its walker joins with the
|
|
// platform separator, so on Windows every path here arrives backslashed.
|
|
const files = {
|
|
'src\\lib\\axios.config.ts': AXIOS_CONFIG,
|
|
'src\\api\\routes.ts': API_ROUTES,
|
|
'src\\api\\links.api.ts': `
|
|
import client from '../lib/axios.config';
|
|
import { API_ROUTE_PATH } from './routes';
|
|
export const load = () => client.get(API_ROUTE_PATH.LINKS);
|
|
`,
|
|
};
|
|
|
|
expect(consumers(scanRepo(files, 'src\\api\\links.api.ts'))).toContainEqual(
|
|
expect.objectContaining({ method: 'GET', path: '/links' }),
|
|
);
|
|
});
|
|
|
|
it('does NOT treat a container that merely HOLDS an axios instance as a client', () => {
|
|
const detections = scanRepo(
|
|
{
|
|
'src/stores.ts': `
|
|
import axios from 'axios';
|
|
const registry = { http: axios.create({ baseURL: '/' }), version: 'v1' };
|
|
const picked = MOCK ? memoryStore : axios.create({ baseURL: '/' });
|
|
const pool = new Map([['api', axios.create({ baseURL: '/' })]]);
|
|
export const read = () => [
|
|
registry.get('/settings'),
|
|
picked.get('/feature-flags'),
|
|
pool.get('/tenant'),
|
|
];
|
|
`,
|
|
},
|
|
'src/stores.ts',
|
|
);
|
|
|
|
expect(consumers(detections)).toEqual([]);
|
|
});
|
|
|
|
it('still proves the factory shape the containment rule existed for', () => {
|
|
const detections = scanRepo(
|
|
{
|
|
'src/lib/client.ts': `
|
|
import axios from 'axios';
|
|
export default withRetries(setupInterceptors(axios.create({ baseURL: '/' })));
|
|
`,
|
|
'src/api/routes.ts': API_ROUTES,
|
|
'src/api/links.api.ts': `
|
|
import client from '../lib/client';
|
|
import { API_ROUTE_PATH } from './routes';
|
|
export const load = () => client.get(API_ROUTE_PATH.LINKS);
|
|
`,
|
|
},
|
|
'src/api/links.api.ts',
|
|
);
|
|
|
|
expect(consumers(detections)).toContainEqual(
|
|
expect.objectContaining({ method: 'GET', path: '/links' }),
|
|
);
|
|
});
|
|
|
|
it('refuses a resolved constant that is not path-shaped', () => {
|
|
const detections = scanRepo(
|
|
{
|
|
'src/lib/axios.config.ts': AXIOS_CONFIG,
|
|
'src/api/strings.ts': `
|
|
export const CONFIG = { TIMEOUT: "5000" } as const;
|
|
export const MSG = { ERROR: "Could not reach the server" } as const;
|
|
`,
|
|
'src/api/calls.api.ts': `
|
|
import api from '../lib/axios.config';
|
|
import { CONFIG, MSG } from './strings';
|
|
export const a = () => api.get(CONFIG.TIMEOUT);
|
|
export const b = () => api.post(MSG.ERROR);
|
|
`,
|
|
},
|
|
'src/api/calls.api.ts',
|
|
);
|
|
|
|
// "5000" normalizes to /{param} and matches every one-segment provider
|
|
// route in the group; the message normalizes to a path with spaces in it.
|
|
expect(consumers(detections)).toEqual([]);
|
|
});
|
|
|
|
it('keeps an all-numeric path that is written as a path', () => {
|
|
const detections = scanRepo(
|
|
{
|
|
'src/lib/axios.config.ts': AXIOS_CONFIG,
|
|
'src/api/legacy.api.ts': `
|
|
import api from '../lib/axios.config';
|
|
export const load = () => api.get('/123');
|
|
`,
|
|
},
|
|
'src/api/legacy.api.ts',
|
|
);
|
|
|
|
// The leading slash is what separates a route from a folded timeout; the
|
|
// consumer normalizer reads the segment as {param} either way.
|
|
expect(consumers(detections)).toContainEqual(
|
|
expect.objectContaining({ method: 'GET', path: '/123' }),
|
|
);
|
|
});
|
|
|
|
it('refuses a path whose leading term never resolved', () => {
|
|
const detections = scanRepo(
|
|
{
|
|
'src/lib/axios.config.ts': AXIOS_CONFIG,
|
|
'src/api/unanchored.api.ts': `
|
|
import client from '../lib/axios.config';
|
|
const BASE = process.env.NEXT_PUBLIC_API_URL;
|
|
export const a = (x, y) => client.get(\`\${x}\${y}\`);
|
|
export const b = () => client.get(BASE + '/users');
|
|
`,
|
|
},
|
|
'src/api/unanchored.api.ts',
|
|
);
|
|
|
|
// `${x}${y}` squashes to /{param}{param} and `${BASE}/users` to
|
|
// /{param}/users — both exact-match real provider routes.
|
|
expect(consumers(detections)).toEqual([]);
|
|
});
|
|
|
|
it('caps the folded output instead of building a path of unbounded length', () => {
|
|
const pad = 'a'.repeat(4000);
|
|
const detections = scanRepo(
|
|
{
|
|
'src/lib/axios.config.ts': AXIOS_CONFIG,
|
|
'src/api/big.api.ts': `
|
|
import client from '../lib/axios.config';
|
|
const PAD = "/${pad}";
|
|
export const load = () => client.get(PAD + PAD + PAD);
|
|
`,
|
|
},
|
|
'src/api/big.api.ts',
|
|
);
|
|
|
|
// Each term is under the core's 8 192-char cap; their concatenation is not,
|
|
// and the result is persisted into contractId / meta.path.
|
|
expect(consumers(detections)).toEqual([]);
|
|
});
|
|
|
|
it('terminates on expressions deep enough to overflow the stack', () => {
|
|
// `scan` is contractually non-throwing: `sync.ts` turns a throw here into an
|
|
// unexplained "missing repo" that silently drops every contract of every
|
|
// kind for that repo. Both shapes recursed once per term before this.
|
|
// 3 000 is near this tree-sitter build's own parse ceiling for a `+` chain;
|
|
// nested templates parse to ~6 000, and at 4 000 the unbounded fold threw
|
|
// `RangeError: Maximum call stack size exceeded` straight out of `scan`.
|
|
const chain = Array.from({ length: 3000 }, (_, i) => `"/s${i}"`).join(' + ');
|
|
let nested = '`/x`';
|
|
for (let i = 0; i < 4000; i++) nested = '`${' + nested + '}`';
|
|
|
|
expect(() => scanRepo({ 'src/a.ts': `axios.get(${chain});` }, 'src/a.ts')).not.toThrow();
|
|
expect(() => scanRepo({ 'src/b.ts': `axios.get(${nested});` }, 'src/b.ts')).not.toThrow();
|
|
});
|
|
|
|
it('survives a file whose parse throws, and still resolves the rest of the repo', () => {
|
|
const detections = scanRepoWithParse(
|
|
{
|
|
'src/lib/axios.config.ts': AXIOS_CONFIG,
|
|
'src/api/routes.ts': API_ROUTES,
|
|
'src/api/poison.ts': `export const X = "/x";`,
|
|
'src/api/links.api.ts': `
|
|
import client from '../lib/axios.config';
|
|
import { API_ROUTE_PATH } from './routes';
|
|
export const load = () => client.get(API_ROUTE_PATH.LINKS);
|
|
`,
|
|
},
|
|
'src/api/links.api.ts',
|
|
(parser, src) => {
|
|
if (src.includes('"/x"')) throw new Error('ParseTimeoutError');
|
|
return parser.parse(src);
|
|
},
|
|
);
|
|
|
|
expect(consumers(detections)).toContainEqual(
|
|
expect.objectContaining({ method: 'GET', path: '/links' }),
|
|
);
|
|
});
|
|
|
|
it('sees an axios import declared below the binding that uses it', () => {
|
|
const detections = scanRepo(
|
|
{
|
|
// ES module bindings are hoisted, so this is legal and binds the same `ax`.
|
|
'src/lib/late.ts': `
|
|
const client = ax.create({ baseURL: '/' });
|
|
import ax from 'axios';
|
|
export default client;
|
|
`,
|
|
'src/api/routes.ts': API_ROUTES,
|
|
'src/api/links.api.ts': `
|
|
import client from '../lib/late';
|
|
import { API_ROUTE_PATH } from './routes';
|
|
export const load = () => client.get(API_ROUTE_PATH.LINKS);
|
|
`,
|
|
},
|
|
'src/api/links.api.ts',
|
|
);
|
|
|
|
expect(consumers(detections)).toContainEqual(
|
|
expect.objectContaining({ method: 'GET', path: '/links' }),
|
|
);
|
|
});
|
|
|
|
it('keeps a partially folded path whose unresolved term contains spaces', () => {
|
|
const detections = scanRepo(
|
|
{
|
|
'src/lib/axios.config.ts': AXIOS_CONFIG,
|
|
'src/api/routes.ts': API_ROUTES,
|
|
'src/api/events.api.ts': `
|
|
import client from '../lib/axios.config';
|
|
import { API_ROUTE_PATH } from './routes';
|
|
export const list = (draft: boolean, page?: number) => [
|
|
client.get(\`\${API_ROUTE_PATH.EVENTS}/\${draft ? 'draft' : 'live'}\`),
|
|
client.get(\`\${API_ROUTE_PATH.LINKS}/\${page ?? 1}\`),
|
|
];
|
|
`,
|
|
},
|
|
'src/api/events.api.ts',
|
|
);
|
|
|
|
// The placeholder is a runtime value that consumer normalization reads as
|
|
// {param}; its source text is not part of the path shape.
|
|
expect(consumers(detections).map((d) => d.path)).toEqual(
|
|
expect.arrayContaining(["/events/${draft ? 'draft' : 'live'}", '/links/${page ?? 1}']),
|
|
);
|
|
});
|
|
|
|
it('does not remove a detection the literal axios receiver already produced', () => {
|
|
// Before the query was widened this shape matched and normalized to
|
|
// /{param}/users. Anchoring applies to what the widening newly admits, not
|
|
// to output that already shipped.
|
|
const detections = JAVASCRIPT_HTTP_PLUGIN.scan(
|
|
jsParser.parse('axios.get(`${API_BASE}/users`); axios.get(`${a}${b}`);'),
|
|
);
|
|
|
|
expect(consumers(detections).map((d) => d.path)).toEqual(['${API_BASE}/users', '${a}${b}']);
|
|
});
|
|
|
|
it('caps the literal fallback of an oversized template too', () => {
|
|
const pad = 'a'.repeat(9000);
|
|
const detections = scanRepo(
|
|
{
|
|
'src/lib/axios.config.ts': AXIOS_CONFIG,
|
|
'src/api/big.api.ts': `
|
|
import client from '../lib/axios.config';
|
|
export const load = (id: string) => client.get(\`/${pad}\${id}\`);
|
|
`,
|
|
},
|
|
'src/api/big.api.ts',
|
|
);
|
|
|
|
expect(consumers(detections)).toEqual([]);
|
|
});
|
|
|
|
it('proves a client handed to a factory inside a nested options object', () => {
|
|
const detections = scanRepo(
|
|
{
|
|
'src/lib/client.ts': `
|
|
import axios from 'axios';
|
|
export default createClient({ transport: { instance: axios.create({}) } });
|
|
`,
|
|
'src/lib/composed.ts': `
|
|
import axios from 'axios';
|
|
export default compose([axios.create({}), withAuth]);
|
|
`,
|
|
'src/api/routes.ts': API_ROUTES,
|
|
'src/api/links.api.ts': `
|
|
import nested from '../lib/client';
|
|
import composed from '../lib/composed';
|
|
import { API_ROUTE_PATH } from './routes';
|
|
export const a = () => nested.get(API_ROUTE_PATH.LINKS);
|
|
export const b = () => composed.get(API_ROUTE_PATH.EVENTS);
|
|
`,
|
|
},
|
|
'src/api/links.api.ts',
|
|
);
|
|
|
|
expect(consumers(detections).map((d) => d.path)).toEqual(
|
|
expect.arrayContaining(['/links', '/events']),
|
|
);
|
|
});
|
|
|
|
it('does NOT trust the spelling `axios` when the file binds that name itself', () => {
|
|
const detections = scanRepo(
|
|
{
|
|
'src/shadow.ts': `
|
|
const axios = fakeFactory;
|
|
const api = axios.create();
|
|
export const a = () => api.get('/x');
|
|
export const b = () => axios.get('/y');
|
|
`,
|
|
'src/mock.ts': `
|
|
const axios = { create: () => ({ get: (u: string) => u }) };
|
|
const api = axios.create();
|
|
export const c = () => api.get('/z');
|
|
`,
|
|
},
|
|
'src/shadow.ts',
|
|
);
|
|
|
|
// The spelling is the only evidence here, and it is false.
|
|
expect(consumers(detections)).toEqual([]);
|
|
expect(
|
|
consumers(
|
|
scanRepo(
|
|
{
|
|
'src/mock.ts': `
|
|
const axios = { create: () => ({ get: (u: string) => u }) };
|
|
const api = axios.create();
|
|
export const c = () => api.get('/z');
|
|
`,
|
|
},
|
|
'src/mock.ts',
|
|
),
|
|
),
|
|
).toEqual([]);
|
|
});
|
|
|
|
it('resolves a CommonJS require of axios, aliased or not', () => {
|
|
const cjs = (local: string) => `
|
|
const ${local} = require('axios');
|
|
const api = ${local}.create({ baseURL: '/' });
|
|
export const viaInstance = () => api.get('/instance');
|
|
export const viaModule = () => ${local}.get('/module');
|
|
`;
|
|
|
|
for (const local of ['axios', 'ax']) {
|
|
const detections = scanRepo({ 'src/cjs.ts': cjs(local) }, 'src/cjs.ts');
|
|
expect(consumers(detections).map((d) => d.path)).toEqual(
|
|
expect.arrayContaining(['/instance', '/module']),
|
|
);
|
|
}
|
|
});
|
|
|
|
it('resolves the axios module used directly under an import alias', () => {
|
|
const detections = scanRepo(
|
|
{
|
|
'src/aliased.ts': `
|
|
import ax from 'axios';
|
|
export const f = () => ax.get('/health');
|
|
`,
|
|
},
|
|
'src/aliased.ts',
|
|
);
|
|
|
|
expect(consumers(detections)).toContainEqual(
|
|
expect.objectContaining({ method: 'GET', path: '/health' }),
|
|
);
|
|
});
|
|
|
|
it('refuses a name two `export *` barrels both provide', () => {
|
|
const detections = scanRepo(
|
|
{
|
|
'src/lib/axios.config.ts': AXIOS_CONFIG,
|
|
'src/api/a.ts': `export const API_ROUTE_PATH = { LINKS: "/links-a" } as const;`,
|
|
'src/api/b.ts': `export const API_ROUTE_PATH = { LINKS: "/links-b" } as const;`,
|
|
'src/api/index.ts': `
|
|
export * from './a';
|
|
export * from './b';
|
|
`,
|
|
'src/api/links.api.ts': `
|
|
import client from '../lib/axios.config';
|
|
import { API_ROUTE_PATH } from './index';
|
|
export const load = () => client.get(API_ROUTE_PATH.LINKS);
|
|
`,
|
|
},
|
|
'src/api/links.api.ts',
|
|
);
|
|
|
|
expect(consumers(detections)).toEqual([]);
|
|
});
|
|
|
|
it('does NOT bind a Node builtin specifier to a same-named repo file', () => {
|
|
const detections = scanRepo(
|
|
{
|
|
'src/lib/http.ts': `
|
|
import axios from 'axios';
|
|
export default axios.create({ baseURL: '/' });
|
|
`,
|
|
'src/api/health.ts': `
|
|
import http from 'http';
|
|
export const ping = () => http.get('http://example.com/health');
|
|
`,
|
|
},
|
|
'src/api/health.ts',
|
|
);
|
|
|
|
expect(consumers(detections)).toEqual([]);
|
|
});
|
|
|
|
it('measures the pre-pass ceiling in bytes, not UTF-16 code units', () => {
|
|
// Under 512 Ki code units, over 512 KiB of UTF-8 — the ceiling mirrors the
|
|
// analyzer's byte-size limit, so this file must be skipped.
|
|
const detections = scanRepo(
|
|
{
|
|
'src/lib/huge.ts': `
|
|
import axios from 'axios';
|
|
// ${'á'.repeat(300_000)}
|
|
export default axios.create({ baseURL: '/' });
|
|
`,
|
|
'src/api/links.api.ts': `
|
|
import client from '../lib/huge';
|
|
export const load = () => client.get('/links');
|
|
`,
|
|
},
|
|
'src/api/links.api.ts',
|
|
);
|
|
|
|
expect(consumers(detections)).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('resolveJsImport', () => {
|
|
const keys = (...paths: string[]) => new Set(paths);
|
|
|
|
it('refuses a tail two different modules claim, across extensions', () => {
|
|
expect(
|
|
resolveJsImport(
|
|
'src/x.ts',
|
|
'@/shared/routes',
|
|
keys('a/shared/routes.ts', 'b/shared/routes.ts'),
|
|
),
|
|
).toBeNull();
|
|
expect(
|
|
resolveJsImport(
|
|
'src/x.ts',
|
|
'@/shared/routes',
|
|
keys('a/shared/routes.ts', 'b/shared/routes.tsx'),
|
|
),
|
|
).toBeNull();
|
|
expect(
|
|
resolveJsImport(
|
|
'src/x.ts',
|
|
'@/shared/routes',
|
|
keys('a/shared/routes.ts', 'b/shared/routes.js'),
|
|
),
|
|
).toBeNull();
|
|
expect(
|
|
resolveJsImport(
|
|
'src/x.ts',
|
|
'@/shared/routes',
|
|
keys('a/shared/routes.ts', 'b/shared/routes/index.ts'),
|
|
),
|
|
).toBeNull();
|
|
});
|
|
|
|
it('keeps extension precedence when the matches are one module', () => {
|
|
// `x/routes.ts` and `x/routes/index.ts` are two spellings of `x/routes`;
|
|
// Node and tsc both pick the file, so this is precedence, not ambiguity.
|
|
expect(
|
|
resolveJsImport('src/a.ts', '@/x/routes', keys('src/x/routes.ts', 'src/x/routes/index.ts')),
|
|
).toBe('src/x/routes.ts');
|
|
expect(resolveJsImport('src/a.ts', '@/x/routes', keys('src/x/routes.tsx'))).toBe(
|
|
'src/x/routes.tsx',
|
|
);
|
|
});
|
|
|
|
it('never resolves a single-segment bare specifier to a repo file', () => {
|
|
// A bare npm package or Node builtin is not ours to resolve — and this is
|
|
// also the hot path: the unindexed sweep that ran here cost 19.6x on a
|
|
// 4 000-file repo whose only trigger was `import _ from 'lodash'`.
|
|
expect(resolveJsImport('src/a.ts', 'http', keys('src/lib/http.ts'))).toBeNull();
|
|
expect(resolveJsImport('src/a.ts', 'axios', keys('src/lib/axios.ts'))).toBeNull();
|
|
expect(resolveJsImport('src/a.ts', 'lodash', keys('src/lodash.ts'))).toBeNull();
|
|
});
|
|
|
|
it('still resolves alias and relative specifiers', () => {
|
|
expect(resolveJsImport('src/a/b.ts', './c', keys('src/a/c.ts'))).toBe('src/a/c.ts');
|
|
expect(resolveJsImport('src/a/b.ts', '@/lib/http', keys('src/lib/http.ts'))).toBe(
|
|
'src/lib/http.ts',
|
|
);
|
|
expect(resolveJsImport('src/a/b.ts', 'lib/http', keys('src/lib/http.ts'))).toBe(
|
|
'src/lib/http.ts',
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('wrapped X.request({ url, method }) detections', () => {
|
|
it('keeps the raw template so mid-path interpolations survive to the normalizer', () => {
|
|
const detections = consumers(
|
|
JAVASCRIPT_HTTP_PLUGIN.scan(
|
|
jsParser.parse(
|
|
'httpClient.request({ url: `${client}/api/${tenant}/orders`, method: "GET" });',
|
|
),
|
|
),
|
|
);
|
|
expect(detections).toHaveLength(1);
|
|
expect(detections[0]?.framework).toBe('request');
|
|
expect(detections[0]?.method).toBe('GET');
|
|
expect(detections[0]?.path).toBe('${client}/api/${tenant}/orders');
|
|
});
|
|
|
|
it('emits * when method is present but not a literal', () => {
|
|
const detections = consumers(
|
|
JAVASCRIPT_HTTP_PLUGIN.scan(
|
|
jsParser.parse('httpClient.request({ url: "/api/orders", method: verb });'),
|
|
),
|
|
);
|
|
expect(detections).toHaveLength(1);
|
|
expect(detections[0]?.method).toBe('*');
|
|
});
|
|
|
|
it('uses the last duplicate method key, matching JS evaluation', () => {
|
|
const dynamicLast = consumers(
|
|
JAVASCRIPT_HTTP_PLUGIN.scan(
|
|
jsParser.parse("httpClient.request({ url: '/api/orders', method: 'GET', method: verb });"),
|
|
),
|
|
);
|
|
expect(dynamicLast[0]?.method).toBe('*');
|
|
const literalLast = consumers(
|
|
JAVASCRIPT_HTTP_PLUGIN.scan(
|
|
jsParser.parse("httpClient.request({ url: '/api/orders', method: verb, method: 'POST' });"),
|
|
),
|
|
);
|
|
expect(literalLast[0]?.method).toBe('POST');
|
|
});
|
|
|
|
it('defaults to GET only when method/type is absent', () => {
|
|
const detections = consumers(
|
|
JAVASCRIPT_HTTP_PLUGIN.scan(jsParser.parse('httpClient.request({ url: "/api/orders" });')),
|
|
);
|
|
expect(detections).toHaveLength(1);
|
|
expect(detections[0]?.method).toBe('GET');
|
|
});
|
|
|
|
it('drops a static relative url at scan time', () => {
|
|
const detections = consumers(
|
|
JAVASCRIPT_HTTP_PLUGIN.scan(
|
|
jsParser.parse("httpClient.request({ url: 'api/orders', method: 'GET' });"),
|
|
),
|
|
);
|
|
expect(detections).toHaveLength(0);
|
|
});
|
|
|
|
it('does not treat cy.request or queue.request as HTTP consumers', () => {
|
|
const detections = consumers(
|
|
JAVASCRIPT_HTTP_PLUGIN.scan(
|
|
jsParser.parse(`
|
|
cy.request({ url: '/api/v1/orders', method: 'GET' });
|
|
queue.request({ url: '/admin', method: 'DELETE' });
|
|
`),
|
|
),
|
|
);
|
|
expect(detections).toHaveLength(0);
|
|
});
|
|
|
|
it('ignores a config object that is not the first request argument', () => {
|
|
const detections = consumers(
|
|
JAVASCRIPT_HTTP_PLUGIN.scan(
|
|
jsParser.parse("httpClient.request('/actual', { url: '/metadata', method: 'GET' });"),
|
|
),
|
|
);
|
|
expect(detections).toHaveLength(0);
|
|
});
|
|
|
|
it('admits $http by spelling but not a bare api without axios proof', () => {
|
|
const detections = consumers(
|
|
JAVASCRIPT_HTTP_PLUGIN.scan(
|
|
jsParser.parse(`
|
|
$http.request({ url: '/api/orders', method: 'GET' });
|
|
api.request({ url: '/api/users', method: 'POST' });
|
|
`),
|
|
),
|
|
);
|
|
expect(detections).toEqual([
|
|
expect.objectContaining({ method: 'GET', path: '/api/orders', framework: 'request' }),
|
|
]);
|
|
});
|
|
|
|
it('reads quoted method/type keys and type: as the verb', () => {
|
|
const quoted = consumers(
|
|
JAVASCRIPT_HTTP_PLUGIN.scan(
|
|
jsParser.parse('httpClient.request({ url: "/api/orders", "method": "POST" });'),
|
|
),
|
|
);
|
|
expect(quoted[0]?.method).toBe('POST');
|
|
const typed = consumers(
|
|
JAVASCRIPT_HTTP_PLUGIN.scan(
|
|
jsParser.parse("httpClient.request({ url: '/api/items', type: 'PUT' });"),
|
|
),
|
|
);
|
|
expect(typed[0]?.method).toBe('PUT');
|
|
});
|
|
|
|
it('emits * when method may arrive via object spread', () => {
|
|
const detections = consumers(
|
|
JAVASCRIPT_HTTP_PLUGIN.scan(
|
|
jsParser.parse('httpClient.request({ url: "/api/orders", ...config });'),
|
|
),
|
|
);
|
|
expect(detections).toHaveLength(1);
|
|
expect(detections[0]?.method).toBe('*');
|
|
});
|
|
|
|
it('keeps static absolute wrapped-request urls for host stripping', () => {
|
|
const detections = consumers(
|
|
JAVASCRIPT_HTTP_PLUGIN.scan(
|
|
jsParser.parse("httpClient.request({ url: 'https://host/api/x', method: 'GET' });"),
|
|
),
|
|
);
|
|
expect(detections).toHaveLength(1);
|
|
expect(detections[0]?.path).toBe('https://host/api/x');
|
|
});
|
|
|
|
it('admits axios.create instances calling .request', () => {
|
|
const detections = consumers(
|
|
scanRepo(
|
|
{
|
|
'src/lib/client.ts': `
|
|
import axios from 'axios';
|
|
export const api = axios.create({ baseURL: '/' });
|
|
`,
|
|
'src/api/orders.ts': `
|
|
import { api } from '../lib/client';
|
|
export const create = () => api.request({ url: '/api/orders', method: 'POST' });
|
|
`,
|
|
},
|
|
'src/api/orders.ts',
|
|
),
|
|
);
|
|
expect(detections).toContainEqual(
|
|
expect.objectContaining({ role: 'consumer', method: 'POST', path: '/api/orders' }),
|
|
);
|
|
});
|
|
|
|
it('admits member-verb calls with a gateway-prefixed template', () => {
|
|
const detections = consumers(
|
|
scanRepo(
|
|
{
|
|
'src/lib/client.ts': `
|
|
import axios from 'axios';
|
|
export default axios.create({ baseURL: '/' });
|
|
`,
|
|
'src/api/users.ts': `
|
|
import api from '../lib/client';
|
|
export const list = (gateway: string) => api.get(\`\${gateway}/api/v1/users\`);
|
|
export const bare = (id: string) => api.get(\`\${id}\`);
|
|
export const glue = (c: string) => api.get(\`\${c}api/x\`);
|
|
`,
|
|
},
|
|
'src/api/users.ts',
|
|
),
|
|
);
|
|
expect(detections.map((d) => d.path)).toEqual(['${gateway}/api/v1/users']);
|
|
});
|
|
|
|
it('trims whitespace-prefixed absolute paths at scan time', () => {
|
|
const detections = consumers(
|
|
JAVASCRIPT_HTTP_PLUGIN.scan(
|
|
jsParser.parse("httpClient.request({ url: ' /api/orders', method: 'GET' });"),
|
|
),
|
|
);
|
|
expect(detections).toHaveLength(1);
|
|
expect(detections[0]?.path).toBe('/api/orders');
|
|
});
|
|
|
|
it('emits * for interpolated method templates and shorthand method keys', () => {
|
|
const interpolated = consumers(
|
|
JAVASCRIPT_HTTP_PLUGIN.scan(
|
|
jsParser.parse('httpClient.request({ url: "/api/orders", method: `${verb}` });'),
|
|
),
|
|
);
|
|
expect(interpolated[0]?.method).toBe('*');
|
|
const shorthand = consumers(
|
|
JAVASCRIPT_HTTP_PLUGIN.scan(
|
|
jsParser.parse("httpClient.request({ url: '/api/orders', method });"),
|
|
),
|
|
);
|
|
expect(shorthand[0]?.method).toBe('*');
|
|
});
|
|
});
|