From 783b098f307a653b1cd683b342b56c2d93ef7c72 Mon Sep 17 00:00:00 2001 From: Tom Maiaroto Date: Sat, 21 Mar 2026 13:43:17 -0700 Subject: [PATCH 01/11] add pattern registry to gui. remove ast-grep rule management mcp tools --- .gitignore | 1 - app.go | 147 ++++++++ frontend/dist/.gitkeep | 0 frontend/src/App.tsx | 2 + frontend/src/Layout.tsx | 8 + frontend/src/PatternRegistry.tsx | 574 ++++++++++++++++++++++++++++++ frontend/tsconfig.tsbuildinfo | 2 +- frontend/wailsjs/go/main/App.d.ts | 15 + frontend/wailsjs/go/main/App.js | 28 ++ frontend/wailsjs/go/models.ts | 29 ++ pkg/mcp/server.go | 440 ++++++----------------- pkg/mcp/server_test.go | 442 +---------------------- 12 files changed, 906 insertions(+), 782 deletions(-) delete mode 100644 frontend/dist/.gitkeep create mode 100644 frontend/src/PatternRegistry.tsx diff --git a/.gitignore b/.gitignore index d5af0fa..cdd080c 100644 --- a/.gitignore +++ b/.gitignore @@ -56,7 +56,6 @@ Thumbs.db # Build artifacts dist/ build/ -frontend/ # But do NOT ignore the .gitkeep file !frontend/dist/.gitkeep diff --git a/app.go b/app.go index b012139..0b3ba97 100644 --- a/app.go +++ b/app.go @@ -2447,3 +2447,150 @@ func detectLanguage(docs []*scip.Document) string { return "Generic" } } +// SearchCommunityRules searches for rules in the community repository. +func (a *App) SearchCommunityRules(query string, language string, tags string) ([]mcp.CommunityRule, error) { + index, err := mcp.FetchCommunityRuleIndex() + if err != nil { + return nil, err + } + + language = strings.ToLower(language) + var tagList []string + if tags != "" { + tagList = strings.Split(tags, ",") + for i, t := range tagList { + tagList[i] = strings.ToLower(strings.TrimSpace(t)) + } + } + + var matchingRules []mcp.CommunityRule + for _, rule := range index.Rules { + if language != "" && strings.ToLower(rule.Language) != language { + continue + } + + if len(tagList) > 0 { + hasAll := true + for _, rt := range tagList { + found := false + for _, t := range rule.Tags { + if strings.ToLower(t) == rt { + found = true + break + } + } + if !found { + hasAll = false + break + } + } + if !hasAll { + continue + } + } + matchingRules = append(matchingRules, rule) + } + + if query != "" { + queryLower := strings.ToLower(query) + var queryMatches []mcp.CommunityRule + for _, rule := range matchingRules { + if strings.Contains(strings.ToLower(rule.ID), queryLower) || + strings.Contains(strings.ToLower(rule.Description), queryLower) { + queryMatches = append(queryMatches, rule) + continue + } + for _, t := range rule.Tags { + if strings.Contains(strings.ToLower(t), queryLower) { + queryMatches = append(queryMatches, rule) + break + } + } + } + matchingRules = queryMatches + } + + return matchingRules, nil +} + +// GetCommunityRuleDetails returns the rule metadata and YAML content. +func (a *App) GetCommunityRuleDetails(ruleID string) (map[string]interface{}, error) { + rule, content, err := mcp.FetchCommunityRuleContent(ruleID) + if err != nil { + return nil, err + } + + return map[string]interface{}{ + "rule": rule, + "content": content, + }, nil +} + +// ImportCommunityRule downloads a community rule to a specific configuration's rule directory. +func (a *App) ImportCommunityRule(configPath string, ruleID string) error { + rule, content, err := mcp.FetchCommunityRuleContent(ruleID) + if err != nil { + return err + } + + // Validate the rule content + if err := mcp.ValidateAstGrepRule(content); err != nil { + return err + } + + // Resolve rule directory (usually 'rules' relative to config) + configDir := filepath.Dir(configPath) + ruleDir := filepath.Join(configDir, "rules") // Default fallback + + data, err := os.ReadFile(configPath) + if err == nil { + var config mcp.SgConfig + if err := yaml.Unmarshal(data, &config); err == nil && len(config.RuleDirs) > 0 { + ruleDir = filepath.Join(configDir, config.RuleDirs[0]) + } + } + + if err := os.MkdirAll(ruleDir, 0755); err != nil { + return fmt.Errorf("failed to create rule directory: %w", err) + } + + filename := filepath.Base(rule.Path) + destPath := filepath.Join(ruleDir, filename) + + return os.WriteFile(destPath, []byte(content), 0644) +} + +// GetWorkspaceConfigs recursively searches for all sgconfig.yml files in the workspace. +func (a *App) GetWorkspaceConfigs(workspaceRoot string) ([]map[string]interface{}, error) { + return mcp.GetWorkspaceConfigs(workspaceRoot) +} + +// InitializeAstGrepConfig sets up a new ast-grep configuration in the target directory. +func (a *App) InitializeAstGrepConfig(directory string, language string) error { + return mcp.InitializeAstGrepConfig(directory, language) +} + +// RemoveLocalRule removes a specific rule from a configuration's rule directory. +func (a *App) RemoveLocalRule(configPath string, ruleID string) error { + return mcp.RemoveLocalRule(configPath, ruleID) +} + +// GetLocalRulesInDir returns a list of installed rules in a specific directory. +func (a *App) GetLocalRulesInDir(directory string) ([]string, error) { + files, err := os.ReadDir(directory) + if err != nil { + if os.IsNotExist(err) { + return []string{}, nil + } + return nil, err + } + + var rules []string + for _, f := range files { + if !f.IsDir() && (strings.HasSuffix(f.Name(), ".yml") || strings.HasSuffix(f.Name(), ".yaml")) { + rules = append(rules, f.Name()) + } + } + return rules, nil +} + diff --git a/frontend/dist/.gitkeep b/frontend/dist/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index a995b50..c0d0c64 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -5,6 +5,7 @@ import Settings from './Settings'; import Atlas from './Atlas'; import Help from './Help'; import Skills from './Skills'; +import PatternRegistry from './PatternRegistry'; import { GetPreferences, SavePreferences, GetWorkspaces } from '../wailsjs/go/main/App'; function App() { @@ -53,6 +54,7 @@ function App() { {activeTab === 'home' && } {activeTab === 'atlas' && } {activeTab === 'skills' && } + {activeTab === 'rules' && } {activeTab === 'help' && } {activeTab === 'settings' && } diff --git a/frontend/src/Layout.tsx b/frontend/src/Layout.tsx index e54c46d..4406ab1 100644 --- a/frontend/src/Layout.tsx +++ b/frontend/src/Layout.tsx @@ -47,6 +47,14 @@ export default function Layout({ activeTab, setActiveTab, children }: LayoutProp > + + {/* Bottom Menu Items */} diff --git a/frontend/src/PatternRegistry.tsx b/frontend/src/PatternRegistry.tsx new file mode 100644 index 0000000..29a274e --- /dev/null +++ b/frontend/src/PatternRegistry.tsx @@ -0,0 +1,574 @@ +import { useState, useEffect } from 'react'; +import { Icon } from '@iconify/react'; +import { + GetWorkspaces, + SearchCommunityRules, + GetCommunityRuleDetails, + ImportCommunityRule, + GetWorkspaceConfigs, + InitializeAstGrepConfig, + RemoveLocalRule, + GetLocalRulesInDir +} from '../wailsjs/go/main/App'; +import { EventsOn, EventsOff } from '../wailsjs/runtime/runtime'; + +type RulesProps = { + workspaceRoot: string; + onWorkspaceChange: (root: string) => void; +}; + +type CommunityRule = { + id: string; + language: string; + author: string; + description: string; + tags: string[]; +}; + +type SgConfig = { + id: string; + language: string; + path: string; + directory: string; + ruleDirs: string[]; +}; + +export default function PatternRegistry({ workspaceRoot, onWorkspaceChange }: RulesProps) { + const [searchQuery, setSearchQuery] = useState(''); + const [rules, setRules] = useState([]); + const [localRules, setLocalRules] = useState<{ name: string; dir: string }[]>([]); + const [loading, setLoading] = useState(false); + const [selectedRule, setSelectedRule] = useState(null); + const [ruleDetails, setRuleDetails] = useState(null); + const [detailsLoading, setDetailsLoading] = useState(false); + const [importing, setImporting] = useState(null); + const [allWorkspaces, setAllWorkspaces] = useState([]); + + // New phase 5 state + const [configs, setConfigs] = useState([]); + const [selectedConfig, setSelectedConfig] = useState(null); + const [isInitModalOpen, setIsInitModalOpen] = useState(false); + const [initPath, setInitPath] = useState(''); + const [initLang, setInitLang] = useState('go'); + const [isInitializing, setIsInitializing] = useState(false); + + useEffect(() => { + const fetchWorkspaces = async () => { + try { + const ws = await GetWorkspaces(); + const unique = Array.from(new Map((ws as any[]).map(item => [item.root, item])).values()); + setAllWorkspaces(unique); + } catch (e) { + console.error("Error fetching workspaces:", e); + } + }; + + fetchWorkspaces(); + EventsOn('workspace-updated', (ws: any[]) => { + const unique = Array.from(new Map(ws.map(item => [item.root, item])).values()); + setAllWorkspaces(unique); + }); + + return () => EventsOff('workspace-updated'); + }, []); + + useEffect(() => { + if (workspaceRoot) { + discoverConfigs(); + setInitPath(workspaceRoot); + } + }, [workspaceRoot]); + + useEffect(() => { + if (selectedConfig) { + fetchLocalRules(); + handleSearch(); + } else { + setLocalRules([]); + setRules([]); + } + }, [selectedConfig]); + + const discoverConfigs = async () => { + try { + const discovered = await GetWorkspaceConfigs(workspaceRoot) as SgConfig[]; + setConfigs(discovered || []); + if (discovered && discovered.length > 0) { + // Keep selection if it still exists, otherwise pick first + const stillExists = discovered.find(c => c.path === selectedConfig?.path); + if (!stillExists) { + setSelectedConfig(discovered[0]); + } else { + setSelectedConfig(stillExists); + } + } else { + setSelectedConfig(null); + } + } catch (e) { + console.error("Error discovering configs:", e); + } + }; + + const handleSearch = async () => { + if (!selectedConfig) return; + setLoading(true); + try { + // Auto-filter by language based on selected config + const results = await SearchCommunityRules(searchQuery, selectedConfig.language, ''); + setRules(results || []); + } catch (e) { + console.error("Error searching rules:", e); + } finally { + setLoading(false); + } + }; + + const fetchLocalRules = async () => { + if (!selectedConfig) return; + try { + let allRules: { name: string; dir: string }[] = []; + for (const rd of selectedConfig.ruleDirs) { + const fullRuleDir = `${selectedConfig.directory}/${rd}`; + const dirRules = await GetLocalRulesInDir(fullRuleDir); + if (dirRules) { + allRules = [...allRules, ...dirRules.map(name => ({ name, dir: fullRuleDir }))]; + } + } + setLocalRules(allRules); + } catch (e) { + console.error("Error fetching local rules:", e); + } + }; + + const showRuleDetails = async (rule: CommunityRule) => { + setSelectedRule(rule); + setDetailsLoading(true); + try { + const details = await GetCommunityRuleDetails(rule.id); + setRuleDetails(details); + } catch (e) { + console.error("Error fetching rule details:", e); + } finally { + setDetailsLoading(false); + } + }; + + const handleImport = async (ruleID: string) => { + if (!selectedConfig) return; + setImporting(ruleID); + try { + await ImportCommunityRule(selectedConfig.path, ruleID); + await fetchLocalRules(); + } catch (e) { + console.error("Error importing rule:", e); + } finally { + setImporting(null); + } + }; + + const handleRemove = async (ruleName: string) => { + if (!selectedConfig) return; + const ruleID = ruleName.replace(/\.ya?ml$/, ''); + try { + await RemoveLocalRule(selectedConfig.path, ruleID); + await fetchLocalRules(); + } catch (e) { + console.error("Error removing rule:", e); + } + }; + + const handleInitialize = async () => { + setIsInitializing(true); + try { + await InitializeAstGrepConfig(initPath, initLang); + await discoverConfigs(); + setIsInitModalOpen(false); + } catch (e) { + console.error("Error initializing config:", e); + alert("Error: " + e); + } finally { + setIsInitializing(false); + } + }; + + const isRuleInstalled = (ruleID: string) => { + return localRules.some(lr => lr.name.startsWith(ruleID)); + }; + useEffect(() => { + const timer = setTimeout(() => { + if (selectedConfig) { + handleSearch(); + } + }, 400); + return () => clearTimeout(timer); + }, [searchQuery, selectedConfig]); + + return ( +
+ {/* Top Header - Unified with other sections */} +
+
+

Pattern Registry

+
+
+ {workspaceRoot} + +
+
    +
    Loaded Workspaces
    +
    + {allWorkspaces.map(ws => ( +
  • + +
  • + ))} +
    +
+
+
+
+ +
+ {/* Sidebar for Config Selection */} +
+
+

Configurations

+ +
+
+ {configs.map(config => ( + + ))} + {configs.length === 0 && ( +
+ +

No configs found

+
+ )} +
+
+ + {/* Main Content Area */} +
+ {!selectedConfig ? ( +
+ +

Pattern Registry

+

Select or create an ast-grep configuration in the sidebar to start managing patterns.

+
+ ) : ( + <> + {/* Active Config Header */} +
+
+
+

+ {selectedConfig.id} +
{selectedConfig.language}
+

+

+ + {selectedConfig.path} +

+
+
+
+ + {/* Content Split */} +
+ {/* Community Rules (Main) */} +
+
+
+ +

Community Patterns

+
+ + {/* Live Search Bar */} +
+ setSearchQuery(e.target.value)} + /> + + {loading && ( +
+ +
+ )} +
+
+ +
+ {rules.map(rule => ( +
+
+
+

{rule.id}

+ {isRuleInstalled(rule.id) && ( +
+ + Installed +
+ )} +
+

{rule.description}

+
+
+ {rule.tags?.slice(0, 3).map(tag => ( + #{tag} + ))} +
+
+ + {!isRuleInstalled(rule.id) && ( + + )} +
+
+
+
+ ))} + {rules.length === 0 && !loading && ( +
+ +

No patterns matched

+
+ )} +
+
+ + {/* Local Rules (Side) */} +
+
+
+ +
+

Local Patterns

+
{localRules.length}
+
+
+ {localRules.map(rule => ( +
+
+ {rule.name.replace(/\.ya?ml$/, '')} + {rule.dir.replace(workspaceRoot, '').replace(/^[\\/]/, '') || './'} +
+ +
+ ))} + {localRules.length === 0 && ( +
+ +

Workspace Empty

+
+ )} +
+
+
+ + )} +
+
+ + {/* Rule Details Modal */} + {selectedRule && ( +
+
+
+
+
{selectedRule.language}
+
+

{selectedRule.id}

+

+ + {selectedRule.author} +

+
+
+ +
+
+
+

Overview

+
+ {selectedRule.description} +
+
+
+
+

Pattern Source

+ {ruleDetails?.content && ( + + )} +
+ {detailsLoading ? ( +
+ +
+ ) : ( +
+
+                                            {ruleDetails?.content}
+                                        
+
+ )} +
+
+
+ + {!isRuleInstalled(selectedRule.id) && ( + + )} +
+
+
setSelectedRule(null)}>
+
+ )} + + {/* Initialization Modal */} + {isInitModalOpen && ( +
+
+
+
+ +
+
+

Initialize ast-grep

+

Setup pattern management

+
+
+ +
+
+ +
+ + setInitPath(e.target.value)} + placeholder="Full path to directory..." + /> +
+ +
+ +
+ +
+ {['go', 'python', 'typescript', 'javascript', 'rust', 'cpp', 'java'].map(lang => ( + + ))} +
+
+
+ +
+ + +
+
+
!isInitializing && setIsInitModalOpen(false)}>
+
+ )} +
+ ); +} diff --git a/frontend/tsconfig.tsbuildinfo b/frontend/tsconfig.tsbuildinfo index 7801717..68d3bc5 100644 --- a/frontend/tsconfig.tsbuildinfo +++ b/frontend/tsconfig.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/app.tsx","./src/atlas.tsx","./src/help.tsx","./src/home.tsx","./src/layout.tsx","./src/settings.tsx","./src/skills.tsx","./src/cytoscape-fcose.d.ts","./src/main.tsx","./src/components/markdowneditor.tsx","./src/components/skillcatalog.tsx","./wailsjs/go/models.ts","./wailsjs/go/main/app.d.ts","./wailsjs/runtime/runtime.d.ts"],"version":"5.9.3"} \ No newline at end of file +{"root":["./src/app.tsx","./src/atlas.tsx","./src/help.tsx","./src/home.tsx","./src/layout.tsx","./src/patternregistry.tsx","./src/settings.tsx","./src/skills.tsx","./src/cytoscape-fcose.d.ts","./src/main.tsx","./src/components/markdowneditor.tsx","./src/components/skillcatalog.tsx","./wailsjs/go/models.ts","./wailsjs/go/main/app.d.ts","./wailsjs/runtime/runtime.d.ts"],"version":"5.9.3"} \ No newline at end of file diff --git a/frontend/wailsjs/go/main/App.d.ts b/frontend/wailsjs/go/main/App.d.ts index b55441b..c7ee295 100644 --- a/frontend/wailsjs/go/main/App.d.ts +++ b/frontend/wailsjs/go/main/App.d.ts @@ -2,6 +2,7 @@ // This file is automatically generated. DO NOT EDIT import {main} from '../models'; import {inference} from '../models'; +import {mcp} from '../models'; export function DeleteAstGrep():Promise; @@ -17,6 +18,8 @@ export function FocusWorkspaceClient(arg1:number):Promise; export function GetAstGrepStatus():Promise>; +export function GetCommunityRuleDetails(arg1:string):Promise>; + export function GetDownloadProgress(arg1:string):Promise; export function GetFileContent(arg1:string,arg2:string):Promise; @@ -25,12 +28,20 @@ export function GetGraphData(arg1:string):Promise; export function GetInferenceModels():Promise>; +export function GetLocalRulesInDir(arg1:string):Promise>; + export function GetPreferences():Promise; export function GetScipIndexerStatus(arg1:string):Promise>; +export function GetWorkspaceConfigs(arg1:string):Promise>>; + export function GetWorkspaces():Promise>; +export function ImportCommunityRule(arg1:string,arg2:string):Promise; + +export function InitializeAstGrepConfig(arg1:string,arg2:string):Promise; + export function InstallAstGrep():Promise; export function InstallScipIndexer(arg1:string):Promise; @@ -53,12 +64,16 @@ export function RegenerateIndex(arg1:string,arg2:string):Promise; export function RegisterWorkspace(arg1:string):Promise; +export function RemoveLocalRule(arg1:string,arg2:string):Promise; + export function RunIndexingTask(arg1:string):Promise; export function RunInference(arg1:string,arg2:string):Promise; export function SavePreferences(arg1:main.UserPreferences):Promise; +export function SearchCommunityRules(arg1:string,arg2:string,arg3:string):Promise>; + export function SearchForIndexes(arg1:string):Promise>; export function TestInferenceConnection(arg1:string,arg2:string):Promise; diff --git a/frontend/wailsjs/go/main/App.js b/frontend/wailsjs/go/main/App.js index 97c7913..35438b9 100644 --- a/frontend/wailsjs/go/main/App.js +++ b/frontend/wailsjs/go/main/App.js @@ -30,6 +30,10 @@ export function GetAstGrepStatus() { return window['go']['main']['App']['GetAstGrepStatus'](); } +export function GetCommunityRuleDetails(arg1) { + return window['go']['main']['App']['GetCommunityRuleDetails'](arg1); +} + export function GetDownloadProgress(arg1) { return window['go']['main']['App']['GetDownloadProgress'](arg1); } @@ -46,6 +50,10 @@ export function GetInferenceModels() { return window['go']['main']['App']['GetInferenceModels'](); } +export function GetLocalRulesInDir(arg1) { + return window['go']['main']['App']['GetLocalRulesInDir'](arg1); +} + export function GetPreferences() { return window['go']['main']['App']['GetPreferences'](); } @@ -54,10 +62,22 @@ export function GetScipIndexerStatus(arg1) { return window['go']['main']['App']['GetScipIndexerStatus'](arg1); } +export function GetWorkspaceConfigs(arg1) { + return window['go']['main']['App']['GetWorkspaceConfigs'](arg1); +} + export function GetWorkspaces() { return window['go']['main']['App']['GetWorkspaces'](); } +export function ImportCommunityRule(arg1, arg2) { + return window['go']['main']['App']['ImportCommunityRule'](arg1, arg2); +} + +export function InitializeAstGrepConfig(arg1, arg2) { + return window['go']['main']['App']['InitializeAstGrepConfig'](arg1, arg2); +} + export function InstallAstGrep() { return window['go']['main']['App']['InstallAstGrep'](); } @@ -102,6 +122,10 @@ export function RegisterWorkspace(arg1) { return window['go']['main']['App']['RegisterWorkspace'](arg1); } +export function RemoveLocalRule(arg1, arg2) { + return window['go']['main']['App']['RemoveLocalRule'](arg1, arg2); +} + export function RunIndexingTask(arg1) { return window['go']['main']['App']['RunIndexingTask'](arg1); } @@ -114,6 +138,10 @@ export function SavePreferences(arg1) { return window['go']['main']['App']['SavePreferences'](arg1); } +export function SearchCommunityRules(arg1, arg2, arg3) { + return window['go']['main']['App']['SearchCommunityRules'](arg1, arg2, arg3); +} + export function SearchForIndexes(arg1) { return window['go']['main']['App']['SearchForIndexes'](arg1); } diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index 5b5cf38..fb5fde8 100644 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -158,3 +158,32 @@ export namespace main { } +export namespace mcp { + + export class CommunityRule { + id: string; + tool: string; + path: string; + language: string; + author: string; + tags: string[]; + description: string; + + static createFrom(source: any = {}) { + return new CommunityRule(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.id = source["id"]; + this.tool = source["tool"]; + this.path = source["path"]; + this.language = source["language"]; + this.author = source["author"]; + this.tags = source["tags"]; + this.description = source["description"]; + } + } + +} + diff --git a/pkg/mcp/server.go b/pkg/mcp/server.go index 03acf5a..099bbf2 100644 --- a/pkg/mcp/server.go +++ b/pkg/mcp/server.go @@ -58,6 +58,8 @@ func GetHubLockPath() string { // SgConfig represents the structure of sgconfig.yml type SgConfig struct { + ID string `yaml:"id"` + Language string `yaml:"language"` RuleDirs []string `yaml:"ruleDirs"` } @@ -370,62 +372,7 @@ severity: error`), ), ) - // Add remove_rule tool - removeRuleTool := mcp.NewTool("remove_rule", - mcp.WithDescription("Remove a specific ast-grep rule file from the local workspace's rule directory."), - mcp.WithString("rule_id", - mcp.Required(), - mcp.Description("The unique ID of the rule to be removed (e.g., 'no-sql-injection'). This should match the filename without the .yml extension."), - ), - ) - - // Add initialize_ast_grep tool - initializeAstGrepTool := mcp.NewTool("initialize_ast_grep", - mcp.WithDescription("Sets up the current workspace for ast-grep by creating a default `sgconfig.yml` file and a `rules/` directory. This is a required first step before adding or importing local rules."), - ) - - // Add search_community_rules tool - searchCommunityRulesTool := mcp.NewTool("search_community_rules", - mcp.WithDescription(`Search the community rule repository for ast-grep rules. -ast-grep uses abstract syntax trees to find specific code patterns, making it more accurate than text-based tools. - -Use this when you want to: -- Detect specific code patterns or anti-patterns -- Enforce coding standards and best practices -- Find security vulnerabilities (SQL injection, etc.) -- Catch maintenance issues or code smells -- Analyze code quality and consistency - -Example: "Create a rule to catch SQL injection" → generates ast-grep YAML rules`), - mcp.WithString("query", - mcp.Required(), - mcp.Description("Natural language query (e.g., 'sql injection', 'check for todos')"), - ), - mcp.WithString("language", - mcp.Description("Programming language (e.g., 'go', 'python')"), - ), - mcp.WithString("tags", - mcp.Description("Comma-separated list of tags to filter by (e.g., 'security,database')"), - ), - ) - - // Add get_community_rule_details tool - getCommunityRuleDetailsTool := mcp.NewTool("get_community_rule_details", - mcp.WithDescription("Get the full YAML content and explanation for a community rule"), - mcp.WithString("rule_id", - mcp.Required(), - mcp.Description("Unique identifier of the rule (e.g., 'ast-grep-go-sql-injection')"), - ), - ) - // Add import_community_rule tool - importCommunityRuleTool := mcp.NewTool("import_community_rule", - mcp.WithDescription("Download a community rule and add it to the local workspace"), - mcp.WithString("rule_id", - mcp.Required(), - mcp.Description("Unique identifier of the rule to import"), - ), - ) // Add list_symbols_in_file tool listSymbolsInFileTool := mcp.NewTool("list_symbols_in_file", @@ -488,11 +435,6 @@ Example: "Create a rule to catch SQL injection" → generates ast-grep YAML rule s.AddTool(searchDefinitionsTool, searchDefinitionsHandler) s.AddTool(initializeScipTool, initializeScipHandler) s.AddTool(addOrUpdateRuleTool, addOrUpdateRuleHandler) - s.AddTool(removeRuleTool, removeRuleHandler) - s.AddTool(initializeAstGrepTool, initializeAstGrepHandler) - s.AddTool(searchCommunityRulesTool, searchCommunityRulesHandler) - s.AddTool(getCommunityRuleDetailsTool, getCommunityRuleDetailsHandler) - s.AddTool(importCommunityRuleTool, importCommunityRuleHandler) s.AddTool(astGrepScanTool, astGrepScanHandler) // Test ast-grep binary and log version information @@ -2170,73 +2112,111 @@ func getRuleDir() (string, error) { return "", fmt.Errorf("sgconfig.yml not found. Please run 'ast-grep new' to initialize an ast-grep workspace first") } -func removeRuleHandler(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { - ruleID, err := req.RequireString("rule_id") +// RemoveLocalRule removes a specific rule from a configuration's rule directory. +func RemoveLocalRule(configPath string, ruleID string) error { + data, err := os.ReadFile(configPath) if err != nil { - return mcp.NewToolResultError(err.Error()), nil + return fmt.Errorf("error reading config file: %v", err) } - // Get the rule directory from sgconfig.yml - ruleDir, err := getRuleDir() - if err != nil { - // If sgconfig.yml doesn't exist, suggest using the initialize tool - if strings.Contains(err.Error(), "sgconfig.yml not found") { - return mcp.NewToolResultText(fmt.Sprintf("Error: %s. Please run the 'initialize_ast_grep' tool first to set up the workspace.", err.Error())), nil - } - return mcp.NewToolResultError(err.Error()), nil + var config SgConfig + if err := yaml.Unmarshal(data, &config); err != nil { + return fmt.Errorf("error parsing config file: %v", err) } - ruleFile := fmt.Sprintf("%s/%s.yml", ruleDir, ruleID) - if err := os.Remove(ruleFile); err != nil { - if os.IsNotExist(err) { - return mcp.NewToolResultText(fmt.Sprintf("Rule '%s' not found.", ruleID)), nil - } - return mcp.NewToolResultError(fmt.Sprintf("Error removing rule file: %v", err)), nil + if len(config.RuleDirs) == 0 { + return fmt.Errorf("no ruleDirs specified in config: %s", configPath) } - return mcp.NewToolResultText(fmt.Sprintf("Rule '%s' was removed successfully.", ruleID)), nil -} - -// initializeAstGrepHandler handles the initialize_ast_grep tool -func initializeAstGrepHandler(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { - var workspaceRoot string - var err error - - if workspaceRootOverride != "" { - workspaceRoot = workspaceRootOverride - } else { - workspaceRoot, err = os.Getwd() - if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Error getting current directory: %v", err)), nil + configDir := filepath.Dir(configPath) + // Try each rule directory until we find and remove the rule + for _, rd := range config.RuleDirs { + ruleFile := filepath.Join(configDir, rd, ruleID+".yml") + if _, err := os.Stat(ruleFile); err == nil { + return os.Remove(ruleFile) } } + return fmt.Errorf("rule '%s' not found in any rule directories of %s", ruleID, configPath) +} + +// InitializeAstGrepConfig sets up a new ast-grep configuration in the target directory. +func InitializeAstGrepConfig(directory string, language string) error { // Create rules directory - rulesDir := filepath.Join(workspaceRoot, "rules") + rulesDir := filepath.Join(directory, "rules") if err := os.MkdirAll(rulesDir, 0755); err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Error creating rules directory: %v", err)), nil + return fmt.Errorf("error creating rules directory: %v", err) } // Create default sgconfig.yml - sgconfigPath := filepath.Join(workspaceRoot, "sgconfig.yml") + sgconfigPath := filepath.Join(directory, "sgconfig.yml") if _, err := os.Stat(sgconfigPath); os.IsNotExist(err) { config := SgConfig{ + ID: filepath.Base(directory) + "-config", + Language: language, RuleDirs: []string{"rules"}, } data, err := yaml.Marshal(config) if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Error creating sgconfig.yml: %v", err)), nil + return fmt.Errorf("error creating sgconfig.yml: %v", err) } if err := os.WriteFile(sgconfigPath, data, 0644); err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Error writing sgconfig.yml: %v", err)), nil + return fmt.Errorf("error writing sgconfig.yml: %v", err) } + } else { + return fmt.Errorf("sgconfig.yml already exists in %s", directory) } - return mcp.NewToolResultText("ast-grep has been initialized successfully. You can now add rules to the 'rules' directory."), nil + return nil } -// fetchCommunityRuleIndex fetches and caches the community rule index -func fetchCommunityRuleIndex() (*CommunityRuleIndex, error) { +// GetWorkspaceConfigs recursively searches for all sgconfig.yml files in the workspace. +func GetWorkspaceConfigs(workspaceRoot string) ([]map[string]interface{}, error) { + var configs []map[string]interface{} + + err := filepath.WalkDir(workspaceRoot, func(path string, d os.DirEntry, err error) error { + if err != nil { + return err + } + + if d.IsDir() { + // Skip common ignored directories + name := d.Name() + if name == ".git" || name == "node_modules" || name == "vendor" || name == ".context-sherpa" { + return filepath.SkipDir + } + return nil + } + + if d.Name() == "sgconfig.yml" { + data, err := os.ReadFile(path) + if err != nil { + return nil // Skip files we can't read + } + + var config SgConfig + if err := yaml.Unmarshal(data, &config); err != nil { + return nil // Skip malformed configs + } + + // Add metadata for frontend + configs = append(configs, map[string]interface{}{ + "id": config.ID, + "language": config.Language, + "path": path, + "directory": filepath.Dir(path), + "ruleDirs": config.RuleDirs, + }) + } + + return nil + }) + + return configs, err +} + +// FetchCommunityRuleIndex fetches and caches the community rule index +func FetchCommunityRuleIndex() (*CommunityRuleIndex, error) { // Check if we have a valid cached index if communityRuleCache != nil && time.Since(cacheTimestamp) < cacheTTL { verboseLog("Using cached community rule index") @@ -2330,125 +2310,12 @@ func resolvePathRelativeToWorkspaceRoot(path, workspaceRoot string) string { return resolvedPath } -// searchCommunityRulesHandler handles the search_community_rules tool -func searchCommunityRulesHandler(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { - query, err := req.RequireString("query") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - // Get optional parameters - var language string - if langVal, ok := req.Params.Arguments.(map[string]interface{})["language"]; ok { - if langStr, ok := langVal.(string); ok { - language = strings.ToLower(langStr) - } - } - - var tags []string - if tagsVal, ok := req.Params.Arguments.(map[string]interface{})["tags"]; ok { - if tagsStr, ok := tagsVal.(string); ok && tagsStr != "" { - tags = strings.Split(tagsStr, ",") - for i, tag := range tags { - tags[i] = strings.ToLower(strings.TrimSpace(tag)) - } - } - } - +// FetchCommunityRuleContent fetches the YAML content for a community rule +func FetchCommunityRuleContent(ruleID string) (*CommunityRule, string, error) { // Fetch the community rule index - index, err := fetchCommunityRuleIndex() + index, err := FetchCommunityRuleIndex() if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Failed to fetch community rules: %v", err)), nil - } - - // Filter rules based on criteria - var matchingRules []CommunityRule - for _, rule := range index.Rules { - // Filter by language if specified - if language != "" && strings.ToLower(rule.Language) != language { - continue - } - - // Filter by tags if specified (rule must have ALL specified tags) - if len(tags) > 0 { - hasAllTags := true - for _, requiredTag := range tags { - found := false - for _, ruleTag := range rule.Tags { - if strings.ToLower(ruleTag) == requiredTag { - found = true - break - } - } - if !found { - hasAllTags = false - break - } - } - if !hasAllTags { - continue - } - } - - // If we get here, the rule matches our filters - matchingRules = append(matchingRules, rule) - } - - // Apply query search to the filtered results - if query != "" { - queryLower := strings.ToLower(query) - var queryMatches []CommunityRule - - for _, rule := range matchingRules { - // Search in ID, description, and tags - if strings.Contains(strings.ToLower(rule.ID), queryLower) || - strings.Contains(strings.ToLower(rule.Description), queryLower) { - queryMatches = append(queryMatches, rule) - continue - } - - // Search in tags - for _, tag := range rule.Tags { - if strings.Contains(strings.ToLower(tag), queryLower) { - queryMatches = append(queryMatches, rule) - break - } - } - } - - matchingRules = queryMatches - } - - // Format results - if len(matchingRules) == 0 { - return mcp.NewToolResultText("No community rules found matching your criteria."), nil - } - - result := fmt.Sprintf("Found %d community rule(s) matching your criteria:\n\n", len(matchingRules)) - for i, rule := range matchingRules { - result += fmt.Sprintf("%d. **%s** (%s)\n", i+1, rule.ID, rule.Language) - result += fmt.Sprintf(" Author: %s\n", rule.Author) - result += fmt.Sprintf(" Description: %s\n", rule.Description) - if len(rule.Tags) > 0 { - result += fmt.Sprintf(" Tags: %s\n", strings.Join(rule.Tags, ", ")) - } - result += "\n" - } - - return mcp.NewToolResultText(result), nil -} - -// getCommunityRuleDetailsHandler handles the get_community_rule_details tool -func getCommunityRuleDetailsHandler(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { - ruleID, err := req.RequireString("rule_id") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - // Fetch the community rule index - index, err := fetchCommunityRuleIndex() - if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Failed to fetch community rules: %v", err)), nil + return nil, "", fmt.Errorf("failed to fetch community rules: %v", err) } // Find the rule @@ -2461,153 +2328,44 @@ func getCommunityRuleDetailsHandler(ctx context.Context, req mcp.CallToolRequest } if foundRule == nil { - return mcp.NewToolResultText(fmt.Sprintf("Rule '%s' not found in community repository.", ruleID)), nil + return nil, "", fmt.Errorf("rule '%s' not found in community repository", ruleID) } // Fetch the actual rule YAML content ruleURL := fmt.Sprintf("https://raw.githubusercontent.com/hackafterdark/context-sherpa-community-rules/main/%s", foundRule.Path) resp, err := http.Get(ruleURL) if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Failed to fetch rule content: %v", err)), nil + return foundRule, "", fmt.Errorf("failed to fetch rule content: %v", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - return mcp.NewToolResultError(fmt.Sprintf("Failed to fetch rule content: HTTP %d", resp.StatusCode)), nil + return foundRule, "", fmt.Errorf("failed to fetch rule content: HTTP %d", resp.StatusCode) } // Read the YAML content var buf []byte - if resp.ContentLength > 0 { - buf = make([]byte, resp.ContentLength) - } else { - // Read in chunks if ContentLength is unknown - chunkSize := 1024 - buffer := make([]byte, chunkSize) - for { - n, err := resp.Body.Read(buffer) - if n > 0 { - buf = append(buf, buffer[:n]...) - } - if err != nil { - if err.Error() == "EOF" { - break - } - return mcp.NewToolResultError(fmt.Sprintf("Failed to read rule content: %v", err)), nil - } - } - } - - yamlContent := string(buf) - - // Format the response - result := fmt.Sprintf("Rule Details for '%s':\n\n", foundRule.ID) - result += fmt.Sprintf("**ID:** %s\n", foundRule.ID) - result += fmt.Sprintf("**Tool:** %s\n", foundRule.Tool) - result += fmt.Sprintf("**Language:** %s\n", foundRule.Language) - result += fmt.Sprintf("**Author:** %s\n", foundRule.Author) - result += fmt.Sprintf("**Description:** %s\n", foundRule.Description) - if len(foundRule.Tags) > 0 { - result += fmt.Sprintf("**Tags:** %s\n", strings.Join(foundRule.Tags, ", ")) - } - result += "\n**YAML Content:**\n```yaml\n" - result += yamlContent - result += "\n```\n" - - return mcp.NewToolResultText(result), nil -} - -// importCommunityRuleHandler handles the import_community_rule tool -func importCommunityRuleHandler(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { - ruleID, err := req.RequireString("rule_id") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - // Fetch the community rule index - index, err := fetchCommunityRuleIndex() - if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Failed to fetch community rules: %v", err)), nil - } - - // Find the rule - var foundRule *CommunityRule - for _, rule := range index.Rules { - if rule.ID == ruleID { - foundRule = &rule - break + chunkSize := 1024 + buffer := make([]byte, chunkSize) + for { + n, err := resp.Body.Read(buffer) + if n > 0 { + buf = append(buf, buffer[:n]...) } - } - - if foundRule == nil { - return mcp.NewToolResultText(fmt.Sprintf("Rule '%s' not found in community repository.", ruleID)), nil - } - - // Fetch the actual rule YAML content - ruleURL := fmt.Sprintf("https://raw.githubusercontent.com/hackafterdark/context-sherpa-community-rules/main/%s", foundRule.Path) - resp, err := http.Get(ruleURL) - if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Failed to fetch rule content: %v", err)), nil - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return mcp.NewToolResultError(fmt.Sprintf("Failed to fetch rule content: HTTP %d", resp.StatusCode)), nil - } - - // Read the YAML content - var buf []byte - if resp.ContentLength > 0 { - buf = make([]byte, resp.ContentLength) - } else { - // Read in chunks if ContentLength is unknown - chunkSize := 1024 - buffer := make([]byte, chunkSize) - for { - n, err := resp.Body.Read(buffer) - if n > 0 { - buf = append(buf, buffer[:n]...) - } - if err != nil { - if err.Error() == "EOF" { - break - } - return mcp.NewToolResultError(fmt.Sprintf("Failed to read rule content: %v", err)), nil + if err != nil { + if err == io.EOF { + break } + return foundRule, "", fmt.Errorf("failed to read rule content: %v", err) } } - yamlContent := string(buf) - - // Validate the YAML content before writing to disk - if err := validateAstGrepRule(yamlContent); err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Invalid rule file for '%s': %v", ruleID, err)), nil - } - - // Get the rule directory and save the file - ruleDir, err := getRuleDir() - if err != nil { - if strings.Contains(err.Error(), "sgconfig.yml not found") { - return mcp.NewToolResultText(fmt.Sprintf("Error: %s. Please run the 'initialize_ast_grep' tool first to set up the workspace.", err.Error())), nil - } - return mcp.NewToolResultError(err.Error()), nil - } - - // Extract just the filename from the path - pathParts := strings.Split(foundRule.Path, "/") - filename := pathParts[len(pathParts)-1] - ruleFile := filepath.Join(ruleDir, filename) - - if err := os.WriteFile(ruleFile, []byte(yamlContent), 0644); err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Error writing rule file: %v", err)), nil - } - - return mcp.NewToolResultText(fmt.Sprintf("Rule '%s' was imported successfully from the community repository to %s.", ruleID, ruleFile)), nil + return foundRule, string(buf), nil } -// validateAstGrepRule checks if the given YAML content is a valid ast-grep rule. +// ValidateAstGrepRule checks if the given YAML content is a valid ast-grep rule. // It ensures the YAML is well-formed and contains the essential fields 'id', 'language', and 'rule'. -func validateAstGrepRule(yamlContent string) error { +func ValidateAstGrepRule(yamlContent string) error { var rule AstGrepRule if err := yaml.Unmarshal([]byte(yamlContent), &rule); err != nil { return fmt.Errorf("could not parse YAML: %v", err) diff --git a/pkg/mcp/server_test.go b/pkg/mcp/server_test.go index 75fda97..37e1cd9 100644 --- a/pkg/mcp/server_test.go +++ b/pkg/mcp/server_test.go @@ -333,250 +333,6 @@ rule: ` } -func TestSearchCommunityRulesHandler(t *testing.T) { - tests := []struct { - name string - query string - language string - tags string - expectedCount int - expectedResult string - }{ - { - name: "Search by query - SQL injection", - query: "sql injection", - expectedCount: 1, - expectedResult: "Found 1 community rule(s)", - }, - { - name: "Search by language - Go", - query: "", - language: "go", - expectedCount: 2, - expectedResult: "Found 2 community rule(s)", - }, - { - name: "Search by tags - security", - query: "", - tags: "security", - expectedCount: 1, - expectedResult: "Found 1 community rule(s)", - }, - { - name: "Search with no results", - query: "nonexistent", - expectedCount: 0, - expectedResult: "No community rules found", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - // Create a test server that returns our mock index - mockIndex := mockCommunityRuleIndex() - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(mockIndex) - })) - defer server.Close() - - // Temporarily override the community rules repo URL for testing - originalRepo := communityRulesRepo - communityRulesRepo = server.URL - defer func() { communityRulesRepo = originalRepo }() - - // Clear cache for consistent testing - communityRuleCache = nil - - // Create test request - arguments := map[string]interface{}{ - "query": tt.query, - } - if tt.language != "" { - arguments["language"] = tt.language - } - if tt.tags != "" { - arguments["tags"] = tt.tags - } - - req := mcp.CallToolRequest{ - Params: mcp.CallToolParams{ - Arguments: arguments, - }, - } - - // Call the handler - result, err := searchCommunityRulesHandler(context.Background(), req) - - // Assertions - if err != nil { - t.Fatalf("Expected no error, got: %v", err) - } - - if result == nil || len(result.Content) == 0 { - t.Fatal("Expected result content, got nil or empty") - } - - // HTTP integration test passed - handler successfully processed request - }) - } -} - -func TestSearchCommunityRulesHandlerErrors(t *testing.T) { - tests := []struct { - name string - query string - serverFunc func(w http.ResponseWriter, r *http.Request) - expectError bool - }{ - { - name: "Server returns 404", - query: "test", - serverFunc: func(w http.ResponseWriter, r *http.Request) { - http.Error(w, "Not Found", http.StatusNotFound) - }, - expectError: true, - }, - { - name: "Server returns invalid JSON", - query: "test", - serverFunc: func(w http.ResponseWriter, r *http.Request) { - w.Write([]byte("invalid json")) - }, - expectError: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(tt.serverFunc)) - defer server.Close() - - originalRepo := communityRulesRepo - communityRulesRepo = server.URL - defer func() { communityRulesRepo = originalRepo }() - - // Clear cache - communityRuleCache = nil - - req := mcp.CallToolRequest{ - Params: mcp.CallToolParams{ - Arguments: map[string]interface{}{ - "query": tt.query, - }, - }, - } - - result, err := searchCommunityRulesHandler(context.Background(), req) - - if err != nil { - t.Fatalf("Handler returned an unexpected error: %v", err) - } - - // For error cases, the handler should return a ToolResult with an error message, not a direct error. - if tt.expectError { - if result == nil { - t.Fatal("Expected a tool result for the error case, but got nil") - } - if len(result.Content) == 0 { - t.Fatal("Expected content in the error tool result, but it was empty") - } - // We can't easily inspect the content type here, but we confirm a result was returned. - // The live test already showed this works. - } else { - if result == nil { - t.Fatal("Expected a successful tool result, but got nil") - } - } - }) - } -} - -func TestGetCommunityRuleDetailsHandler(t *testing.T) { - t.Run("Get existing rule", func(t *testing.T) { - // Create test server for index - mockIndex := mockCommunityRuleIndex() - indexServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(mockIndex) - })) - defer indexServer.Close() - - // Create test server for rule content - ruleServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "text/plain") - w.Write([]byte(mockRuleYAML())) - })) - defer ruleServer.Close() - - // Override URLs for testing - originalRepo := communityRulesRepo - communityRulesRepo = indexServer.URL - defer func() { communityRulesRepo = originalRepo }() - - // Clear cache - communityRuleCache = nil - - req := mcp.CallToolRequest{ - Params: mcp.CallToolParams{ - Arguments: map[string]interface{}{ - "rule_id": "ast-grep-go-sql-injection", - }, - }, - } - - result, err := getCommunityRuleDetailsHandler(context.Background(), req) - - if err != nil { - t.Fatalf("Expected no error, got: %v", err) - } - - if result == nil || len(result.Content) == 0 { - t.Fatal("Expected result content, got nil or empty") - } - - // HTTP integration test passed - handler successfully fetched rule details - }) - - t.Run("Rule not found", func(t *testing.T) { - mockIndex := CommunityRuleIndex{ - Version: 1, - Rules: []CommunityRule{}, - } - - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(mockIndex) - })) - defer server.Close() - - originalRepo := communityRulesRepo - communityRulesRepo = server.URL - defer func() { communityRulesRepo = originalRepo }() - - communityRuleCache = nil - - req := mcp.CallToolRequest{ - Params: mcp.CallToolParams{ - Arguments: map[string]interface{}{ - "rule_id": "nonexistent-rule", - }, - }, - } - - result, err := getCommunityRuleDetailsHandler(context.Background(), req) - - if err != nil { - t.Fatalf("Expected no error, got: %v", err) - } - - if result == nil || len(result.Content) == 0 { - t.Fatal("Expected result content, got nil or empty") - } - - // HTTP integration test passed - handler successfully processed rule not found case - }) -} func TestFetchCommunityRuleIndex(t *testing.T) { t.Run("Successful fetch", func(t *testing.T) { @@ -594,7 +350,7 @@ func TestFetchCommunityRuleIndex(t *testing.T) { // Clear cache before this test communityRuleCache = nil - index, err := fetchCommunityRuleIndex() + index, err := FetchCommunityRuleIndex() if err != nil { t.Fatalf("Expected no error, got: %v", err) @@ -625,13 +381,13 @@ func TestFetchCommunityRuleIndex(t *testing.T) { communityRuleCache = nil // First fetch - index1, err := fetchCommunityRuleIndex() + index1, err := FetchCommunityRuleIndex() if err != nil { t.Fatalf("First fetch failed: %v", err) } // Second fetch should use cache - index2, err := fetchCommunityRuleIndex() + index2, err := FetchCommunityRuleIndex() if err != nil { t.Fatalf("Second fetch failed: %v", err) } @@ -1258,157 +1014,6 @@ message: "Test rule"`, }) } -func TestRemoveRuleHandler(t *testing.T) { - t.Run("Valid rule removal", func(t *testing.T) { - req := mcp.CallToolRequest{ - Params: mcp.CallToolParams{ - Arguments: map[string]interface{}{ - "rule_id": "test-rule", - }, - }, - } - - result, err := removeRuleHandler(context.Background(), req) - - if err != nil { - t.Fatalf("Expected no error from handler, got: %v", err) - } - - if result == nil { - t.Fatal("Expected result from handler") - } - }) - - t.Run("Missing rule_id parameter", func(t *testing.T) { - req := mcp.CallToolRequest{ - Params: mcp.CallToolParams{ - Arguments: map[string]interface{}{}, - }, - } - - result, err := removeRuleHandler(context.Background(), req) - - if err != nil { - t.Fatalf("Expected no error from handler, got: %v", err) - } - - if result == nil { - t.Fatal("Expected result from handler") - } - }) -} - -func TestInitializeAstGrepHandler(t *testing.T) { - t.Run("Initialize workspace", func(t *testing.T) { - // Create a temporary directory for this test - tempDir := t.TempDir() - - // Change to temp directory for testing - originalDir, err := os.Getwd() - if err != nil { - t.Fatalf("Failed to get current directory: %v", err) - } - defer os.Chdir(originalDir) - - if err := os.Chdir(tempDir); err != nil { - t.Fatalf("Failed to change directory: %v", err) - } - - req := mcp.CallToolRequest{ - Params: mcp.CallToolParams{ - Arguments: map[string]interface{}{}, - }, - } - - result, err := initializeAstGrepHandler(context.Background(), req) - - if err != nil { - t.Fatalf("Expected no error from handler, got: %v", err) - } - - if result == nil { - t.Fatal("Expected result from handler") - } - - // Verify files were created - if _, err := os.Stat("sgconfig.yml"); os.IsNotExist(err) { - t.Error("sgconfig.yml was not created") - } - - if _, err := os.Stat("rules"); os.IsNotExist(err) { - t.Error("rules directory was not created") - } - }) -} - -func TestValidateAstGrepRule(t *testing.T) { - t.Run("Valid rule YAML", func(t *testing.T) { - validYAML := `id: test-rule -language: go -rule: - pattern: test` - - err := validateAstGrepRule(validYAML) - if err != nil { - t.Errorf("Expected valid YAML to pass validation, got error: %v", err) - } - }) - - t.Run("Invalid YAML", func(t *testing.T) { - invalidYAML := `invalid: yaml: content: [` - - err := validateAstGrepRule(invalidYAML) - if err == nil { - t.Error("Expected invalid YAML to fail validation") - } - }) - - t.Run("Missing id field", func(t *testing.T) { - yamlWithoutID := `language: go -rule: - pattern: test` - - err := validateAstGrepRule(yamlWithoutID) - if err == nil { - t.Error("Expected YAML without id to fail validation") - } - }) - - t.Run("Missing language field", func(t *testing.T) { - yamlWithoutLanguage := `id: test-rule -rule: - pattern: test` - - err := validateAstGrepRule(yamlWithoutLanguage) - if err == nil { - t.Error("Expected YAML without language to fail validation") - } - }) - - t.Run("Empty id field", func(t *testing.T) { - yamlWithEmptyID := `id: "" -language: go -rule: - pattern: test` - - err := validateAstGrepRule(yamlWithEmptyID) - if err == nil { - t.Error("Expected YAML with empty id to fail validation") - } - }) - - t.Run("Empty language field", func(t *testing.T) { - yamlWithEmptyLanguage := `id: test-rule -language: "" -rule: - pattern: test` - - err := validateAstGrepRule(yamlWithEmptyLanguage) - if err == nil { - t.Error("Expected YAML with empty language to fail validation") - } - }) -} func TestGetRuleDir(t *testing.T) { t.Run("Get rule directory without config", func(t *testing.T) { @@ -1494,47 +1099,6 @@ func TestFindSgBinary(t *testing.T) { }) } -func TestImportCommunityRuleHandler(t *testing.T) { - t.Run("Valid import request", func(t *testing.T) { - req := mcp.CallToolRequest{ - Params: mcp.CallToolParams{ - Arguments: map[string]interface{}{ - "rule_id": "test-rule", - }, - }, - } - - // This will fail due to missing sgconfig.yml, but we can test - // that the handler processes the input correctly - result, err := importCommunityRuleHandler(context.Background(), req) - - if err != nil { - t.Fatalf("Expected no error from handler, got: %v", err) - } - - if result == nil { - t.Fatal("Expected result from handler") - } - }) - - t.Run("Missing rule_id parameter", func(t *testing.T) { - req := mcp.CallToolRequest{ - Params: mcp.CallToolParams{ - Arguments: map[string]interface{}{}, - }, - } - - result, err := importCommunityRuleHandler(context.Background(), req) - - if err != nil { - t.Fatalf("Expected no error from handler, got: %v", err) - } - - if result == nil { - t.Fatal("Expected result from handler") - } - }) -} func TestToScipPath(t *testing.T) { // We need to be careful with filepath.Abs in tests as it depends on the OS From 4c0cd4f1d8bcafbf63c3d0d02c50bcda78093e1e Mon Sep 17 00:00:00 2001 From: Tom Maiaroto Date: Sat, 21 Mar 2026 13:57:47 -0700 Subject: [PATCH 02/11] delete confirmation, create new config form enhancements, view details on local patterns --- app.go | 52 ++++++- frontend/src/PatternRegistry.tsx | 217 +++++++++++++++++++++++++----- frontend/wailsjs/go/main/App.d.ts | 4 + frontend/wailsjs/go/main/App.js | 8 ++ frontend/wailsjs/go/models.ts | 22 +++ 5 files changed, 271 insertions(+), 32 deletions(-) diff --git a/app.go b/app.go index 0b3ba97..edd1a6a 100644 --- a/app.go +++ b/app.go @@ -67,6 +67,16 @@ type MarkdownEntry struct { FrontMatter map[string]string `json:"frontMatter"` } +// LocalRuleDetails represents the parsed content of a local rule file +type LocalRuleDetails struct { + ID string `json:"id"` + Message string `json:"message"` + Severity string `json:"severity"` + Content string `json:"content"` + Language string `json:"language"` + Path string `json:"path"` +} + // NewApp creates a new App application struct func NewApp() *App { return &App{} @@ -2575,7 +2585,6 @@ func (a *App) RemoveLocalRule(configPath string, ruleID string) error { return mcp.RemoveLocalRule(configPath, ruleID) } -// GetLocalRulesInDir returns a list of installed rules in a specific directory. func (a *App) GetLocalRulesInDir(directory string) ([]string, error) { files, err := os.ReadDir(directory) if err != nil { @@ -2594,3 +2603,44 @@ func (a *App) GetLocalRulesInDir(directory string) ([]string, error) { return rules, nil } +// GetLocalRuleDetails reads a local rule file and extracts key information +func (a *App) GetLocalRuleDetails(rulePath string) (*LocalRuleDetails, error) { + data, err := os.ReadFile(rulePath) + if err != nil { + return nil, err + } + + var raw map[string]interface{} + if err := yaml.Unmarshal(data, &raw); err != nil { + return nil, fmt.Errorf("failed to parse YAML: %w", err) + } + + details := &LocalRuleDetails{ + Content: string(data), + Path: rulePath, + } + + if id, ok := raw["id"].(string); ok { + details.ID = id + } + if msg, ok := raw["message"].(string); ok { + details.Message = msg + } + if sev, ok := raw["severity"].(string); ok { + details.Severity = sev + } + if lang, ok := raw["language"].(string); ok { + details.Language = lang + } + + return details, nil +} + +// PickDirectoryWithRoot opens a native directory picker starting from the given root +func (a *App) PickDirectoryWithRoot(startDir string) (string, error) { + return wailsRuntime.OpenDirectoryDialog(a.ctx, wailsRuntime.OpenDialogOptions{ + Title: "Select Directory", + DefaultDirectory: startDir, + }) +} + diff --git a/frontend/src/PatternRegistry.tsx b/frontend/src/PatternRegistry.tsx index 29a274e..f9a1ee2 100644 --- a/frontend/src/PatternRegistry.tsx +++ b/frontend/src/PatternRegistry.tsx @@ -8,7 +8,9 @@ import { GetWorkspaceConfigs, InitializeAstGrepConfig, RemoveLocalRule, - GetLocalRulesInDir + GetLocalRulesInDir, + GetLocalRuleDetails, + PickDirectoryWithRoot } from '../wailsjs/go/main/App'; import { EventsOn, EventsOff } from '../wailsjs/runtime/runtime'; @@ -33,6 +35,13 @@ type SgConfig = { ruleDirs: string[]; }; +const SUPPORTED_LANGUAGES = [ + 'go', 'python', 'typescript', 'javascript', 'rust', 'cpp', 'java', + 'kotlin', 'swift', 'c', 'css', 'html', 'json', 'yaml', 'toml', + 'ruby', 'php', 'scala', 'bash', 'dart', 'elixir', 'haskell', + 'lua', 'perl', 'sql', 'zig' +]; + export default function PatternRegistry({ workspaceRoot, onWorkspaceChange }: RulesProps) { const [searchQuery, setSearchQuery] = useState(''); const [rules, setRules] = useState([]); @@ -52,6 +61,11 @@ export default function PatternRegistry({ workspaceRoot, onWorkspaceChange }: Ru const [initLang, setInitLang] = useState('go'); const [isInitializing, setIsInitializing] = useState(false); + // Phase 5 additions + const [localRuleDetails, setLocalRuleDetails] = useState(null); + const [isLocalDetailsLoading, setIsLocalDetailsLoading] = useState(false); + const [ruleToDelete, setRuleToDelete] = useState<{ name: string; dir: string } | null>(null); + useEffect(() => { const fetchWorkspaces = async () => { try { @@ -168,15 +182,40 @@ export default function PatternRegistry({ workspaceRoot, onWorkspaceChange }: Ru const handleRemove = async (ruleName: string) => { if (!selectedConfig) return; - const ruleID = ruleName.replace(/\.ya?ml$/, ''); try { - await RemoveLocalRule(selectedConfig.path, ruleID); - await fetchLocalRules(); + await RemoveLocalRule(selectedConfig.path, ruleName.replace(/\.ya?ml$/, '')); + setRuleToDelete(null); + fetchLocalRules(); } catch (e) { console.error("Error removing rule:", e); } }; + const handleInspectLocalRule = async (rule: { name: string; dir: string }) => { + setIsLocalDetailsLoading(true); + setLocalRuleDetails(null); + try { + const path = rule.dir + '/' + rule.name; + const details = await GetLocalRuleDetails(path); + setLocalRuleDetails(details); + } catch (e) { + console.error("Error fetching local rule details:", e); + } finally { + setIsLocalDetailsLoading(false); + } + }; + + const handleBrowse = async () => { + try { + const selected = await PickDirectoryWithRoot(workspaceRoot); + if (selected) { + setInitPath(selected); + } + } catch (e) { + console.error("Error picking directory:", e); + } + }; + const handleInitialize = async () => { setIsInitializing(true); try { @@ -398,17 +437,27 @@ export default function PatternRegistry({ workspaceRoot, onWorkspaceChange }: Ru
{localRules.map(rule => ( -
+
{rule.name.replace(/\.ya?ml$/, '')} {rule.dir.replace(workspaceRoot, '').replace(/^[\\/]/, '') || './'}
- +
+ + +
))} {localRules.length === 0 && ( @@ -515,15 +564,24 @@ export default function PatternRegistry({ workspaceRoot, onWorkspaceChange }: Ru -
- - setInitPath(e.target.value)} - placeholder="Full path to directory..." - /> +
+
+ + setInitPath(e.target.value)} + placeholder="Full path to directory..." + /> +
+
@@ -569,6 +626,104 @@ export default function PatternRegistry({ workspaceRoot, onWorkspaceChange }: Ru
!isInitializing && setIsInitModalOpen(false)}>
)} + {/* Local Rule Details Modal */} + {localRuleDetails && ( +
+
+
+
+
{localRuleDetails.language || 'generic'}
+
+

{localRuleDetails.id || 'Unnamed Rule'}

+

+ + Severity: {localRuleDetails.severity || 'n/a'} +

+
+
+ +
+
+ {localRuleDetails.message && ( +
+

Message

+
+ {localRuleDetails.message} +
+
+ )} +
+
+

Full YAML

+ +
+
+
+                                        {localRuleDetails.content}
+                                    
+
+
+
+
+ +
+
+
setLocalRuleDetails(null)}>
+
+ )} + + {/* Inspect Loading State */} + {isLocalDetailsLoading && ( +
+
+ +

Loading rule details...

+
+
+ )} + + {/* Delete Confirmation Modal */} + {ruleToDelete && ( +
+
+
+
+ +
+

Delete Rule?

+

+ Are you sure you want to remove {ruleToDelete.name.replace(/\.ya?ml$/, '')}? + This action cannot be undone. +

+
+
+ + +
+
+
setRuleToDelete(null)}>
+
+ )}
); } diff --git a/frontend/wailsjs/go/main/App.d.ts b/frontend/wailsjs/go/main/App.d.ts index c7ee295..ddb9d68 100644 --- a/frontend/wailsjs/go/main/App.d.ts +++ b/frontend/wailsjs/go/main/App.d.ts @@ -28,6 +28,8 @@ export function GetGraphData(arg1:string):Promise; export function GetInferenceModels():Promise>; +export function GetLocalRuleDetails(arg1:string):Promise; + export function GetLocalRulesInDir(arg1:string):Promise>; export function GetPreferences():Promise; @@ -56,6 +58,8 @@ export function OpenWorkspace(arg1:string):Promise; export function PickDirectory():Promise; +export function PickDirectoryWithRoot(arg1:string):Promise; + export function PullInferenceModel(arg1:string):Promise; export function ReadMarkdown(arg1:string):Promise; diff --git a/frontend/wailsjs/go/main/App.js b/frontend/wailsjs/go/main/App.js index 35438b9..aa00775 100644 --- a/frontend/wailsjs/go/main/App.js +++ b/frontend/wailsjs/go/main/App.js @@ -50,6 +50,10 @@ export function GetInferenceModels() { return window['go']['main']['App']['GetInferenceModels'](); } +export function GetLocalRuleDetails(arg1) { + return window['go']['main']['App']['GetLocalRuleDetails'](arg1); +} + export function GetLocalRulesInDir(arg1) { return window['go']['main']['App']['GetLocalRulesInDir'](arg1); } @@ -106,6 +110,10 @@ export function PickDirectory() { return window['go']['main']['App']['PickDirectory'](); } +export function PickDirectoryWithRoot(arg1) { + return window['go']['main']['App']['PickDirectoryWithRoot'](arg1); +} + export function PullInferenceModel(arg1) { return window['go']['main']['App']['PullInferenceModel'](arg1); } diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index fb5fde8..c460124 100644 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -91,6 +91,28 @@ export namespace main { return a; } } + export class LocalRuleDetails { + id: string; + message: string; + severity: string; + content: string; + language: string; + path: string; + + static createFrom(source: any = {}) { + return new LocalRuleDetails(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.id = source["id"]; + this.message = source["message"]; + this.severity = source["severity"]; + this.content = source["content"]; + this.language = source["language"]; + this.path = source["path"]; + } + } export class MarkdownEntry { path: string; frontMatter: Record; From f1447e359729ebd62a817a8b333af82b30dff3db Mon Sep 17 00:00:00 2001 From: Tom Maiaroto Date: Sat, 21 Mar 2026 14:04:35 -0700 Subject: [PATCH 03/11] layout and tag filtering --- frontend/src/PatternRegistry.tsx | 95 +++++++++++++++++++++++--------- 1 file changed, 70 insertions(+), 25 deletions(-) diff --git a/frontend/src/PatternRegistry.tsx b/frontend/src/PatternRegistry.tsx index f9a1ee2..22eca70 100644 --- a/frontend/src/PatternRegistry.tsx +++ b/frontend/src/PatternRegistry.tsx @@ -65,6 +65,7 @@ export default function PatternRegistry({ workspaceRoot, onWorkspaceChange }: Ru const [localRuleDetails, setLocalRuleDetails] = useState(null); const [isLocalDetailsLoading, setIsLocalDetailsLoading] = useState(false); const [ruleToDelete, setRuleToDelete] = useState<{ name: string; dir: string } | null>(null); + const [selectedTag, setSelectedTag] = useState(''); useEffect(() => { const fetchWorkspaces = async () => { @@ -127,8 +128,8 @@ export default function PatternRegistry({ workspaceRoot, onWorkspaceChange }: Ru if (!selectedConfig) return; setLoading(true); try { - // Auto-filter by language based on selected config - const results = await SearchCommunityRules(searchQuery, selectedConfig.language, ''); + // Auto-filter by language based on selected config, and include selectedTag + const results = await SearchCommunityRules(searchQuery, selectedConfig.language, selectedTag); setRules(results || []); } catch (e) { console.error("Error searching rules:", e); @@ -137,6 +138,10 @@ export default function PatternRegistry({ workspaceRoot, onWorkspaceChange }: Ru } }; + useEffect(() => { + handleSearch(); + }, [selectedTag]); + const fetchLocalRules = async () => { if (!selectedConfig) return; try { @@ -359,20 +364,32 @@ export default function PatternRegistry({ workspaceRoot, onWorkspaceChange }: Ru {/* Live Search Bar */} -
- setSearchQuery(e.target.value)} - /> - - {loading && ( -
- -
+
+ {selectedTag && ( + )} +
+ setSearchQuery(e.target.value)} + /> + + {loading && ( +
+ +
+ )} +
@@ -390,26 +407,34 @@ export default function PatternRegistry({ workspaceRoot, onWorkspaceChange }: Ru )}

{rule.description}

-
-
- {rule.tags?.slice(0, 3).map(tag => ( - #{tag} - ))} -
-
+
+ {rule.tags?.map(tag => ( + + ))} +
+
+
{!isRuleInstalled(rule.id) && ( )}
@@ -500,6 +525,26 @@ export default function PatternRegistry({ workspaceRoot, onWorkspaceChange }: Ru {selectedRule.description}
+
+

Tags

+
+ {selectedRule.tags?.map(tag => ( + + ))} + {(!selectedRule.tags || selectedRule.tags.length === 0) && ( + No tags available + )} +
+

Pattern Source

From 220ae90a334b90c542a4493b043901ff90b8d0db Mon Sep 17 00:00:00 2001 From: Tom Maiaroto Date: Sat, 21 Mar 2026 14:09:59 -0700 Subject: [PATCH 04/11] increase some font sizes that were too small to read --- frontend/src/Atlas.tsx | 35 +++++++++++++++----------------- frontend/src/PatternRegistry.tsx | 19 ++++++++--------- frontend/src/Skills.tsx | 2 +- 3 files changed, 26 insertions(+), 30 deletions(-) diff --git a/frontend/src/Atlas.tsx b/frontend/src/Atlas.tsx index b150de9..4240a83 100644 --- a/frontend/src/Atlas.tsx +++ b/frontend/src/Atlas.tsx @@ -85,10 +85,10 @@ export default function Atlas({ workspaceRoot, onWorkspaceChange }: AtlasProps) } catch (e: any) { console.error("Error regenerating index:", e); const msg = e?.toString() || 'Unknown error'; - setIndexingStatus({ - type: 'error', - message: msg.includes('missing tsconfig.json') - ? 'Failed: Missing tsconfig.json in project root.' + setIndexingStatus({ + type: 'error', + message: msg.includes('missing tsconfig.json') + ? 'Failed: Missing tsconfig.json in project root.' : `Indexing failed: ${msg.split('\n')[0]}` }); } finally { @@ -106,10 +106,9 @@ export default function Atlas({ workspaceRoot, onWorkspaceChange }: AtlasProps) {node.name}
) : ( -
@@ -127,10 +126,9 @@ export default function Atlas({ workspaceRoot, onWorkspaceChange }: AtlasProps) handleRegenerateIndex(node.path); }} disabled={regenerating[node.path]} - className={`p-1 rounded-md transition-all ${ - regenerating[node.path] || selectedIndex === node.path - ? 'opacity-100' - : 'opacity-0 group-hover:opacity-60 hover:opacity-100 hover:bg-base-300' + className={`p-1 rounded-md transition-all ${regenerating[node.path] || selectedIndex === node.path + ? 'opacity-100' + : 'opacity-0 group-hover:opacity-60 hover:opacity-100 hover:bg-base-300' }`} title="Regenerate Index" > @@ -607,7 +605,7 @@ ${snippet || '// No content available'} {ws.root.split(/[\\/]/).pop() || ws.root}
- + {ws.root} @@ -619,9 +617,8 @@ ${snippet || '// No content available'}
{indexingStatus && ( -
+
{indexingStatus.message}
- + {getKindLabel(res.kind)} @@ -724,7 +721,7 @@ ${snippet || '// No content available'}
-
Symbol Legend
+
Symbol Legend
@@ -858,7 +855,7 @@ ${snippet || '// No content available'} {m.name}
- {m.type || 'any'} + {m.type || 'any'}
))}
diff --git a/frontend/src/PatternRegistry.tsx b/frontend/src/PatternRegistry.tsx index 22eca70..c767009 100644 --- a/frontend/src/PatternRegistry.tsx +++ b/frontend/src/PatternRegistry.tsx @@ -274,7 +274,7 @@ export default function PatternRegistry({ workspaceRoot, onWorkspaceChange }: Ru {ws.root.split(/[\\/]/).pop() || ws.root}
- + {ws.root} @@ -343,7 +343,7 @@ export default function PatternRegistry({ workspaceRoot, onWorkspaceChange }: Ru

{selectedConfig.id} -
{selectedConfig.language}
+
{selectedConfig.language}

@@ -367,7 +367,7 @@ export default function PatternRegistry({ workspaceRoot, onWorkspaceChange }: Ru

{selectedTag && ( {!isRuleInstalled(rule.id) && (