Skip to content
Merged
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
9 changes: 9 additions & 0 deletions corpus/backend/golang/context-first-param.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
name: context-first-param
topic: concurrency
priority: P0
rule: Pass context.Context as the first parameter to every function that does I/O.
reason: Enables cancellation propagation and deadline enforcement across service boundaries.
good: |
func (s *Service) FetchUser(ctx context.Context, id string) (*User, error) { ... }
bad: |
func (s *Service) FetchUser(id string) (*User, error) { ... } // no cancellation possible
13 changes: 13 additions & 0 deletions corpus/backend/golang/error-wrapping.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
name: error-wrapping
topic: error-handling
priority: P0
rule: 'Always wrap errors with context: fmt.Errorf("context: %w", err)'
reason: Provides call stack context without expensive stack traces. %w enables errors.Is/As.
good: |
if err := db.Query(ctx, q); err != nil {
return fmt.Errorf("userRepo.FindByID %s: %w", id, err)
}
bad: |
if err := db.Query(ctx, q); err != nil {
return err // context lost - which query failed?
}
19 changes: 19 additions & 0 deletions corpus/backend/golang/goroutine-lifecycle.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
name: goroutine-lifecycle
topic: concurrency
priority: P0
rule: Never start a goroutine without knowing how it stops.
reason: Goroutine leaks exhaust memory. Every goroutine needs a clear exit condition.
good: |
go func() {
defer wg.Done()
for {
select {
case <-ctx.Done(): return // clean exit
case job := <-jobCh: process(job)
}
}
}()
bad: |
go func() {
for job := range jobCh { process(job) } // what if jobCh never closes?
}()
8 changes: 8 additions & 0 deletions corpus/backend/golang/index.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
namespace: backend.golang
practices:
error-wrapping:
file: error-wrapping.yaml
goroutine-lifecycle:
file: goroutine-lifecycle.yaml
context-first-param:
file: context-first-param.yaml
2 changes: 2 additions & 0 deletions corpus/index.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ namespaces:
index: frontend/design-tokens/index.yaml
frontend.shadcn:
index: frontend/shadcn/index.yaml
backend.golang:
index: backend/golang/index.yaml
frontend.ui-ux:
index: frontend/ui-ux/index.yaml
frontend.react:
Expand Down
108 changes: 106 additions & 2 deletions src/plugins/golang/tools/get-practice.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,102 @@
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import YAML from "yaml";
import { z } from "zod";
import { BEST_PRACTICES } from "../data.js";
import { BEST_PRACTICES, type BestPractice } from "../data.js";

type CorpusIndex = {
namespaces?: {
"backend.golang"?: {
index?: string;
};
};
};

type CorpusNamespaceIndex = {
namespace?: string;
practices?: Record<string, { file?: string }>;
};

type CorpusPracticeEntry = {
name?: string;
topic?: string;
priority?: string;
rule?: string;
reason?: string;
good?: string;
bad?: string;
};

type LoadedCorpusPractice = { practice: BestPractice; source: string };

const moduleDir = dirname(fileURLToPath(import.meta.url));
const corpusRoot = join(moduleDir, "../../../../corpus");
const corpusNamespace = "backend.golang";

const cachedCorpusPractices = new Map<string, LoadedCorpusPractice | null>();

function normalize(name: string): string {
return name.toLowerCase().trim();
}

function loadCorpusPractice(name: string): LoadedCorpusPractice | null {
const key = normalize(name);
if (cachedCorpusPractices.has(key)) {
return cachedCorpusPractices.get(key) ?? null;
}

try {
const indexRaw = readFileSync(join(corpusRoot, "index.yaml"), "utf8");
const index = YAML.parse(indexRaw) as CorpusIndex | null;
const namespaceIndexPath = index?.namespaces?.[corpusNamespace]?.index;
if (!namespaceIndexPath) {
cachedCorpusPractices.set(key, null);
return null;
}

const namespaceRaw = readFileSync(join(corpusRoot, namespaceIndexPath), "utf8");
const namespaceIndex = YAML.parse(namespaceRaw) as CorpusNamespaceIndex | null;
const practicePath = namespaceIndex?.practices?.[key]?.file;
if (!practicePath) {
cachedCorpusPractices.set(key, null);
return null;
}

const raw = readFileSync(join(corpusRoot, "backend/golang", practicePath), "utf8");
const entry = YAML.parse(raw) as CorpusPracticeEntry | null;
if (
!entry ||
normalize(entry.name ?? "") !== key ||
!entry.topic ||
(entry.priority !== "P0" && entry.priority !== "P1") ||
!entry.rule ||
!entry.reason
) {
cachedCorpusPractices.set(key, null);
return null;
}

const loaded: LoadedCorpusPractice = {
practice: {
name: entry.name ?? name,
topic: entry.topic as BestPractice["topic"],
priority: entry.priority,
rule: entry.rule,
reason: entry.reason,
good: entry.good,
bad: entry.bad,
},
source: corpusNamespace,
};
cachedCorpusPractices.set(key, loaded);
return loaded;
} catch {
cachedCorpusPractices.set(key, null);
return null;
}
}

export function register(server: McpServer): void {
server.tool(
Expand All @@ -10,7 +106,10 @@ export function register(server: McpServer): void {
name: z.string().describe("Practice name (e.g. 'error-wrapping', 'goroutine-lifecycle', 'crypto-rand', 'table-driven-tests', 'thin-handlers')"),
},
async ({ name }) => {
const practice = BEST_PRACTICES.find((p) => p.name.toLowerCase() === name.toLowerCase());
const corpusEntry = loadCorpusPractice(name);
const practice =
corpusEntry?.practice ??
BEST_PRACTICES.find((p) => p.name.toLowerCase() === name.toLowerCase());
if (!practice) {
return {
content: [{ type: "text", text: `Practice "${name}" not found.\n\nAvailable: ${BEST_PRACTICES.map((p) => p.name).join(", ")}` }],
Expand All @@ -23,6 +122,11 @@ export function register(server: McpServer): void {
text += `**Why:** ${practice.reason}\n\n`;
if (practice.good) text += `## ✅ Good\n\`\`\`go\n${practice.good}\n\`\`\`\n\n`;
if (practice.bad) text += `## ❌ Bad\n\`\`\`go\n${practice.bad}\n\`\`\`\n`;

if (corpusEntry) {
text += `\n**Corpus Source:** ${corpusEntry.source}`;
}

return { content: [{ type: "text", text }] };
}
);
Expand Down
40 changes: 40 additions & 0 deletions tests/golang-practice-corpus-backed-tools-behaviour.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { expect, test } from "bun:test";
import { captureTool, extractTextContent } from "./helpers";
import { register as registerGolangGetPractice } from "../src/plugins/golang/tools/get-practice.ts";

const golangGetPractice = captureTool(registerGolangGetPractice);

test("golang_get_practice prefers corpus metadata for error-wrapping", async () => {
const result = await golangGetPractice.invoke({ name: "error-wrapping" });
const text = extractTextContent(result);

expect(text).toContain("# error-wrapping [error-handling] - P0");
expect(text).toContain("**Corpus Source:** backend.golang");
expect(text).toContain('fmt.Errorf("userRepo.FindByID');
});

test("golang_get_practice prefers corpus metadata for goroutine-lifecycle", async () => {
const result = await golangGetPractice.invoke({ name: "goroutine-lifecycle" });
const text = extractTextContent(result);

expect(text).toContain("# goroutine-lifecycle [concurrency] - P0");
expect(text).toContain("**Corpus Source:** backend.golang");
expect(text).toContain("case <-ctx.Done(): return");
});

test("golang_get_practice prefers corpus metadata for context-first-param", async () => {
const result = await golangGetPractice.invoke({ name: "context-first-param" });
const text = extractTextContent(result);

expect(text).toContain("# context-first-param [concurrency] - P0");
expect(text).toContain("**Corpus Source:** backend.golang");
expect(text).toContain("ctx context.Context, id string");
});

test("golang_get_practice falls back to in-file data for non-corpus practices", async () => {
const result = await golangGetPractice.invoke({ name: "handle-once" });
const text = extractTextContent(result);

expect(text).toContain("# handle-once [error-handling] - P0");
expect(text).not.toContain("**Corpus Source:** backend.golang");
});
Loading