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:
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?.();
|
||||
});
|
||||
});
|
||||
@@ -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\]/);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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?.();
|
||||
});
|
||||
});
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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?.();
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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$/);
|
||||
});
|
||||
});
|
||||
@@ -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?.();
|
||||
});
|
||||
});
|
||||
@@ -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?.();
|
||||
});
|
||||
});
|
||||
@@ -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?.();
|
||||
});
|
||||
});
|
||||
@@ -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?.();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user