Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### New Features

- C# projects that use derived MediatR handler interfaces β€” such as `ICommandHandler<>` or `IQueryHandler<>` instead of `IRequestHandler<>` directly β€” can now register those interface names in `codegraph.json` under `csharp.mediatrHandlerInterfaces`. `codegraph_explore` then follows `_mediator.Send(...)` into the matching `Handle` method the same way it already does for `IRequestHandler<>` and `INotificationHandler<>`. Built-in MediatR interfaces are always recognized; the config adds any extras your CQRS layout uses. Re-index after changing the list.

### Fixes

- CodeGraph now indexes nested repositories that git records as gitlinks, so a workspace built by stacking several repos inside one another indexes completely from a single `codegraph init` at the top. When a repo contains another git repo that was `git add`ed into it β€” so git tracks it as a `160000` "commit" pointer rather than a folder of files β€” or a submodule that isn't an active, initialized submodule in your checkout, that nested repo's source used to be skipped entirely: indexing the top level stopped at the nested repo's boundary and pulled in only the outer repo's own files, so a stacked-repo project came up nearly empty (one report saw ~10 files indexed at the root). CodeGraph now descends into each such nested repo that has a real working tree on disk and indexes it as its own embedded repository, recursively, so every layer of a stacked workspace is covered. Active submodules (already handled) and plain untracked nested clones are unchanged; a nested repo under a dependency directory such as `vendor/` or `node_modules/` stays excluded; and a submodule with nothing checked out on disk is correctly left alone rather than reported as empty. Thanks @ofergr and @kun-yx for the reports. (#1031, #1033)
Expand Down
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -641,6 +641,22 @@ language or a malformed file is warned about and skipped β€” it never breaks
indexing β€” and a project with no `codegraph.json` behaves exactly as before.
Re-index (`codegraph index`) after adding or changing mappings.

C# projects that implement handlers through derived CQRS interfaces (e.g.
`ICommandHandler<>` / `IQueryHandler<>` instead of `IRequestHandler<>` directly)
can register those names under `csharp.mediatrHandlerInterfaces` so MediatR
dispatch tracing picks them up:

```json
{
"csharp": {
"mediatrHandlerInterfaces": ["ICommandHandler", "IQueryHandler"]
}
}
```

`IRequestHandler<>` and `INotificationHandler<>` are always recognized; this
list adds any extras. Re-index after changing it.

## Telemetry

CodeGraph collects **anonymous usage statistics** β€” which tools and commands get
Expand Down
74 changes: 73 additions & 1 deletion __tests__/extension-mapping.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import * as path from 'node:path';
import * as os from 'node:os';
import { CodeGraph } from '../src';
import { detectLanguage, isSourceFile } from '../src/extraction/grammars';
import { loadExtensionOverrides, clearProjectConfigCache } from '../src/project-config';
import { loadExtensionOverrides, loadMediatrHandlerInterfaces, clearProjectConfigCache } from '../src/project-config';

describe('custom extension β†’ language mapping (#906)', () => {
describe('detectLanguage / isSourceFile overrides argument', () => {
Expand Down Expand Up @@ -103,6 +103,78 @@ describe('custom extension β†’ language mapping (#906)', () => {
});
});

describe('loadMediatrHandlerInterfaces (codegraph.json)', () => {
let dir: string;
beforeEach(() => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-mediatr-cfg-'));
clearProjectConfigCache();
});
afterEach(() => {
clearProjectConfigCache();
fs.rmSync(dir, { recursive: true, force: true });
});
const writeConfig = (obj: unknown) =>
fs.writeFileSync(
path.join(dir, 'codegraph.json'),
typeof obj === 'string' ? obj : JSON.stringify(obj)
);

it('returns an empty array when there is no codegraph.json', () => {
expect(loadMediatrHandlerInterfaces(dir)).toEqual([]);
});

it('loads additional handler interface names from csharp.mediatrHandlerInterfaces', () => {
writeConfig({
csharp: { mediatrHandlerInterfaces: ['ICommandHandler', 'IQueryHandler'] },
});
expect(loadMediatrHandlerInterfaces(dir)).toEqual(['ICommandHandler', 'IQueryHandler']);
});

it('filters out built-in IRequestHandler and INotificationHandler duplicates', () => {
writeConfig({
csharp: {
mediatrHandlerInterfaces: ['IRequestHandler', 'ICommandHandler', 'INotificationHandler'],
},
});
expect(loadMediatrHandlerInterfaces(dir)).toEqual(['ICommandHandler']);
});

it('dedupes repeated entries', () => {
writeConfig({
csharp: { mediatrHandlerInterfaces: ['ICommandHandler', 'ICommandHandler'] },
});
expect(loadMediatrHandlerInterfaces(dir)).toEqual(['ICommandHandler']);
});

it('ignores a non-object csharp field', () => {
writeConfig({ csharp: 'nope' });
expect(loadMediatrHandlerInterfaces(dir)).toEqual([]);
});

it('ignores a non-array mediatrHandlerInterfaces field', () => {
writeConfig({ csharp: { mediatrHandlerInterfaces: 'nope' } });
expect(loadMediatrHandlerInterfaces(dir)).toEqual([]);
});

it('skips invalid identifier entries', () => {
writeConfig({
csharp: { mediatrHandlerInterfaces: ['ICommandHandler', 'not-valid!', ''] },
});
expect(loadMediatrHandlerInterfaces(dir)).toEqual(['ICommandHandler']);
});

it('picks up a changed config (mtime-invalidated cache)', () => {
writeConfig({ csharp: { mediatrHandlerInterfaces: ['ICommandHandler'] } });
expect(loadMediatrHandlerInterfaces(dir)).toEqual(['ICommandHandler']);

writeConfig({ csharp: { mediatrHandlerInterfaces: ['IQueryHandler'] } });
const future = new Date(Date.now() + 2000);
fs.utimesSync(path.join(dir, 'codegraph.json'), future, future);

expect(loadMediatrHandlerInterfaces(dir)).toEqual(['IQueryHandler']);
});
});

describe('indexAll honors codegraph.json end-to-end', () => {
let dir: string;
beforeEach(() => {
Expand Down
107 changes: 105 additions & 2 deletions __tests__/mediatr-dispatch-synthesizer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,18 @@ import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import { CodeGraph } from '../src';
import { clearProjectConfigCache } from '../src/project-config';

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 }); });
beforeEach(() => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'mediatr-dispatch-'));
clearProjectConfigCache();
});
afterEach(() => {
clearProjectConfigCache();
fs.rmSync(dir, { recursive: true, force: true });
});

const write = (rel: string, body: string) => {
const p = path.join(dir, rel);
Expand Down Expand Up @@ -108,6 +115,102 @@ public class ThingsController {
cg.close?.();
});

it('bridges Send to handlers implementing configured derived handler interfaces', async () => {
write('codegraph.json', JSON.stringify({
csharp: { mediatrHandlerInterfaces: ['ICommandHandler', 'IQueryHandler'] },
}));
write('Requests.cs', `namespace Shop;
using MediatR;
public record GetThingsQuery : IRequest<ThingsVm>;
public record CreateThingCommand(string Name) : IRequest<int>;
`);
write('Handlers.cs', `namespace Shop;
using System.Threading;
using System.Threading.Tasks;
public class GetThingsQueryHandler : IQueryHandler<GetThingsQuery, ThingsVm> {
public Task<ThingsVm> Handle(GetThingsQuery request, CancellationToken ct) => Task.FromResult(new ThingsVm());
}
public class CreateThingCommandHandler : ICommandHandler<CreateThingCommand, int> {
public Task<int> Handle(CreateThingCommand request, CancellationToken ct) => Task.FromResult(1);
}
`);
write('ThingsController.cs', `namespace Shop;
using System.Threading.Tasks;
public class ThingsController {
private readonly ISender _mediator;
public ThingsController(ISender mediator) { _mediator = mediator; }

public async Task GetThings() {
await _mediator.Send(new GetThingsQuery());
}
public async Task Create(CreateThingCommand command) {
await _mediator.Send(command);
}
}
`);

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();

expect(edges.map((r: any) => r.source).sort()).toEqual(['Create', 'GetThings']);
expect([...new Set(edges.map((r: any) => r.via))].sort()).toEqual([
'CreateThingCommand', 'GetThingsQuery',
]);
expect(edges.every((r: any) => r.target === 'Handle')).toBe(true);

cg.close?.();
});

it('does not bridge derived handler interfaces without codegraph.json config', async () => {
write('Requests.cs', `namespace Shop;
using MediatR;
public record GetThingsQuery : IRequest<ThingsVm>;
public record CreateThingCommand(string Name) : IRequest<int>;
`);
write('Handlers.cs', `namespace Shop;
using System.Threading;
using System.Threading.Tasks;
public class GetThingsQueryHandler : IQueryHandler<GetThingsQuery, ThingsVm> {
public Task<ThingsVm> Handle(GetThingsQuery request, CancellationToken ct) => Task.FromResult(new ThingsVm());
}
public class CreateThingCommandHandler : ICommandHandler<CreateThingCommand, int> {
public Task<int> Handle(CreateThingCommand request, CancellationToken ct) => Task.FromResult(1);
}
`);
write('ThingsController.cs', `namespace Shop;
using System.Threading.Tasks;
public class ThingsController {
private readonly ISender _mediator;
public ThingsController(ISender mediator) { _mediator = mediator; }

public async Task GetThings() {
await _mediator.Send(new GetThingsQuery());
}
public async Task Create(CreateThingCommand command) {
await _mediator.Send(command);
}
}
`);

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?.();
});

it('produces no edges in a C# project with no MediatR (clean control)', async () => {
write('Service.cs', `namespace Shop;
public class Service {
Expand Down
16 changes: 15 additions & 1 deletion site/src/content/docs/getting-started/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ title: Configuration
description: CodeGraph is zero-config by default, with one optional codegraph.json for custom extensions, excluding tracked directories, and indexing nested git repositories.
---

Next to none β€” CodeGraph is **zero-config by default**, with nothing to write or keep in sync to get started. Language support is automatic from the file extension; there's nothing to wire up per language. The one optional file, `codegraph.json`, covers [custom file extensions](#custom-file-extensions), [excluding tracked directories](#excluding-a-tracked-directory), and [indexing nested git repositories](#indexing-nested-git-repositories).
Next to none β€” CodeGraph is **zero-config by default**, with nothing to write or keep in sync to get started. Language support is automatic from the file extension; there's nothing to wire up per language. The one optional file, `codegraph.json`, covers [custom file extensions](#custom-file-extensions), [excluding tracked directories](#excluding-a-tracked-directory), [indexing nested git repositories](#indexing-nested-git-repositories), and [C# MediatR handler interfaces](#c-mediatr-handler-interfaces).

## What it skips out of the box

Expand Down Expand Up @@ -70,6 +70,20 @@ A few things to know:

Re-index (`codegraph index`) after adding or changing `includeIgnored`.

## C# MediatR handler interfaces

`codegraph_explore` follows MediatR `_mediator.Send(...)` and `_mediator.Publish(...)` into the matching handler's `Handle` method. By default it recognizes `IRequestHandler<>` and `INotificationHandler<>`. If your project uses derived CQRS interfaces β€” `ICommandHandler<>`, `IQueryHandler<>`, or similar β€” register them in `codegraph.json`:

```json
{
"csharp": {
"mediatrHandlerInterfaces": ["ICommandHandler", "IQueryHandler"]
}
}
```

Each entry is a C# interface name (no generic arguments). Built-in MediatR interfaces are always recognized; this list adds any extras. Invalid entries are warned about and skipped β€” they never break indexing β€” and a project without this block behaves exactly as before. Re-index (`codegraph index`) after adding or changing the list.

## Where data lives

Per-project data lives in a `.codegraph/` directory at your project root, containing the SQLite database (`codegraph.db`). Nothing leaves your machine.
Loading