mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-01 02:01:24 +00:00
* fix(query): send hub content once across process_symbols rows
With include_content, a symbol in several execution flows carried its
full source text on every (id, process_id) row. Keep content on the
first row for each symbol id and omit it from later rows. Membership
fields, is_entry_point, and symbol_count are unchanged.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* fix(query): point agents to the content row and lock the dedup in tests
The query tool text now says content is kept once per symbol id across
the whole process_symbols array, possibly under a different process_id,
and names context({uid, include_content: true}) as the fallback.
Tests: the integration suite asserts func:validate has two rows with
content on exactly one. Unit tests cover a later entry-point row that
is flagged and stripped, a hub in three processes, and that the shaper
does not mutate its input.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* fix(query): state the content-once rule on include_content and in the query hint
The query tool's include_content property now says content is sent once
per symbol id, on its first process_symbols row, and that
context({uid: "<id>", include_content: true}) returns it for any row.
When a query asked for content, the Next hint adds that same fallback;
without include_content the hint is unchanged. The example call now
uses the "<id>" placeholder style used elsewhere in the tool text.
Tests: pin the property sentence, check the hint with and without
include_content, and cover a max_symbols slice that moves the content
row to a later process and a first row that is also the entry point.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
958 lines
44 KiB
TypeScript
958 lines
44 KiB
TypeScript
/**
|
|
* P0 Integration Tests: Local Backend — callTool dispatch
|
|
*
|
|
* Tests the full LocalBackend.callTool() dispatch with a real LadybugDB
|
|
* instance, verifying cypher, context, impact, and query tools work
|
|
* end-to-end against seeded graph data with FTS indexes.
|
|
*/
|
|
import fs from 'fs/promises';
|
|
import { describe, it, expect, beforeAll, vi } from 'vitest';
|
|
import { LocalBackend } from '../../src/mcp/local/local-backend.js';
|
|
import { listRegisteredRepos, saveMeta } from '../../src/storage/repo-manager.js';
|
|
import { withTestLbugDB } from '../helpers/test-indexed-db.js';
|
|
import {
|
|
LOCAL_BACKEND_SEED_DATA,
|
|
LOCAL_BACKEND_FTS_INDEXES,
|
|
} from '../fixtures/local-backend-seed.js';
|
|
|
|
// Partial mock: registry access is faked, but everything else — critically
|
|
// `loadMeta`, which the staleness check in LocalBackend.ensureInitialized
|
|
// calls on every throttled window — stays REAL. A factory that omitted
|
|
// loadMeta made that call site throw a TypeError that the staleness check's
|
|
// catch silently swallowed, so the code path was never actually exercised.
|
|
vi.mock('../../src/storage/repo-manager.js', async (importActual) => ({
|
|
...(await importActual<typeof import('../../src/storage/repo-manager.js')>()),
|
|
listRegisteredRepos: vi.fn().mockResolvedValue([]),
|
|
cleanupOldKuzuFiles: vi.fn().mockResolvedValue({ found: false, needsReindex: false }),
|
|
findSiblingClones: vi.fn().mockResolvedValue([]),
|
|
}));
|
|
|
|
// ─── Block 2: callTool dispatch tests ────────────────────────────────
|
|
|
|
withTestLbugDB(
|
|
'local-backend-calltool',
|
|
(handle) => {
|
|
describe('callTool dispatch with real DB', () => {
|
|
let backend: LocalBackend;
|
|
|
|
beforeAll(async () => {
|
|
// backend is created in afterSetup and attached to the handle
|
|
const ext = handle as typeof handle & { _backend?: LocalBackend };
|
|
if (!ext._backend) {
|
|
throw new Error(
|
|
'LocalBackend not initialized — afterSetup did not attach _backend to handle',
|
|
);
|
|
}
|
|
backend = ext._backend;
|
|
});
|
|
|
|
it('cypher tool returns function names', async () => {
|
|
const result = await backend.callTool('cypher', {
|
|
query: 'MATCH (n:Function) RETURN n.name AS name ORDER BY n.name',
|
|
});
|
|
// cypher tool wraps results as markdown
|
|
expect(result).toHaveProperty('markdown');
|
|
expect(result).toHaveProperty('row_count');
|
|
expect(result.row_count).toBeGreaterThanOrEqual(3);
|
|
expect(result.markdown).toContain('login');
|
|
expect(result.markdown).toContain('validate');
|
|
expect(result.markdown).toContain('hash');
|
|
});
|
|
|
|
it('cypher no-match write probe returns read-only error or empty rows', async () => {
|
|
const result = await backend.callTool('cypher', {
|
|
query:
|
|
"MATCH (n:Function) WHERE n.name = '__missing__' SET n.name = 'x' RETURN n.name AS name",
|
|
});
|
|
if (result?.error) {
|
|
expect(result.error).toMatch(/write operations|read-only/i);
|
|
return;
|
|
}
|
|
expect(result).toEqual([]);
|
|
});
|
|
|
|
it('context tool returns symbol info with callers and callees', async () => {
|
|
const result = await backend.callTool('context', { name: 'login' });
|
|
expect(result).not.toHaveProperty('error');
|
|
expect(result.status).toBe('found');
|
|
// Should have the symbol identity
|
|
expect(result.symbol).toBeDefined();
|
|
expect(result.symbol.name).toBe('login');
|
|
expect(result.symbol.filePath).toBe('src/auth.ts');
|
|
// login calls validate and hash — should appear in outgoing.calls
|
|
expect(result.outgoing).toBeDefined();
|
|
expect(result.outgoing.calls).toBeDefined();
|
|
expect(result.outgoing.calls.length).toBeGreaterThanOrEqual(2);
|
|
const calleeNames = result.outgoing.calls.map((c: any) => c.name);
|
|
expect(calleeNames).toContain('validate');
|
|
expect(calleeNames).toContain('hash');
|
|
});
|
|
|
|
it('impact tool returns upstream dependents', async () => {
|
|
const result = await backend.callTool('impact', {
|
|
target: 'validate',
|
|
direction: 'upstream',
|
|
});
|
|
expect(result).not.toHaveProperty('error');
|
|
// validate is called by login, so login should appear at depth 1
|
|
expect(result.impactedCount).toBeGreaterThanOrEqual(1);
|
|
expect(result.byDepth).toBeDefined();
|
|
const directDeps = result.byDepth[1] || result.byDepth['1'] || [];
|
|
expect(directDeps.length).toBeGreaterThanOrEqual(1);
|
|
const depNames = directDeps.map((d: any) => d.name);
|
|
expect(depNames).toContain('login');
|
|
});
|
|
|
|
it.each(['name', 'symbol'] as const)(
|
|
'impact tool resolves the %s compatibility alias against a real index',
|
|
async (alias) => {
|
|
const result = await backend.callTool('impact', {
|
|
[alias]: 'validate',
|
|
direction: 'upstream',
|
|
});
|
|
expect(result).not.toHaveProperty('error');
|
|
expect(result.target?.name).toBe('validate');
|
|
const directDeps = result.byDepth[1] || result.byDepth['1'] || [];
|
|
expect(directDeps.map((d: any) => d.name)).toContain('login');
|
|
},
|
|
);
|
|
|
|
it('context tool resolves the file compatibility alias against a real index', async () => {
|
|
const result = await backend.callTool('context', {
|
|
name: 'authenticate',
|
|
file: 'src/base.ts',
|
|
});
|
|
expect(result).not.toHaveProperty('error');
|
|
expect(result.status).toBe('found');
|
|
expect(result.symbol?.name).toBe('authenticate');
|
|
expect(result.symbol?.filePath).toBe('src/base.ts');
|
|
});
|
|
|
|
it('query tool returns results for keyword search', async () => {
|
|
const result = await backend.callTool('query', { query: 'login' });
|
|
expect(result).not.toHaveProperty('error');
|
|
expect(result).toHaveProperty('processes');
|
|
expect(result).toHaveProperty('definitions');
|
|
expect(result.processes.map((p: any) => p.id)).toContain('proc:login-flow');
|
|
expect(result.process_symbols.map((s: any) => s.id)).toContain('func:login');
|
|
|
|
// #553: query response carries per-phase timing metadata.
|
|
expect(result.timing).toBeDefined();
|
|
expect(typeof result.timing.wall).toBe('number');
|
|
expect(result.timing.wall).toBeGreaterThanOrEqual(0);
|
|
// At least one of the search phases must have fired for any
|
|
// non-error response — bm25 and/or vector always runs.
|
|
expect(result.timing.bm25 ?? result.timing.vector).toBeGreaterThanOrEqual(0);
|
|
|
|
// Success path (FTS present + Process/Community tables exist): no degraded
|
|
// signal. Guards R6 — the response shape stays byte-identical when nothing
|
|
// fails (the `warning`/`partial` fields appear only on degradation).
|
|
expect(result).not.toHaveProperty('warning');
|
|
expect(result).not.toHaveProperty('partial');
|
|
});
|
|
|
|
// #2175: end-to-end proof that the renamed parameters work against a real
|
|
// index (Claude Code drops a tool arg named exactly "query").
|
|
it('query tool returns results via the new search_query param (#2175)', async () => {
|
|
const result = await backend.callTool('query', { search_query: 'login' });
|
|
expect(result).not.toHaveProperty('error');
|
|
expect(result).toHaveProperty('processes');
|
|
expect(result.processes.map((p: any) => p.id)).toContain('proc:login-flow');
|
|
expect(result.process_symbols.map((s: any) => s.id)).toContain('func:login');
|
|
});
|
|
|
|
it('cypher tool executes via the new statement param (#2175)', async () => {
|
|
const result = await backend.callTool('cypher', {
|
|
statement: 'MATCH (n:Function) RETURN n.name AS name ORDER BY n.name',
|
|
});
|
|
expect(result).toHaveProperty('markdown');
|
|
expect(result).toHaveProperty('row_count');
|
|
expect(result.row_count).toBeGreaterThanOrEqual(3);
|
|
expect(result.markdown).toContain('login');
|
|
});
|
|
|
|
// PR #222 port: the query tool batches per-symbol process/cohesion/content
|
|
// lookups (N+1 → 2-3 `WHERE n.id IN $nodeIds` queries). These assertions
|
|
// guard the batch-adaptation hazards that a naive cherry-pick would break:
|
|
// (1) each symbol keeps ITS OWN community (the per-node first-row pick that
|
|
// replaced the per-symbol `LIMIT 1`), and (2) content maps to the right
|
|
// node — both depend on the +1 positional-index shift after prepending
|
|
// `n.id AS nodeId`. func:login is MEMBER_OF comm:auth ("Authentication");
|
|
// func:validate has no community, so it must NOT inherit login's.
|
|
it('query batches per-symbol enrichment without cross-assigning community/content', async () => {
|
|
const findSym = (res: any, id: string) =>
|
|
(res.process_symbols ?? []).find((s: any) => s.id === id) ??
|
|
(res.definitions ?? []).find((s: any) => s.id === id);
|
|
|
|
const loginRes = await backend.callTool('query', {
|
|
query: 'login',
|
|
include_content: true,
|
|
});
|
|
expect(loginRes).not.toHaveProperty('error');
|
|
const login = findSym(loginRes, 'func:login');
|
|
expect(login).toBeDefined();
|
|
// Community correctly associated to its own node (not dropped, not leaked).
|
|
expect(login.module).toBe('Authentication');
|
|
// Content correctly mapped to its own node (positional [1] after nodeId).
|
|
expect(login.content).toBe('function login() {}');
|
|
|
|
const validateRes = await backend.callTool('query', {
|
|
query: 'validate',
|
|
include_content: true,
|
|
});
|
|
expect(validateRes).not.toHaveProperty('error');
|
|
const validate = findSym(validateRes, 'func:validate');
|
|
expect(validate).toBeDefined();
|
|
// validate has no MEMBER_OF edge — a flat batched `LIMIT 1` would have
|
|
// leaked some other node's community onto it. It must have none.
|
|
expect(validate.module).toBeUndefined();
|
|
expect(validate.content).toBe('function validate() {}');
|
|
// validate is a step in two processes, so it has two process_symbols
|
|
// rows; content is emitted once per symbol id — only the first row
|
|
// carries it, the sibling row omits the key entirely.
|
|
const validateRows = (validateRes.process_symbols ?? []).filter(
|
|
(s: { id: string }) => s.id === 'func:validate',
|
|
);
|
|
expect(validateRows).toHaveLength(2);
|
|
const withContent = validateRows.filter(
|
|
(s: { content?: string }) => s.content === 'function validate() {}',
|
|
);
|
|
expect(withContent).toHaveLength(1);
|
|
const withoutContent = validateRows.filter((s: object) => !('content' in s));
|
|
expect(withoutContent).toHaveLength(1);
|
|
});
|
|
|
|
it('reports content capability for the default full profile', async () => {
|
|
const query = await backend.callTool('query', { query: 'login', include_content: true });
|
|
const context = await backend.callTool('context', { name: 'login', include_content: true });
|
|
|
|
expect(query.contentAvailability).toEqual({
|
|
requested: true,
|
|
profile: 'full',
|
|
available: true,
|
|
scope: 'full',
|
|
});
|
|
expect(context.contentAvailability).toEqual(query.contentAvailability);
|
|
expect(context.symbol.content).toBe('function login() {}');
|
|
});
|
|
|
|
it('does not disclose lingering source text when metadata says retention is none', async () => {
|
|
const storagePath = handle.tmpHandle.dbPath;
|
|
await saveMeta(storagePath, {
|
|
repoPath: '/test/repo',
|
|
storagePath,
|
|
lastCommit: 'abc123',
|
|
indexedAt: new Date().toISOString(),
|
|
contentRetention: 'none',
|
|
contentRetentionSchemaVersion: 1,
|
|
ftsProfile: 'name-only',
|
|
});
|
|
try {
|
|
const query = await backend.callTool('query', { query: 'login', include_content: true });
|
|
const context = await backend.callTool('context', {
|
|
name: 'login',
|
|
include_content: true,
|
|
});
|
|
const login = (query.process_symbols ?? []).find(
|
|
(symbol: any) => symbol.id === 'func:login',
|
|
);
|
|
|
|
expect(query.contentAvailability).toEqual({
|
|
requested: true,
|
|
profile: 'none',
|
|
available: false,
|
|
scope: 'none',
|
|
reason: 'Source-derived content is not retained by this index.',
|
|
});
|
|
expect(login?.content).toBeUndefined();
|
|
expect(context.contentAvailability).toEqual(query.contentAvailability);
|
|
expect(context.symbol.content).toBeUndefined();
|
|
} finally {
|
|
await Promise.all([
|
|
fs.rm(`${storagePath}/gitnexus.json`, { force: true }),
|
|
fs.rm(`${storagePath}/meta.json`, { force: true }),
|
|
]);
|
|
}
|
|
});
|
|
|
|
it('does not disclose lingering source text when retention metadata is invalid', async () => {
|
|
const storagePath = handle.tmpHandle.dbPath;
|
|
await saveMeta(storagePath, {
|
|
repoPath: '/test/repo',
|
|
storagePath,
|
|
lastCommit: 'abc123',
|
|
indexedAt: new Date().toISOString(),
|
|
contentRetention: 'invalid' as never,
|
|
contentRetentionSchemaVersion: 1,
|
|
ftsProfile: 'name-only',
|
|
});
|
|
try {
|
|
const query = await backend.callTool('query', { query: 'login', include_content: true });
|
|
const context = await backend.callTool('context', {
|
|
name: 'login',
|
|
include_content: true,
|
|
});
|
|
const login = (query.process_symbols ?? []).find(
|
|
(symbol: any) => symbol.id === 'func:login',
|
|
);
|
|
|
|
expect(query.contentAvailability).toMatchObject({
|
|
requested: true,
|
|
profile: 'none',
|
|
available: false,
|
|
scope: 'none',
|
|
});
|
|
expect(login?.content).toBeUndefined();
|
|
expect(context.contentAvailability).toEqual(query.contentAvailability);
|
|
expect(context.symbol.content).toBeUndefined();
|
|
} finally {
|
|
await Promise.all([
|
|
fs.rm(`${storagePath}/gitnexus.json`, { force: true }),
|
|
fs.rm(`${storagePath}/meta.json`, { force: true }),
|
|
]);
|
|
}
|
|
});
|
|
|
|
// PR #222 port: a symbol in MULTIPLE processes is what fully exercises the
|
|
// +1 positional shift in the batched STEP_IN_PROCESS aggregation — with a
|
|
// single process row, `row.pid ?? row[1]` succeeds whether the shift is
|
|
// right or wrong. func:validate is a step in BOTH proc:login-flow (step 2)
|
|
// and proc:beta-flow (step 3), so both rows for the one node must be parsed
|
|
// (pid=row[1], step=row[6]); an off-by-one would drop a process or mis-pair
|
|
// pid↔step. Also pins process ranking (totalScore via the regroup-by-nodeId).
|
|
it('query batches a multi-process symbol and ranks processes (positional shift across rows)', async () => {
|
|
const res = await backend.callTool('query', { query: 'validate' });
|
|
expect(res).not.toHaveProperty('error');
|
|
const processIds = (res.processes ?? []).map((p: any) => p.id);
|
|
// Both of validate's processes must appear — both STEP_IN_PROCESS rows
|
|
// were parsed and grouped by the correct pid (row[1]).
|
|
expect(processIds).toContain('proc:login-flow');
|
|
expect(processIds).toContain('proc:beta-flow');
|
|
|
|
// process_symbols keeps one row per (id, process_id): validate is a
|
|
// step in both flows and must appear under each, with that row's
|
|
// shifted pid↔step pairing (login-flow⇒step 2, beta-flow⇒step 3).
|
|
const validates = (res.process_symbols ?? []).filter((s: any) => s.id === 'func:validate');
|
|
expect(validates).toHaveLength(2);
|
|
const byProcess = Object.fromEntries(validates.map((s: any) => [s.process_id, s]));
|
|
expect(byProcess['proc:login-flow']?.step_index).toBe(2);
|
|
expect(byProcess['proc:beta-flow']?.step_index).toBe(3);
|
|
expect(byProcess['proc:login-flow']?.is_entry_point).toBeUndefined();
|
|
expect(byProcess['proc:beta-flow']?.is_entry_point).toBeUndefined();
|
|
for (const procId of ['proc:login-flow', 'proc:beta-flow'] as const) {
|
|
const card = (res.processes ?? []).find((p: any) => p.id === procId);
|
|
const attachCount = (res.process_symbols ?? []).filter(
|
|
(s: any) => s.process_id === procId,
|
|
).length;
|
|
expect(card?.symbol_count).toBe(attachCount);
|
|
}
|
|
|
|
// Ranking: 'login' surfaces proc:login-flow as the top process.
|
|
const loginRes = await backend.callTool('query', { query: 'login' });
|
|
expect((loginRes.processes ?? [])[0]?.id).toBe('proc:login-flow');
|
|
});
|
|
|
|
it('tool_map returns per-tool flows without cross-attributing same-file tools', async () => {
|
|
const result = await backend.callTool('tool_map', {});
|
|
expect(result).not.toHaveProperty('error');
|
|
|
|
const tools = new Map(result.tools.map((tool: any) => [tool.name, tool]));
|
|
expect(tools.get('alpha')?.description).toBe('Calls chain A.');
|
|
expect(tools.get('beta')?.description).toBe('Calls chain B.');
|
|
expect(tools.get('alpha')?.flows).toEqual(['AlphaFlow']);
|
|
expect(tools.get('beta')?.flows).toEqual(['BetaFlow']);
|
|
});
|
|
|
|
it('unknown tool throws', async () => {
|
|
await expect(backend.callTool('nonexistent_tool', {})).rejects.toThrow(/unknown tool/i);
|
|
});
|
|
});
|
|
|
|
describe('impact tool relationTypes filtering', () => {
|
|
let backend: LocalBackend;
|
|
|
|
beforeAll(async () => {
|
|
const ext = handle as typeof handle & { _backend?: LocalBackend };
|
|
if (!ext._backend) {
|
|
throw new Error(
|
|
'LocalBackend not initialized — afterSetup did not attach _backend to handle',
|
|
);
|
|
}
|
|
backend = ext._backend;
|
|
});
|
|
|
|
it('filters by HAS_METHOD only', async () => {
|
|
const result = await backend.callTool('impact', {
|
|
target: 'AuthService',
|
|
direction: 'downstream',
|
|
relationTypes: ['HAS_METHOD'],
|
|
});
|
|
expect(result).not.toHaveProperty('error');
|
|
expect(result.impactedCount).toBeGreaterThanOrEqual(1);
|
|
const d1 = result.byDepth[1] || result.byDepth['1'] || [];
|
|
const names = d1.map((d: any) => d.name);
|
|
expect(names).toContain('authenticate');
|
|
// Should NOT include CALLS-reachable symbols like validate/hash
|
|
expect(names).not.toContain('validate');
|
|
expect(names).not.toContain('hash');
|
|
});
|
|
|
|
it('filters by OVERRIDES only', async () => {
|
|
// The seed has two Method nodes named 'authenticate' (AuthService's
|
|
// override and BaseService's base). Per #470, `impact` now returns
|
|
// a ranked-ambiguous response when the target name hits multiple
|
|
// symbols, so we must disambiguate with file_path to get the
|
|
// AuthService override (the one with the outgoing METHOD_OVERRIDES
|
|
// edge we want to follow downstream).
|
|
const result = await backend.callTool('impact', {
|
|
target: 'authenticate',
|
|
file_path: 'src/auth.ts',
|
|
direction: 'downstream',
|
|
relationTypes: ['METHOD_OVERRIDES'],
|
|
});
|
|
expect(result).not.toHaveProperty('error');
|
|
expect(result.status).not.toBe('ambiguous');
|
|
// AuthService.authenticate overrides BaseService.authenticate
|
|
expect(result.impactedCount).toBeGreaterThanOrEqual(1);
|
|
const d1 = result.byDepth[1] || result.byDepth['1'] || [];
|
|
const names = d1.map((d: any) => d.name);
|
|
expect(names).toContain('authenticate');
|
|
});
|
|
|
|
it('expands legacy OVERRIDES to include METHOD_OVERRIDES (dual-read)', async () => {
|
|
// Pass the LEGACY alias 'OVERRIDES' — impactByUid should flatMap-expand
|
|
// it to ['OVERRIDES', 'METHOD_OVERRIDES'] so the METHOD_OVERRIDES edge
|
|
// between BaseService.authenticate and AuthService.authenticate is found.
|
|
// file_path hint disambiguates the two 'authenticate' methods per #470.
|
|
const result = await backend.callTool('impact', {
|
|
target: 'authenticate',
|
|
file_path: 'src/auth.ts',
|
|
direction: 'downstream',
|
|
relationTypes: ['OVERRIDES'],
|
|
});
|
|
expect(result).not.toHaveProperty('error');
|
|
expect(result.status).not.toBe('ambiguous');
|
|
expect(result.impactedCount).toBeGreaterThanOrEqual(1);
|
|
const d1 = result.byDepth[1] || result.byDepth['1'] || [];
|
|
const names = d1.map((d: any) => d.name);
|
|
expect(names).toContain('authenticate');
|
|
});
|
|
|
|
it('does not return HAS_METHOD results when filtering by CALLS only', async () => {
|
|
const result = await backend.callTool('impact', {
|
|
target: 'AuthService',
|
|
direction: 'downstream',
|
|
relationTypes: ['CALLS'],
|
|
});
|
|
expect(result).not.toHaveProperty('error');
|
|
// AuthService has no outgoing CALLS edges, only HAS_METHOD
|
|
expect(result.impactedCount).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('tool parameter edge cases', () => {
|
|
let backend: LocalBackend;
|
|
|
|
beforeAll(async () => {
|
|
const ext = handle as typeof handle & { _backend?: LocalBackend };
|
|
if (!ext._backend) {
|
|
throw new Error(
|
|
'LocalBackend not initialized — afterSetup did not attach _backend to handle',
|
|
);
|
|
}
|
|
backend = ext._backend;
|
|
});
|
|
|
|
it('context tool returns error for nonexistent symbol', async () => {
|
|
const result = await backend.callTool('context', { name: 'nonexistent_xyz_symbol_999' });
|
|
expect(result).toHaveProperty('error');
|
|
expect(result.error).toMatch(/not found/i);
|
|
});
|
|
|
|
it('query tool returns error for empty query', async () => {
|
|
const result = await backend.callTool('query', { query: '' });
|
|
expect(result).toHaveProperty('error');
|
|
expect(result.error).toMatch(/required/i);
|
|
});
|
|
|
|
it('query tool returns error for missing query param', async () => {
|
|
const result = await backend.callTool('query', {});
|
|
expect(result).toHaveProperty('error');
|
|
});
|
|
|
|
it('cypher tool returns error for invalid Cypher syntax', async () => {
|
|
const result = await backend.callTool('cypher', {
|
|
query: 'THIS IS NOT VALID CYPHER AT ALL',
|
|
});
|
|
expect(result).toHaveProperty('error');
|
|
});
|
|
|
|
it('context tool returns error when no name or uid provided', async () => {
|
|
const result = await backend.callTool('context', {});
|
|
expect(result).toHaveProperty('error');
|
|
expect(result.error).toMatch(/required/i);
|
|
});
|
|
|
|
// ─── impact error handling tests (#321) ───────────────────────────
|
|
// Verify that impact() returns structured JSON instead of crashing
|
|
|
|
it('impact tool returns structured error for unknown symbol', async () => {
|
|
const result = await backend.callTool('impact', {
|
|
target: 'nonexistent_symbol_xyz_999',
|
|
direction: 'upstream',
|
|
});
|
|
// Must return structured JSON, not throw
|
|
expect(result).toBeDefined();
|
|
// Should have either an error field (not found) or impactedCount 0
|
|
// Either outcome is valid — the key is it doesn't crash
|
|
if (result.error) {
|
|
expect(typeof result.error).toBe('string');
|
|
} else {
|
|
expect(result.impactedCount).toBe(0);
|
|
}
|
|
});
|
|
|
|
it('impact error response has consistent target shape', async () => {
|
|
const result = await backend.callTool('impact', {
|
|
target: 'nonexistent_symbol_xyz_999',
|
|
direction: 'downstream',
|
|
});
|
|
// When an error is returned, target must be an object (not raw string)
|
|
// so downstream API consumers can safely access result.target.name
|
|
if (result.error && result.target !== undefined) {
|
|
expect(typeof result.target).toBe('object');
|
|
expect(result.target).not.toBeNull();
|
|
}
|
|
});
|
|
|
|
it('impact partial results: traversalComplete flag when depth fails', async () => {
|
|
// Even if traversal fails at some depth, partial results should be returned
|
|
// and partial:true should only be set when some results were collected
|
|
const result = await backend.callTool('impact', {
|
|
target: 'validate',
|
|
direction: 'upstream',
|
|
maxDepth: 10, // Large depth to trigger multi-level traversal
|
|
});
|
|
// Should succeed (validate exists in seed data)
|
|
expect(result).not.toHaveProperty('error');
|
|
if (result.partial) {
|
|
// If partial, must still have some results
|
|
expect(result.impactedCount).toBeGreaterThan(0);
|
|
}
|
|
});
|
|
});
|
|
|
|
// ─── impact disambiguation + label-scoped resolution (#1907) ─────────
|
|
// Covers the disambiguation surface the CLI --uid/--file/--kind flags
|
|
// wire through to, and guards the label-scoped resolver against the
|
|
// binder failure that motivated the fix.
|
|
describe('impact disambiguation (#1907)', () => {
|
|
let backend: LocalBackend;
|
|
|
|
beforeAll(async () => {
|
|
const ext = handle as typeof handle & { _backend?: LocalBackend };
|
|
if (!ext._backend) {
|
|
throw new Error(
|
|
'LocalBackend not initialized — afterSetup did not attach _backend to handle',
|
|
);
|
|
}
|
|
backend = ext._backend;
|
|
});
|
|
|
|
it('reports an ambiguous target with disambiguation guidance', async () => {
|
|
// Two Methods named 'authenticate' (AuthService + BaseService).
|
|
const result = await backend.callTool('impact', {
|
|
target: 'authenticate',
|
|
direction: 'upstream',
|
|
});
|
|
expect(result).not.toHaveProperty('error');
|
|
expect(result.status).toBe('ambiguous');
|
|
expect(result.message).toMatch(/disambiguate/i);
|
|
const uids = (result.candidates ?? []).map((c: any) => c.uid);
|
|
expect(uids).toContain('method:AuthService.authenticate');
|
|
expect(uids).toContain('method:BaseService.authenticate');
|
|
});
|
|
|
|
it('resolves the ambiguous target via target_uid (the --uid flag path)', async () => {
|
|
const result = await backend.callTool('impact', {
|
|
target: 'authenticate',
|
|
target_uid: 'method:BaseService.authenticate',
|
|
direction: 'upstream',
|
|
});
|
|
expect(result).not.toHaveProperty('error');
|
|
expect(result.status).not.toBe('ambiguous');
|
|
// target_uid selects the exact symbol, bypassing the name ranker.
|
|
expect(result.target?.id).toBe('method:BaseService.authenticate');
|
|
expect(result.target?.filePath).toBe('src/base.ts');
|
|
});
|
|
|
|
it('resolves the ambiguous target via file_path (the --file flag path)', async () => {
|
|
const result = await backend.callTool('impact', {
|
|
target: 'authenticate',
|
|
file_path: 'src/base.ts',
|
|
direction: 'upstream',
|
|
});
|
|
expect(result).not.toHaveProperty('error');
|
|
expect(result.status).not.toBe('ambiguous');
|
|
expect(result.target?.id).toBe('method:BaseService.authenticate');
|
|
});
|
|
|
|
it('does not crash when a name collides across symbol and non-symbol labels', async () => {
|
|
// 'alpha' exists as both a Function and a Tool sharing src/tools.py.
|
|
// The Tool node table has no startLine/endLine columns, so the
|
|
// resolver's `RETURN n.startLine` projection only binds because the
|
|
// candidate set also contains a label that *does* have those columns
|
|
// (lenient multi-table binding). This guards that the disambiguation
|
|
// path keeps tolerating non-symbol node types — and would catch a
|
|
// future naive label-scoping that reintroduces the #1907 binder error
|
|
// ("Cannot find property … for n") by matching property-poor tables
|
|
// in isolation.
|
|
const result = await backend.callTool('impact', {
|
|
target: 'alpha',
|
|
direction: 'upstream',
|
|
});
|
|
expect(result).not.toHaveProperty('error');
|
|
expect(result.status).toBe('ambiguous');
|
|
const uids = (result.candidates ?? []).map((c: any) => c.uid);
|
|
expect(uids).toContain('func:alpha');
|
|
expect(uids).toContain('Tool:alpha');
|
|
});
|
|
|
|
it('context resolves the same cross-label collision without crashing', async () => {
|
|
const result = await backend.callTool('context', { name: 'alpha' });
|
|
expect(result).not.toHaveProperty('error');
|
|
expect(result.status).toBe('ambiguous');
|
|
const uids = (result.candidates ?? []).map((c: any) => c.uid);
|
|
expect(uids).toContain('func:alpha');
|
|
// Assert the non-symbol Tool node stays in the candidate set, not just
|
|
// that nothing crashed — a regression that silently dropped Tool from
|
|
// the lenient-binding match would otherwise pass the non-crash check.
|
|
expect(uids).toContain('Tool:alpha');
|
|
});
|
|
|
|
it('retries unfiltered and still ranks by kind when the hint matches no id prefix (the --kind flag path, #2787 review F5)', async () => {
|
|
// WHY THIS FIXTURE TAKES THE FALLBACK, NOT THE FILTER (#2787 review F5):
|
|
// `kind` is no longer a pure scoring term — it filters with
|
|
// `n.id STARTS WITH 'Function:'`, which relies on the production node-id
|
|
// convention `Label:filePath:qualifiedName`. This fixture predates that
|
|
// convention: its Function node is `func:alpha` (lowercase, in
|
|
// test/fixtures/local-backend-seed.ts), so NO row in this DB satisfies
|
|
// the `Function:` prefix and the filtered window comes back EMPTY.
|
|
// `kind` is a free-form string on the tool schema, so an empty filtered
|
|
// window must not degrade a real name to `not_found`: the resolver
|
|
// retries UNFILTERED and the hint reverts to scoreCandidate's +0.20
|
|
// ranking term. That fallback — not the filter — is what this pins.
|
|
// The filtering path is covered against production-shaped ids by the
|
|
// `resolver-kind-filter-2787` suite below.
|
|
const result = await backend.callTool('impact', {
|
|
target: 'alpha',
|
|
kind: 'Function',
|
|
direction: 'upstream',
|
|
});
|
|
expect(result).not.toHaveProperty('error');
|
|
|
|
// The unfiltered retry ran: BOTH candidates are back, including the
|
|
// Tool the filter would have excluded. Without the fallback this is
|
|
// `{ error: "Symbol 'alpha' not found" }`.
|
|
expect(result.status).toBe('ambiguous');
|
|
const candidates = result.candidates ?? [];
|
|
expect(candidates.map((c: any) => c.uid).sort()).toEqual(['Tool:alpha', 'func:alpha']);
|
|
|
|
// …and the hint still ranks, exactly as it did before the filter
|
|
// existed: 0.50 + 0.20 = 0.70, below the 0.95 confident gate, so the
|
|
// response stays ambiguous with the Function promoted.
|
|
expect(candidates[0]).toMatchObject({ uid: 'func:alpha', kind: 'Function' });
|
|
const tool = candidates.find((c: any) => c.uid === 'Tool:alpha');
|
|
expect(candidates[0]?.score).toBeGreaterThan(tool?.score);
|
|
});
|
|
});
|
|
},
|
|
{
|
|
seed: LOCAL_BACKEND_SEED_DATA,
|
|
ftsIndexes: LOCAL_BACKEND_FTS_INDEXES,
|
|
poolAdapter: true,
|
|
afterSetup: async (handle) => {
|
|
// Configure listRegisteredRepos mock with handle values
|
|
vi.mocked(listRegisteredRepos).mockResolvedValue([
|
|
{
|
|
name: 'test-repo',
|
|
path: '/test/repo',
|
|
storagePath: handle.tmpHandle.dbPath,
|
|
indexedAt: new Date().toISOString(),
|
|
lastCommit: 'abc123',
|
|
stats: { files: 2, nodes: 3, communities: 1, processes: 1 },
|
|
},
|
|
]);
|
|
|
|
const backend = new LocalBackend();
|
|
await backend.init();
|
|
// Stash backend on handle so tests can access it
|
|
(handle as any)._backend = backend;
|
|
},
|
|
},
|
|
);
|
|
|
|
// ─── impact BFS bound parameters (#1907 review F5) ───────────────────────
|
|
// Isolated DB (not the shared seed) with a frontier node whose id contains a
|
|
// single quote. Under the old string-interpolated query this id had to be
|
|
// hand-escaped; the parameterized query (executeParameterized with bound
|
|
// $frontierIds/$relTypes) carries it as data. Guards that a quote-bearing id
|
|
// traverses without a Prepare/parser error, and that a no-caller symbol
|
|
// returns an empty result rather than erroring.
|
|
withTestLbugDB(
|
|
'local-backend-impact-param',
|
|
(handle) => {
|
|
describe('impact BFS bound parameters (#1907 F5)', () => {
|
|
let backend: LocalBackend;
|
|
|
|
beforeAll(() => {
|
|
const ext = handle as typeof handle & { _backend?: LocalBackend };
|
|
if (!ext._backend) {
|
|
throw new Error('LocalBackend not initialized — afterSetup did not attach _backend');
|
|
}
|
|
backend = ext._backend;
|
|
});
|
|
|
|
it('traverses a caller whose id contains a single quote without a query error', async () => {
|
|
const result = await backend.callTool('impact', { target: 'sink', direction: 'upstream' });
|
|
expect(result).not.toHaveProperty('error');
|
|
const d1 = result.byDepth?.[1] || result.byDepth?.['1'] || [];
|
|
const callerIds = d1.map((d: any) => d.uid ?? d.id);
|
|
expect(callerIds).toContain("func:o'd");
|
|
});
|
|
|
|
it('returns an empty result (not an error) for a symbol with no callers', async () => {
|
|
const result = await backend.callTool('impact', {
|
|
target: 'sink',
|
|
direction: 'downstream',
|
|
});
|
|
expect(result).not.toHaveProperty('error');
|
|
expect(result.impactedCount).toBe(0);
|
|
});
|
|
});
|
|
},
|
|
{
|
|
seed: [
|
|
`CREATE (a:Function {id: "func:o'd", name: 'odd', filePath: 'src/q.ts', startLine: 1, endLine: 3, isExported: true, content: 'function odd() {}', description: 'caller with a quote in its id'})`,
|
|
`CREATE (b:Function {id: 'func:sink', name: 'sink', filePath: 'src/q.ts', startLine: 5, endLine: 8, isExported: true, content: 'function sink() {}', description: 'callee'})`,
|
|
`MATCH (a:Function), (b:Function) WHERE a.id = "func:o'd" AND b.id = 'func:sink'
|
|
CREATE (a)-[:CodeRelation {type: 'CALLS', confidence: 1.0, reason: 'direct', step: 0}]->(b)`,
|
|
],
|
|
poolAdapter: true,
|
|
afterSetup: async (handle) => {
|
|
vi.mocked(listRegisteredRepos).mockResolvedValue([
|
|
{
|
|
name: 'param-repo',
|
|
path: '/param/repo',
|
|
storagePath: handle.tmpHandle.dbPath,
|
|
indexedAt: new Date().toISOString(),
|
|
lastCommit: 'abc123',
|
|
stats: { files: 1, nodes: 2, communities: 0, processes: 0 },
|
|
},
|
|
]);
|
|
const backend = new LocalBackend();
|
|
await backend.init();
|
|
(handle as any)._backend = backend;
|
|
},
|
|
},
|
|
);
|
|
|
|
// ─── resolver `kind` hint FILTERS (#2787 review F5) ──────────────────────
|
|
// See `resolveSymbolCandidates` (#2787 F5) for why the id order is label-major
|
|
// and why the hint is therefore a WHERE-clause filter on BOTH the window and
|
|
// its COUNT rather than a scoring term.
|
|
//
|
|
// This suite is deliberately seeded with PRODUCTION-shaped ids, unlike the
|
|
// shared `local-backend-seed` fixture whose Function is `func:alpha` — that
|
|
// legacy shape exercises the unfiltered-retry fallback instead (see the
|
|
// "retries unfiltered…" test above).
|
|
const KIND_RUNNER_METHOD_ID = 'Method:src/pipeline.ts:Runner.run';
|
|
const KIND_RUN_FUNCTION_IDS = ['Function:src/cli/exec.ts:run', 'Function:src/pipeline.ts:run'];
|
|
|
|
withTestLbugDB(
|
|
'resolver-kind-filter-2787',
|
|
(handle) => {
|
|
describe('resolver kind hint filters rather than scores (#2787 review F5)', () => {
|
|
let backend: LocalBackend;
|
|
|
|
beforeAll(() => {
|
|
const ext = handle as typeof handle & { _backend?: LocalBackend };
|
|
if (!ext._backend) {
|
|
throw new Error('LocalBackend not initialized — afterSetup did not attach _backend');
|
|
}
|
|
backend = ext._backend;
|
|
});
|
|
|
|
it('narrows a 3-way name collision to a confident resolution', async () => {
|
|
// Three symbols are named `run`: two Functions and one Method. As a
|
|
// scoring term the Method could only reach 0.50 + 0.20 = 0.70, far
|
|
// below the 0.95 confident gate, so this came back `ambiguous` and the
|
|
// caller had to round-trip for a uid. As a filter the window contains
|
|
// exactly one row, which resolves outright.
|
|
const result = await backend.callTool('impact', {
|
|
target: 'run',
|
|
kind: 'Method',
|
|
direction: 'upstream',
|
|
});
|
|
|
|
expect(result).not.toHaveProperty('error');
|
|
expect(result.status).not.toBe('ambiguous');
|
|
expect(result.target).toMatchObject({ id: KIND_RUNNER_METHOD_ID, name: 'run' });
|
|
// The BFS still ran on the filtered pick: `boot` calls Runner.run.
|
|
expect(result.impactedCount).toBeGreaterThanOrEqual(1);
|
|
});
|
|
|
|
it('excludes non-matching kinds from the candidate set AND from the match count', async () => {
|
|
// The load-bearing difference between filtering and scoring: with
|
|
// kind:'Function' the Method must be ABSENT, not merely ranked last —
|
|
// and `totalCandidates` (the COUNT leg) must agree with the page, or a
|
|
// filtered window ships alongside the unfiltered population.
|
|
const result = await backend.callTool('context', { name: 'run', kind: 'Function' });
|
|
|
|
expect(result).toMatchObject({ status: 'ambiguous', totalCandidates: 2 });
|
|
expect((result.candidates as Array<{ uid: string }>).map((c) => c.uid).sort()).toEqual(
|
|
KIND_RUN_FUNCTION_IDS,
|
|
);
|
|
expect(result).not.toHaveProperty('totalIsLowerBound');
|
|
});
|
|
});
|
|
},
|
|
{
|
|
seed: [
|
|
`CREATE (:Function {id: 'Function:src/pipeline.ts:run', name: 'run', filePath: 'src/pipeline.ts', startLine: 1, endLine: 9, isExported: true, content: 'export function run() {}', description: 'pipeline entry'})`,
|
|
`CREATE (:Function {id: 'Function:src/cli/exec.ts:run', name: 'run', filePath: 'src/cli/exec.ts', startLine: 1, endLine: 9, isExported: true, content: 'export function run() {}', description: 'cli entry'})`,
|
|
`CREATE (:Method {id: '${KIND_RUNNER_METHOD_ID}', name: 'run', filePath: 'src/pipeline.ts', startLine: 20, endLine: 30, isExported: false, content: 'run() {}', description: 'Runner.run'})`,
|
|
`CREATE (:Function {id: 'Function:src/main.ts:boot', name: 'boot', filePath: 'src/main.ts', startLine: 1, endLine: 5, isExported: true, content: 'function boot() {}', description: 'caller of Runner.run'})`,
|
|
`MATCH (a:Function), (b:Method) WHERE a.id = 'Function:src/main.ts:boot' AND b.id = '${KIND_RUNNER_METHOD_ID}'
|
|
CREATE (a)-[:CodeRelation {type: 'CALLS', confidence: 0.9, reason: 'direct', step: 0}]->(b)`,
|
|
],
|
|
poolAdapter: true,
|
|
afterSetup: async (handle) => {
|
|
vi.mocked(listRegisteredRepos).mockResolvedValue([
|
|
{
|
|
name: 'kind-filter-repo',
|
|
path: '/kind/repo',
|
|
storagePath: handle.tmpHandle.dbPath,
|
|
indexedAt: new Date().toISOString(),
|
|
lastCommit: 'abc123',
|
|
stats: { files: 4, nodes: 4, communities: 0, processes: 0 },
|
|
},
|
|
]);
|
|
const backend = new LocalBackend();
|
|
await backend.init();
|
|
(handle as any)._backend = backend;
|
|
},
|
|
},
|
|
);
|
|
|
|
// ─── context ref window must SPREAD across categories (#2787 review F1) ──
|
|
// See the incoming-ref window in `_contextImpl` (#2787 F1) for why the 30-row
|
|
// page is keyed `ORDER BY uid, relType` and not category-major.
|
|
//
|
|
// Shape below: 40 incoming refs on one Method — 35 CALLS, 3 ACCESSES, 1 USES,
|
|
// 1 HAS_METHOD (the owning class, which is how `context` names the owner).
|
|
// relType-major → ACCESSES(3) + CALLS(27); HAS_METHOD and USES gone.
|
|
// uid-major → HAS_METHOD(1) + ACCESSES(3) + USES(1) + CALLS(25).
|
|
// The caller ids are spread through the id space on purpose (`a1` in f05, `u1`
|
|
// in f10, `a2` in f15, `a3` in f25), so the rare categories are NOT stacked at
|
|
// the front of the uid order — they survive because the key interleaves them,
|
|
// not because they were placed first.
|
|
const REF_TARGET_ID = 'Method:src/owner.ts:Owner.handle';
|
|
const REF_OWNER_ID = 'Class:src/owner.ts:Owner';
|
|
const REF_ACCESSES_IDS = [
|
|
'Function:src/f05.ts:a1',
|
|
'Function:src/f15.ts:a2',
|
|
'Function:src/f25.ts:a3',
|
|
];
|
|
const REF_USES_ID = 'Function:src/f10.ts:u1';
|
|
|
|
const refCaller = (id: string, name: string, filePath: string): string =>
|
|
`CREATE (:Function {id: '${id}', name: '${name}', filePath: '${filePath}', startLine: 1, endLine: 3, isExported: true, content: '', description: ''})`;
|
|
|
|
const refEdge = (fromLabel: string, fromId: string, relType: string): string =>
|
|
`MATCH (a:${fromLabel}), (b:Method) WHERE a.id = '${fromId}' AND b.id = '${REF_TARGET_ID}'
|
|
CREATE (a)-[:CodeRelation {type: '${relType}', confidence: 0.9, reason: 'direct', step: 0}]->(b)`;
|
|
|
|
const REF_CALLS_IDS = Array.from({ length: 35 }, (_, i) => {
|
|
const nn = String(i + 1).padStart(2, '0');
|
|
return `Function:src/f${nn}.ts:c${nn}`;
|
|
});
|
|
|
|
withTestLbugDB(
|
|
'context-ref-window-2787',
|
|
(handle) => {
|
|
describe('context incoming-ref window spreads across categories (#2787 review F1)', () => {
|
|
let backend: LocalBackend;
|
|
|
|
beforeAll(() => {
|
|
const ext = handle as typeof handle & { _backend?: LocalBackend };
|
|
if (!ext._backend) {
|
|
throw new Error('LocalBackend not initialized — afterSetup did not attach _backend');
|
|
}
|
|
backend = ext._backend;
|
|
});
|
|
|
|
it('keeps single-edge categories that a category-major ORDER BY starved', async () => {
|
|
const result = await backend.callTool('context', { uid: REF_TARGET_ID });
|
|
|
|
expect(result).not.toHaveProperty('error');
|
|
expect(result.status).toBe('found');
|
|
|
|
// The rare categories are present at all — this is the assertion the
|
|
// pre-fix key fails: HAS_METHOD and USES sort after CALLS, whose 35 rows
|
|
// overflow the window on their own.
|
|
expect(Object.keys(result.incoming).sort()).toEqual([
|
|
'accesses',
|
|
'calls',
|
|
'has_method',
|
|
'uses',
|
|
]);
|
|
expect(result.incoming.has_method.map((r: { uid: string }) => r.uid)).toEqual([
|
|
REF_OWNER_ID,
|
|
]);
|
|
expect(result.incoming.uses.map((r: { uid: string }) => r.uid)).toEqual([REF_USES_ID]);
|
|
expect(result.incoming.accesses.map((r: { uid: string }) => r.uid)).toEqual(
|
|
REF_ACCESSES_IDS,
|
|
);
|
|
|
|
// …and the window is still exactly 30 rows: the fix REDISTRIBUTES the
|
|
// page, it does not widen it. The dominant category gives up the 5 slots
|
|
// the starved ones need.
|
|
expect(result.incoming.calls).toHaveLength(25);
|
|
expect(Object.values(result.incoming).flat()).toHaveLength(30);
|
|
});
|
|
});
|
|
},
|
|
{
|
|
seed: [
|
|
`CREATE (:Method {id: '${REF_TARGET_ID}', name: 'handle', filePath: 'src/owner.ts', startLine: 10, endLine: 20, isExported: false, content: '', description: ''})`,
|
|
`CREATE (:Class {id: '${REF_OWNER_ID}', name: 'Owner', filePath: 'src/owner.ts', startLine: 1, endLine: 40, isExported: true, content: '', description: ''})`,
|
|
...REF_CALLS_IDS.map((id, i) => {
|
|
const nn = String(i + 1).padStart(2, '0');
|
|
return refCaller(id, `c${nn}`, `src/f${nn}.ts`);
|
|
}),
|
|
...REF_ACCESSES_IDS.map((id, i) => refCaller(id, `a${i + 1}`, id.split(':')[1])),
|
|
refCaller(REF_USES_ID, 'u1', 'src/f10.ts'),
|
|
...REF_CALLS_IDS.map((id) => refEdge('Function', id, 'CALLS')),
|
|
...REF_ACCESSES_IDS.map((id) => refEdge('Function', id, 'ACCESSES')),
|
|
refEdge('Function', REF_USES_ID, 'USES'),
|
|
refEdge('Class', REF_OWNER_ID, 'HAS_METHOD'),
|
|
],
|
|
poolAdapter: true,
|
|
afterSetup: async (handle) => {
|
|
vi.mocked(listRegisteredRepos).mockResolvedValue([
|
|
{
|
|
name: 'ref-window-repo',
|
|
path: '/ref/repo',
|
|
storagePath: handle.tmpHandle.dbPath,
|
|
indexedAt: new Date().toISOString(),
|
|
lastCommit: 'abc123',
|
|
stats: { files: 41, nodes: 41, communities: 0, processes: 0 },
|
|
},
|
|
]);
|
|
const backend = new LocalBackend();
|
|
await backend.init();
|
|
(handle as any)._backend = backend;
|
|
},
|
|
},
|
|
);
|