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}
+
+
+
+
+
+
+
+
+ {/* Sidebar for Config Selection */}
+
+
+
Configurations
+
+
+
+ {configs.map(config => (
+
+ ))}
+ {configs.length === 0 && (
+
+ )}
+
+
+
+ {/* 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 && (
+
+ )}
+
+
+
+ >
+ )}
+
+
+
+ {/* 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