mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-28 05:57:29 +08:00
Merge remote-tracking branch 'origin/main' into fix/kotlin-property-initializer-walk
# Conflicts: # CHANGELOG.md
This commit is contained in:
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* Regression for #1711 — when codegraph_explore trims a file, elided symbols
|
||||
* must be named (gap markers + header bias), not left as a bare `... (gap) ...`
|
||||
* while the footer asks for "exact names" the model was never given.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import CodeGraph from '../src/index';
|
||||
import {
|
||||
ToolHandler,
|
||||
formatGapMarker,
|
||||
symbolsBetweenRanges,
|
||||
biasHeaderSymbols,
|
||||
joinPartsWithNamedGaps,
|
||||
} from '../src/mcp/tools';
|
||||
|
||||
describe('#1711 helpers — name what a trim dropped', () => {
|
||||
it('formatGapMarker stays bare when the hole has no symbols', () => {
|
||||
expect(formatGapMarker('a.ts', [])).toBe('\n\n... (gap) ...\n\n');
|
||||
});
|
||||
|
||||
it('formatGapMarker lists name (file:line) for elided symbols', () => {
|
||||
const marker = formatGapMarker('src/obs.ts', [
|
||||
{ name: 'syncStateNow', kind: 'method', startLine: 1913 },
|
||||
{ name: 'performHeavyDraftSync', kind: 'method', startLine: 1867 },
|
||||
]);
|
||||
expect(marker).toContain('syncStateNow (src/obs.ts:1913)');
|
||||
expect(marker).toContain('performHeavyDraftSync (src/obs.ts:1867)');
|
||||
expect(marker).toMatch(/\.\.\. \(gap: .+\) \.\.\./);
|
||||
});
|
||||
|
||||
it('symbolsBetweenRanges only returns defs that start in the hole', () => {
|
||||
const nodes = [
|
||||
{ name: 'keep', kind: 'method', startLine: 10, endLine: 20 },
|
||||
{ name: 'elided', kind: 'method', startLine: 30, endLine: 40 },
|
||||
{ name: 'also', kind: 'method', startLine: 45, endLine: 50 },
|
||||
{ name: 'later', kind: 'method', startLine: 60, endLine: 70 },
|
||||
{ name: 'imp', kind: 'import', startLine: 35, endLine: 35 },
|
||||
];
|
||||
const hit = symbolsBetweenRanges(nodes, 20, 60);
|
||||
expect(hit.map((h) => h.name)).toEqual(['elided', 'also']);
|
||||
});
|
||||
|
||||
it('biasHeaderSymbols prefers elided labels over frequency alone', () => {
|
||||
const { shown } = biasHeaderSymbols(
|
||||
[
|
||||
'imports0(method)', 'imports0(method)', 'imports0(method)',
|
||||
'imports1(method)', 'imports1(method)',
|
||||
'noise(method)',
|
||||
],
|
||||
[{ name: 'syncStateNow', kind: 'method', startLine: 100 }],
|
||||
3,
|
||||
);
|
||||
expect(shown[0]).toBe('syncStateNow(method)');
|
||||
expect(shown).toContain('imports0(method)');
|
||||
});
|
||||
|
||||
it('joinPartsWithNamedGaps annotates the hole between parts', () => {
|
||||
const text = joinPartsWithNamedGaps(
|
||||
'f.ts',
|
||||
[
|
||||
{ range: { start: 1, end: 5 }, text: 'ONE' },
|
||||
{ range: { start: 40, end: 45 }, text: 'TWO' },
|
||||
],
|
||||
[{ name: 'mid', kind: 'function', startLine: 20, endLine: 25 }],
|
||||
);
|
||||
expect(text).toContain('ONE');
|
||||
expect(text).toContain('TWO');
|
||||
expect(text).toContain('mid (f.ts:20)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('#1711 explore — trimmed file names its elisions', () => {
|
||||
let dir: string;
|
||||
let cg: CodeGraph;
|
||||
let response: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-1711-'));
|
||||
fs.writeFileSync(path.join(dir, 'package.json'), '{"name":"cg1711","version":"1.0.0"}\n');
|
||||
const srcDir = path.join(dir, 'src');
|
||||
fs.mkdirSync(srcDir);
|
||||
// One large observer + noise files so the budget trims rather than shipping whole.
|
||||
const lines: string[] = ['export class EspnDraftObserver {'];
|
||||
for (let i = 0; i < 40; i++) {
|
||||
lines.push(` imports${i}() { return ${i}; }`, '');
|
||||
}
|
||||
const big = (name: string, next: string | null, n: number) => {
|
||||
lines.push(` ${name}() {`);
|
||||
lines.push(` const marker = "${name}_MARKER";`);
|
||||
for (let j = 0; j < n; j++) lines.push(` const x${j} = ${j} + marker.length;`);
|
||||
lines.push(next ? ` return this.${next}();` : ' return marker;');
|
||||
lines.push(' }', '');
|
||||
};
|
||||
big('persistDraftState', null, 60);
|
||||
big('performHeavyDraftSync', 'persistDraftState', 60);
|
||||
big('syncStateNow', 'performHeavyDraftSync', 60);
|
||||
big('scrapeFullDraftState', 'syncStateNow', 60);
|
||||
for (let i = 0; i < 30; i++) {
|
||||
lines.push(` calls${i}() { return ${i}; }`, '');
|
||||
}
|
||||
lines.push('}', '');
|
||||
fs.writeFileSync(path.join(srcDir, 'espn-draft-observer.ts'), lines.join('\n'));
|
||||
for (let i = 1; i <= 20; i++) {
|
||||
fs.writeFileSync(path.join(srcDir, `noise${i}.ts`), `export const n${i} = ${i};\n`);
|
||||
}
|
||||
|
||||
cg = CodeGraph.initSync(dir);
|
||||
await cg.indexAll();
|
||||
const result = await new ToolHandler(cg).execute('codegraph_explore', {
|
||||
query:
|
||||
'In this repos ESPN draft observer (espn-draft-observer.ts), name in order the chain of methods from scrapeFullDraftState to the method that calls storage.saveDraftState. One line.',
|
||||
});
|
||||
response = result.content?.[0]?.text ?? '';
|
||||
}, 120_000);
|
||||
|
||||
afterAll(() => {
|
||||
cg?.destroy();
|
||||
if (dir && fs.existsSync(dir)) fs.rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('still renders the observer file (ranker chooses the right file)', () => {
|
||||
expect(response).toContain('espn-draft-observer.ts');
|
||||
});
|
||||
|
||||
it('names elided symbols inside gap markers as name (file:line)', () => {
|
||||
// Match only in-fence gap markers that list at least one path:line ref.
|
||||
const namedGaps = response.match(/\.\.\. \(gap: [^\n]*?\([^\n]+?:\d+\)[^\n]*?\) \.\.\./g) ?? [];
|
||||
expect(namedGaps.length, `response head:\n${response.slice(0, 2000)}`).toBeGreaterThan(0);
|
||||
for (const g of namedGaps) {
|
||||
expect(g).toMatch(/\w+ \([^\s)]+:\d+\)/);
|
||||
}
|
||||
});
|
||||
|
||||
it('footer points at named gaps / header instead of asking for unknown names', () => {
|
||||
if (!response.includes('trimmed for size')) return;
|
||||
expect(response).toMatch(/preferred in the file header|named inside gap markers/);
|
||||
});
|
||||
|
||||
it('biases the file header away from filler-only when symbols were elided', () => {
|
||||
const header = response.split('\n').find((l) => l.includes('**`src/espn-draft-observer.ts`**'));
|
||||
expect(header).toBeDefined();
|
||||
// Either the header names a chain method, or a named gap does — never
|
||||
// neither while the footer asks for exact names.
|
||||
const namesAnswer = /syncStateNow|performHeavyDraftSync|persistDraftState|scrapeFullDraftState/;
|
||||
const namedSomewhere =
|
||||
namesAnswer.test(header!) ||
|
||||
namesAnswer.test(response);
|
||||
expect(namedSomewhere).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -12,7 +12,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { planFrontload, findIndexedSubprojectRoots, isStructuralPrompt, hasStructuralKeyword, extractCodeTokens } from '../src/directory';
|
||||
import { planFrontload, findIndexedSubprojectRoots, isStructuralPrompt, hasStructuralKeyword, extractCodeTokens, PROMPT_HOOK_INJECTION_MAX, CLAUDE_CODE_INLINE_HOOK_OUTPUT_LIMIT, capPromptHookInjection } from '../src/directory';
|
||||
|
||||
/** Make `dir` look indexed (isInitialized needs `.codegraph/codegraph.db`). */
|
||||
function mkIndexed(dir: string): string {
|
||||
@@ -321,3 +321,28 @@ describe('isStructuralPrompt — cheap candidate gate (keyword OR code-token)',
|
||||
expect(isStructuralPrompt('')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('prompt-hook injection cap (#1694)', () => {
|
||||
it('PROMPT_HOOK_INJECTION_MAX stays under Claude Code\'s 10k inline hook-output limit', () => {
|
||||
expect(PROMPT_HOOK_INJECTION_MAX).toBe(9000);
|
||||
expect(CLAUDE_CODE_INLINE_HOOK_OUTPUT_LIMIT).toBe(10_000);
|
||||
expect(PROMPT_HOOK_INJECTION_MAX).toBeLessThan(CLAUDE_CODE_INLINE_HOOK_OUTPUT_LIMIT);
|
||||
// Leave headroom for the <codegraph_context> wrapper + projectPath nudge lines.
|
||||
expect(CLAUDE_CODE_INLINE_HOOK_OUTPUT_LIMIT - PROMPT_HOOK_INJECTION_MAX).toBeGreaterThanOrEqual(500);
|
||||
});
|
||||
|
||||
it('capPromptHookInjection leaves short payloads intact', () => {
|
||||
expect(capPromptHookInjection('hello')).toBe('hello');
|
||||
expect(capPromptHookInjection('x'.repeat(PROMPT_HOOK_INJECTION_MAX))).toBe('x'.repeat(PROMPT_HOOK_INJECTION_MAX));
|
||||
});
|
||||
|
||||
it('capPromptHookInjection truncates oversize payloads with the explore notice', () => {
|
||||
const over = 'a'.repeat(PROMPT_HOOK_INJECTION_MAX + 500);
|
||||
const out = capPromptHookInjection(over);
|
||||
expect(out.length).toBeLessThan(over.length);
|
||||
expect(out.startsWith('a'.repeat(PROMPT_HOOK_INJECTION_MAX))).toBe(true);
|
||||
expect(out).toContain('…(truncated; call codegraph_explore for the rest)');
|
||||
// Capped body alone must still fit under the host inline limit.
|
||||
expect(out.length).toBeLessThan(CLAUDE_CODE_INLINE_HOOK_OUTPUT_LIMIT);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* A function nested inside another function is only callable from inside its
|
||||
* container. matchByExactName already filters candidates that way; matchFuzzy
|
||||
* must too, or a call to a builtin method (`res.text()`) whose only same-named
|
||||
* project symbol is some file's closure resolves onto that closure.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import { CodeGraph } from '../src';
|
||||
import { matchFuzzy } from '../src/resolution/name-matcher';
|
||||
import type { Node } from '../src/types';
|
||||
import type { ResolutionContext, UnresolvedRef } from '../src/resolution/types';
|
||||
|
||||
describe('fuzzy matching respects lexical reachability of nested functions', () => {
|
||||
let tempDir: string;
|
||||
let cg: CodeGraph | null = null;
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-fuzzy-reach-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cg?.destroy();
|
||||
cg = null;
|
||||
try {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// Windows can still hold the SQLite handle for a moment; the OS temp dir is swept anyway.
|
||||
}
|
||||
});
|
||||
|
||||
it('does not resolve a builtin method call onto another file\'s closure of the same name', async () => {
|
||||
fs.writeFileSync(
|
||||
path.join(tempDir, 'seed.ts'),
|
||||
[
|
||||
'export function readSeedState(raw: string): string {',
|
||||
' function text(): string {',
|
||||
' return raw.trim();',
|
||||
' }',
|
||||
' return text();',
|
||||
'}',
|
||||
'',
|
||||
].join('\n')
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(tempDir, 'fetch.ts'),
|
||||
[
|
||||
'export async function readOkText(settled: { value: Response }): Promise<string> {',
|
||||
' // A chained receiver reaches the resolver as the bare method name.',
|
||||
' return settled.value.text();',
|
||||
'}',
|
||||
'',
|
||||
].join('\n')
|
||||
);
|
||||
cg = await CodeGraph.init(tempDir, { index: true });
|
||||
cg.resolveReferences();
|
||||
|
||||
const closure = cg
|
||||
.getNodesByKind('function')
|
||||
.find((n) => n.name === 'text' && n.filePath === 'seed.ts');
|
||||
const caller = cg.getNodesByKind('function').find((n) => n.name === 'readOkText');
|
||||
expect(closure).toBeDefined();
|
||||
expect(caller).toBeDefined();
|
||||
|
||||
const fromCaller = cg.getOutgoingEdges(caller!.id).filter((e) => e.kind === 'calls');
|
||||
expect(fromCaller.map((e) => e.target)).not.toContain(closure!.id);
|
||||
|
||||
// The in-container call still resolves.
|
||||
const container = cg.getNodesByKind('function').find((n) => n.name === 'readSeedState');
|
||||
const inside = cg.getOutgoingEdges(container!.id).filter((e) => e.kind === 'calls');
|
||||
expect(inside.map((e) => e.target)).toContain(closure!.id);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The reachability check must sit on the one candidate matchFuzzy would
|
||||
* commit to, never on the candidate set. Filtering a crowd of same-named
|
||||
* definitions down to the reachable ones leaves a single survivor, and the
|
||||
* strategy then hands it every call of that name: vite has a dozen `resolve`
|
||||
* definitions, most nested, and one reachable `resolve` method inherited 59
|
||||
* `import { resolve } from 'node:path'` calls that way (#1709). Driven
|
||||
* directly, so the shape is pinned regardless of what the earlier strategies
|
||||
* make of a given fixture.
|
||||
*/
|
||||
describe('fuzzy reachability rejects a unique guess but never manufactures one', () => {
|
||||
const node = (partial: Partial<Node> & Pick<Node, 'id' | 'kind' | 'name' | 'filePath'>): Node => ({
|
||||
qualifiedName: partial.name,
|
||||
language: 'typescript',
|
||||
startLine: 1,
|
||||
endLine: 1,
|
||||
startColumn: 0,
|
||||
endColumn: 0,
|
||||
updatedAt: 0,
|
||||
...partial,
|
||||
});
|
||||
// build.ts: function build() { const resolve = …; function resolve() {} }
|
||||
const container = node({ id: 'f:build', kind: 'function', name: 'build', filePath: 'build.ts', startLine: 1, endLine: 40 });
|
||||
const closure = node({ id: 'f:build.resolve', kind: 'function', name: 'resolve', qualifiedName: 'build::resolve', filePath: 'build.ts', startLine: 10, endLine: 12 });
|
||||
// pluginContainer.ts: class PluginContainer { resolve() {} }
|
||||
const method = node({ id: 'm:resolve', kind: 'method', name: 'resolve', qualifiedName: 'PluginContainer::resolve', filePath: 'pluginContainer.ts', startLine: 5, endLine: 9 });
|
||||
const contextWith = (nodes: Node[]): ResolutionContext =>
|
||||
({
|
||||
getNodesInFile: () => [],
|
||||
getNodesByName: (name: string) => nodes.filter((n) => n.name === name),
|
||||
getNodesByLowerName: (name: string) => nodes.filter((n) => n.name.toLowerCase() === name),
|
||||
getNodesByQualifiedName: (qn: string) => [container].filter((n) => n.qualifiedName === qn),
|
||||
getNodesByKind: () => [],
|
||||
fileExists: () => false,
|
||||
readFile: () => null,
|
||||
getFileLines: () => [],
|
||||
getProjectRoot: () => '',
|
||||
getAllFiles: () => [],
|
||||
getImportMappings: () => [],
|
||||
}) as unknown as ResolutionContext;
|
||||
const callFrom = (filePath: string, line: number): UnresolvedRef => ({
|
||||
fromNodeId: 'f:caller',
|
||||
referenceName: 'resolve',
|
||||
referenceKind: 'calls',
|
||||
line,
|
||||
column: 2,
|
||||
filePath,
|
||||
language: 'typescript',
|
||||
});
|
||||
|
||||
it('declines the sole candidate when it is a closure the call cannot reach', () => {
|
||||
expect(matchFuzzy(callFrom('vite.config.js', 3), contextWith([closure]))).toBeNull();
|
||||
});
|
||||
|
||||
it('still resolves the sole candidate from inside its container', () => {
|
||||
expect(matchFuzzy(callFrom('build.ts', 20), contextWith([closure]))?.targetNodeId).toBe('f:build.resolve');
|
||||
});
|
||||
|
||||
it('does not let the unreachable closure drop out and leave the method as a "unique" match', () => {
|
||||
// Two same-named callables: ambiguous, exactly as before the check existed.
|
||||
expect(matchFuzzy(callFrom('vite.config.js', 3), contextWith([closure, method]))).toBeNull();
|
||||
});
|
||||
|
||||
it('trusts no nesting in C, where a nested function is an extraction artifact', () => {
|
||||
// betaflight: tree-sitter-c's recovery from `RESET_CONFIG(…, .pid = {…})`
|
||||
// runs resetPidProfile to the end of pid.c, so every function after it is
|
||||
// "nested" in the graph. C has no nested named functions; the call reaches it.
|
||||
const cClosure = node({ ...closure, id: 'f:c', language: 'c' as Node['language'], filePath: 'pid.c' });
|
||||
const cRef = { ...callFrom('core.c', 3), language: 'c' as UnresolvedRef['language'] };
|
||||
expect(matchFuzzy(cRef, contextWith([cClosure]))?.targetNodeId).toBe('f:c');
|
||||
});
|
||||
|
||||
it('resolves a lone reachable method as before', () => {
|
||||
expect(matchFuzzy(callFrom('vite.config.js', 3), contextWith([method]))?.targetNodeId).toBe('m:resolve');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* TypeScript's node16/nodenext/bundler resolution writes the EMITTED extension
|
||||
* in a relative specifier (`./util.js` for `util.ts`). The import resolver must
|
||||
* map that back to the source file that is actually in the repo; otherwise the
|
||||
* imported names fall through to bare-name matching and a method that wraps a
|
||||
* same-named import resolves to itself.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import { CodeGraph } from '../src';
|
||||
import { resolveImportPath } from '../src/resolution/import-resolver';
|
||||
import type { ResolutionContext } from '../src/resolution';
|
||||
|
||||
function contextWithFiles(files: string[]): ResolutionContext {
|
||||
const set = new Set(files);
|
||||
return {
|
||||
getNodesInFile: () => [],
|
||||
getNodesByName: () => [],
|
||||
getNodesByQualifiedName: () => [],
|
||||
getNodesByKind: () => [],
|
||||
fileExists: (p: string) => set.has(p),
|
||||
readFile: () => null,
|
||||
getProjectRoot: () => '/test',
|
||||
getAllFiles: () => files,
|
||||
getNodesByLowerName: () => [],
|
||||
getImportMappings: () => [],
|
||||
} as unknown as ResolutionContext;
|
||||
}
|
||||
|
||||
describe('emitted-extension import specifiers (`./x.js` naming `x.ts`)', () => {
|
||||
it('maps a relative .js specifier onto the .ts source', () => {
|
||||
const ctx = contextWithFiles(['shared/engine.ts', 'shared/util.ts']);
|
||||
expect(resolveImportPath('./util.js', 'shared/engine.ts', 'typescript', ctx)).toBe('shared/util.ts');
|
||||
});
|
||||
|
||||
it('prefers a real .js file over the remap when both exist', () => {
|
||||
const ctx = contextWithFiles(['shared/engine.ts', 'shared/util.js', 'shared/util.ts']);
|
||||
expect(resolveImportPath('./util.js', 'shared/engine.ts', 'typescript', ctx)).toBe('shared/util.js');
|
||||
});
|
||||
|
||||
it('maps .jsx, .mjs and .cjs onto their TypeScript sources', () => {
|
||||
const ctx = contextWithFiles(['app/a.tsx', 'app/View.tsx', 'app/esm.mts', 'app/cjs.cts']);
|
||||
expect(resolveImportPath('./View.jsx', 'app/a.tsx', 'tsx', ctx)).toBe('app/View.tsx');
|
||||
expect(resolveImportPath('./esm.mjs', 'app/a.tsx', 'tsx', ctx)).toBe('app/esm.mts');
|
||||
expect(resolveImportPath('./cjs.cjs', 'app/a.tsx', 'tsx', ctx)).toBe('app/cjs.cts');
|
||||
});
|
||||
|
||||
it('maps an aliased .js specifier through tsconfig paths', () => {
|
||||
const files = ['src/main.ts', 'src/lib/util.ts'];
|
||||
const ctx = {
|
||||
...contextWithFiles(files),
|
||||
getProjectAliases: () => ({
|
||||
baseUrl: '/test',
|
||||
patterns: [{ prefix: '@/', suffix: '', hasWildcard: true, replacements: ['src/*'] }],
|
||||
}),
|
||||
} as unknown as ResolutionContext;
|
||||
expect(resolveImportPath('@/lib/util.js', 'src/main.ts', 'typescript', ctx)).toBe('src/lib/util.ts');
|
||||
});
|
||||
|
||||
it('leaves a specifier that names no source unresolved', () => {
|
||||
const ctx = contextWithFiles(['shared/engine.ts']);
|
||||
expect(resolveImportPath('./missing.js', 'shared/engine.ts', 'typescript', ctx)).toBeNull();
|
||||
});
|
||||
|
||||
it('does not remap for a language without TypeScript emit (python)', () => {
|
||||
const ctx = contextWithFiles(['pkg/a.py', 'pkg/b.ts']);
|
||||
expect(resolveImportPath('./b.js', 'pkg/a.py', 'python', ctx)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('end to end: a wrapper method calling the same-named import it wraps', () => {
|
||||
let tempDir: string;
|
||||
let cg: CodeGraph | null = null;
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-emitted-spec-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cg?.destroy();
|
||||
cg = null;
|
||||
try {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// Windows can still hold the SQLite handle for a moment; the OS temp dir is swept anyway.
|
||||
}
|
||||
});
|
||||
|
||||
it('links the call to the imported function, not to the method itself', async () => {
|
||||
fs.writeFileSync(
|
||||
path.join(tempDir, 'template.ts'),
|
||||
'export function renderDockStyles(): string {\n return ".dock {}";\n}\n'
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(tempDir, 'sidebar.ts'),
|
||||
[
|
||||
'import { renderDockStyles } from "./template.js";',
|
||||
'',
|
||||
'export class Sidebar {',
|
||||
' renderDockStyles(): string {',
|
||||
' return renderDockStyles();',
|
||||
' }',
|
||||
'}',
|
||||
'',
|
||||
].join('\n')
|
||||
);
|
||||
cg = await CodeGraph.init(tempDir, { index: true });
|
||||
cg.resolveReferences();
|
||||
|
||||
const method = cg.getNodesByKind('method').find((n) => n.name === 'renderDockStyles');
|
||||
const fn = cg
|
||||
.getNodesByKind('function')
|
||||
.find((n) => n.name === 'renderDockStyles' && n.filePath === 'template.ts');
|
||||
expect(method).toBeDefined();
|
||||
expect(fn).toBeDefined();
|
||||
const targets = cg
|
||||
.getOutgoingEdges(method!.id)
|
||||
.filter((e) => e.kind === 'calls')
|
||||
.map((e) => e.target);
|
||||
expect(targets).toContain(fn!.id);
|
||||
expect(targets).not.toContain(method!.id);
|
||||
});
|
||||
});
|
||||
@@ -138,6 +138,8 @@ describe('Installer targets — contract', () => {
|
||||
// opencode uses `mcp` not `mcpServers`. Match its shape too.
|
||||
if (target.id === 'opencode') {
|
||||
delete seed.mcpServers;
|
||||
// Keep a v1-shaped sibling — real configs mix shapes during the
|
||||
// OpenCode 1→2 transition; install must not disturb it (#1698).
|
||||
seed.mcp = { other: { type: 'local', command: ['x'], enabled: true } };
|
||||
}
|
||||
// VS Code's mcp.json uses `servers`; the JetBrains Copilot
|
||||
@@ -153,7 +155,10 @@ describe('Installer targets — contract', () => {
|
||||
const after = JSON.parse(fs.readFileSync(jsonPath, 'utf-8'));
|
||||
if (target.id === 'opencode') {
|
||||
expect(after.mcp.other).toBeDefined();
|
||||
expect(after.mcp.codegraph).toBeDefined();
|
||||
expect(after.mcp.servers.codegraph).toBeDefined();
|
||||
expect(after.mcp.servers.codegraph.codemode).toBe(false);
|
||||
expect(after.mcp.servers.codegraph.disabled).toBe(false);
|
||||
expect(after.mcp.codegraph).toBeUndefined();
|
||||
} else if (target.id === 'copilot-vscode' || target.id === 'copilot-jetbrains') {
|
||||
expect(after.servers.other).toBeDefined();
|
||||
expect(after.servers.codegraph).toBeDefined();
|
||||
@@ -875,7 +880,7 @@ describe('Installer targets — partial-state idempotency', () => {
|
||||
expect(body).toContain(' telegram:\n - hermes-telegram');
|
||||
});
|
||||
|
||||
it('opencode: uninstall removes only mcp.codegraph, preserves comments and siblings', () => {
|
||||
it('opencode: uninstall removes only mcp.servers.codegraph, preserves comments and siblings', () => {
|
||||
const opencode = getTarget('opencode')!;
|
||||
const dir = path.join(tmpHome, '.config', 'opencode');
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
@@ -892,13 +897,15 @@ describe('Installer targets — partial-state idempotency', () => {
|
||||
].join('\n'));
|
||||
|
||||
opencode.install('global', { autoAllow: true });
|
||||
const afterInstall = fs.readFileSync(file, 'utf-8');
|
||||
expect(afterInstall).toContain('"codegraph"');
|
||||
expect(afterInstall).toContain('"other"');
|
||||
const afterInstall = parseJsonc(fs.readFileSync(file, 'utf-8'));
|
||||
expect(afterInstall.mcp.servers.codegraph).toBeDefined();
|
||||
expect(afterInstall.mcp.servers.codegraph.codemode).toBe(false);
|
||||
expect(afterInstall.mcp.other).toBeDefined();
|
||||
|
||||
opencode.uninstall('global');
|
||||
const afterUninstall = fs.readFileSync(file, 'utf-8');
|
||||
expect(afterUninstall).not.toContain('codegraph');
|
||||
expect(afterUninstall).not.toContain('"servers"');
|
||||
expect(afterUninstall).toContain('// important comment');
|
||||
expect(afterUninstall).toContain('"other"');
|
||||
});
|
||||
@@ -976,6 +983,22 @@ describe('Installer targets — partial-state idempotency', () => {
|
||||
expect(fs.existsSync(path.join(tmpCwd, '.claude.json'))).toBe(false);
|
||||
const cfg = JSON.parse(fs.readFileSync(path.join(tmpCwd, '.mcp.json'), 'utf-8'));
|
||||
expect(cfg.mcpServers.codegraph).toBeDefined();
|
||||
// Exempt from Claude Code's tool-search deferral (#1696).
|
||||
expect(cfg.mcpServers.codegraph.alwaysLoad).toBe(true);
|
||||
});
|
||||
|
||||
it('claude: re-running install on an entry that predates alwaysLoad adds the key (#1696)', () => {
|
||||
const claude = getTarget('claude')!;
|
||||
fs.writeFileSync(
|
||||
path.join(tmpCwd, '.mcp.json'),
|
||||
JSON.stringify({ mcpServers: { codegraph: { type: 'stdio', command: 'codegraph', args: ['serve', '--mcp'] } } }, null, 2),
|
||||
);
|
||||
const result = claude.install('local', { autoAllow: false });
|
||||
const mcp = result.files.find((f) => f.path.replace(/\\/g, '/').endsWith('/.mcp.json'));
|
||||
expect(mcp?.action).toBe('updated');
|
||||
const cfg = JSON.parse(fs.readFileSync(path.join(tmpCwd, '.mcp.json'), 'utf-8'));
|
||||
expect(cfg.mcpServers.codegraph.alwaysLoad).toBe(true);
|
||||
expect(cfg.mcpServers.codegraph.args).toEqual(['serve', '--mcp']);
|
||||
});
|
||||
|
||||
it('claude: install creates the CLAUDE.md codegraph block (#704)', () => {
|
||||
@@ -1010,6 +1033,7 @@ describe('Installer targets — partial-state idempotency', () => {
|
||||
claude.install('global', { autoAllow: false });
|
||||
const cfg = JSON.parse(fs.readFileSync(path.join(tmpHome, '.claude.json'), 'utf-8'));
|
||||
expect(cfg.mcpServers.codegraph).toBeDefined();
|
||||
expect(cfg.mcpServers.codegraph.alwaysLoad).toBe(true);
|
||||
});
|
||||
|
||||
it('claude: local install migrates a legacy ./.claude.json codegraph entry into ./.mcp.json', () => {
|
||||
@@ -1813,6 +1837,149 @@ function listAllFiles(dir: string): string[] {
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// opencode OpenCode 2 native MCP shape (#1698)
|
||||
//
|
||||
// OpenCode 2 reads `mcp.servers.<name>` with `disabled` / `codemode`. The
|
||||
// v1 `mcp.<name>` + `enabled` shape still connects but drops `codemode`
|
||||
// during normalization — so the installer must write the native shape and
|
||||
// migrate/uninstall either.
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('Installer targets — opencode native MCP shape (#1698)', () => {
|
||||
let tmpHome: string;
|
||||
let tmpCwd: string;
|
||||
let origCwd: string;
|
||||
let homeRestore: { restore: () => void };
|
||||
|
||||
beforeEach(() => {
|
||||
tmpHome = mkTmpDir('home');
|
||||
tmpCwd = mkTmpDir('cwd');
|
||||
origCwd = process.cwd();
|
||||
process.chdir(tmpCwd);
|
||||
homeRestore = setHome(tmpHome);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
homeRestore.restore();
|
||||
process.chdir(origCwd);
|
||||
fs.rmSync(tmpHome, { recursive: true, force: true });
|
||||
fs.rmSync(tmpCwd, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const configFile = () => path.join(tmpHome, '.config', 'opencode', 'opencode.jsonc');
|
||||
|
||||
it('install writes mcp.servers.codegraph with disabled:false and codemode:false', () => {
|
||||
const opencode = getTarget('opencode')!;
|
||||
opencode.install('global', { autoAllow: true });
|
||||
const cfg = JSON.parse(fs.readFileSync(configFile(), 'utf-8'));
|
||||
expect(cfg.mcp.codegraph).toBeUndefined();
|
||||
expect(cfg.mcp.servers.codegraph).toEqual({
|
||||
type: 'local',
|
||||
command: ['codegraph', 'serve', '--mcp'],
|
||||
disabled: false,
|
||||
codemode: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('printConfig shows the native OpenCode 2 shape', () => {
|
||||
const out = getTarget('opencode')!.printConfig('global');
|
||||
expect(out).toContain('"servers"');
|
||||
expect(out).toContain('"codemode": false');
|
||||
expect(out).toContain('"disabled": false');
|
||||
expect(out).not.toContain('"enabled"');
|
||||
// No v1 top-level mcp.codegraph key in the snippet.
|
||||
expect(out).not.toMatch(/"mcp"\s*:\s*\{\s*"codegraph"/);
|
||||
});
|
||||
|
||||
it('re-install migrates a v1 mcp.codegraph entry to mcp.servers.codegraph', () => {
|
||||
const dir = path.dirname(configFile());
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(configFile(), [
|
||||
'{',
|
||||
' // keep me',
|
||||
' "$schema": "https://opencode.ai/config.json",',
|
||||
' "mcp": {',
|
||||
' "codegraph": { "type": "local", "command": ["codegraph", "serve", "--mcp"], "enabled": true },',
|
||||
' "other": { "type": "local", "command": ["x"], "enabled": true }',
|
||||
' }',
|
||||
'}',
|
||||
'',
|
||||
].join('\n'));
|
||||
|
||||
const opencode = getTarget('opencode')!;
|
||||
expect(opencode.detect('global').alreadyConfigured).toBe(true);
|
||||
|
||||
const result = opencode.install('global', { autoAllow: true });
|
||||
expect(result.files.find((f) => f.path === configFile())!.action).toBe('updated');
|
||||
|
||||
const text = fs.readFileSync(configFile(), 'utf-8');
|
||||
expect(text).toContain('// keep me');
|
||||
const cfg = parseJsonc(text);
|
||||
expect(cfg.mcp.codegraph).toBeUndefined();
|
||||
expect(cfg.mcp.other).toBeDefined();
|
||||
expect(cfg.mcp.servers.codegraph).toEqual({
|
||||
type: 'local',
|
||||
command: ['codegraph', 'serve', '--mcp'],
|
||||
disabled: false,
|
||||
codemode: false,
|
||||
});
|
||||
|
||||
// Idempotent after migration.
|
||||
const second = opencode.install('global', { autoAllow: true });
|
||||
expect(second.files.find((f) => f.path === configFile())!.action).toBe('unchanged');
|
||||
});
|
||||
|
||||
it('uninstall removes a leftover v1 mcp.codegraph entry', () => {
|
||||
const dir = path.dirname(configFile());
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(configFile(), [
|
||||
'{',
|
||||
' // keep me',
|
||||
' "$schema": "https://opencode.ai/config.json",',
|
||||
' "mcp": {',
|
||||
' "codegraph": { "type": "local", "command": ["codegraph", "serve", "--mcp"], "enabled": true },',
|
||||
' "other": { "type": "local", "command": ["x"], "enabled": true }',
|
||||
' }',
|
||||
'}',
|
||||
'',
|
||||
].join('\n'));
|
||||
|
||||
const opencode = getTarget('opencode')!;
|
||||
opencode.uninstall('global');
|
||||
const text = fs.readFileSync(configFile(), 'utf-8');
|
||||
expect(text).toContain('// keep me');
|
||||
expect(text).toContain('"other"');
|
||||
expect(text).not.toContain('codegraph');
|
||||
expect(opencode.detect('global').alreadyConfigured).toBe(false);
|
||||
});
|
||||
|
||||
it('uninstall removes a native mcp.servers.codegraph entry and an emptied servers wrapper', () => {
|
||||
const dir = path.dirname(configFile());
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(configFile(), JSON.stringify({
|
||||
$schema: 'https://opencode.ai/config.json',
|
||||
mcp: {
|
||||
servers: {
|
||||
codegraph: {
|
||||
type: 'local',
|
||||
command: ['codegraph', 'serve', '--mcp'],
|
||||
disabled: false,
|
||||
codemode: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
}, null, 2) + '\n');
|
||||
|
||||
const opencode = getTarget('opencode')!;
|
||||
opencode.uninstall('global');
|
||||
const text = fs.readFileSync(configFile(), 'utf-8');
|
||||
expect(text).not.toContain('codegraph');
|
||||
expect(text).not.toContain('"servers"');
|
||||
expect(text).not.toContain('"mcp"');
|
||||
expect(opencode.detect('global').alreadyConfigured).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// opencode global config path — XDG on every platform (#535)
|
||||
//
|
||||
@@ -2180,7 +2347,7 @@ describe('Installer targets — Copilot family', () => {
|
||||
|
||||
// ---- copilot-cli ----
|
||||
|
||||
it('copilot-cli: global install writes ~/.copilot/mcp-config.json with the documented entry shape (tools: ["*"])', () => {
|
||||
it('copilot-cli: global install writes ~/.copilot/mcp-config.json with the documented entry shape (tools: ["*"], deferTools: "never")', () => {
|
||||
const t = getTarget('copilot-cli')!;
|
||||
const result = t.install('global', { autoAllow: true });
|
||||
|
||||
@@ -2193,9 +2360,26 @@ describe('Installer targets — Copilot family', () => {
|
||||
command: 'codegraph',
|
||||
args: ['serve', '--mcp'],
|
||||
tools: ['*'],
|
||||
// Exempt from Copilot CLI's tool search, the same way `alwaysLoad` exempts it in Claude Code (#1696).
|
||||
deferTools: 'never',
|
||||
});
|
||||
});
|
||||
|
||||
it('copilot-cli: re-running install on an entry that predates deferTools adds the key (#1696)', () => {
|
||||
const t = getTarget('copilot-cli')!;
|
||||
const file = path.join(tmpHome, '.copilot', 'mcp-config.json');
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
file,
|
||||
JSON.stringify({ mcpServers: { codegraph: { type: 'stdio', command: 'codegraph', args: ['serve', '--mcp'], tools: ['*'] } } }, null, 2),
|
||||
);
|
||||
const result = t.install('global', { autoAllow: true });
|
||||
expect(result.files[0].action).toBe('updated');
|
||||
const cfg = JSON.parse(fs.readFileSync(file, 'utf-8'));
|
||||
expect(cfg.mcpServers.codegraph.deferTools).toBe('never');
|
||||
expect(cfg.mcpServers.codegraph.tools).toEqual(['*']);
|
||||
});
|
||||
|
||||
it('copilot-cli: is global-only — local install skips with a clear note, uninstall is a no-op', () => {
|
||||
const t = getTarget('copilot-cli')!;
|
||||
expect(t.supportsLocation('local')).toBe(false);
|
||||
|
||||
@@ -12,6 +12,9 @@
|
||||
* rewrites codegraph_explore's description via spread), and the no-default-
|
||||
* project surface (`withRequiredProjectPath`, which clones the schema). A drop in
|
||||
* any of those would silently re-block the tools in Ask mode.
|
||||
*
|
||||
* `codegraph_explore`'s `_meta` (`anthropic/alwaysLoad`, #1696) rides the same
|
||||
* spreads, so each surface is checked for it here too.
|
||||
*/
|
||||
import { describe, it, expect, afterEach, beforeEach } from 'vitest';
|
||||
import * as fs from 'fs';
|
||||
@@ -34,6 +37,13 @@ function expectReadOnly(tool: ToolDefinition): void {
|
||||
expect(tool.annotations!.openWorldHint).toBe(false);
|
||||
}
|
||||
|
||||
/** Assert the explore tool in a `tools/list` surface is marked always-load for Claude Code (#1696). */
|
||||
function expectExploreAlwaysLoad(surface: ToolDefinition[]): void {
|
||||
const explore = surface.find((t) => t.name === 'codegraph_explore');
|
||||
expect(explore, 'codegraph_explore is missing from the surface').toBeDefined();
|
||||
expect(explore!._meta).toEqual({ 'anthropic/alwaysLoad': true });
|
||||
}
|
||||
|
||||
describe('Read-only annotations on the codegraph MCP tools (#1018)', () => {
|
||||
const original = process.env[ENV];
|
||||
afterEach(() => {
|
||||
@@ -44,6 +54,7 @@ describe('Read-only annotations on the codegraph MCP tools (#1018)', () => {
|
||||
it('every tool in the master array is annotated read-only', () => {
|
||||
expect(tools.length).toBeGreaterThan(0);
|
||||
for (const tool of tools) expectReadOnly(tool);
|
||||
expectExploreAlwaysLoad(tools);
|
||||
});
|
||||
|
||||
it('the static proxy surface carries annotations on every exposed tool', () => {
|
||||
@@ -52,6 +63,7 @@ describe('Read-only annotations on the codegraph MCP tools (#1018)', () => {
|
||||
const got = getStaticTools();
|
||||
expect(got.map((t) => t.name).sort()).toEqual(tools.map((t) => t.name).sort());
|
||||
for (const tool of got) expectReadOnly(tool);
|
||||
expectExploreAlwaysLoad(got);
|
||||
});
|
||||
|
||||
it('the no-default-project surface keeps annotations through the schema clone', () => {
|
||||
@@ -65,6 +77,7 @@ describe('Read-only annotations on the codegraph MCP tools (#1018)', () => {
|
||||
// Sanity: this IS the clone path (projectPath got marked required).
|
||||
expect(tool.inputSchema.required ?? []).toContain('projectPath');
|
||||
}
|
||||
expectExploreAlwaysLoad(got);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -101,5 +114,6 @@ describe('Live tool surface keeps annotations with a project open (#1018)', () =
|
||||
expect(explore).toBeDefined();
|
||||
expect(explore!.description).toMatch(/Budget: make at most/);
|
||||
expectReadOnly(explore!);
|
||||
expectExploreAlwaysLoad(got);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* A quoted (forward-reference) parameter annotation names a receiver type too
|
||||
* (#1684): `def f(o: "Alpha")` resolves `o.render()` exactly like `def f(o:
|
||||
* Alpha)`. Quoted annotations are ordinary Python — forward references, and
|
||||
* everything under `from __future__ import annotations`.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { CodeGraph } from '../src';
|
||||
import { initGrammars, loadAllGrammars } from '../src/extraction/grammars';
|
||||
|
||||
let dir: string;
|
||||
let cg: CodeGraph;
|
||||
|
||||
beforeAll(async () => {
|
||||
await initGrammars();
|
||||
await loadAllGrammars();
|
||||
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-1684-'));
|
||||
fs.mkdirSync(path.join(dir, 'pkg'));
|
||||
fs.writeFileSync(path.join(dir, 'pkg', '__init__.py'), '');
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'pkg', 'a.py'),
|
||||
'def render(x):\n return x\n\nclass Alpha:\n def render(self):\n return "a"\n\nclass Beta:\n def render(self):\n return "b"\n'
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'pkg', 'b.py'),
|
||||
'from __future__ import annotations\nfrom pkg.a import Alpha, Beta\n\n' +
|
||||
'def quoted(o: "Alpha"):\n return o.render()\n\n' +
|
||||
"def single_quoted(o: 'Beta'):\n return o.render()\n\n" +
|
||||
'def unquoted(o: Alpha):\n return o.render()\n'
|
||||
);
|
||||
cg = CodeGraph.initSync(dir);
|
||||
await cg.indexAll();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
cg.destroy();
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const calleeOf = (fn: string): string[] =>
|
||||
cg
|
||||
.getCallees(cg.getNodesByName(fn).find((n) => n.kind === 'function')!.id)
|
||||
.map(({ node }) => node.qualifiedName)
|
||||
.sort();
|
||||
|
||||
describe('quoted forward-reference annotations (#1684)', () => {
|
||||
it('resolves the method on the quoted type, the same as the unquoted annotation', () => {
|
||||
expect(calleeOf('unquoted')).toEqual(['Alpha::render']);
|
||||
expect(calleeOf('quoted')).toEqual(['Alpha::render']);
|
||||
expect(calleeOf('single_quoted')).toEqual(['Beta::render']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* A TS/JS member call reached through a host namespace — `chrome.storage.local
|
||||
* .get(k)`, `document.body.querySelector(s)` — ends in a platform API. Emitting
|
||||
* the bare method name for it let every such call exact-match whatever project
|
||||
* symbol shared the name, so a storage wrapper's `get` called itself (#1707).
|
||||
* Those are dropped. A chain rooted at a project value keeps the bare name:
|
||||
* `window.MyNs.run()` and `this.<field>.m()` reach real targets.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { CodeGraph } from '../src';
|
||||
|
||||
let dir: string;
|
||||
let cg: CodeGraph;
|
||||
|
||||
beforeAll(async () => {
|
||||
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-1707-'));
|
||||
const w = (rel: string, body: string) => fs.writeFileSync(path.join(dir, rel), body);
|
||||
w(
|
||||
'storage.ts',
|
||||
'declare const chrome: any;\n' +
|
||||
'export const DraftHubStorage = {\n' +
|
||||
' async get(key: string): Promise<unknown> {\n' +
|
||||
' const result = await chrome.storage.local.get([key]);\n' +
|
||||
' return result[key];\n' +
|
||||
' },\n' +
|
||||
'};\n'
|
||||
);
|
||||
w(
|
||||
'dom.ts',
|
||||
'export function querySelector(sel: string): string { return sel; }\n' +
|
||||
'export function findRow(): unknown {\n' +
|
||||
' return document.body.querySelector("tr");\n' +
|
||||
'}\n'
|
||||
);
|
||||
w(
|
||||
'service.ts',
|
||||
'declare const window: any;\n' +
|
||||
'export function ping(): string { return "pong"; }\n' +
|
||||
'export function viaGlobal(): string {\n' +
|
||||
' return window.MyNs.ping();\n' +
|
||||
'}\n' +
|
||||
'export class Runner {\n' +
|
||||
' constructor(private svc: { ping(): string }) {}\n' +
|
||||
' run(): string { return this.svc.ping(); }\n' +
|
||||
'}\n'
|
||||
);
|
||||
cg = await CodeGraph.init(dir, { index: true });
|
||||
cg.resolveReferences();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
cg.destroy();
|
||||
try {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// Windows can still hold the SQLite handle for a moment; the OS temp dir is swept anyway.
|
||||
}
|
||||
});
|
||||
|
||||
const fn = (name: string, file: string) =>
|
||||
cg.getNodesByKind('function').find((n) => n.name === name && n.filePath === file)!;
|
||||
const method = (qn: string) => cg.getNodesByKind('method').find((n) => n.qualifiedName === qn)!;
|
||||
const callTargets = (id: string) =>
|
||||
cg
|
||||
.getOutgoingEdges(id)
|
||||
.filter((e) => e.kind === 'calls')
|
||||
.map((e) => e.target);
|
||||
|
||||
describe('TS/JS call through a host-global chain (#1707)', () => {
|
||||
it('does not make a storage wrapper call itself through chrome.storage.local.get', () => {
|
||||
const get = fn('get', 'storage.ts');
|
||||
expect(get).toBeDefined();
|
||||
expect(callTargets(get.id)).not.toContain(get.id);
|
||||
});
|
||||
|
||||
it('does not bind document.body.querySelector to a same-named project function', () => {
|
||||
expect(callTargets(fn('findRow', 'dom.ts').id)).not.toContain(
|
||||
fn('querySelector', 'dom.ts').id
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps a chain rooted at a project value — window.MyNs.m() and this.<field>.m()', () => {
|
||||
const ping = fn('ping', 'service.ts').id;
|
||||
expect(callTargets(fn('viaGlobal', 'service.ts').id)).toContain(ping);
|
||||
expect(callTargets(method('Runner::run').id)).toContain(ping);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user