diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 5872359..b25029a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -6,12 +6,13 @@ jobs: name: Test with Coverage runs-on: ubuntu-latest steps: + - name: Check out code + uses: actions/checkout@v4 + - name: Set up Go uses: actions/setup-go@v5 with: - go-version: '1.24.3' - - name: Check out code - uses: actions/checkout@v4 + go-version-file: go.mod - name: Download ast-grep binary for tests run: | diff --git a/.github/workflows/wails-multiplatform-build.yml b/.github/workflows/wails-multiplatform-build.yml index 8c4bd94..bcb4d99 100644 --- a/.github/workflows/wails-multiplatform-build.yml +++ b/.github/workflows/wails-multiplatform-build.yml @@ -56,7 +56,7 @@ jobs: build-tags: ${{ matrix.tags }} package: true # Using project's preferred versions - go-version: '1.25' + go-version-file: go.mod node-version: '20' create-release: diff --git a/.gitignore b/.gitignore index d5af0fa..dc15bc2 100644 --- a/.gitignore +++ b/.gitignore @@ -54,11 +54,15 @@ Thumbs.db *.log # Build artifacts -dist/ build/ -frontend/ +frontend/dist/assets/ +frontend/dist/index.html # But do NOT ignore the .gitkeep file !frontend/dist/.gitkeep +# keep these as well +!build/icon.ico +!build/appicon.ico +!build/windows/icon.ico # Environment files .env diff --git a/app.go b/app.go index b012139..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{} @@ -2447,3 +2457,190 @@ 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) +} + +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 +} + +// 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/build/appicon.ico b/build/appicon.ico new file mode 100644 index 0000000..9627e0b Binary files /dev/null and b/build/appicon.ico differ diff --git a/build/appicon.png b/build/appicon.png new file mode 100644 index 0000000..a9fcce8 Binary files /dev/null and b/build/appicon.png differ diff --git a/build/icon.ico b/build/icon.ico new file mode 100644 index 0000000..9627e0b Binary files /dev/null and b/build/icon.ico differ diff --git a/build/windows/icon.ico b/build/windows/icon.ico new file mode 100644 index 0000000..9627e0b Binary files /dev/null and b/build/windows/icon.ico differ 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/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/Help.tsx b/frontend/src/Help.tsx index 1683f33..55fb0f4 100644 --- a/frontend/src/Help.tsx +++ b/frontend/src/Help.tsx @@ -15,14 +15,9 @@ const Help = () => { { name: 'scan_path', desc: 'Scan files/directories/globs for rule violations.' }, { name: 'get_symbol_map', desc: 'Definitions and references for a symbol (SCIP).' }, { name: 'add_or_update_rule', desc: 'Create/update ast-grep rules via YAML.' }, - { name: 'remove_rule', desc: 'Remove an ast-grep rule from the workspace.' }, - { name: 'initialize_ast_grep', desc: 'Setup workspace for ast-grep (sgconfig.yml/rules).' }, - { name: 'search_community_rules', desc: 'Search pre-built rules in the community repo.' }, - { name: 'get_community_rule_details', desc: 'Full YAML content for community rules.' }, - { name: 'import_community_rule', desc: 'Import community rules to local workspace.' }, { name: 'ast_grep_scan', desc: 'Perform a structural search across the codebase using ast-grep patterns.' }, - { name: 'list_symbols_in_file', desc: 'List classes, functions, and variables in a file.' }, - { name: 'search_definitions', desc: 'Search for symbol definitions project-wide.' }, + { name: 'list_symbols_in_file', desc: 'Lists symbols in a file with enriched metadata. Can be distilled into a summary.' }, + { name: 'search_definitions', desc: 'Search for symbol definitions project-wide using SCIP.' }, { name: 'initialize_scip', desc: 'Index workspace for SCIP-based navigation.' }, ]; 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..c767009 --- /dev/null +++ b/frontend/src/PatternRegistry.tsx @@ -0,0 +1,773 @@ +import { useState, useEffect } from 'react'; +import { Icon } from '@iconify/react'; +import { + GetWorkspaces, + SearchCommunityRules, + GetCommunityRuleDetails, + ImportCommunityRule, + GetWorkspaceConfigs, + InitializeAstGrepConfig, + RemoveLocalRule, + GetLocalRulesInDir, + GetLocalRuleDetails, + PickDirectoryWithRoot +} 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[]; +}; + +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([]); + 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); + + // Phase 5 additions + 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 () => { + 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, and include selectedTag + const results = await SearchCommunityRules(searchQuery, selectedConfig.language, selectedTag); + setRules(results || []); + } catch (e) { + console.error("Error searching rules:", e); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + handleSearch(); + }, [selectedTag]); + + 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; + try { + 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 { + 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 */} +
+ {selectedTag && ( + + )} +
+ setSearchQuery(e.target.value)} + /> + + {loading && ( +
+ +
+ )} +
+
+
+ +
+ {rules.map(rule => ( +
+
+
+

{rule.id}

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

{rule.description}

+
+ {rule.tags?.map(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} +
+
+
+

Tags

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

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..." + /> +
+ +
+ +
+ +
+ +
+ +
+
+
+ +
+ + +
+
+
!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/src/Skills.tsx b/frontend/src/Skills.tsx index 6540a81..75c7be9 100644 --- a/frontend/src/Skills.tsx +++ b/frontend/src/Skills.tsx @@ -222,7 +222,7 @@ export default function Skills({ workspaceRoot, onWorkspaceChange, theme }: Skil {ws.root.split(/[\\/]/).pop() || ws.root} - + {ws.root} 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..ddb9d68 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,22 @@ 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; 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; @@ -45,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; @@ -53,12 +68,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..aa00775 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,14 @@ 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); +} + export function GetPreferences() { return window['go']['main']['App']['GetPreferences'](); } @@ -54,10 +66,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'](); } @@ -86,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); } @@ -102,6 +130,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 +146,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..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; @@ -158,3 +180,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/main.go b/main.go index c819a3e..97210de 100644 --- a/main.go +++ b/main.go @@ -16,12 +16,16 @@ import ( "github.com/wailsapp/wails/v2" "github.com/wailsapp/wails/v2/pkg/options" "github.com/wailsapp/wails/v2/pkg/options/assetserver" + "github.com/wailsapp/wails/v2/pkg/options/linux" "github.com/wailsapp/wails/v2/pkg/options/mac" ) //go:embed all:frontend/dist var assets embed.FS +//go:embed build/appicon.png +var icon []byte + func initDebugLog(path string) *os.File { if path == "" { path = "production_debug.log" @@ -186,8 +190,15 @@ func main() { About: &mac.AboutInfo{ Title: "Context Sherpa Hub", Message: "© 2026 Hack After Dark", + Icon: icon, }, }, + Linux: &linux.Options{ + Icon: icon, + WindowIsTranslucent: false, + WebviewGpuPolicy: linux.WebviewGpuPolicyAlways, + ProgramName: "context-sherpa-hub", + }, }) if err != nil { diff --git a/pkg/database/db_test.go b/pkg/database/db_test.go new file mode 100644 index 0000000..9c86ea8 --- /dev/null +++ b/pkg/database/db_test.go @@ -0,0 +1,55 @@ +package database + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestInitDB(t *testing.T) { + // Create a temporary directory for the test database + tmpDir, err := os.MkdirTemp("", "sherpa-test-*") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + dbPath := filepath.Join(tmpDir, "test.db") + + t.Run("Initialize new database", func(t *testing.T) { + db, err := InitDB(dbPath) + assert.NoError(t, err) + assert.NotNil(t, db) + defer db.Close() + + // Verify WAL mode is enabled + var mode string + err = db.QueryRow("PRAGMA journal_mode;").Scan(&mode) + assert.NoError(t, err) + assert.Equal(t, "wal", mode) + + // Verify directory was created + _, err = os.Stat(filepath.Dir(dbPath)) + assert.NoError(t, err) + }) + + t.Run("Reopen existing database", func(t *testing.T) { + db, err := InitDB(dbPath) + assert.NoError(t, err) + assert.NotNil(t, db) + db.Close() + }) + + t.Run("Error on invalid directory path", func(t *testing.T) { + // Create a file where a directory should be + invalidDir := filepath.Join(tmpDir, "file_as_dir") + err := os.WriteFile(invalidDir, []byte("not a dir"), 0644) + require.NoError(t, err) + + invalidPath := filepath.Join(invalidDir, "test.db") + db, err := InitDB(invalidPath) + assert.Error(t, err) + assert.Nil(t, db) + }) +} diff --git a/pkg/mcp/server.go b/pkg/mcp/server.go index 03acf5a..b882be1 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"` } @@ -182,7 +184,6 @@ func toScipPath(workspaceRoot, inputPath string) string { return res } - // Start initializes and starts the MCP server. func Start(workspaceRoot string, verbose bool, logFilePath string, astGrepPath string, clientName string) { if workspaceRoot == "" { @@ -216,7 +217,6 @@ func Start(workspaceRoot string, verbose bool, logFilePath string, astGrepPath s server.WithToolCapabilities(false), ) - s.AddTool(mcp.NewTool("query_local_reasoning", mcp.WithDescription("(The Fallback) A catch-all tool for asking any open-ended semantic question about a code snippet that doesn't fit a specific template."), mcp.WithString("prompt", mcp.Required(), mcp.Description("The question or instruction for the local SLM to reason about.")), @@ -370,63 +370,6 @@ 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", mcp.WithDescription("Lists symbols in a file with enriched metadata. Limited to 50 definitions with truncated docstrings. Can be 'distilled' into a semantic summary."), @@ -488,11 +431,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 @@ -2041,7 +1979,6 @@ func isSystemDir(path string) bool { return false } - func findAstGrepBinary(astGrepPath string) (string, error) { // 1. User explicitly specified path (highest priority) if astGrepPath != "" { @@ -2119,7 +2056,6 @@ Please ensure ast-grep is available in one of these ways: context-sherpa --astGrepPath="/path/to/ast-grep"`) } - // getRuleDir determines the directory where rules should be stored by searching // for sgconfig.yml in the current and parent directories. func getRuleDir() (string, error) { @@ -2170,73 +2106,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 +2304,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 +2322,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) @@ -2625,7 +2377,6 @@ func validateAstGrepRule(yamlContent string) error { return nil } - func runSLM(ctx context.Context, request mcp.CallToolRequest, prompt string) (*mcp.CallToolResult, error) { modelID := "" maxTokens := 512 @@ -3034,7 +2785,7 @@ func (sr *SniffingReader) extractRoot(captured []byte) { if customLogger != nil { customLogger.Printf("SniffingReader: Found matching field %s with value %s", string(match[1]), uri) } - + path := strings.TrimPrefix(uri, "file://") // Windows: file:///C:/path -> /C:/path if strings.HasPrefix(path, "/") && len(path) > 2 && path[2] == ':' { 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