diff --git a/corpus/backend/golang/context-first-param.yaml b/corpus/backend/golang/context-first-param.yaml new file mode 100644 index 0000000..d96b656 --- /dev/null +++ b/corpus/backend/golang/context-first-param.yaml @@ -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 diff --git a/corpus/backend/golang/error-wrapping.yaml b/corpus/backend/golang/error-wrapping.yaml new file mode 100644 index 0000000..4876609 --- /dev/null +++ b/corpus/backend/golang/error-wrapping.yaml @@ -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? + } diff --git a/corpus/backend/golang/goroutine-lifecycle.yaml b/corpus/backend/golang/goroutine-lifecycle.yaml new file mode 100644 index 0000000..82f5467 --- /dev/null +++ b/corpus/backend/golang/goroutine-lifecycle.yaml @@ -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? + }() diff --git a/corpus/backend/golang/index.yaml b/corpus/backend/golang/index.yaml new file mode 100644 index 0000000..554773a --- /dev/null +++ b/corpus/backend/golang/index.yaml @@ -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 diff --git a/corpus/index.yaml b/corpus/index.yaml index 479274f..ff0295d 100644 --- a/corpus/index.yaml +++ b/corpus/index.yaml @@ -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: diff --git a/src/plugins/golang/tools/get-practice.ts b/src/plugins/golang/tools/get-practice.ts index 47eb7f8..2c1b352 100644 --- a/src/plugins/golang/tools/get-practice.ts +++ b/src/plugins/golang/tools/get-practice.ts @@ -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; +}; + +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(); + +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( @@ -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(", ")}` }], @@ -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 }] }; } ); diff --git a/tests/golang-practice-corpus-backed-tools-behaviour.test.ts b/tests/golang-practice-corpus-backed-tools-behaviour.test.ts new file mode 100644 index 0000000..6eaf8ca --- /dev/null +++ b/tests/golang-practice-corpus-backed-tools-behaviour.test.ts @@ -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"); +});