From 162d2a60fc748418334c64850da349f938290d1f Mon Sep 17 00:00:00 2001 From: Chris Kelly Date: Thu, 25 Jun 2026 23:14:16 +1000 Subject: [PATCH] feat: Add support for custom MediatR interfaces --- CHANGELOG.md | 4 + README.md | 16 +++ __tests__/extension-mapping.test.ts | 74 +++++++++++- .../mediatr-dispatch-synthesizer.test.ts | 107 +++++++++++++++++- .../docs/getting-started/configuration.md | 16 ++- src/project-config.ts | 103 +++++++++++++++-- src/resolution/callback-synthesizer.ts | 27 ++++- 7 files changed, 332 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0913ee52e..b665f1635 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. + ## [1.1.1] - 2026-06-24 diff --git a/README.md b/README.md index 66021206a..626eac6bf 100644 --- a/README.md +++ b/README.md @@ -629,6 +629,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 diff --git a/__tests__/extension-mapping.test.ts b/__tests__/extension-mapping.test.ts index 7dcb9ffc5..33739be46 100644 --- a/__tests__/extension-mapping.test.ts +++ b/__tests__/extension-mapping.test.ts @@ -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', () => { @@ -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(() => { diff --git a/__tests__/mediatr-dispatch-synthesizer.test.ts b/__tests__/mediatr-dispatch-synthesizer.test.ts index 221b75981..3e56064b9 100644 --- a/__tests__/mediatr-dispatch-synthesizer.test.ts +++ b/__tests__/mediatr-dispatch-synthesizer.test.ts @@ -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); @@ -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; +public record CreateThingCommand(string Name) : IRequest; +`); + write('Handlers.cs', `namespace Shop; +using System.Threading; +using System.Threading.Tasks; +public class GetThingsQueryHandler : IQueryHandler { + public Task Handle(GetThingsQuery request, CancellationToken ct) => Task.FromResult(new ThingsVm()); +} +public class CreateThingCommandHandler : ICommandHandler { + public Task 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; +public record CreateThingCommand(string Name) : IRequest; +`); + write('Handlers.cs', `namespace Shop; +using System.Threading; +using System.Threading.Tasks; +public class GetThingsQueryHandler : IQueryHandler { + public Task Handle(GetThingsQuery request, CancellationToken ct) => Task.FromResult(new ThingsVm()); +} +public class CreateThingCommandHandler : ICommandHandler { + public Task 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 { diff --git a/site/src/content/docs/getting-started/configuration.md b/site/src/content/docs/getting-started/configuration.md index 5091e3056..33a3847d9 100644 --- a/site/src/content/docs/getting-started/configuration.md +++ b/site/src/content/docs/getting-started/configuration.md @@ -3,7 +3,7 @@ title: Configuration description: CodeGraph is zero-config by default, with one optional codegraph.json for custom extensions 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) 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), [indexing nested git repositories](#indexing-nested-git-repositories), and [C# MediatR handler interfaces](#c-mediatr-handler-interfaces). ## What it skips out of the box @@ -56,6 +56,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. diff --git a/src/project-config.ts b/src/project-config.ts index 44383752b..5c1335761 100644 --- a/src/project-config.ts +++ b/src/project-config.ts @@ -2,17 +2,24 @@ * Project-scoped configuration: a committed `codegraph.json` at the project * root that a team shares through version control. * - * Today it carries one thing — `extensions`, an opt-in map from a custom file - * extension to one of CodeGraph's supported languages. The built-in - * extension → language table (`EXTENSION_MAP` in `extraction/grammars.ts`) is - * otherwise hardcoded, so a codebase that uses a non-standard extension for a - * supported language (e.g. `.dota_lua` for Lua) sees those files silently - * skipped. This lets the project map them once, in a version-controlled file: + * Today it carries: + * - `extensions` — an opt-in map from a custom file extension to one of + * CodeGraph's supported languages. The built-in extension → language table + * (`EXTENSION_MAP` in `extraction/grammars.ts`) is otherwise hardcoded, so a + * codebase that uses a non-standard extension for a supported language (e.g. + * `.dota_lua` for Lua) sees those files silently skipped. + * - `csharp.mediatrHandlerInterfaces` — additional C# handler interface names + * (e.g. `ICommandHandler`, `IQueryHandler`) for MediatR dispatch bridging. + * + * Example: * * { * "extensions": { * ".dota_lua": "lua", * ".tpl": "php" + * }, + * "csharp": { + * "mediatrHandlerInterfaces": ["ICommandHandler", "IQueryHandler"] * } * } * @@ -42,12 +49,18 @@ export interface ProjectConfig { * are never discovered or indexed (#970, #976). */ includeIgnored?: string[]; + /** C#-specific options. */ + csharp?: { + /** Additional handler interface names for MediatR dispatch bridging. */ + mediatrHandlerInterfaces?: string[]; + }; } /** Parsed, validated view of a project's `codegraph.json`. */ interface ParsedConfig { extensions: Record; includeIgnored: string[]; + mediatrHandlerInterfaces: string[]; } interface CacheEntry { @@ -65,11 +78,19 @@ const cache = new Map(); /** Shared frozen empties so the no-config path allocates nothing. */ const EMPTY_EXTENSIONS: Record = Object.freeze({}); +const EMPTY_MEDIATR_HANDLER_INTERFACES: string[] = Object.freeze([]) as unknown as string[]; const EMPTY_CONFIG: ParsedConfig = Object.freeze({ extensions: EMPTY_EXTENSIONS, includeIgnored: Object.freeze([]) as unknown as string[], + mediatrHandlerInterfaces: EMPTY_MEDIATR_HANDLER_INTERFACES, }); +/** Built-in MediatR handler interfaces — always recognized; config duplicates are ignored. */ +const BUILTIN_MEDIATR_HANDLER_INTERFACES = new Set(['IRequestHandler', 'INotificationHandler']); + +/** A C# identifier suitable for an interface name in handler detection. */ +const CSHARP_IDENTIFIER_RE = /^[A-Za-z_]\w*$/; + /** * Normalize a user-provided extension key to the `.ext` lowercase form used by * the built-in map. Returns null for keys that can never match a real file @@ -118,8 +139,15 @@ function parseConfig(file: string): ParsedConfig { const extensions = extractExtensions(parsed, file); const includeIgnored = extractIncludeIgnored(parsed, file); - if (extensions === EMPTY_EXTENSIONS && includeIgnored.length === 0) return EMPTY_CONFIG; - return { extensions, includeIgnored }; + const mediatrHandlerInterfaces = extractCsharpMediatrHandlerInterfaces(parsed, file); + if ( + extensions === EMPTY_EXTENSIONS && + includeIgnored.length === 0 && + mediatrHandlerInterfaces.length === 0 + ) { + return EMPTY_CONFIG; + } + return { extensions, includeIgnored, mediatrHandlerInterfaces }; } /** @@ -172,6 +200,56 @@ function extractIncludeIgnored(parsed: object, file: string): string[] { return out; } +/** + * Validate `csharp.mediatrHandlerInterfaces`: an array of C# interface names + * to treat as MediatR handlers in addition to the built-in IRequestHandler / + * INotificationHandler. Invalid entries warn-and-skip; never throws. + */ +function extractCsharpMediatrHandlerInterfaces(parsed: object, file: string): string[] { + const csharp = (parsed as ProjectConfig).csharp; + if (!csharp || typeof csharp !== 'object' || Array.isArray(csharp)) { + if (csharp !== undefined) { + logWarn(`Ignoring "csharp" in ${PROJECT_CONFIG_FILENAME}: must be an object`, { file }); + } + return []; + } + + const raw = csharp.mediatrHandlerInterfaces; + if (raw === undefined) return []; + if (!Array.isArray(raw)) { + logWarn( + `Ignoring "csharp.mediatrHandlerInterfaces" in ${PROJECT_CONFIG_FILENAME}: must be an array of interface names`, + { file }, + ); + return []; + } + + const out: string[] = []; + const seen = new Set(); + for (const entry of raw) { + if (typeof entry !== 'string' || !entry.trim()) { + logWarn( + `Ignoring a "csharp.mediatrHandlerInterfaces" entry in ${PROJECT_CONFIG_FILENAME}: every name must be a non-empty string`, + { file }, + ); + continue; + } + const name = entry.trim(); + if (!CSHARP_IDENTIFIER_RE.test(name)) { + logWarn( + `Ignoring "csharp.mediatrHandlerInterfaces" entry "${entry}" in ${PROJECT_CONFIG_FILENAME}: not a valid C# identifier`, + { file }, + ); + continue; + } + if (BUILTIN_MEDIATR_HANDLER_INTERFACES.has(name)) continue; + if (seen.has(name)) continue; + seen.add(name); + out.push(name); + } + return out; +} + /** * Load the parsed `codegraph.json` for a project, mtime-cached. A missing or * malformed file yields the zero-config default. One `stat` (and at most one @@ -221,6 +299,15 @@ export function loadIncludeIgnoredPatterns(rootDir: string): string[] { return loadParsedConfig(rootDir).includeIgnored; } +/** + * Load additional C# MediatR handler interface names from `csharp.mediatrHandlerInterfaces`, + * mtime-cached. Built-in `IRequestHandler` / `INotificationHandler` are always recognized + * by the synthesizer; this returns only the user-configured extras. Empty when absent. + */ +export function loadMediatrHandlerInterfaces(rootDir: string): string[] { + return loadParsedConfig(rootDir).mediatrHandlerInterfaces; +} + /** Test/maintenance hook: forget cached config (e.g. after rewriting it in a test). */ export function clearProjectConfigCache(): void { cache.clear(); diff --git a/src/resolution/callback-synthesizer.ts b/src/resolution/callback-synthesizer.ts index ec4649ba3..554c209fe 100644 --- a/src/resolution/callback-synthesizer.ts +++ b/src/resolution/callback-synthesizer.ts @@ -28,6 +28,7 @@ import { isGeneratedFile } from '../extraction/generated-detection'; import { stripCommentsForRegex } from './strip-comments'; import { cFnPointerDispatchEdges } from './c-fnptr-synthesizer'; import { goframeRouteEdges } from './goframe-synthesizer'; +import { loadMediatrHandlerInterfaces } from '../project-config'; const REGISTRAR_NAME = /^(on[A-Z]\w*|subscribe|addListener|addEventListener|register|watch|listen|addCallback)$/; const DISPATCHER_NAME = /(emit|trigger|notify|dispatch|fire|publish|flush)/i; @@ -2319,7 +2320,24 @@ function springEventEdges(ctx: ResolutionContext): Edge[] { // type must be a known handler request type (so a same-named non-request DTO is never bridged). // C# has no `signature` on method nodes, so the handler's request type is read from the class // base-list source (`: IRequestHandler`), not a param signature. -const MEDIATR_HANDLER_BASE_RE = /(?:IRequestHandler|INotificationHandler)\s*<\s*([A-Za-z_]\w*)/; +// base-list source (`: IRequestHandler` or a configured alias like +// `: ICommandHandler`), not a param signature. Additional handler interface +// names come from `codegraph.json` → `csharp.mediatrHandlerInterfaces`. +const DEFAULT_MEDIATR_HANDLER_INTERFACES = ['IRequestHandler', 'INotificationHandler']; + +function buildMediatrHandlerBaseRe(extra: string[]): RegExp { + const names = [...DEFAULT_MEDIATR_HANDLER_INTERFACES, ...extra]; + const escaped = names.map((n) => n.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|'); + return new RegExp(`(?:${escaped})\\s*<\\s*([A-Za-z_]\\w*)`); +} + +function fileMightHaveMediatrHandlers(content: string, extra: string[]): boolean { + for (const name of [...DEFAULT_MEDIATR_HANDLER_INTERFACES, ...extra]) { + if (content.includes(`${name}<`)) return true; + } + return false; +} + const MEDIATR_DISPATCH_RE = /([A-Za-z_][\w.]*)\s*\.\s*(?:Send|Publish)\s*\(\s*(new\s+[A-Z]\w*|[A-Za-z_]\w*)/g; const MEDIATR_RECEIVER_RE = /(?:mediator|sender|publisher)/i; const MEDIATR_CS_EXT = /\.cs$/; @@ -2349,18 +2367,21 @@ function resolveMediatrArgType(arg: string, lines: string[], methodStart: number } function mediatrDispatchEdges(ctx: ResolutionContext): Edge[] { + const extra = loadMediatrHandlerInterfaces(ctx.getProjectRoot()); + const handlerRe = buildMediatrHandlerBaseRe(extra); + // Pass 1 — request/notification type → the Handle method of each handler class. const handlers = new Map(); for (const file of ctx.getAllFiles()) { if (!MEDIATR_CS_EXT.test(file)) continue; const content = ctx.readFile(file); - if (!content || (!content.includes('IRequestHandler<') && !content.includes('INotificationHandler<'))) continue; + if (!content || !fileMightHaveMediatrHandlers(content, extra)) continue; const lines = content.split('\n'); const nodesInFile = ctx.getNodesInFile(file); for (const cls of nodesInFile) { if (cls.kind !== 'class') continue; const decl = lines.slice(cls.startLine - 1, cls.startLine - 1 + MEDIATR_HANDLER_DECL_LOOKAHEAD).join('\n'); - const m = MEDIATR_HANDLER_BASE_RE.exec(decl); + const m = handlerRe.exec(decl); if (!m) continue; const type = m[1]!; const end = cls.endLine ?? cls.startLine;