Merge pull request #937 from colbymchenry/codegraph-ai

Engine batch: dispatch-synthesizer family, React component/route coverage, installer UX + front-load hook
This commit is contained in:
Colby Mchenry authored and GitHub committed 2026-06-21 12:59:21 -05:00
commit b218f625f7
57 files changed
+6246 -357

No files matched your search

@@ -0,0 +1,129 @@
/**
* Celery task-dispatch bridge (Python).
*
* Celery decouples a task's call site from its body: a `@shared_task` / `@app.task`
* decorated `def` is invoked through `task.delay(...)` / `task.apply_async(...)`, a
* dynamic hop with no static edge. This bridges each `.delay`/`.apply_async` site to
* the task function, gated on the DECORATOR (read from the source above the `def`) so a
* `.delay()` on a non-task object resolves to nothing. Covers both decorator dialects
* (`@shared_task`, `@app.task(...)`), the module-qualified `mod.task.apply_async()` form,
* and proves the precision gates: a plain function called with `.delay()` and a canvas
* `group(...).delay()` (no single identifier before `.delay`) both contribute no edge.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import { CodeGraph } from '../src';
describe('celery-dispatch synthesizer', () => {
let dir: string;
beforeEach(() => { dir = fs.mkdtempSync(path.join(os.tmpdir(), 'celery-dispatch-')); });
afterEach(() => { fs.rmSync(dir, { recursive: true, force: true }); });
it('bridges .delay()/.apply_async() to decorated tasks, ignoring non-task and canvas dispatch', async () => {
// Two decorator dialects: bare @shared_task and arg'd @app.task(...).
fs.writeFileSync(
path.join(dir, 'tasks.py'),
`from celery import shared_task
from myapp.celery import app
@shared_task
def send_email(to):
return to
@app.task(bind=True, max_retries=3)
def crunch(self, n):
return n * 2
`
);
fs.mkdirSync(path.join(dir, 'services'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'services', 'tickets.py'),
`from celery import shared_task
@shared_task
def invalidate_cache():
return None
`
);
// A plain function — NOT a celery task — that nonetheless has .delay() called on it.
fs.writeFileSync(
path.join(dir, 'utils.py'),
`def process_data(x):
return x
`
);
// Dispatch sites, all inside one enclosing function.
fs.writeFileSync(
path.join(dir, 'views.py'),
`from tasks import send_email, crunch
from services import tickets
from utils import process_data
from celery import group
def handle_request(req):
send_email.delay(req.addr) # → send_email task (cross-file)
crunch.apply_async(args=[5]) # → crunch task (@app.task dialect)
tickets.invalidate_cache.apply_async() # module-qualified → invalidate_cache
process_data.delay(req.x) # NOT a task → no edge
group([send_email.s(a) for a in req.addrs]).delay() # canvas → no edge
`
);
const cg = await CodeGraph.init(dir, { silent: true });
await cg.indexAll();
const db = (cg as any).db.db;
const edges = db
.prepare(
`SELECT s.name source, t.name target, t.file_path tf, json_extract(e.metadata,'$.via') via
FROM edges e JOIN nodes s ON s.id = e.source JOIN nodes t ON t.id = e.target
WHERE json_extract(e.metadata,'$.synthesizedBy') = 'celery-dispatch'`
)
.all();
const targets = (src: string) => edges.filter((r: any) => r.source === src).map((r: any) => r.target).sort();
// handle_request dispatches exactly the three real tasks (both dialects + module-qualified).
expect(targets('handle_request')).toEqual(['crunch', 'invalidate_cache', 'send_email']);
// The @app.task target resolved to the task def, not anything else.
const crunchEdge = edges.find((r: any) => r.target === 'crunch');
expect(crunchEdge.tf).toMatch(/tasks\.py$/);
// Module-qualified `tickets.invalidate_cache.apply_async()` resolved by the last identifier.
const cacheEdge = edges.find((r: any) => r.target === 'invalidate_cache');
expect(cacheEdge.tf).toMatch(/services[\\/]tickets\.py$/);
expect(cacheEdge.via).toBe('invalidate_cache');
// PRECISION: a plain function called with .delay() is never targeted (no decorator).
expect(edges.some((r: any) => r.target === 'process_data')).toBe(false);
cg.close?.();
});
it('produces no edges in a Celery-free project (clean control)', async () => {
fs.writeFileSync(
path.join(dir, 'app.py'),
`def schedule(job):
job.delay() # a .delay() that has nothing to do with Celery
return job
def run():
schedule(make_job())
`
);
const cg = await CodeGraph.init(dir, { silent: true });
await cg.indexAll();
const db = (cg as any).db.db;
const count = db
.prepare(
`SELECT count(*) c FROM edges WHERE json_extract(metadata,'$.synthesizedBy') = 'celery-dispatch'`
)
.get();
expect(count.c).toBe(0);
cg.close?.();
});
});
+108 -1
View File
@@ -8,7 +8,7 @@
* showing nothing. Deterministic, query-time only, no graph mutation, and a
* fully connected flow must never produce the section.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
@@ -16,6 +16,19 @@ import CodeGraph from '../src/index';
import { ToolHandler } from '../src/mcp/tools';
import { scanDynamicDispatch } from '../src/mcp/dynamic-boundaries';
// These suites assert on the RAW codegraph_explore output (the Flow / boundary
// sections). The managed reasoning-offload, when configured on the dev machine
// (~/.codegraph/config.json `{"offload":{"managed":true}}`), REPLACES that output
// with a remote Cerebras synthesis — so the structural assertions only hold with
// the offload off. Disable it for this file so the suite is hermetic regardless
// of machine config, then restore.
let _prevOffloadDisable: string | undefined;
beforeAll(() => { _prevOffloadDisable = process.env.CODEGRAPH_OFFLOAD_DISABLE; process.env.CODEGRAPH_OFFLOAD_DISABLE = '1'; });
afterAll(() => {
if (_prevOffloadDisable === undefined) delete process.env.CODEGRAPH_OFFLOAD_DISABLE;
else process.env.CODEGRAPH_OFFLOAD_DISABLE = _prevOffloadDisable;
});
// ---------------------------------------------------------------------------
// Unit: the scanner
// ---------------------------------------------------------------------------
@@ -297,3 +310,97 @@ describe('codegraph_explore — dynamic boundaries', () => {
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');
});
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');
});
});
@@ -0,0 +1,121 @@
/**
* codegraph_explore — multi-term corroboration tier (cross-layer monorepo ranking).
*
* BEHAVIOURAL coverage for the `isCorroborated` tier in handleExplore's file sort:
* a backend file that is BOTH an entry/central file AND matched by >=2 DISTINCT
* query terms must be surfaced (rendered as a `#### <path>` source section) for a
* backend-flow query in a multi-layer repo — not displaced by a denser frontend
* layer. The tier exists because explore's primary file ranker is graph-centrality
* (Random-Walk-with-Restart) mass, which — seeded from text matches that skew to
* the bigger, internally dense layer — can bury a query-matching backend file under
* an off-topic cluster. The entry/central GUARD keeps the tier safe: an INCIDENTAL
* multi-term file that is neither entry nor central is NOT promoted, so it cannot
* displace a graph-central answer file (the regression a blunt hits-only tier caused
* on excalidraw, where `binding.ts`/`elbowArrow.ts` displaced `renderNewElementScene`).
*
* NOTE: the full directus-scale burial (where frontend RWR mass exceeds a
* query-matching backend file) is an EMERGENT property of thousands of real frontend
* symbols — a self-contained fixture can't reach the cluster size past
* findRelevantContext's retrieval cap. That regression is isolated by the
* deterministic ranking harness on real indexes (directus/n8n/excalidraw), where the
* api/ service moves from "absent/mentioned" to "sourced" with no control regression.
* These tests lock the user-visible behaviour the tier guarantees on a fixture.
*/
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/index';
import { ToolHandler } from '../src/mcp/tools';
/** Paths that explore rendered as full-body `#### <path> —` source sections. */
function sourcedFiles(text: string): string[] {
const out: string[] = [];
for (const line of text.split('\n')) {
const m = line.match(/^#### (.+?) —/);
if (m) out.push(m[1].trim());
}
return out;
}
describe('codegraph_explore — multi-term corroboration tier', () => {
let testDir: string;
let cg: CodeGraph;
let handler: ToolHandler;
beforeEach(async () => {
testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-corrob-'));
// --- The large, internally DENSE frontend layer ---------------------------
// Many `app/` files whose SYMBOLS all match the word "item" and form a tight
// call mesh, so Random-Walk-with-Restart mass (seeded from those text matches)
// concentrates here. They are NOT the answer to a backend query — but at scale
// their cluster mass out-ranks the call-isolated backend file.
// "item" is a PATH token (app/item/...) so FTS (token-based, not substring)
// retrieves every file for the query term "item" — matching directus's `app/`
// tree where "item" is a real path/symbol token, not a camelCase fragment.
const appItem = path.join(testDir, 'app', 'item');
fs.mkdirSync(appItem, { recursive: true });
const N = 30;
for (let i = 0; i < N; i++) {
const next = (i + 1) % N;
const prev = (i + N - 1) % N;
// Each file imports two neighbours → a dense mesh of `references`/`calls`.
// snake_case so FTS tokenizes "item" out of the symbol name (camelCase would
// leave `itemview0` as a single unmatchable token).
fs.writeFileSync(path.join(appItem, `view${i}.ts`),
`import { item_view_${next} } from './view${next}';\n` +
`import { item_view_${prev} } from './view${prev}';\n` +
`export function item_view_${i}() {\n` +
` return item_view_${next}() + item_view_${prev}();\n` +
`}\n`);
}
// --- The small, call-ISOLATED backend file (the answer) -------------------
// Its PATH matches TWO distinct query terms (api/item/service.ts → item +
// service), so it IS a search root (an entry file) with file-term-hits >=2 —
// but its generic SYMBOLS don't text-match, and nothing in the frontend mesh
// calls it, so it gets no RWR inflow and its restart mass is diluted across the
// large frontend seed set. This is the directus shape: ItemsService is
// search-relevant by name/path yet call-isolated from the frontend seed cluster,
// so RWR alone buries it under the mesh. Only the corroboration tier (path/name
// matches >=2 query terms AND it's an entry file) keeps it in.
const apiItem = path.join(testDir, 'api', 'item');
fs.mkdirSync(apiItem, { recursive: true });
fs.writeFileSync(path.join(apiItem, 'service.ts'),
`export class DataService {\n` +
` read() { return this.load(); }\n` +
` load(): string[] { return []; }\n` +
`}\n`);
cg = CodeGraph.initSync(testDir, { config: { include: ['**/*.ts'], exclude: [] } });
await cg.indexAll();
handler = new ToolHandler(cg);
});
afterEach(() => {
if (cg) cg.destroy();
if (fs.existsSync(testDir)) fs.rmSync(testDir, { recursive: true, force: true });
});
it('sources the corroborated backend file alongside a denser frontend cluster in a multi-layer repo', async () => {
const res = await handler.execute('codegraph_explore', { query: 'item service' });
const text = res.content[0].text;
const sourced = sourcedFiles(text);
// The backend service — matched by item+service and a search root — must
// be rendered, not truncated out by the frontend mesh's graph mass.
expect(sourced).toContain('api/item/service.ts');
});
it('still leads with the backend file when the query names its symbol directly', async () => {
// A query naming the backend symbol directly: the answer is the DataService
// file; the frontend mesh stays subordinate (it matches only "item").
const res = await handler.execute('codegraph_explore', { query: 'DataService read load' });
const text = res.content[0].text;
const sourced = sourcedFiles(text);
expect(sourced).toContain('api/item/service.ts');
// The named backend file leads — it is not displaced by the frontend layer.
expect(sourced[0]).toBe('api/item/service.ts');
});
});
@@ -0,0 +1,86 @@
/**
* Regression: codegraph_explore must SURFACE a synthesized edge whose endpoints are
* `constant` nodes (RTK thunk→thunk), on a SMALL repo.
*
* `buildFlowFromNamedSymbols` historically filtered its "named" set to CALLABLE kinds
* (method/function/component/constructor), excluding `constant`. RTK thunks are
* `export const X = createAsyncThunk(...)`, so a thunk→thunk hop is constant→constant —
* it never entered the flow scan and surfaced nowhere on the Flow path. The kind-agnostic
* "### Relationships" section would have caught it, but that is disabled below 500 files.
* Net: on a small RTK app the synthesized edge existed in the graph yet was invisible to
* the agent. The fix feeds a `dynNamed` set (named non-callable endpoints that participate
* in a heuristic edge) to the tier-independent "## Dynamic-dispatch links" scan. This test
* pins it on a deliberately tiny (<150-file) fixture so the Relationships gate is OFF and
* the dynamic-dispatch-links path is the ONLY thing that can surface the hop.
*/
import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import CodeGraph from '../src/index';
import { ToolHandler } from '../src/mcp/tools';
// Assertions read RAW codegraph_explore output; managed offload would replace it. Disable
// it for this file so the suite is hermetic regardless of dev-machine config, then restore.
let _prevOffloadDisable: string | undefined;
beforeAll(() => { _prevOffloadDisable = process.env.CODEGRAPH_OFFLOAD_DISABLE; process.env.CODEGRAPH_OFFLOAD_DISABLE = '1'; });
afterAll(() => {
if (_prevOffloadDisable === undefined) delete process.env.CODEGRAPH_OFFLOAD_DISABLE;
else process.env.CODEGRAPH_OFFLOAD_DISABLE = _prevOffloadDisable;
});
describe('codegraph_explore — synthesized constant→constant edges surface on small repos', () => {
let dir: string;
let cg: CodeGraph;
let handler: ToolHandler;
afterEach(() => {
if (cg) cg.destroy();
if (dir && fs.existsSync(dir)) fs.rmSync(dir, { recursive: true, force: true });
});
it('surfaces an RTK thunk→thunk hop (both `constant`) in the Dynamic-dispatch links section', async () => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'explore-thunk-surface-'));
fs.writeFileSync(
path.join(dir, 'package.json'),
JSON.stringify({ name: 'app', dependencies: { '@reduxjs/toolkit': '^2' } })
);
fs.writeFileSync(
path.join(dir, 'thunks.ts'),
`import { createAsyncThunk } from '@reduxjs/toolkit';
export const deepThunk = createAsyncThunk('app/deep', async (n: number) => n * 2);
export const innerThunk = createAsyncThunk('app/inner', async (n: number, { dispatch }) => {
return dispatch(deepThunk(n));
});
export const outerThunk = createAsyncThunk('app/outer', async (n: number, { dispatch }) => {
await dispatch(innerThunk(n));
});
`
);
cg = CodeGraph.initSync(dir, { config: { include: ['**/*.ts'], exclude: [] } });
await cg.indexAll();
// Precondition: the endpoints really are `constant` nodes — the exact kind the old
// CALLABLE-only flow scan dropped (if extraction ever classed them as functions the
// test would pass vacuously, so assert the case we actually fixed).
const db = (cg as any).db.db;
const outerKind = db.prepare(`SELECT kind FROM nodes WHERE name = 'outerThunk' LIMIT 1`).get()?.kind;
expect(outerKind).toBe('constant');
handler = new ToolHandler(cg);
const res = await handler.execute('codegraph_explore', { query: 'outerThunk innerThunk' });
const text = res.content[0].text as string;
// The synthesized hop now surfaces (was invisible: both endpoints `constant` AND the
// small-repo Relationships section is off).
expect(text).toContain('## Dynamic-dispatch links among your symbols');
expect(text).toMatch(/outerThunk\s+→\s+innerThunk/);
// It reads as a dynamic-dispatch bridge with its wiring site, not a bare `calls`.
expect(text).toMatch(/dynamic: redux thunk @/);
expect(text).not.toMatch(/outerThunk\s+→\s+innerThunk\s+\[calls\]/);
});
});
+53
View File
@@ -908,3 +908,56 @@ describe('Go gRPC stub→impl synthesis', () => {
}
});
});
describe('React Router end-to-end route extraction (.tsx/.jsx)', () => {
let tmpDir: string | undefined;
afterEach(() => {
if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true });
tmpDir = undefined;
});
// Regression for the resolver language-gate bug: the `react` resolver's
// `extract()` was filtered out of the .tsx/.jsx grammars, so `<Route>` routes
// — which only live in JSX files — were never indexed through the real
// indexing path (the unit tests call extract() directly and so missed this).
it('indexes <Route element={<X/>}> routes from a .tsx file and links them to the component', async () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-rr-'));
fs.writeFileSync(
path.join(tmpDir, 'package.json'),
'{"dependencies":{"react":"^18.0.0","react-router-dom":"^6.0.0"}}'
);
fs.writeFileSync(
path.join(tmpDir, 'Home.tsx'),
'export function Home() { return null; }\n'
);
fs.writeFileSync(
path.join(tmpDir, 'routes.tsx'),
`import { Routes, Route } from 'react-router-dom';
import { Home } from './Home';
export function AppRoutes() {
return (
<Routes>
<Route path="/home" element={<Home/>} />
</Routes>
);
}
`
);
const cg = CodeGraph.initSync(tmpDir);
await cg.indexAll();
try {
// The route node from the .tsx file exists (the bug: it didn't).
const route = cg.getNodesByKind('route').find((n) => n.name === '/home');
expect(route, '/home route from .tsx should be indexed').toBeDefined();
// ...and it links to the Home component.
const home = cg.getNodesByName('Home').find((n) => n.kind === 'function');
expect(home).toBeDefined();
const toHome = cg.getOutgoingEdges(route!.id).find((e) => e.target === home!.id);
expect(toHome, 'route → Home component edge').toBeDefined();
} finally {
cg.close();
}
});
});
+80 -2
View File
@@ -21,7 +21,7 @@ import * as os from 'os';
import { ALL_TARGETS, getTarget, resolveTargetFlag } from '../src/installer/targets/registry';
import { uninstallTargets } from '../src/installer';
import { upsertTomlTable, removeTomlTable, buildTomlTable } from '../src/installer/targets/toml';
import { cleanupLegacyHooks } from '../src/installer/targets/claude';
import { cleanupLegacyHooks, writePromptHookEntry, removePromptHookEntry } from '../src/installer/targets/claude';
function mkTmpDir(label: string): string {
return fs.mkdtempSync(path.join(os.tmpdir(), `cg-targets-${label}-`));
@@ -1031,7 +1031,7 @@ describe('Installer targets — partial-state idempotency', () => {
// The unrelated GitKraken hook survives untouched.
expect(stopCommands.some((c: string) => c.includes('gk') && c.includes('ai hook run'))).toBe(true);
// Permissions still written as normal alongside the cleanup.
expect(after.permissions?.allow).toContain('mcp__codegraph__codegraph_search');
expect(after.permissions?.allow).toContain('mcp__codegraph__*');
});
it('claude: cleanupLegacyHooks preserves a sibling hook sharing our matcher group', () => {
@@ -1097,6 +1097,84 @@ describe('Installer targets — partial-state idempotency', () => {
// Both events emptied → the whole `hooks` object is removed.
expect(after.hooks).toBeUndefined();
});
// ---- Front-load prompt hook (UserPromptSubmit) — #841 follow-up ----
// Opt-in (default-yes in the installer) UserPromptSubmit hook that runs
// `codegraph prompt-hook`. Must write/remove surgically, be idempotent, and
// round-trip an opt-out — without disturbing the user's own hooks.
const promptCommands = (s: any): string[] =>
(s.hooks?.UserPromptSubmit ?? []).flatMap((g: any) => (g.hooks ?? []).map((h: any) => h.command));
it('claude: install with promptHook:true writes the UserPromptSubmit hook (alongside permissions)', () => {
const claude = getTarget('claude')!;
claude.install('global', { autoAllow: true, promptHook: true });
const s = JSON.parse(fs.readFileSync(path.join(tmpHome, '.claude', 'settings.json'), 'utf-8'));
expect(promptCommands(s)).toContain('codegraph prompt-hook');
expect(s.permissions?.allow).toContain('mcp__codegraph__*');
});
it('claude: install without promptHook does NOT add the hook', () => {
const claude = getTarget('claude')!;
claude.install('global', { autoAllow: true });
const s = JSON.parse(fs.readFileSync(path.join(tmpHome, '.claude', 'settings.json'), 'utf-8'));
expect(promptCommands(s)).not.toContain('codegraph prompt-hook');
});
it('claude: install with promptHook:true is idempotent (no duplicate, byte-identical re-run)', () => {
const claude = getTarget('claude')!;
const file = path.join(tmpHome, '.claude', 'settings.json');
claude.install('global', { autoAllow: true, promptHook: true });
const first = fs.readFileSync(file, 'utf-8');
claude.install('global', { autoAllow: true, promptHook: true });
expect(fs.readFileSync(file, 'utf-8')).toBe(first);
const s = JSON.parse(first);
expect(promptCommands(s).filter((c: string) => c === 'codegraph prompt-hook')).toHaveLength(1);
});
it('claude: install with promptHook:false strips a hook a prior install wrote (opt-out round-trips)', () => {
const claude = getTarget('claude')!;
claude.install('global', { autoAllow: true, promptHook: true });
claude.install('global', { autoAllow: true, promptHook: false });
const s = JSON.parse(fs.readFileSync(path.join(tmpHome, '.claude', 'settings.json'), 'utf-8'));
expect(promptCommands(s)).not.toContain('codegraph prompt-hook');
});
it('claude: writePromptHookEntry preserves a sibling UserPromptSubmit hook', () => {
const file = seedSettings('global', {
hooks: { UserPromptSubmit: [{ hooks: [{ type: 'command', command: 'my-own-hook' }] }] },
});
expect(writePromptHookEntry('global').action).toBe('updated');
const s = JSON.parse(fs.readFileSync(file, 'utf-8'));
expect(promptCommands(s)).toEqual(['my-own-hook', 'codegraph prompt-hook']);
});
it('claude: uninstall removes the prompt hook but keeps the user\'s sibling', () => {
const file = seedSettings('global', {
hooks: {
UserPromptSubmit: [
{ hooks: [{ type: 'command', command: 'codegraph prompt-hook' }] },
{ hooks: [{ type: 'command', command: 'my-own-hook' }] },
],
},
});
getTarget('claude')!.uninstall('global');
const s = JSON.parse(fs.readFileSync(file, 'utf-8'));
expect(promptCommands(s)).toEqual(['my-own-hook']);
});
it('claude: removePromptHookEntry leaves the legacy auto-sync hook untouched', () => {
const file = seedSettings('global', {
hooks: {
UserPromptSubmit: [{ hooks: [{ type: 'command', command: 'codegraph prompt-hook' }] }],
Stop: [{ hooks: [{ type: 'command', command: 'codegraph sync-if-dirty' }] }],
},
});
expect(removePromptHookEntry('global').action).toBe('removed');
const s = JSON.parse(fs.readFileSync(file, 'utf-8'));
expect(promptCommands(s)).not.toContain('codegraph prompt-hook');
const stopCmds = (s.hooks?.Stop ?? []).flatMap((g: any) => (g.hooks ?? []).map((h: any) => h.command));
expect(stopCmds).toContain('codegraph sync-if-dirty');
});
});
describe('Installer targets — registry', () => {
+169
View File
@@ -0,0 +1,169 @@
/**
* Laravel event-dispatch bridge (PHP).
*
* Laravel decouples an event dispatch from its listener(s), linked by the event class:
* `event(new SongLiked($id))` has no static edge to the `handle(SongLiked $e)` that runs it
* (usually a separate `app/Listeners/` file). This bridges each `event(new X(...))` site to every
* listener's `handle` for X, via TWO registration mechanisms: (A) a typed `handle(EventType $e)`
* (auto-discovery, union-split for `A|B`) and (B) the `protected $listen` map in an
* EventServiceProvider (which also covers a listener whose `handle()` is untyped). Queued JOBS
* dispatch via `::dispatch()`/`dispatch()` and their `handle()` takes a service — so only
* `event(new X)` is matched and jobs are excluded.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import { CodeGraph } from '../src';
describe('laravel-event synthesizer', () => {
let dir: string;
beforeEach(() => { dir = fs.mkdtempSync(path.join(os.tmpdir(), 'laravel-event-')); });
afterEach(() => { fs.rmSync(dir, { recursive: true, force: true }); });
const write = (rel: string, body: string) => {
const p = path.join(dir, rel);
fs.mkdirSync(path.dirname(p), { recursive: true });
fs.writeFileSync(p, body);
};
it('bridges event(new X) to listener handles via typed handles, the $listen map, unions, and fan-out; excludes jobs', async () => {
for (const [name, body] of [
['SongLiked', 'public int $id; public function __construct(int $id) { $this->id = $id; }'],
['LibraryChanged', ''],
['ScanDone', ''],
['OwnerTest', ''],
['UserTest', ''],
] as const) {
write(`app/Events/${name}.php`, `<?php\nnamespace App\\Events;\nclass ${name} {\n ${body}\n}\n`);
}
// (A) typed-handle listener — auto-discovery, no $listen entry needed.
write('app/Listeners/LoveTrack.php', `<?php
namespace App\\Listeners;
use App\\Events\\SongLiked;
class LoveTrack {
public function handle(SongLiked $event): void {}
}
`);
// (B) UNTYPED handle — linkable only through the $listen map.
write('app/Listeners/PruneLibrary.php', `<?php
namespace App\\Listeners;
class PruneLibrary {
public function handle(): void {}
}
`);
// Fan-out: two listeners for ScanDone.
write('app/Listeners/WriteScanLog.php', `<?php
namespace App\\Listeners;
use App\\Events\\ScanDone;
class WriteScanLog {
public function handle(ScanDone $event): void {}
}
`);
write('app/Listeners/DeleteStale.php', `<?php
namespace App\\Listeners;
use App\\Events\\ScanDone;
class DeleteStale {
public function handle(ScanDone $event): void {}
}
`);
// Union-typed handle — one listener, two events.
write('app/Listeners/SendsTestNotification.php', `<?php
namespace App\\Listeners;
use App\\Events\\OwnerTest;
use App\\Events\\UserTest;
class SendsTestNotification {
public function handle(OwnerTest|UserTest $event): void {}
}
`);
// A queued JOB — handle takes a service, dispatched via ::dispatch()/dispatch(). Never an edge.
write('app/Jobs/ProcessAudio.php', `<?php
namespace App\\Jobs;
use App\\Services\\AudioService;
class ProcessAudio implements ShouldQueue {
public function handle(AudioService $svc): void {}
}
`);
// The $listen map — registers the untyped PruneLibrary for LibraryChanged.
write('app/Providers/EventServiceProvider.php', `<?php
namespace App\\Providers;
use App\\Events\\LibraryChanged;
use App\\Listeners\\PruneLibrary;
class EventServiceProvider {
protected $listen = [
LibraryChanged::class => [
PruneLibrary::class,
],
];
}
`);
write('app/Services/SongService.php', `<?php
namespace App\\Services;
use App\\Events\\SongLiked;
use App\\Events\\LibraryChanged;
use App\\Events\\ScanDone;
use App\\Events\\OwnerTest;
use App\\Events\\UserTest;
use App\\Jobs\\ProcessAudio;
class SongService {
public function like(int $id): void { event(new SongLiked($id)); }
public function deleteSongs(): void { event(new LibraryChanged()); }
public function scan(): void { event(new ScanDone()); }
public function ownerTest(): void { event(new OwnerTest()); }
public function userTest(): void { event(new UserTest()); }
public function process(): void {
ProcessAudio::dispatch();
dispatch(new ProcessAudio());
}
}
`);
const cg = await CodeGraph.init(dir, { silent: true });
await cg.indexAll();
const db = (cg as any).db.db;
const edges = db
.prepare(
`SELECT s.name source, t.name target, t.file_path tf, json_extract(e.metadata,'$.via') via
FROM edges e JOIN nodes s ON s.id = e.source JOIN nodes t ON t.id = e.target
WHERE json_extract(e.metadata,'$.synthesizedBy') = 'laravel-event'`
)
.all();
const bySrc = (s: string) => edges.filter((r: any) => r.source === s);
const file = (r: any) => /(\w+)\.php$/.exec(r.tf)![1];
expect(edges.length).toBe(6);
expect(edges.every((r: any) => r.target === 'handle')).toBe(true);
// (A) typed handle.
expect(bySrc('like').map((r: any) => [r.via, file(r)])).toEqual([['SongLiked', 'LoveTrack']]);
// (B) untyped handle via the $listen map.
expect(bySrc('deleteSongs').map((r: any) => [r.via, file(r)])).toEqual([['LibraryChanged', 'PruneLibrary']]);
// Fan-out: ScanDone → both listeners.
expect(new Set(bySrc('scan').map(file))).toEqual(new Set(['WriteScanLog', 'DeleteStale']));
// Union split: OwnerTest and UserTest each reach the one listener (separate dispatchers,
// so they aren't deduped to a single source→target edge).
expect(bySrc('ownerTest').map((r: any) => [r.via, file(r)])).toEqual([['OwnerTest', 'SendsTestNotification']]);
expect(bySrc('userTest').map((r: any) => [r.via, file(r)])).toEqual([['UserTest', 'SendsTestNotification']]);
// PRECISION: a queued job (::dispatch / dispatch()) produces nothing.
expect(edges.some((r: any) => r.source === 'process')).toBe(false);
cg.close?.();
});
it('produces no edges in a PHP project with no Laravel events (clean control)', async () => {
write('src/Client.php', `<?php
namespace Acme;
class Client {
public function send(string $url): string { return $url; }
}
`);
const cg = await CodeGraph.init(dir, { silent: true });
await cg.indexAll();
const db = (cg as any).db.db;
const count = db
.prepare(`SELECT count(*) c FROM edges WHERE json_extract(metadata,'$.synthesizedBy') = 'laravel-event'`)
.get();
expect(count.c).toBe(0);
cg.close?.();
});
});
+7 -13
View File
@@ -17,18 +17,13 @@ describe('CODEGRAPH_MCP_TOOLS allowlist', () => {
const listed = () => new ToolHandler(null).getTools().map(t => t.name).sort();
it('exposes the default 4-tool surface when unset', () => {
it('exposes ONLY codegraph_explore by default when unset', () => {
delete process.env[ENV];
// The default set (see DEFAULT_MCP_TOOLS): explore + node are the
// validated workhorses, search the cheap lookup, callers the one
// irreplaceable enumerator. callees/impact/files/status stay defined
// and executable but unlisted — impact appeared in ZERO recorded runs.
expect(listed()).toEqual([
'codegraph_callers',
'codegraph_explore',
'codegraph_node',
'codegraph_search',
]);
// The default set (see DEFAULT_MCP_TOOLS) is pared to explore alone — the one
// tool that earns its place (verbatim source grouped by file).
// node/search/callers/callees/impact/files/status stay defined and executable
// but unlisted; CODEGRAPH_MCP_TOOLS re-enables them.
expect(listed()).toEqual(['codegraph_explore']);
});
it('re-enables an unlisted tool via the allowlist (impact)', () => {
@@ -48,8 +43,7 @@ describe('CODEGRAPH_MCP_TOOLS allowlist', () => {
it('treats an empty/whitespace value as unset (default surface)', () => {
process.env[ENV] = ' ';
expect(listed()).toHaveLength(4);
expect(listed()).toContain('codegraph_explore');
expect(listed()).toEqual(['codegraph_explore']);
});
it('rejects a disabled tool on execute (defense in depth)', async () => {
+7 -7
View File
@@ -116,7 +116,7 @@ describe('Unindexed-workspace session policy', () => {
expect(instructions).toMatch(/inactive/i);
expect(instructions).toMatch(/codegraph init/);
// The full playbook must NOT be sent into a session where every call fails
expect(instructions).not.toMatch(/Tool selection by intent/);
expect(instructions).not.toMatch(/How to query/);
expect(instructions).not.toMatch(/codegraph_explore/);
});
@@ -128,7 +128,7 @@ describe('Unindexed-workspace session policy', () => {
expect((res.result as { tools: unknown[] }).tools).toEqual([]);
});
it('an INDEXED workspace still gets the full playbook and all tools', async () => {
it('an INDEXED workspace still gets the full playbook and the explore tool', async () => {
fs.writeFileSync(path.join(tempDir, 'index.ts'), 'export function hello(): string { return "hi"; }\n');
const cg = await CodeGraph.init(tempDir, { index: true });
cg.close();
@@ -136,15 +136,15 @@ describe('Unindexed-workspace session policy', () => {
child = spawnServer(tempDir);
const init = await request(child, { id: 0, method: 'initialize', params: initializeParams(tempDir) });
const instructions = (init.result as { instructions: string }).instructions;
expect(instructions).toMatch(/Tool selection by intent/);
expect(instructions).toMatch(/How to query/);
expect(instructions).not.toMatch(/inactive/i);
const list = await request(child, { id: 1, method: 'tools/list' });
const tools = (list.result as { tools: Array<{ name: string }> }).tools;
// A 1-file project triggers the pre-existing tiny-repo tool gating (a
// reduced core set) — the contract under test is "indexed → tools are
// PRESENT", in contrast to the unindexed empty list above.
expect(tools.length).toBeGreaterThanOrEqual(3);
// The default surface is pared to explore alone (see DEFAULT_MCP_TOOLS) — the
// contract under test is "indexed → tools are PRESENT", in contrast to the
// unindexed empty list above.
expect(tools.length).toBeGreaterThanOrEqual(1);
expect(tools.map((t) => t.name)).toContain('codegraph_explore');
});
});
@@ -0,0 +1,128 @@
/**
* MediatR request/notification dispatch bridge (C#/.NET).
*
* MediatR decouples a `_mediator.Send(x)` / `_mediator.Publish(x)` call from the `Handle`
* method that runs it, linked by the request/notification TYPE (the `IRequestHandler<T,…>`
* generic). This bridges each mediator dispatch → the `Handle` of the matching handler.
* The sent type is resolved from the argument three ways — inline `new X(...)`, a local
* `var v = new X(...)`, and a parameter/local declared `X v` — and precision rests on two
* gates proven here: the receiver must be mediator-ish (a `MessagingCenter.Send` is ignored),
* and the type must have a handler (an `IRequest` with no handler is never bridged). Covers
* `IRequest<T>`, void `IRequest` (single-arg `IRequestHandler<T>`), and `INotification`.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import { CodeGraph } from '../src';
describe('mediatr-dispatch synthesizer', () => {
let dir: string;
beforeEach(() => { dir = fs.mkdtempSync(path.join(os.tmpdir(), 'mediatr-dispatch-')); });
afterEach(() => { fs.rmSync(dir, { recursive: true, force: true }); });
const write = (rel: string, body: string) => {
const p = path.join(dir, rel);
fs.mkdirSync(path.dirname(p), { recursive: true });
fs.writeFileSync(p, body);
};
it('bridges Send/Publish to the matching Handle across inline, local, and param arg forms', async () => {
write('Requests.cs', `namespace Shop;
using MediatR;
public record GetThingsQuery : IRequest<ThingsVm>;
public record CreateThingCommand(string Name) : IRequest<int>;
public record DeleteThingCommand(int Id) : IRequest;
public record ThingDeletedNotification(int Id) : INotification;
public class UnhandledCommand : IRequest<int> { }
`);
write('Handlers.cs', `namespace Shop;
using MediatR;
using System.Threading;
using System.Threading.Tasks;
public class GetThingsQueryHandler : IRequestHandler<GetThingsQuery, ThingsVm> {
public Task<ThingsVm> Handle(GetThingsQuery request, CancellationToken ct) => Task.FromResult(new ThingsVm());
}
public class CreateThingCommandHandler : IRequestHandler<CreateThingCommand, int> {
public Task<int> Handle(CreateThingCommand request, CancellationToken ct) => Task.FromResult(1);
}
public class DeleteThingCommandHandler : IRequestHandler<DeleteThingCommand> {
public Task Handle(DeleteThingCommand request, CancellationToken ct) => Task.CompletedTask;
}
public class ThingDeletedNotificationHandler : INotificationHandler<ThingDeletedNotification> {
public Task Handle(ThingDeletedNotification notification, CancellationToken ct) => Task.CompletedTask;
}
`);
write('ThingsController.cs', `namespace Shop;
using MediatR;
using System.Threading.Tasks;
public class ThingsController {
private readonly ISender _mediator;
public ThingsController(ISender mediator) { _mediator = mediator; }
public async Task GetThings() {
var vm = await _mediator.Send(new GetThingsQuery());
}
public async Task Create(CreateThingCommand command) {
var id = await _mediator.Send(command);
}
public async Task Delete(int id) {
var command = new DeleteThingCommand(id);
await _mediator.Send(command);
}
public async Task Notify(int id) {
await _mediator.Publish(new ThingDeletedNotification(id));
}
public async Task Bogus() {
await _mediator.Send(new UnhandledCommand());
}
public void ViaMessagingCenter() {
MessagingCenter.Send(this, "evt", new CreateThingCommand("x"));
}
}
`);
const cg = await CodeGraph.init(dir, { silent: true });
await cg.indexAll();
const db = (cg as any).db.db;
const edges = db
.prepare(
`SELECT s.name source, t.name target, json_extract(e.metadata,'$.via') via
FROM edges e JOIN nodes s ON s.id = e.source JOIN nodes t ON t.id = e.target
WHERE json_extract(e.metadata,'$.synthesizedBy') = 'mediatr-dispatch'`
)
.all();
// Four bridged dispatches: inline (GetThings, Notify), param-typed (Create), local var (Delete).
expect(edges.map((r: any) => r.source).sort()).toEqual(['Create', 'Delete', 'GetThings', 'Notify']);
expect([...new Set(edges.map((r: any) => r.via))].sort()).toEqual([
'CreateThingCommand', 'DeleteThingCommand', 'GetThingsQuery', 'ThingDeletedNotification',
]);
// Every target is a Handle method.
expect(edges.every((r: any) => r.target === 'Handle')).toBe(true);
// PRECISION: an IRequest with no handler is never bridged; a non-mediator .Send is ignored.
expect(edges.some((r: any) => r.via === 'UnhandledCommand')).toBe(false);
expect(edges.some((r: any) => r.source === 'ViaMessagingCenter')).toBe(false);
cg.close?.();
});
it('produces no edges in a C# project with no MediatR (clean control)', async () => {
write('Service.cs', `namespace Shop;
public class Service {
private readonly IRepo _repo;
public Service(IRepo repo) { _repo = repo; }
public string Find(string id) => _repo.Get(id);
}
`);
const cg = await CodeGraph.init(dir, { silent: true });
await cg.indexAll();
const db = (cg as any).db.db;
const count = db
.prepare(`SELECT count(*) c FROM edges WHERE json_extract(metadata,'$.synthesizedBy') = 'mediatr-dispatch'`)
.get();
expect(count.c).toBe(0);
cg.close?.();
});
});
@@ -0,0 +1,83 @@
/**
* Object-literal registry dispatch synthesizer.
*
* A command registry maps keys → handler classes/functions in an object literal, then
* dispatches by a RUNTIME key (`new registry[command]().execute()`) that static parsing
* can't follow. The synthesizer links each dispatching method → each registered handler's
* callable entry. Validates: a class registry resolves to the handler's `.execute` method;
* the field-initializer form (`commands = {…}` matched against a `this.commands[k]` dispatch);
* and the dispatch GATE — a look-alike object literal that is only ever accessed statically
* (never `registry[var]`) yields no edges.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import { CodeGraph } from '../src';
describe('object-registry synthesizer', () => {
let dir: string;
beforeEach(() => { dir = fs.mkdtempSync(path.join(os.tmpdir(), 'obj-registry-')); });
afterEach(() => { fs.rmSync(dir, { recursive: true, force: true }); });
it('links a dispatcher to each registered command class’s execute method, gated on dynamic dispatch', async () => {
fs.writeFileSync(
path.join(dir, 'commands.ts'),
`export class AddCommand { execute() { return 'add'; } }
export class RemoveCommand { execute() { return 'remove'; } }
export class MoveCommand { execute() { return 'move'; } }
`
);
fs.writeFileSync(
path.join(dir, 'manager.ts'),
`import { AddCommand, RemoveCommand, MoveCommand } from './commands';
const Cmd = { ADD: 'add', REMOVE: 'remove', MOVE: 'move' };
class CommandManager {
commands = {
[Cmd.ADD]: AddCommand,
[Cmd.REMOVE]: RemoveCommand,
[Cmd.MOVE]: MoveCommand,
};
executeCommand(command: string) {
return new this.commands[command]().execute();
}
}
`
);
// A look-alike registry that is NEVER dynamically dispatched (only a static `.add`
// member access) — must yield NO edges. The dynamic `registry[var]` dispatch is the gate.
fs.writeFileSync(
path.join(dir, 'static.ts'),
`import { AddCommand, RemoveCommand } from './commands';
const table = { add: AddCommand, remove: RemoveCommand };
export function direct() { return new table.add().execute(); }
`
);
const cg = await CodeGraph.init(dir, { silent: true });
await cg.indexAll();
const db = (cg as any).db.db;
const rows = db
.prepare(
`SELECT s.name source_name, t.name target_name, t.kind target_kind, t.file_path target_file
FROM edges e
JOIN nodes s ON s.id = e.source
JOIN nodes t ON t.id = e.target
WHERE json_extract(e.metadata,'$.synthesizedBy') = 'object-registry'`
)
.all();
cg.close?.();
// Exactly the 3 dispatcher→handler-entry edges: executeCommand → {Add,Remove,Move}Command.execute.
expect(rows.length).toBe(3);
expect(rows.every((r: any) => r.source_name === 'executeCommand')).toBe(true);
expect(rows.every((r: any) => r.target_kind === 'method' && r.target_name === 'execute')).toBe(true);
expect(rows.every((r: any) => /commands\.ts$/.test(r.target_file))).toBe(true);
// The statically-accessed look-alike registry contributed nothing.
expect(rows.some((r: any) => /static\.ts$/.test(r.target_file))).toBe(false);
});
});
+304
View File
@@ -0,0 +1,304 @@
/**
* Reasoning offload — config resolution, persistence, and strict degradation.
*
* The offload sends explore's assembled source to a BYO OpenAI-compatible
* reasoning endpoint and returns the synthesized answer. Two invariants are
* load-bearing and covered here:
* 1. The API key is NEVER written to disk — the config stores only the NAME of
* an env var (`keyEnv`); the key is resolved at call time.
* 2. The path is STRICTLY DEGRADABLE — any failure (no endpoint, network error,
* non-2xx, empty body) returns null so the caller serves local source; it
* never throws and never surfaces an error to the agent.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import {
readOffloadConfig,
writeOffloadConfig,
resolveOffload,
MANAGED_DEFAULT_URL,
MANAGED_DEFAULT_MODEL,
} from '../src/reasoning/config';
import { readOffloadToken, writeOffloadToken } from '../src/reasoning/credentials';
import { isOffloadEnabled, synthesizeOffload, stripAgentDirectives } from '../src/reasoning/reasoner';
describe('reasoning offload', () => {
let home: string;
// Point ~/.codegraph at a throwaway dir (os.homedir() honors $HOME on POSIX,
// $USERPROFILE on Windows) + start from a clean env each test.
const HOME_ENV = ['HOME', 'USERPROFILE'];
const OFFLOAD_ENV = [
'CODEGRAPH_OFFLOAD_URL', 'CODEGRAPH_OFFLOAD_MODEL', 'CODEGRAPH_OFFLOAD_KEY',
'CODEGRAPH_OFFLOAD_EFFORT', 'CODEGRAPH_OFFLOAD_STYLE', 'CODEGRAPH_OFFLOAD_TIMEOUT_MS',
'CODEGRAPH_OFFLOAD_MAXTOKENS', 'CODEGRAPH_OFFLOAD_STRIP', 'CODEGRAPH_OFFLOAD_DEBUG',
'CODEGRAPH_OFFLOAD_DISABLE', 'CODEGRAPH_OFFLOAD_USAGE_LOG', 'CEREBRAS_API_KEY',
];
let saved: Record<string, string | undefined>;
beforeEach(() => {
home = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-offload-'));
saved = {};
for (const k of [...HOME_ENV, ...OFFLOAD_ENV]) { saved[k] = process.env[k]; delete process.env[k]; }
process.env.HOME = home;
process.env.USERPROFILE = home;
});
afterEach(() => {
for (const k of [...HOME_ENV, ...OFFLOAD_ENV]) {
if (saved[k] === undefined) delete process.env[k];
else process.env[k] = saved[k];
}
vi.restoreAllMocks();
if (fs.existsSync(home)) fs.rmSync(home, { recursive: true, force: true });
});
describe('config persistence', () => {
it('is off, with sensible defaults, when nothing is configured', () => {
const c = resolveOffload();
expect(c.enabled).toBe(false);
expect(c.origin).toBe('none');
expect(c.model).toBe('gpt-oss-120b');
expect(c.effort).toBe('low');
expect(c.style).toBe('plain');
expect(isOffloadEnabled()).toBe(false);
});
it('round-trips the config block and never writes the API key to disk', () => {
writeOffloadConfig({ url: 'https://api.cerebras.ai/v1', model: 'gpt-oss-120b', keyEnv: 'CEREBRAS_API_KEY' });
expect(readOffloadConfig().url).toBe('https://api.cerebras.ai/v1');
const raw = fs.readFileSync(path.join(home, '.codegraph', 'config.json'), 'utf8');
expect(raw).toContain('CEREBRAS_API_KEY'); // the env var NAME is stored
// ...but no actual secret material. Set a key and confirm it isn't on disk.
process.env.CEREBRAS_API_KEY = 'sk-super-secret-value';
expect(fs.readFileSync(path.join(home, '.codegraph', 'config.json'), 'utf8'))
.not.toContain('sk-super-secret-value');
});
it('resolves the API key from the configured env var at call time', () => {
writeOffloadConfig({ url: 'https://api.cerebras.ai/v1', keyEnv: 'CEREBRAS_API_KEY' });
expect(resolveOffload().apiKey).toBeUndefined(); // env var not set yet
process.env.CEREBRAS_API_KEY = 'sk-live';
const c = resolveOffload();
expect(c.enabled).toBe(true);
expect(c.apiKey).toBe('sk-live');
expect(c.keySource).toBe('CEREBRAS_API_KEY');
expect(c.origin).toBe('config');
});
it('clears the offload block on disable, leaving other config keys intact', () => {
const cfgPath = path.join(home, '.codegraph', 'config.json');
fs.mkdirSync(path.dirname(cfgPath), { recursive: true });
fs.writeFileSync(cfgPath, JSON.stringify({ somethingElse: 1, offload: { url: 'x' } }));
writeOffloadConfig(null);
const after = JSON.parse(fs.readFileSync(cfgPath, 'utf8'));
expect(after.offload).toBeUndefined();
expect(after.somethingElse).toBe(1);
});
});
describe('env overrides config', () => {
it('lets CODEGRAPH_OFFLOAD_URL override the file and report origin=env', () => {
writeOffloadConfig({ url: 'https://file.example/v1' });
process.env.CODEGRAPH_OFFLOAD_URL = 'https://env.example/v1';
const c = resolveOffload();
expect(c.url).toBe('https://env.example/v1');
expect(c.origin).toBe('env');
});
it('reads the key directly from CODEGRAPH_OFFLOAD_KEY when set', () => {
process.env.CODEGRAPH_OFFLOAD_URL = 'https://env.example/v1';
process.env.CODEGRAPH_OFFLOAD_KEY = 'sk-direct';
const c = resolveOffload();
expect(c.apiKey).toBe('sk-direct');
expect(c.keySource).toBe('CODEGRAPH_OFFLOAD_KEY');
});
});
describe('CODEGRAPH_OFFLOAD_DISABLE kill-switch', () => {
it('forces the offload off even when managed + signed in', () => {
writeOffloadConfig({ managed: true });
writeOffloadToken('cgai_live');
expect(resolveOffload().enabled).toBe(true); // sanity: on without the flag
process.env.CODEGRAPH_OFFLOAD_DISABLE = '1';
const c = resolveOffload();
expect(c.enabled).toBe(false);
expect(c.managed).toBe(false);
expect(c.origin).toBe('none');
expect(isOffloadEnabled()).toBe(false);
});
it('forces the offload off even with a BYO endpoint + key', () => {
process.env.CODEGRAPH_OFFLOAD_URL = 'https://env.example/v1';
process.env.CODEGRAPH_OFFLOAD_KEY = 'sk-direct';
expect(resolveOffload().enabled).toBe(true);
process.env.CODEGRAPH_OFFLOAD_DISABLE = '1';
expect(resolveOffload().enabled).toBe(false);
});
});
describe('per-call usage log (CODEGRAPH_OFFLOAD_USAGE_LOG)', () => {
const okResponse = () => ({
ok: true, status: 200,
headers: { get: (h: string) => (h === 'x-cg-credits-charged' ? '127' : null) },
json: async () => ({
choices: [{ message: { content: 'Coverage: full.\nThe answer.' }, finish_reason: 'stop' }],
usage: { prompt_tokens: 700, completion_tokens: 80, total_tokens: 780 },
}),
});
it('appends one JSON line with tokens + charged credits when the log path is set', async () => {
writeOffloadConfig({ url: 'https://api.cerebras.ai/v1', keyEnv: 'CEREBRAS_API_KEY' });
process.env.CEREBRAS_API_KEY = 'sk-live';
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(okResponse()));
const logPath = path.join(home, 'usage.jsonl');
process.env.CODEGRAPH_OFFLOAD_USAGE_LOG = logPath;
await synthesizeOffload({ query: 'q', context: 'src' });
const line = JSON.parse(fs.readFileSync(logPath, 'utf8').trim());
expect(line.totalTokens).toBe(780);
expect(line.promptTokens).toBe(700);
expect(line.creditsCharged).toBe(127);
expect(line.costUsd).toBeCloseTo(0.00127, 6); // 100k credits = $1
expect(line.answerLen).toBeGreaterThan(0);
});
it('is a no-op (and never throws) when the log path is unset', async () => {
writeOffloadConfig({ url: 'https://api.cerebras.ai/v1', keyEnv: 'CEREBRAS_API_KEY' });
process.env.CEREBRAS_API_KEY = 'sk-live';
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(okResponse()));
// no CODEGRAPH_OFFLOAD_USAGE_LOG set → answer still returns fine
const out = await synthesizeOffload({ query: 'q', context: 'src' });
expect(out).toContain('Coverage: full.');
});
});
describe('strict degradation (never throws, returns null to fall back)', () => {
it('returns null when no endpoint is configured', async () => {
expect(await synthesizeOffload({ query: 'q', context: 'ctx' })).toBeNull();
});
it('returns null when the upstream request rejects', async () => {
writeOffloadConfig({ url: 'https://api.cerebras.ai/v1' });
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('ECONNREFUSED')));
expect(await synthesizeOffload({ query: 'q', context: 'ctx' })).toBeNull();
});
it('returns null on a non-2xx response', async () => {
writeOffloadConfig({ url: 'https://api.cerebras.ai/v1' });
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: false, status: 500, text: async () => 'boom',
}));
expect(await synthesizeOffload({ query: 'q', context: 'ctx' })).toBeNull();
});
it('returns null when the model returns an empty answer', async () => {
writeOffloadConfig({ url: 'https://api.cerebras.ai/v1' });
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true, status: 200, json: async () => ({ choices: [{ message: { content: ' ' } }] }),
}));
expect(await synthesizeOffload({ query: 'q', context: 'ctx' })).toBeNull();
});
});
describe('success path', () => {
it('returns the synthesized answer (with the plain footer) and posts an OpenAI-compatible body with the key', async () => {
writeOffloadConfig({ url: 'https://api.cerebras.ai/v1', model: 'gpt-oss-120b', keyEnv: 'CEREBRAS_API_KEY' });
process.env.CEREBRAS_API_KEY = 'sk-live';
const fetchMock = vi.fn().mockResolvedValue({
ok: true, status: 200,
json: async () => ({ choices: [{ message: { content: 'Coverage: full.\nThe answer.' }, finish_reason: 'stop' }] }),
});
vi.stubGlobal('fetch', fetchMock);
const out = await synthesizeOffload({ query: 'how does X work', context: 'source here' });
expect(out).toContain('Coverage: full.');
expect(out).toContain('Synthesized by CodeGraph'); // plain footer present
const [calledUrl, init] = fetchMock.mock.calls[0];
expect(calledUrl).toBe('https://api.cerebras.ai/v1/chat/completions');
expect((init.headers as Record<string, string>).authorization).toBe('Bearer sk-live');
const body = JSON.parse(init.body as string);
expect(body.model).toBe('gpt-oss-120b');
expect(body.messages[1].content).toContain('source here');
expect(body.messages[1].content).toContain('how does X work');
});
});
describe('stripAgentDirectives', () => {
it('drops the agent-directed header but keeps source sections', () => {
const ctx = [
'## Exploration: how does X work',
'Found 12 symbols across 3 files.',
'',
'#### src/a.ts — foo(function)',
'code body',
].join('\n');
const stripped = stripAgentDirectives(ctx);
expect(stripped).not.toContain('## Exploration:');
expect(stripped).not.toContain('Found 12 symbols');
expect(stripped).toContain('#### src/a.ts');
expect(stripped).toContain('code body');
});
});
describe('managed tier (CodeGraph AI)', () => {
it('stores the org token at 0600 in credentials.json, not in config.json', () => {
writeOffloadConfig({ managed: true });
writeOffloadToken('cgai_secrettoken');
expect(readOffloadToken()).toBe('cgai_secrettoken');
// config.json carries the managed flag but NOT the token.
const cfg = fs.readFileSync(path.join(home, '.codegraph', 'config.json'), 'utf8');
expect(cfg).toContain('managed');
expect(cfg).not.toContain('cgai_secrettoken');
const credPath = path.join(home, '.codegraph', 'credentials.json');
expect(fs.readFileSync(credPath, 'utf8')).toContain('cgai_secrettoken');
// POSIX perms must be owner-only (0600). (Windows has no POSIX mode bits.)
if (process.platform !== 'win32') {
expect(fs.statSync(credPath).mode & 0o777).toBe(0o600);
}
});
it('resolves managed mode to the gateway URL + public model id + login token', () => {
writeOffloadConfig({ managed: true });
writeOffloadToken('cgai_live');
const c = resolveOffload();
expect(c.enabled).toBe(true);
expect(c.managed).toBe(true);
expect(c.url).toBe(MANAGED_DEFAULT_URL);
expect(c.model).toBe(MANAGED_DEFAULT_MODEL);
expect(c.apiKey).toBe('cgai_live');
expect(c.keySource).toBe('codegraph login');
});
it('is NOT enabled when managed but signed out (no token)', () => {
writeOffloadConfig({ managed: true });
const c = resolveOffload();
expect(c.managed).toBe(true);
expect(c.enabled).toBe(false); // url defaults, but no token → effectively logged out
expect(isOffloadEnabled()).toBe(false);
});
it('clears the token on logout', () => {
writeOffloadToken('cgai_live');
writeOffloadToken(null);
expect(readOffloadToken()).toBeUndefined();
});
it('lets env override the managed endpoint and token (for testing)', () => {
writeOffloadConfig({ managed: true });
writeOffloadToken('cgai_stored');
process.env.CODEGRAPH_OFFLOAD_URL = 'http://localhost:8787/v1';
process.env.CODEGRAPH_OFFLOAD_KEY = 'cgai_env';
const c = resolveOffload();
expect(c.url).toBe('http://localhost:8787/v1');
expect(c.apiKey).toBe('cgai_env');
expect(c.keySource).toBe('CODEGRAPH_OFFLOAD_KEY');
});
});
});
+108
View File
@@ -0,0 +1,108 @@
/**
* Pinia `useStore().action()` dispatch bridge.
*
* A Pinia store factory `export const useXStore = defineStore(...)` exposes its
* actions as methods on the store instance; a consumer does `const s = useXStore()`
* then `s.action()`. That method-on-instance call has no static edge to the action
* (which lives in the store module). This bridges consumer → action by binding the
* store var to its factory's file and resolving `s.method()` to a function node IN
* THAT FILE — so it covers both the options and setup store forms, stays precise
* (a Pinia built-in like `$patch`, or an unrelated same-named method, resolves to
* nothing), and fires only when a `defineStore` factory actually exists.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import { CodeGraph } from '../src';
describe('pinia-store synthesizer', () => {
let dir: string;
beforeEach(() => { dir = fs.mkdtempSync(path.join(os.tmpdir(), 'pinia-store-')); });
afterEach(() => { fs.rmSync(dir, { recursive: true, force: true }); });
it('bridges `const s = useXStore(); s.action()` to the action, across options + setup forms', async () => {
// Options-form store.
fs.writeFileSync(
path.join(dir, 'authStore.ts'),
`import { defineStore } from 'pinia';
export const useAuthStore = defineStore({
id: 'auth',
state: () => ({ token: '' }),
actions: {
async getMenu() { return loadMenu(); },
setToken(t: string) { this.token = t; },
},
});
`
);
// Setup-form store.
fs.writeFileSync(
path.join(dir, 'chatStore.ts'),
`import { defineStore } from 'pinia';
export const useChatStore = defineStore('chat', () => {
const getList = async () => { return fetchList(); };
return { getList };
});
`
);
// Consumer binds both stores and calls their actions (plus a Pinia built-in).
fs.writeFileSync(
path.join(dir, 'init.ts'),
`import { useAuthStore } from './authStore';
import { useChatStore } from './chatStore';
export function init() {
const authStore = useAuthStore();
const chatStore = useChatStore();
authStore.getMenu();
authStore.setToken('x');
authStore.$patch({}); // Pinia built-in — must not bridge
chatStore.getList();
}
`
);
const cg = await CodeGraph.init(dir, { silent: true });
await cg.indexAll();
const db = (cg as any).db.db;
const edges = db
.prepare(
`SELECT s.name source, t.name target, t.file_path tf
FROM edges e JOIN nodes s ON s.id = e.source JOIN nodes t ON t.id = e.target
WHERE json_extract(e.metadata,'$.synthesizedBy') = 'pinia-store'`
)
.all();
const pairs = edges.map((r: any) => `${r.source}->${r.target}`).sort();
// Exactly the three real actions, all from `init`.
expect(pairs).toEqual(['init->getList', 'init->getMenu', 'init->setToken']);
// Each target is the action in its own store file (cross-file, store-scoped).
expect(edges.every((r: any) => /Store\.ts$/.test(r.tf))).toBe(true);
// The Pinia built-in `$patch` produced no edge.
expect(pairs.some((p: string) => p.includes('patch'))).toBe(false);
cg.close?.();
});
it('produces nothing when there is no defineStore factory (not a Pinia store)', async () => {
fs.writeFileSync(
path.join(dir, 'thing.ts'),
`function useThing() { return { run() { return 1; } }; }
export function go() {
const thing = useThing();
thing.run();
}
`
);
const cg = await CodeGraph.init(dir, { silent: true });
await cg.indexAll();
const db = (cg as any).db.db;
const c = db
.prepare(`SELECT count(*) c FROM edges WHERE json_extract(metadata,'$.synthesizedBy') = 'pinia-store'`)
.get().c;
expect(c).toBe(0);
cg.close?.();
});
});
+145
View File
@@ -0,0 +1,145 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import { CodeGraph } from '../src';
/**
* #841 — React components declared via an HOC wrapper
* (`const Button = forwardRef(...)`, `memo(...)`, `styled.x\`…\``) were indexed
* as plain `constant` nodes, so their JSX usages (`<Button/>`) got no render
* edge and `getCallers` / `getImpactRadius` returned empty — a dangerous silent
* false negative for every shadcn/ui-style design system. They must now be
* `component` nodes that receive jsx-render edges like function components do.
*/
describe('React HOC-wrapped component recognition (#841)', () => {
let dir: string;
let cg: any;
beforeEach(() => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'react-hoc-'));
fs.writeFileSync(path.join(dir, 'package.json'), '{"dependencies":{"react":"^18.0.0"}}');
});
afterEach(() => {
cg?.close?.();
fs.rmSync(dir, { recursive: true, force: true });
});
async function index() {
cg = await CodeGraph.init(dir, { silent: true });
await cg.indexAll();
return (cg as any).db.db;
}
const kindsOf = (db: any, name: string): string[] =>
db
.prepare('SELECT kind FROM nodes WHERE name=? ORDER BY kind')
.all(name)
.map((r: any) => r.kind);
it('classifies forwardRef / memo / styled consts as component nodes (not constant)', async () => {
fs.writeFileSync(
path.join(dir, 'ui.tsx'),
`import * as React from 'react';
import styled from 'styled-components';
export const Button = React.forwardRef<HTMLButtonElement, {}>((props, ref) => <button ref={ref} {...props} />);
export const Bare = forwardRef((props, ref) => <span ref={ref} />);
export const Card = memo((props: { t: string }) => <div>{props.t}</div>);
export const Named = memo(function Named(props: { t: string }) { return <div>{props.t}</div>; });
export const Boxed = styled.div\`color: red;\`;
export const Wrapped = styled(Button)\`padding: 4px;\`;
export const Rewrapped = memo(Button);
`
);
const db = await index();
for (const name of ['Button', 'Bare', 'Card', 'Named', 'Boxed', 'Wrapped', 'Rewrapped']) {
expect(kindsOf(db, name), `${name} should be a component`).toContain('component');
// The bug was that these stayed plain constants.
expect(kindsOf(db, name), `${name} should not remain a constant`).not.toContain('constant');
}
});
it('emits jsx-render edges so getCallers/getImpactRadius resolve a forwardRef component', async () => {
fs.writeFileSync(
path.join(dir, 'button.tsx'),
`import * as React from 'react';
export const Button = React.forwardRef<HTMLButtonElement, {}>((props, ref) => <button ref={ref} {...props} />);
`
);
fs.writeFileSync(
path.join(dir, 'page.tsx'),
`import { Button } from './button';
export function Page() {
return <Button>Click</Button>;
}
`
);
const db = await index();
// The render edge exists and is the synthesized jsx-render kind.
const edgeRows = db
.prepare(
`SELECT s.name caller FROM edges e
JOIN nodes s ON s.id = e.source
JOIN nodes t ON t.id = e.target
WHERE json_extract(e.metadata, '$.synthesizedBy') = 'jsx-render'
AND t.kind = 'component' AND t.name = 'Button'`
)
.all();
expect(edgeRows.map((r: any) => r.caller)).toContain('Page');
// ...and it surfaces through the public callers API (the issue's symptom:
// "No callers found" before the fix).
const buttonId = db
.prepare("SELECT id FROM nodes WHERE name='Button' AND kind='component'")
.get().id as string;
const callers = cg.getCallers(buttonId).map((c: any) => c.node.name);
expect(callers).toContain('Page');
});
it('captures the inner render-fn body callees under the component', async () => {
fs.writeFileSync(
path.join(dir, 'widget.tsx'),
`import * as React from 'react';
function useThing() { return 1; }
export const Widget = React.forwardRef((props, ref) => {
const v = useThing();
return <div ref={ref}>{v}</div>;
});
`
);
const db = await index();
const rows = db
.prepare(
`SELECT t.name FROM edges e
JOIN nodes s ON s.id = e.source
JOIN nodes t ON t.id = e.target
WHERE s.name = 'Widget' AND s.kind = 'component'
AND e.kind = 'calls' AND t.name = 'useThing'`
)
.all();
expect(rows.length).toBeGreaterThanOrEqual(1);
});
it('does not misclassify non-component PascalCase consts (precision)', async () => {
fs.writeFileSync(
path.join(dir, 'controls.tsx'),
`import * as React from 'react';
const cache = memo(expensiveFn);
export const Config = loadConfig();
export const Client = new ApiClient();
export const Styles = styledHelper();
export const Total = [1, 2].reduce((a, b) => a + b, 0);
export const Theme = { color: 'red' };
`
);
const db = await index();
for (const name of ['Config', 'Client', 'Styles', 'Total', 'Theme']) {
expect(kindsOf(db, name), `${name} must stay a constant`).toContain('constant');
expect(kindsOf(db, name), `${name} must not be a component`).not.toContain('component');
}
// A lowercase-named memo() result is a memoization util, not a component.
expect(kindsOf(db, 'cache')).not.toContain('component');
});
});
+129
View File
@@ -0,0 +1,129 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import { CodeGraph } from '../src';
/**
* End-to-end test for the redux-thunk dispatch-chain synthesizer.
*
* `createAsyncThunk(prefix, async (a, api) => {...})` passes the async body as an argument, so
* tree-sitter never makes it its own function node — the thunk `constant`'s body calls (incl.
* `dispatch(nextThunk(...))`) are orphaned and `callees(thunk)` is empty. Verify the synthesizer
* body-scans each thunk constant and links it → each dispatched thunk, so the chain
* `outer → inner → deep` connects end-to-end; and that a non-thunk constant is skipped.
*/
describe('redux-thunk synthesizer', () => {
let dir: string;
beforeEach(() => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'redux-thunk-fixture-'));
});
afterEach(() => {
fs.rmSync(dir, { recursive: true, force: true });
});
it('links each thunk constant to the thunks it dispatches, and skips non-thunks', async () => {
fs.writeFileSync(
path.join(dir, 'package.json'),
JSON.stringify({ name: 'app', dependencies: { '@reduxjs/toolkit': '^2' } })
);
fs.writeFileSync(
path.join(dir, 'thunks.ts'),
`import { createAsyncThunk } from '@reduxjs/toolkit';
export const deepThunk = createAsyncThunk('app/deep', async (n: number) => {
return n * 2;
});
export const innerThunk = createAsyncThunk('app/inner', async (n: number, { dispatch }) => {
return dispatch(deepThunk(n));
});
export const outerThunk = createAsyncThunk('app/outer', async (n: number, { dispatch }) => {
await dispatch(innerThunk(n));
});
// Non-thunk constant that only MENTIONS dispatch in a string — must be skipped.
export const notAThunk = 'dispatch(innerThunk())';
`
);
const cg = await CodeGraph.init(dir, { silent: true });
await cg.indexAll();
const db = (cg as any).db.db;
const rows = db
.prepare(
`SELECT s.name source_name, s.kind source_kind, t.name target_name,
json_extract(e.metadata,'$.via') via,
json_extract(e.metadata,'$.registeredAt') registeredAt
FROM edges e
JOIN nodes s ON s.id = e.source
JOIN nodes t ON t.id = e.target
WHERE json_extract(e.metadata,'$.synthesizedBy') = 'redux-thunk'`
)
.all();
cg.close?.();
// The dispatch chain connects: outer → inner → deep.
const pairs = new Set(rows.map((r: any) => `${r.source_name}>${r.target_name}`));
expect(pairs.has('outerThunk>innerThunk')).toBe(true);
expect(pairs.has('innerThunk>deepThunk')).toBe(true);
// Sources are thunk constants; the non-thunk string constant is never a source.
expect(rows.every((r: any) => r.source_kind === 'constant')).toBe(true);
expect(rows.some((r: any) => r.source_name === 'notAThunk')).toBe(false);
// Edges are 'calls' with the wiring site surfaced for the agent.
const outer = rows.find((r: any) => r.source_name === 'outerThunk');
expect(outer.via).toBe('innerThunk');
expect(outer.registeredAt).toMatch(/thunks\.ts:\d+/);
});
it('on a name collision, a dispatch resolves to the THUNK, not a same-named service function', async () => {
// Regression for the octo-call case: `leaveCall` exists as BOTH a `createAsyncThunk`
// const and an unrelated service function. `dispatch(leaveCall())` targets the thunk,
// but the old first-match resolver could pick the function. The resolver now prefers a
// thunk-signature const > other const > same-file > first.
fs.writeFileSync(
path.join(dir, 'package.json'),
JSON.stringify({ name: 'app', dependencies: { '@reduxjs/toolkit': '^2' } })
);
// A plain service function that shares the name `leaveCall` with the thunk below.
fs.writeFileSync(path.join(dir, 'service.ts'), `export function leaveCall(id: string) { return id; }\n`);
fs.writeFileSync(
path.join(dir, 'thunks.ts'),
`import { createAsyncThunk } from '@reduxjs/toolkit';
export const leaveCall = createAsyncThunk('call/leave', async () => {
return 1;
});
export const logout = createAsyncThunk('user/logout', async (_: void, { dispatch }) => {
dispatch(leaveCall());
});
`
);
const cg = await CodeGraph.init(dir, { silent: true });
await cg.indexAll();
const db = (cg as any).db.db;
const row = db
.prepare(
`SELECT t.kind target_kind, t.file_path target_file
FROM edges e
JOIN nodes s ON s.id = e.source
JOIN nodes t ON t.id = e.target
WHERE json_extract(e.metadata,'$.synthesizedBy') = 'redux-thunk'
AND s.name = 'logout' AND t.name = 'leaveCall'`
)
.get();
cg.close?.();
expect(row).toBeTruthy();
// Resolved to the createAsyncThunk constant in thunks.ts, NOT service.ts's function.
expect(row.target_kind).toBe('constant');
expect(row.target_file).toMatch(/thunks\.ts$/);
});
});
+197
View File
@@ -0,0 +1,197 @@
/**
* RTK Query generated-hook → endpoint synthesizer.
*
* RTK Query's `createApi({ endpoints })` defines endpoints as object-literal
* properties (`getX: build.query(...)`) and generates one `useGetXQuery` /
* `useUpdateYMutation` hook per endpoint, exported via a `const {…} = api`
* destructuring. Neither the endpoint nor the generated hook is otherwise a node,
* so a `component → useGetXQuery → getX → queryFn` flow has nothing to connect to.
*
* This validates the two halves: extraction mints a function node for each
* endpoint (named by its key, both the `build => ({...})` arrow form and the
* `endpoints(build){ return {...} }` method-shorthand form) and for each generated
* hook binding; then the synthesizer bridges hook→endpoint by the naming
* convention (incl. the `useLazyGetXQuery` variant → the same endpoint). Precision
* is gated to genuinely-generated hooks: a hand-written `use*Query` arrow is never
* bridged, and no edge ever crosses files.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import { CodeGraph } from '../src';
describe('rtk-query synthesizer', () => {
let dir: string;
beforeEach(() => { dir = fs.mkdtempSync(path.join(os.tmpdir(), 'rtk-query-')); });
afterEach(() => { fs.rmSync(dir, { recursive: true, force: true }); });
it('extracts endpoints + generated hooks and bridges hook→endpoint (arrow + method + lazy + factory forms)', async () => {
// Arrow form (shapeshift-style): `endpoints: build => ({...})`, `queryFn: () => {}`.
fs.writeFileSync(
path.join(dir, 'fiatRampApi.ts'),
`import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';
import { fetchRamps } from './ramps';
export const fiatRampApi = createApi({
reducerPath: 'fiatRampApi',
baseQuery: fetchBaseQuery({ baseUrl: '/' }),
endpoints: build => ({
getFiatRamps: build.query({
queryFn: async () => {
const data = await fetchRamps();
return { data };
},
}),
placeOrder: build.mutation({
query: body => ({ url: 'order', method: 'POST', body }),
}),
}),
});
export const { useGetFiatRampsQuery, usePlaceOrderMutation, useLazyGetFiatRampsQuery } = fiatRampApi;
`
);
// Method-shorthand form (basetool-style): `endpoints(builder){ return {...} }`,
// `query(){}` method handler, plus a factory-handler endpoint (no fn literal).
fs.writeFileSync(
path.join(dir, 'dashApi.ts'),
`import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';
import { makeCheckFn } from './factory';
export const dashApi = createApi({
reducerPath: 'dash',
baseQuery: fetchBaseQuery({ baseUrl: '/' }),
endpoints(builder) {
return {
getDashboards: builder.query({
query() {
return '/dashboards';
},
}),
checkConnection: builder.mutation({
queryFn: makeCheckFn('/check'),
}),
};
},
});
export const { useGetDashboardsQuery, useCheckConnectionMutation } = dashApi;
`
);
// Components consuming the generated hooks.
fs.writeFileSync(
path.join(dir, 'Views.tsx'),
`import { useGetFiatRampsQuery, useLazyGetFiatRampsQuery } from './fiatRampApi';
import { useGetDashboardsQuery } from './dashApi';
export function FiatForm() {
const { data } = useGetFiatRampsQuery();
return data;
}
export function DashList() {
const { data } = useGetDashboardsQuery();
return data;
}
export function LazyForm() {
const [load] = useLazyGetFiatRampsQuery();
return load;
}
`
);
const cg = await CodeGraph.init(dir, { silent: true });
await cg.indexAll();
const db = (cg as any).db.db;
// Endpoints are extracted as function nodes named by their key.
const endpoints = db
.prepare(`SELECT name, kind FROM nodes WHERE name IN ('getFiatRamps','placeOrder','getDashboards','checkConnection')`)
.all();
expect(endpoints.length).toBe(4);
expect(endpoints.every((n: any) => n.kind === 'function')).toBe(true);
// Generated hooks are extracted as function nodes carrying the sentinel.
const hooks = db
.prepare(`SELECT name FROM nodes WHERE signature = '= RTK Query generated hook' ORDER BY name`)
.all()
.map((r: any) => r.name);
expect(hooks).toEqual([
'useCheckConnectionMutation',
'useGetDashboardsQuery',
'useGetFiatRampsQuery',
'useLazyGetFiatRampsQuery',
'usePlaceOrderMutation',
]);
// hook → endpoint synth edges, including the Lazy variant mapping to the same endpoint.
const synth = db
.prepare(
`SELECT s.name source, t.name target, s.file_path sf, t.file_path tf
FROM edges e JOIN nodes s ON s.id = e.source JOIN nodes t ON t.id = e.target
WHERE json_extract(e.metadata,'$.synthesizedBy') = 'rtk-query'`
)
.all();
const pairs = synth.map((r: any) => `${r.source}->${r.target}`).sort();
expect(pairs).toEqual([
'useCheckConnectionMutation->checkConnection',
'useGetDashboardsQuery->getDashboards',
'useGetFiatRampsQuery->getFiatRamps',
'useLazyGetFiatRampsQuery->getFiatRamps',
'usePlaceOrderMutation->placeOrder',
]);
// Every synth edge stays within one file (RTK colocates api + hooks).
expect(synth.every((r: any) => r.sf === r.tf)).toBe(true);
// The component reaches the hook (normal import/call resolution), so the full
// `component → hook → endpoint` chain is connected.
const compToHook = db
.prepare(
`SELECT s.name source, t.name target FROM edges e
JOIN nodes s ON s.id = e.source JOIN nodes t ON t.id = e.target
WHERE s.name = 'FiatForm' AND t.name = 'useGetFiatRampsQuery' AND e.kind = 'calls'`
)
.all();
expect(compToHook.length).toBeGreaterThan(0);
cg.close?.();
});
it('does not bridge a hand-written use*Query hook (no createApi, no sentinel) — 0 synth edges', async () => {
// A real custom hook of the same name shape, plus a same-file `getThing`
// function it could spuriously map to. Without the generated-hook sentinel +
// createApi destructuring, the synthesizer must produce nothing.
fs.writeFileSync(
path.join(dir, 'useGetThingQuery.ts'),
`export function getThing() { return 42; }
export const useGetThingQuery = () => {
return getThing();
};
`
);
fs.writeFileSync(
path.join(dir, 'Thing.tsx'),
`import { useGetThingQuery } from './useGetThingQuery';
export function Thing() {
return useGetThingQuery();
}
`
);
const cg = await CodeGraph.init(dir, { silent: true });
await cg.indexAll();
const db = (cg as any).db.db;
const synth = db
.prepare(`SELECT count(*) c FROM edges WHERE json_extract(metadata,'$.synthesizedBy') = 'rtk-query'`)
.get();
expect(synth.c).toBe(0);
// The hand-written hook keeps its real body (not a sentinel binding).
const sentinel = db
.prepare(`SELECT count(*) c FROM nodes WHERE signature = '= RTK Query generated hook'`)
.get();
expect(sentinel.c).toBe(0);
cg.close?.();
});
});
@@ -0,0 +1,128 @@
/**
* Sidekiq job-dispatch bridge (Ruby).
*
* Sidekiq decouples a job enqueue from the worker's `perform`, linked by the WORKER CLASS
* NAME: `DestroyUserWorker.perform_async(id)` has no static edge to `DestroyUserWorker#perform`
* (usually a different file). This bridges each `Worker.perform_async`/`.perform_in`/`.perform_at`
* site to that worker's instance `perform`, gated on the class including `Sidekiq::Job`/`Worker`.
* Covers both include aliases, the scheduled forms, namespace disambiguation (two `NotifyWorker`s
* in different modules resolve to the right one by qualified name), and the precision boundary: a
* non-worker class with a `perform`, and an ActiveJob `perform_later`, both produce no edge.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import { CodeGraph } from '../src';
describe('sidekiq-dispatch synthesizer', () => {
let dir: string;
beforeEach(() => { dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sidekiq-dispatch-')); });
afterEach(() => { fs.rmSync(dir, { recursive: true, force: true }); });
const write = (rel: string, body: string) => {
const p = path.join(dir, rel);
fs.mkdirSync(path.dirname(p), { recursive: true });
fs.writeFileSync(p, body);
};
it('bridges perform_async/_in to #perform, disambiguates namespaces, ignores non-workers and ActiveJob', async () => {
write('app/workers/destroy_user_worker.rb', `class DestroyUserWorker
include Sidekiq::Worker
def perform(user_id)
User.find(user_id).destroy!
end
end
`);
// Modern Sidekiq::Job alias + the scheduled form.
write('app/workers/send_email_worker.rb', `class SendEmailWorker
include Sidekiq::Job
def perform(addr)
end
end
`);
// Namespace collision: two NotifyWorkers, same simple name, different modules.
write('app/workers/comments/notify_worker.rb', `module Comments
class NotifyWorker
include Sidekiq::Job
def perform(id)
end
end
end
`);
write('app/workers/articles/notify_worker.rb', `module Articles
class NotifyWorker
include Sidekiq::Job
def perform(id)
end
end
end
`);
// A non-worker class that happens to have a `perform` method — never a target.
write('app/services/report.rb', `class Report
def perform(x)
end
end
`);
// An ActiveJob — dispatched via perform_later, a different shape, not matched.
write('app/jobs/cleanup_job.rb', `class CleanupJob < ApplicationJob
def perform
end
end
`);
write('app/services/user_service.rb', `class UserService
def deactivate(user)
DestroyUserWorker.perform_async(user.id)
SendEmailWorker.perform_in(5, user.email)
Comments::NotifyWorker.perform_async(1)
Articles::NotifyWorker.perform_async(2)
Report.perform_async(3)
CleanupJob.perform_later
end
end
`);
const cg = await CodeGraph.init(dir, { silent: true });
await cg.indexAll();
const db = (cg as any).db.db;
const edges = db
.prepare(
`SELECT s.name source, t.name target, t.file_path tf, json_extract(e.metadata,'$.via') via
FROM edges e JOIN nodes s ON s.id = e.source JOIN nodes t ON t.id = e.target
WHERE json_extract(e.metadata,'$.synthesizedBy') = 'sidekiq-dispatch'`
)
.all();
// Four enqueues bridge: both aliases, perform_async + perform_in, two namespaced.
expect(edges.map((r: any) => r.via).sort()).toEqual([
'Articles::NotifyWorker', 'Comments::NotifyWorker', 'DestroyUserWorker', 'SendEmailWorker',
]);
expect(edges.every((r: any) => r.target === 'perform' && r.source === 'deactivate')).toBe(true);
// Namespace disambiguation: each NotifyWorker hits its OWN module's file, not the other.
expect(edges.find((r: any) => r.via === 'Comments::NotifyWorker').tf).toMatch(/comments[\\/]notify_worker\.rb$/);
expect(edges.find((r: any) => r.via === 'Articles::NotifyWorker').tf).toMatch(/articles[\\/]notify_worker\.rb$/);
// PRECISION: a non-worker `perform`, and ActiveJob `perform_later`, contribute nothing.
expect(edges.some((r: any) => r.via === 'Report')).toBe(false);
expect(edges.some((r: any) => /Cleanup/.test(r.via))).toBe(false);
cg.close?.();
});
it('produces no edges in a Ruby project with no Sidekiq (clean control)', async () => {
write('lib/calc.rb', `class Calc
def add(a, b)
a + b
end
end
`);
const cg = await CodeGraph.init(dir, { silent: true });
await cg.indexAll();
const db = (cg as any).db.db;
const count = db
.prepare(`SELECT count(*) c FROM edges WHERE json_extract(metadata,'$.synthesizedBy') = 'sidekiq-dispatch'`)
.get();
expect(count.c).toBe(0);
cg.close?.();
});
});
+132
View File
@@ -0,0 +1,132 @@
/**
* Spring application-event bridge (Java).
*
* Spring decouples an event publisher from its listener(s) through the application
* event bus, linked by the EVENT TYPE: `eventPublisher.publishEvent(new XEvent(...))`
* has no static edge to the `@EventListener void on(XEvent e)` that handles it (usually
* in a different file). This bridges each `publishEvent(new XEvent(...))` site to every
* listener of XEvent. Covers all four listener forms — param-typed `@EventListener`,
* annotation-typed `@EventListener(XEvent.class)`, `@TransactionalEventListener`, and the
* older `implements ApplicationListener<XEvent>` / `onApplicationEvent` — fans out to
* multiple listeners of the same event, and proves precision: a published event with no
* listener, and a same-file non-annotated method, both produce no edge.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import { CodeGraph } from '../src';
describe('spring-event synthesizer', () => {
let dir: string;
beforeEach(() => { dir = fs.mkdtempSync(path.join(os.tmpdir(), 'spring-event-')); });
afterEach(() => { fs.rmSync(dir, { recursive: true, force: true }); });
const write = (rel: string, body: string) => {
const p = path.join(dir, rel);
fs.mkdirSync(path.dirname(p), { recursive: true });
fs.writeFileSync(p, body);
};
it('bridges publishEvent(new X) to every listener form of X, ignoring unheard events and non-listeners', async () => {
write('shop/OrderEvents.java', `package shop;
class OrderShippedEvent { }
class OrderCancelledEvent { }
class UnheardEvent { }
`);
// Publisher — two events, one of them (UnheardEvent) has no listener.
write('shop/OrderService.java', `package shop;
import org.springframework.context.ApplicationEventPublisher;
class OrderService {
private ApplicationEventPublisher publisher;
void ship() {
publisher.publishEvent(new OrderShippedEvent());
publisher.publishEvent(new UnheardEvent());
}
void cancel() {
publisher.publishEvent(new OrderCancelledEvent());
}
}
`);
// Form 1: param-typed @EventListener — plus a same-file NON-listener (no annotation).
write('shop/ShippingListener.java', `package shop;
import org.springframework.context.event.EventListener;
class ShippingListener {
@EventListener
public void onShipped(OrderShippedEvent event) { }
public void helper(OrderShippedEvent event) { }
}
`);
// Form 2: annotation-typed @EventListener(X.class) — fan-out, a 2nd OrderShipped listener.
write('shop/AuditListener.java', `package shop;
import org.springframework.context.event.EventListener;
class AuditListener {
@EventListener(OrderShippedEvent.class)
public void audit(OrderShippedEvent event) { }
}
`);
// Form 3: @TransactionalEventListener — a 3rd OrderShipped listener.
write('shop/TxListener.java', `package shop;
import org.springframework.transaction.event.TransactionalEventListener;
class TxListener {
@TransactionalEventListener
public void afterShipped(OrderShippedEvent event) { }
}
`);
// Form 4: older implements ApplicationListener<X> / onApplicationEvent.
write('shop/LegacyListener.java', `package shop;
import org.springframework.context.ApplicationListener;
class LegacyListener implements ApplicationListener<OrderCancelledEvent> {
@Override
public void onApplicationEvent(OrderCancelledEvent event) { }
}
`);
const cg = await CodeGraph.init(dir, { silent: true });
await cg.indexAll();
const db = (cg as any).db.db;
const edges = db
.prepare(
`SELECT s.name source, t.name target, json_extract(e.metadata,'$.via') via
FROM edges e JOIN nodes s ON s.id = e.source JOIN nodes t ON t.id = e.target
WHERE json_extract(e.metadata,'$.synthesizedBy') = 'spring-event'`
)
.all();
const targets = (src: string) =>
edges.filter((r: any) => r.source === src).map((r: any) => r.target).sort();
// ship() → all three OrderShippedEvent listeners (param-typed, annotation-typed, transactional).
expect(targets('ship')).toEqual(['afterShipped', 'audit', 'onShipped']);
// cancel() → the ApplicationListener<X> form.
expect(targets('cancel')).toEqual(['onApplicationEvent']);
// Every shipped edge is keyed by the event type.
expect(edges.filter((r: any) => r.source === 'ship').every((r: any) => r.via === 'OrderShippedEvent')).toBe(true);
// PRECISION: UnheardEvent has no listener → no edge; the non-annotated helper is never a target.
expect(edges.some((r: any) => r.via === 'UnheardEvent')).toBe(false);
expect(edges.some((r: any) => r.target === 'helper')).toBe(false);
cg.close?.();
});
it('produces no edges in a Spring app with no event bus (clean control)', async () => {
write('shop/PlainService.java', `package shop;
import org.springframework.stereotype.Service;
@Service
class PlainService {
private final Repo repo;
PlainService(Repo repo) { this.repo = repo; }
String find(String id) { return repo.get(id); }
}
`);
const cg = await CodeGraph.init(dir, { silent: true });
await cg.indexAll();
const db = (cg as any).db.db;
const count = db
.prepare(`SELECT count(*) c FROM edges WHERE json_extract(metadata,'$.synthesizedBy') = 'spring-event'`)
.get();
expect(count.c).toBe(0);
cg.close?.();
});
});
+138
View File
@@ -0,0 +1,138 @@
/**
* Vue store action/mutation/getter extraction (the foundation for finding and
* reading store logic — `codegraph_node login` / `getSessionList`).
*
* Vuex/Pinia define a store's callable surface as object-literal members nested
* under `actions`/`mutations`/`getters`, or as body-local consts in a Pinia setup
* store — none of which were extracted, so the symbols an agent looks for didn't
* exist as nodes. This covers the three dominant forms:
* - Vuex module: non-exported `const actions = {…}` / `const mutations = {…}`.
* - Pinia options: `defineStore({ actions: {…}, getters: {…} })`.
* - Pinia setup: `defineStore('id', () => { const foo = …; return { foo } })`.
* And the precision gate: a non-exported `const actions = {…}` in a file that
* isn't a Vue store contributes nothing.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import { CodeGraph } from '../src';
describe('vue store extraction', () => {
let dir: string;
beforeEach(() => { dir = fs.mkdtempSync(path.join(os.tmpdir(), 'vue-store-')); });
afterEach(() => { fs.rmSync(dir, { recursive: true, force: true }); });
it('extracts Vuex module + Pinia options + Pinia setup store members as function nodes', async () => {
// Vuex MODULE form: non-exported `const mutations`/`const actions` collections,
// wired via a default export (element-admin style). Method shorthand + arrow pairs.
fs.writeFileSync(
path.join(dir, 'userModule.js'),
`import { persistToken } from './auth-utils';
const state = { token: '' };
const mutations = {
SET_TOKEN: (state, token) => { state.token = token; },
};
const actions = {
login({ commit }, info) {
persistToken(info.token);
},
async logout({ commit }) {
commit('SET_TOKEN', '');
},
};
export default { namespaced: true, state, mutations, actions };
`
);
fs.writeFileSync(
path.join(dir, 'auth-utils.js'),
`export function persistToken(token) { return token; }
`
);
// Pinia OPTIONS form: actions + getters as object properties of a defineStore config.
fs.writeFileSync(
path.join(dir, 'authStore.ts'),
`import { defineStore } from 'pinia';
export const useAuthStore = defineStore({
id: 'auth',
state: () => ({ name: '' }),
getters: {
upperName: state => state.name.toUpperCase(),
},
actions: {
async fetchMenu() { return loadMenu(); },
setName(n: string) { this.name = n; },
},
});
`
);
// Pinia SETUP form: actions are body-local consts exposed via the return block.
fs.writeFileSync(
path.join(dir, 'chatStore.ts'),
`import { defineStore } from 'pinia';
export const useChatStore = defineStore('chat', () => {
const list = reactive([]);
const getList = async () => { return fetchList(); };
function pushItem(x) { list.push(x); }
return { list, getList, pushItem };
});
`
);
const cg = await CodeGraph.init(dir, { silent: true });
await cg.indexAll();
const db = (cg as any).db.db;
const fn = (name: string) =>
db.prepare(`SELECT count(*) c FROM nodes WHERE name = ? AND kind = 'function'`).get(name).c;
// Vuex module: actions + mutations extracted.
expect(fn('login')).toBeGreaterThan(0);
expect(fn('logout')).toBeGreaterThan(0);
expect(fn('SET_TOKEN')).toBeGreaterThan(0);
// Pinia options: actions + getter extracted.
expect(fn('fetchMenu')).toBeGreaterThan(0);
expect(fn('setName')).toBeGreaterThan(0);
expect(fn('upperName')).toBeGreaterThan(0);
// Pinia setup: body-local actions extracted (and reachable via their bodies).
expect(fn('getList')).toBeGreaterThan(0);
expect(fn('pushItem')).toBeGreaterThan(0);
// The extracted action spans its real body — `login`'s `persistToken(...)`
// call attributes to it (extraction, not the deferred dispatch synthesis).
const loginCalls = db
.prepare(
`SELECT t.name FROM edges e JOIN nodes s ON s.id = e.source JOIN nodes t ON t.id = e.target
WHERE s.name = 'login' AND e.kind = 'calls'`
)
.all()
.map((r: any) => r.name);
expect(loginCalls).toContain('persistToken');
cg.close?.();
});
it('does not extract a non-exported `const actions = {…}` outside a Vue store file', async () => {
// A plain module that happens to hold a non-exported `const actions` object of
// functions, but lacks any second Vue-store signal — the gate must not fire.
fs.writeFileSync(
path.join(dir, 'commands.js'),
`const actions = {
doThing() { return 1; },
doOther() { return 2; },
};
export function run(key) { return actions[key](); }
`
);
const cg = await CodeGraph.init(dir, { silent: true });
await cg.indexAll();
const db = (cg as any).db.db;
expect(db.prepare(`SELECT count(*) c FROM nodes WHERE name = 'doThing'`).get().c).toBe(0);
expect(db.prepare(`SELECT count(*) c FROM nodes WHERE name = 'doOther'`).get().c).toBe(0);
// The real exported function is still extracted normally.
expect(db.prepare(`SELECT count(*) c FROM nodes WHERE name = 'run' AND kind='function'`).get().c).toBeGreaterThan(0);
cg.close?.();
});
});
+100
View File
@@ -0,0 +1,100 @@
/**
* Vuex string-keyed dispatch/commit bridge.
*
* Vuex dispatches actions/mutations by a runtime STRING key — `dispatch('user/login')`,
* `commit('SET_TOKEN')` — with no static edge to the handler (an object-literal
* method in a store module). This bridges the key to its function node: the last
* `/` segment is the action/mutation name, the preceding segment is the namespace
* (≈ the module file). It resolves to a node IN A STORE FILE (excluding a same-named
* `api/` helper — a real collision), disambiguated by the namespace appearing in the
* path, or the same file for a root `commit('M')` inside an action. Redux-style
* `dispatch(actionCreator())` (no string key) produces nothing.
*
* Also exercises the canonical Vuex MODULE shape `export default { namespaced,
* actions: {…}, mutations: {…} }` — whose methods only become nodes via the
* store-collection extraction this bridge depends on.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import { CodeGraph } from '../src';
describe('vuex-dispatch synthesizer', () => {
let dir: string;
beforeEach(() => { dir = fs.mkdtempSync(path.join(os.tmpdir(), 'vuex-dispatch-')); });
afterEach(() => { fs.rmSync(dir, { recursive: true, force: true }); });
it('bridges namespaced dispatch + local commit to the right store handler, excluding an api collision', async () => {
fs.mkdirSync(path.join(dir, 'store', 'modules'), { recursive: true });
fs.mkdirSync(path.join(dir, 'api'), { recursive: true });
// Canonical Vuex module: `export default { namespaced, actions, mutations }`.
fs.writeFileSync(
path.join(dir, 'store', 'modules', 'user.js'),
`import { login as apiLogin } from '../../api/user';
export default {
namespaced: true,
state: { token: '' },
mutations: {
SET_TOKEN(state, t) { state.token = t; },
},
actions: {
login({ commit }, info) {
apiLogin(info);
commit('SET_TOKEN', info.token); // root/local key → SET_TOKEN in THIS module
},
},
};
`
);
// Collision: an api helper ALSO named `login` — must never be the dispatch target.
fs.writeFileSync(
path.join(dir, 'api', 'user.js'),
`export function login(info) { return info; }
`
);
// Consumer dispatches by namespaced string key.
fs.writeFileSync(
path.join(dir, 'app.js'),
`import store from './store';
export function bootstrap() {
store.dispatch('user/login', { token: 'x' });
}
`
);
// Redux-style control: a non-string dispatch must produce no vuex edge.
fs.writeFileSync(
path.join(dir, 'reduxy.js'),
`export function reduxy(dispatch) {
dispatch(someAction());
}
`
);
const cg = await CodeGraph.init(dir, { silent: true });
await cg.indexAll();
const db = (cg as any).db.db;
const edges = db
.prepare(
`SELECT s.name source, t.name target, t.file_path tf, json_extract(e.metadata,'$.via') via
FROM edges e JOIN nodes s ON s.id = e.source JOIN nodes t ON t.id = e.target
WHERE json_extract(e.metadata,'$.synthesizedBy') = 'vuex-dispatch'`
)
.all();
// bootstrap → login, resolving to the STORE module (not api/user.js).
const loginEdge = edges.find((r: any) => r.source === 'bootstrap' && r.target === 'login');
expect(loginEdge).toBeTruthy();
expect(loginEdge.tf).toMatch(/store[\\/]modules[\\/]user\.js$/);
expect(loginEdge.via).toBe('user/login');
// The api helper of the same name was never targeted.
expect(edges.some((r: any) => /api[\\/]user\.js$/.test(r.tf))).toBe(false);
// Local commit('SET_TOKEN') inside the action → the same module's mutation.
expect(edges.some((r: any) => r.source === 'login' && r.target === 'SET_TOKEN')).toBe(true);
// Redux-style non-string dispatch contributed nothing.
expect(edges.some((r: any) => r.source === 'reduxy')).toBe(false);
cg.close?.();
});
});