mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-18 13:53:01 +08:00
66aebd17e2
The pre-release agent A/B (main vs v1.6.0, 7 README repos, 3 runs each) had main returning 15% less source on vscode for the same queries, with Reads in 2 of 3 runs where v1.6.0 had none. Replaying every query both builds' agents issued traced it to three causes: 1. Named gaps (#1711) displaced source. Every cluster fit measured the gap names, which on a long path run to hundreds of chars, so a class shrunk into its file's room overran it by the names alone and was dropped whole: rpcProtocol.ts emitted 222 of 5,496 funded chars (v1.6.0: 5,194), and the test fixture came back as an empty fence. Fits now measure bare gaps; names are added at assembly from what the file's budget has left. 2. Interface members (#1638) corroborated English words. The NL-stopword guard lets a bare word seed a definition when another query word names a symbol in the same file; `readonly host` in an options interface made "main" seed `main()` in agentHostServerMain.ts, which took the named-first tier from the answer files. Members declared inside an interface no longer count as corroboration. 3. The interface-dispatch note announced dispatch through any common base, declared or not: "`extension` -> runtime dispatch to 2706 types implementing Disposable" opened 9 of 31 vscode answers. A supertype now has to declare the member (it, an ancestor, or a same-named Swift extension). When absence can't be judged, because a protocol's requirements aren't indexed, the note stays as before. Replayed queries, source lines vs v1.6.0 (main -> fixed): vscode 10,744 -> 12,577 (12,617), excalidraw 13,065 -> 13,698 (13,764), django 8,420 -> 8,803 (8,954), tokio 13,618 -> 14,151 (14,600), okhttp 6,511 -> 6,883 (7,090), alamofire 7,019 -> 7,249 (7,120); no query below 60% of v1.6.0. Real dispatch notes (Alamofire adapt/retry/asURLRequest, django, okhttp) are unchanged. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
512 lines
21 KiB
TypeScript
512 lines
21 KiB
TypeScript
/**
|
|
* Dynamic-boundary surfacing (#687).
|
|
*
|
|
* When the flow an agent asked codegraph_explore about does NOT fully connect,
|
|
* the Flow section announces WHERE the static path ends — the dynamic-dispatch
|
|
* site (computed member call, getattr, typed bus, runtime-keyed emit), with
|
|
* candidate targets when a key is statically visible — instead of silently
|
|
* showing nothing. Deterministic, query-time only, no graph mutation, and a
|
|
* fully connected flow must never produce the section.
|
|
*/
|
|
import { describe, it, expect, afterEach } from 'vitest';
|
|
import * as fs from 'fs';
|
|
import * as path from 'path';
|
|
import * as os from 'os';
|
|
import CodeGraph from '../src/index';
|
|
import { ToolHandler } from '../src/mcp/tools';
|
|
import { scanDynamicDispatch } from '../src/mcp/dynamic-boundaries';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Unit: the scanner
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('scanDynamicDispatch', () => {
|
|
it('detects a computed member call with a literal key', () => {
|
|
const body = `function go(p) {\n table['save'](p);\n}`;
|
|
const m = scanDynamicDispatch(body, 'typescript', 10);
|
|
expect(m).toHaveLength(1);
|
|
expect(m[0]!.form).toBe('computed-call');
|
|
expect(m[0]!.key).toBe('save');
|
|
expect(m[0]!.line).toBe(11); // absolute: body starts at file line 10
|
|
expect(m[0]!.snippet).toContain("table['save'](p)");
|
|
});
|
|
|
|
it('detects a computed member call with a runtime key (no key extracted)', () => {
|
|
const body = `dispatch(action) {\n this.handlers[action.type](action.payload);\n}`;
|
|
const m = scanDynamicDispatch(body, 'typescript', 1);
|
|
expect(m).toHaveLength(1);
|
|
expect(m[0]!.form).toBe('computed-call');
|
|
expect(m[0]!.key).toBeUndefined();
|
|
});
|
|
|
|
it('does not fire on dispatch shapes inside comments or strings', () => {
|
|
const body = [
|
|
'function safe() {',
|
|
" // this.handlers[action.type](payload) — commented out",
|
|
' const doc = "call handlers[key](p) to dispatch";',
|
|
' return 1;',
|
|
'}',
|
|
].join('\n');
|
|
expect(scanDynamicDispatch(body, 'typescript', 1)).toHaveLength(0);
|
|
});
|
|
|
|
it('does not treat plain indexing or array literals as dispatch', () => {
|
|
const body = `function f(xs) {\n const a = xs[0];\n const b = [1, 2, 3];\n return a + b[1];\n}`;
|
|
expect(scanDynamicDispatch(body, 'typescript', 1)).toHaveLength(0);
|
|
});
|
|
|
|
it('detects python getattr immediate-call', () => {
|
|
const body = `def run(self, name):\n return getattr(self, name)(1)`;
|
|
const m = scanDynamicDispatch(body, 'python', 5);
|
|
expect(m).toHaveLength(1);
|
|
expect(m[0]!.form).toBe('getattr-call');
|
|
});
|
|
|
|
it('detects two-step getattr only when the assigned name is called later', () => {
|
|
const called = `def process(self, kind, p):\n handler = getattr(self, 'handle_' + kind)\n return handler(p)`;
|
|
const m = scanDynamicDispatch(called, 'python', 1);
|
|
expect(m).toHaveLength(1);
|
|
expect(m[0]!.form).toBe('getattr-assign');
|
|
expect(m[0]!.key).toBe('handle_'); // the literal prefix — enough to shortlist
|
|
|
|
const notCalled = `def peek(self, kind):\n handler = getattr(self, 'handle_' + kind)\n return handler`;
|
|
expect(scanDynamicDispatch(notCalled, 'python', 1)).toHaveLength(0);
|
|
});
|
|
|
|
it('detects ruby send with a symbol key', () => {
|
|
const body = `def run(name)\n target.send(:handle_save, 1)\nend`;
|
|
const m = scanDynamicDispatch(body, 'ruby', 1);
|
|
expect(m).toHaveLength(1);
|
|
expect(m[0]!.form).toBe('ruby-send');
|
|
expect(m[0]!.key).toBe('handle_save');
|
|
});
|
|
|
|
it('detects typed message dispatch and marks the key as a type', () => {
|
|
const body = `public async Task<int> Create(CreateCmd c) {\n return await _mediator.Send(new CreateTodoItemCommand(c));\n}`;
|
|
const m = scanDynamicDispatch(body, 'csharp', 1);
|
|
expect(m).toHaveLength(1);
|
|
expect(m[0]!.form).toBe('typed-bus');
|
|
expect(m[0]!.key).toBe('CreateTodoItemCommand');
|
|
expect(m[0]!.keyIsType).toBe(true);
|
|
});
|
|
|
|
it('detects runtime-keyed emit but not literal-keyed emit', () => {
|
|
const runtime = `notify(name, data) {\n this.emitter.emit(name, data);\n}`;
|
|
const m = scanDynamicDispatch(runtime, 'typescript', 1);
|
|
expect(m).toHaveLength(1);
|
|
expect(m[0]!.form).toBe('var-key-dispatch');
|
|
|
|
// Literal keys are the edge synthesizer's territory — not a boundary.
|
|
const literal = `notify(data) {\n this.emitter.emit('saved', data);\n}`;
|
|
expect(scanDynamicDispatch(literal, 'typescript', 1)).toHaveLength(0);
|
|
});
|
|
|
|
it('dedupes repeated same-form/same-key sites and counts the extras', () => {
|
|
const body = [
|
|
'route(a) {',
|
|
' this.table[a.type](a.p);',
|
|
' this.table[a.kind](a.p);',
|
|
' this.table[a.name](a.p);',
|
|
'}',
|
|
].join('\n');
|
|
const m = scanDynamicDispatch(body, 'typescript', 1);
|
|
expect(m).toHaveLength(1);
|
|
expect(m[0]!.moreSites).toBe(2);
|
|
});
|
|
|
|
it('detects reflective dispatch with a literal method name as key', () => {
|
|
const body = `public void run(Object o) {\n o.getClass().getMethod("handlePing").invoke(o);\n}`;
|
|
const m = scanDynamicDispatch(body, 'java', 1);
|
|
expect(m.length).toBeGreaterThanOrEqual(1);
|
|
expect(m[0]!.form).toBe('reflection');
|
|
expect(m[0]!.key).toBe('handlePing');
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Integration: codegraph_explore output
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('codegraph_explore — dynamic boundaries', () => {
|
|
let testDir: string;
|
|
let cg: CodeGraph;
|
|
let handler: ToolHandler;
|
|
|
|
const setup = async (files: Record<string, string>, include: string[]) => {
|
|
testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-boundary-'));
|
|
const src = path.join(testDir, 'src');
|
|
fs.mkdirSync(src, { recursive: true });
|
|
for (const [name, content] of Object.entries(files)) {
|
|
fs.writeFileSync(path.join(src, name), content);
|
|
}
|
|
cg = CodeGraph.initSync(testDir, { config: { include, exclude: [] } });
|
|
await cg.indexAll();
|
|
handler = new ToolHandler(cg);
|
|
};
|
|
|
|
afterEach(() => {
|
|
if (cg) cg.destroy();
|
|
if (testDir && fs.existsSync(testDir)) fs.rmSync(testDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it('announces a template import boundary from indexed source (#1967)', async () => {
|
|
await setup({
|
|
'loader.ts': [
|
|
'export async function loadLocale(lang: string) {',
|
|
' return import(`./locales/${lang}.js`);',
|
|
'}',
|
|
].join('\n'),
|
|
'locale.ts': 'export function translate() { return "hello"; }',
|
|
}, ['**/*.ts']);
|
|
|
|
const res = await handler.execute('codegraph_explore', { query: 'loadLocale translate' });
|
|
const text = res.content[0].text as string;
|
|
expect(res.isError).not.toBe(true);
|
|
expect(text).toContain('**Dynamic boundaries');
|
|
expect(text).toContain('dynamic import');
|
|
expect(text).toMatch(/loader\.ts:2/);
|
|
expect(text).not.toContain('candidates for key');
|
|
});
|
|
|
|
it('announces the boundary site and shortlists the keyed candidate', async () => {
|
|
await setup({
|
|
'router.ts': [
|
|
'type Handler = (p: unknown) => void;',
|
|
'export class Router {',
|
|
' private table: Record<string, Handler> = {};',
|
|
' add(key: string, fn: Handler) { this.table[key] = fn; }',
|
|
' routeSave(payload: unknown) {',
|
|
" this.table['save'](payload);",
|
|
' }',
|
|
'}',
|
|
].join('\n'),
|
|
'handlers.ts': [
|
|
"import { Router } from './router';",
|
|
'export function onSave(payload: unknown) { return payload; }',
|
|
'export function wire(r: Router) { r.add("save", onSave); }',
|
|
].join('\n'),
|
|
}, ['**/*.ts']);
|
|
|
|
const res = await handler.execute('codegraph_explore', { query: 'routeSave onSave' });
|
|
const text = res.content[0].text as string;
|
|
|
|
expect(text).toContain('**Dynamic boundaries');
|
|
expect(text).toContain('computed member call');
|
|
expect(text).toMatch(/router\.ts:6/); // the exact dispatch site
|
|
expect(text).toContain('candidates for key `save`');
|
|
expect(text).toContain('onSave');
|
|
expect(text).toContain('← you named this');
|
|
// Honesty constraint: never steer the agent to Read.
|
|
expect(text).not.toMatch(/\buse Read\b/i);
|
|
});
|
|
|
|
it('announces a runtime-keyed boundary with no candidate list', async () => {
|
|
await setup({
|
|
'bus.ts': [
|
|
'type Action = { type: string; payload?: unknown };',
|
|
'type Handler = (p: unknown) => void;',
|
|
'export class Bus {',
|
|
' private table: Record<string, Handler> = {};',
|
|
' route(action: Action) {',
|
|
' this.table[action.type](action.payload);',
|
|
' }',
|
|
'}',
|
|
].join('\n'),
|
|
'handlers.ts': 'export function onSave(payload: unknown) { return payload; }',
|
|
}, ['**/*.ts']);
|
|
|
|
const res = await handler.execute('codegraph_explore', { query: 'route onSave' });
|
|
const text = res.content[0].text as string;
|
|
|
|
expect(text).toContain('**Dynamic boundaries');
|
|
expect(text).toContain('computed member call');
|
|
expect(text).not.toContain('candidates for key'); // runtime key → no shortlist to claim
|
|
});
|
|
|
|
it('surfaces the boundary even when the other symbol is not in the graph', async () => {
|
|
await setup({
|
|
'bus.ts': [
|
|
'type Action = { type: string; payload?: unknown };',
|
|
'type Handler = (p: unknown) => void;',
|
|
'export class Bus {',
|
|
' private table: Record<string, Handler> = {};',
|
|
' route(action: Action) {',
|
|
' this.table[action.type](action.payload);',
|
|
' }',
|
|
'}',
|
|
].join('\n'),
|
|
}, ['**/*.ts']);
|
|
|
|
// `processPayment` does not exist anywhere — only `route` resolves.
|
|
const res = await handler.execute('codegraph_explore', { query: 'route processPayment' });
|
|
const text = res.content[0].text as string;
|
|
expect(text).toContain('**Dynamic boundaries');
|
|
});
|
|
|
|
it('renders a direct synthesized emit→handler hop as a dynamic-dispatch link (#687 criterion 1)', async () => {
|
|
// Custom EventBus with a LITERAL key: the event-emitter synthesizer
|
|
// bridges emit→handler, but the 2-node chain was invisible — too short
|
|
// for the Flow section and skipped by the links section as "in-chain".
|
|
await setup({
|
|
'bus.ts': [
|
|
'type Handler = (p: unknown) => void;',
|
|
'export class EventBus {',
|
|
' private listeners: Record<string, Handler[]> = {};',
|
|
' on(event: string, fn: Handler) { (this.listeners[event] ??= []).push(fn); }',
|
|
' emit(event: string, payload: unknown) { for (const fn of this.listeners[event] ?? []) fn(payload); }',
|
|
'}',
|
|
'export const bus = new EventBus();',
|
|
].join('\n'),
|
|
'billing.ts': [
|
|
"import { bus } from './bus';",
|
|
'export function settleInvoice(payload: unknown) { return payload; }',
|
|
"bus.on('invoice.settled', settleInvoice);",
|
|
].join('\n'),
|
|
'checkout.ts': [
|
|
"import { bus } from './bus';",
|
|
'export function completeCheckout(order: unknown) {',
|
|
" bus.emit('invoice.settled', order);",
|
|
'}',
|
|
].join('\n'),
|
|
}, ['**/*.ts']);
|
|
|
|
const res = await handler.execute('codegraph_explore', { query: 'completeCheckout settleInvoice' });
|
|
const text = res.content[0].text as string;
|
|
|
|
expect(text).toContain('**Dynamic-dispatch links among your symbols');
|
|
expect(text).toMatch(/completeCheckout → settleInvoice/);
|
|
expect(text).toContain('invoice.settled');
|
|
// Connected via the synthesized edge — no boundary to announce.
|
|
expect(text).not.toContain('**Dynamic boundaries');
|
|
});
|
|
|
|
it('never adds the section to a fully connected flow', async () => {
|
|
await setup({
|
|
'pipeline.ts': [
|
|
'export function stepOne() { return stepTwo(); }',
|
|
'export function stepTwo() { return stepThree(); }',
|
|
'export function stepThree() { return 3; }',
|
|
].join('\n'),
|
|
}, ['**/*.ts']);
|
|
|
|
const res = await handler.execute('codegraph_explore', { query: 'stepOne stepThree' });
|
|
const text = res.content[0].text as string;
|
|
expect(text).toContain('**Flow');
|
|
expect(text).not.toContain('**Dynamic boundaries');
|
|
});
|
|
|
|
it('python getattr dispatch surfaces with a prefix-key candidate', async () => {
|
|
await setup({
|
|
'service.py': [
|
|
'class Service:',
|
|
' def handle_save(self, payload):',
|
|
' return payload',
|
|
'',
|
|
' def process(self, kind, payload):',
|
|
" handler = getattr(self, 'handle_' + kind)",
|
|
' return handler(payload)',
|
|
].join('\n'),
|
|
}, ['**/*.py']);
|
|
|
|
const res = await handler.execute('codegraph_explore', { query: 'process handle_save' });
|
|
const text = res.content[0].text as string;
|
|
|
|
expect(text).toContain('**Dynamic boundaries');
|
|
expect(text).toContain('getattr');
|
|
expect(text).toContain('handle_save');
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Integration: interface/registry dispatch (a named method has many impls)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('codegraph_explore — interface dispatch', () => {
|
|
let testDir: string;
|
|
let cg: CodeGraph;
|
|
let handler: ToolHandler;
|
|
|
|
const setup = async (files: Record<string, string>, include: string[]) => {
|
|
testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-iface-'));
|
|
const src = path.join(testDir, 'src');
|
|
fs.mkdirSync(src, { recursive: true });
|
|
for (const [name, content] of Object.entries(files)) {
|
|
fs.writeFileSync(path.join(src, name), content);
|
|
}
|
|
cg = CodeGraph.initSync(testDir, { config: { include, exclude: [] } });
|
|
await cg.indexAll();
|
|
handler = new ToolHandler(cg);
|
|
};
|
|
|
|
afterEach(() => {
|
|
if (cg) cg.destroy();
|
|
if (testDir && fs.existsSync(testDir)) fs.rmSync(testDir, { recursive: true, force: true });
|
|
});
|
|
|
|
// 9 classes implement INodeType, each with execute(); a runtime registry lookup
|
|
// dispatches to one. The agent names the static entry + `execute`, which can't
|
|
// resolve to a single impl — the boundary IS the answer.
|
|
const nodeFamily = (n: number) => {
|
|
const names = ['Http', 'Set', 'If', 'Merge', 'Code', 'Webhook', 'Cron', 'Func', 'NoOp', 'Switch', 'Wait', 'Filter'];
|
|
return [
|
|
'export interface INodeType { execute(): unknown; }',
|
|
...names.slice(0, n).map((nm, i) => `export class ${nm}Node implements INodeType { execute() { return ${i}; } }`),
|
|
].join('\n');
|
|
};
|
|
const engine = [
|
|
"import { registry } from './registry';",
|
|
'export class WorkflowExecute {',
|
|
' processRunExecutionData() { return this.runNode(); }',
|
|
' runNode() { return this.executeNode(); }',
|
|
' executeNode() {',
|
|
" const nodeType = registry.get('http');",
|
|
' return nodeType.execute();',
|
|
' }',
|
|
'}',
|
|
].join('\n');
|
|
const registry = [
|
|
"import type { INodeType } from './nodes';",
|
|
'class Registry {',
|
|
' private m: Record<string, INodeType> = {};',
|
|
' get(k: string): INodeType { return this.m[k]!; }',
|
|
'}',
|
|
'export const registry = new Registry();',
|
|
].join('\n');
|
|
|
|
it('announces the interface, the TRUE implementer count, and sample targets', async () => {
|
|
await setup({ 'nodes.ts': nodeFamily(9), 'registry.ts': registry, 'engine.ts': engine }, ['**/*.ts']);
|
|
|
|
const res = await handler.execute('codegraph_explore', { query: 'processRunExecutionData executeNode execute' });
|
|
const text = res.content[0].text as string;
|
|
|
|
expect(text).toContain('**Interface dispatch (a named method has many implementations)');
|
|
expect(text).toMatch(/`execute` → runtime dispatch to \*\*9\*\* types implementing `INodeType`/);
|
|
// a couple of concrete targets, with file:line
|
|
expect(text).toMatch(/\b\w+Node\.execute` \(/);
|
|
// never steer to Read
|
|
expect(text).not.toMatch(/\buse Read\b/i);
|
|
});
|
|
|
|
it('stays SILENT on a fully connected flow with no polymorphic family', async () => {
|
|
await setup({
|
|
'pipeline.ts': [
|
|
'export function stepOne() { return stepTwo(); }',
|
|
'export function stepTwo() { return stepThree(); }',
|
|
'export function stepThree() { return 3; }',
|
|
].join('\n'),
|
|
}, ['**/*.ts']);
|
|
|
|
const res = await handler.execute('codegraph_explore', { query: 'stepOne stepThree' });
|
|
const text = res.content[0].text as string;
|
|
expect(text).toContain('**Flow');
|
|
expect(text).not.toContain('**Interface dispatch');
|
|
});
|
|
|
|
// vscode shape: many unrelated classes share a lifecycle base and happen to
|
|
// share a member name the base never declares. That is not dispatch through
|
|
// the base — announcing it put "runtime dispatch to 2706 types implementing
|
|
// Disposable" at the top of every answer whose query said "extension".
|
|
const lifecycleFamily = () => {
|
|
const names = ['Editor', 'Terminal', 'Search', 'Debug', 'Scm', 'Chat', 'Notebook', 'Output', 'Tasks', 'Remote'];
|
|
return [
|
|
'export abstract class Disposable { dispose(): void {} }',
|
|
...names.map((nm, i) => [
|
|
`export class ${nm}Service extends Disposable {`,
|
|
` get extension(): string { return '${nm.toLowerCase()}'; }`,
|
|
` dispose(): void { super.dispose(); }`,
|
|
` describe${nm}() { return this.extension + ${i}; }`,
|
|
'}',
|
|
].join('\n')),
|
|
].join('\n');
|
|
};
|
|
|
|
it('stays SILENT for a shared name the common base never declares', async () => {
|
|
await setup({ 'services.ts': lifecycleFamily() }, ['**/*.ts']);
|
|
const res = await handler.execute('codegraph_explore', { query: 'extension describeEditor describeChat' });
|
|
const text = res.content[0].text as string;
|
|
expect(text).not.toMatch(/`extension` → runtime dispatch/);
|
|
});
|
|
|
|
it('still announces a member the common base declares', async () => {
|
|
await setup({ 'services.ts': lifecycleFamily() }, ['**/*.ts']);
|
|
const res = await handler.execute('codegraph_explore', { query: 'dispose describeEditor describeChat' });
|
|
const text = res.content[0].text as string;
|
|
expect(text).toMatch(/`dispose` → runtime dispatch to \*\*10\*\* types implementing `Disposable`/);
|
|
});
|
|
|
|
it('stays SILENT when the interface family is below the polymorphism threshold (3 impls)', async () => {
|
|
await setup({ 'nodes.ts': nodeFamily(3), 'registry.ts': registry, 'engine.ts': engine }, ['**/*.ts']);
|
|
|
|
const res = await handler.execute('codegraph_explore', { query: 'processRunExecutionData executeNode execute' });
|
|
const text = res.content[0].text as string;
|
|
expect(text).not.toContain('**Interface dispatch');
|
|
});
|
|
});
|
|
|
|
describe('scanDynamicDispatch — dynamic import arguments (#1967)', () => {
|
|
const forms = (body: string): string[] =>
|
|
scanDynamicDispatch(body, 'typescript', 1).map((m) => m.form);
|
|
|
|
it('flags an import built from a template literal with a substitution', () => {
|
|
expect(forms('async function load(lang) {\n return import(`./locales/${lang}.js`);\n}')).toEqual(['dynamic-import']);
|
|
expect(forms('function load(name) {\n return require(`./plugins/${name}`);\n}')).toEqual(['dynamic-import']);
|
|
});
|
|
|
|
it('flags an import built by string concatenation', () => {
|
|
expect(forms("async function load(lang) {\n return import('./locales/' + lang + '.js');\n}")).toEqual(['dynamic-import']);
|
|
expect(forms('function load(dir) {\n return require(dir + "/index");\n}')).toEqual(['dynamic-import']);
|
|
});
|
|
|
|
it('still flags an import of a bare expression', () => {
|
|
expect(forms('async function load(p) {\n return import(p);\n}')).toEqual(['dynamic-import']);
|
|
});
|
|
|
|
it('leaves a single complete literal alone', () => {
|
|
expect(forms("async function a() {\n return import('./fixed.js');\n}")).toEqual([]);
|
|
expect(forms('async function a() {\n return import(`./fixed.js`);\n}')).toEqual([]);
|
|
expect(forms('function a() {\n return require("./fixed");\n}')).toEqual([]);
|
|
expect(forms("async function a() {\n return import('./data.json', { with: { type: 'json' } });\n}")).toEqual([]);
|
|
});
|
|
});
|
|
|
|
|
|
describe('scanDynamicDispatch — import escapes and trivia (#1967)', () => {
|
|
it.each(['import', 'require'])('%s respects escapes and complete literals', (call) => {
|
|
const staticArgs = [
|
|
'`./plugins/\\${name}`',
|
|
'`./plugins/\\\\\\${name}`',
|
|
'`./plugins/\\`fixed`',
|
|
'"./plugins/\\"fixed"',
|
|
"'./plugins/\\'fixed'",
|
|
'/* before */ `./fixed` /* after */',
|
|
'// before\n "./fixed" // after\n',
|
|
'`./fixed`, { with: { type: "json" } }',
|
|
'"./${name}"',
|
|
];
|
|
for (const arg of staticArgs) {
|
|
expect(scanDynamicDispatch(`${call}(${arg})`, 'typescript', 1), arg).toEqual([]);
|
|
}
|
|
const runtimeArgs = [
|
|
'`./plugins/\\\\${name}`',
|
|
'`./plugins/\\${literal}/${name}`',
|
|
'`./plugins/${`nested-${name}`}`',
|
|
'/* before */ "./plugins/" /* after */ + name',
|
|
'`./plugins/\\${literal}` + name',
|
|
'"./plugins/".concat(name)',
|
|
'// before\n `./plugins/${name}`, { with: { type: "json" } }',
|
|
];
|
|
for (const arg of runtimeArgs) {
|
|
const matches = scanDynamicDispatch(`function load() {\n ${call}(${arg});\n}`, 'typescript', 20);
|
|
expect(matches, arg).toHaveLength(1);
|
|
expect(matches[0], arg).toMatchObject({ form: 'dynamic-import', line: 21 });
|
|
}
|
|
});
|
|
|
|
it.each(['javascript', 'typescript', 'jsx', 'tsx', 'vue', 'svelte', 'astro', 'arkts'])(
|
|
'detects runtime imports across %s', (language) => {
|
|
expect(scanDynamicDispatch('import(`./${name}`)', language, 1)[0]?.form).toBe('dynamic-import');
|
|
expect(scanDynamicDispatch('import(`./fixed`)', language, 1)).toEqual([]);
|
|
},
|
|
);
|
|
});
|