Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: |
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/wails-multiplatform-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
8 changes: 6 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
197 changes: 197 additions & 0 deletions app.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{}
Expand Down Expand Up @@ -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,
})
}

Binary file added build/appicon.ico
Binary file not shown.
Binary file added build/appicon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added build/icon.ico
Binary file not shown.
Binary file added build/windows/icon.ico
Binary file not shown.
2 changes: 2 additions & 0 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -53,6 +54,7 @@ function App() {
{activeTab === 'home' && <Home onVisualize={openAtlas} />}
{activeTab === 'atlas' && <Atlas workspaceRoot={atlasWorkspace} onWorkspaceChange={setAtlasWorkspace} />}
{activeTab === 'skills' && <Skills workspaceRoot={atlasWorkspace} onWorkspaceChange={setAtlasWorkspace} theme={theme} />}
{activeTab === 'rules' && <PatternRegistry workspaceRoot={atlasWorkspace} onWorkspaceChange={setAtlasWorkspace} />}
{activeTab === 'help' && <Help />}
{activeTab === 'settings' && <Settings theme={theme} setTheme={setTheme} />}
</Layout>
Expand Down
35 changes: 16 additions & 19 deletions frontend/src/Atlas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -106,10 +106,9 @@ export default function Atlas({ workspaceRoot, onWorkspaceChange }: AtlasProps)
{node.name}
</div>
) : (
<div className={`group flex items-center pr-2 rounded-lg transition-all border ${
selectedIndex === node.path
? 'bg-primary/10 text-primary font-bold border-primary/20'
: 'hover:bg-base-200 border-transparent'
<div className={`group flex items-center pr-2 rounded-lg transition-all border ${selectedIndex === node.path
? 'bg-primary/10 text-primary font-bold border-primary/20'
: 'hover:bg-base-200 border-transparent'
}`}
style={{ marginLeft: depth * 12 }}
>
Expand All @@ -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"
>
Expand Down Expand Up @@ -607,7 +605,7 @@ ${snippet || '// No content available'}
<Icon icon="lucide:folder-tree" className={`w-3.5 h-3.5 ${ws.root === workspaceRoot ? 'text-primary' : 'opacity-40'}`} />
<span className="font-bold text-xs truncate flex-1 leading-none">{ws.root.split(/[\\/]/).pop() || ws.root}</span>
</div>
<span className="text-[9px] opacity-30 font-mono truncate w-full pl-6 leading-none">
<span className="text-[10px] opacity-30 font-mono truncate w-full pl-6 leading-none">
{ws.root}
</span>
</button>
Expand All @@ -619,9 +617,8 @@ ${snippet || '// No content available'}
</div>

{indexingStatus && (
<div className={`p-4 rounded-xl border flex items-center gap-3 animate-in fade-in slide-in-from-top-2 duration-300 shadow-lg ${
indexingStatus.type === 'success' ? 'bg-success/10 text-success border-success/20' : 'bg-error/10 text-error border-error/20'
}`}>
<div className={`p-4 rounded-xl border flex items-center gap-3 animate-in fade-in slide-in-from-top-2 duration-300 shadow-lg ${indexingStatus.type === 'success' ? 'bg-success/10 text-success border-success/20' : 'bg-error/10 text-error border-error/20'
}`}>
<Icon icon={indexingStatus.type === 'success' ? 'lucide:check-circle' : 'lucide:alert-circle'} className="w-4 h-4" />
<span className="text-[11px] font-black uppercase tracking-widest">{indexingStatus.message}</span>
<button onClick={() => setIndexingStatus(null)} className="ml-2 hover:opacity-50">
Expand Down Expand Up @@ -666,7 +663,7 @@ ${snippet || '// No content available'}
/>
<span className="text-xs font-bold truncate">{res.name}</span>
</div>
<span className="text-[9px] uppercase font-black opacity-30 group-hover:opacity-100 bg-base-200/50 group-hover:bg-primary-focus px-1.5 py-0.5 rounded ml-2 shrink-0">
<span className="text-[10px] uppercase font-black opacity-30 group-hover:opacity-100 bg-base-200/50 group-hover:bg-primary-focus px-1.5 py-0.5 rounded ml-2 shrink-0">
{getKindLabel(res.kind)}
</span>
</button>
Expand Down Expand Up @@ -724,7 +721,7 @@ ${snippet || '// No content available'}
</div>

<div className="bg-base-100/90 backdrop-blur-xl border border-base-300/50 rounded-xl p-4 shadow-xl ring-1 ring-black/5 pointer-events-auto">
<div className="text-[9px] font-black uppercase tracking-[0.2em] opacity-30 mb-3 ml-1">Symbol Legend</div>
<div className="text-[10px] font-black uppercase tracking-[0.2em] opacity-30 mb-3 ml-1">Symbol Legend</div>
<div className="grid grid-cols-2 gap-x-6 gap-y-3">
<div className="flex items-center gap-2.5">
<div className="w-2.5 h-2.5 rounded-full bg-[#f59e0b] shadow-sm"></div>
Expand Down Expand Up @@ -858,7 +855,7 @@ ${snippet || '// No content available'}
<Icon icon={m.kind === 'Field' ? 'lucide:box' : 'lucide:function-square'} className={`w-3.5 h-3.5 ${m.kind === 'Field' ? 'text-blue-400' : 'text-purple-400'} opacity-60`} />
<span className="text-[11px] font-bold tracking-tight">{m.name}</span>
</div>
<span className="text-[9px] font-mono opacity-30 group-hover:opacity-60 transition-opacity uppercase font-black">{m.type || 'any'}</span>
<span className="text-[10px] font-mono opacity-30 group-hover:opacity-60 transition-opacity uppercase font-black">{m.type || 'any'}</span>
</div>
))}
</div>
Expand Down
9 changes: 2 additions & 7 deletions frontend/src/Help.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.' },
];

Expand Down
8 changes: 8 additions & 0 deletions frontend/src/Layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,14 @@ export default function Layout({ activeTab, setActiveTab, children }: LayoutProp
>
<Icon icon="lucide:brain" className="text-xl" />
</button>

<button
className={`btn btn-square btn-ghost w-full transition-colors ${activeTab === 'rules' ? 'bg-base-100 shadow-sm text-primary' : 'text-base-content/70 hover:bg-base-100 hover:text-base-content'} tooltip tooltip-right`}
data-tip="Pattern Registry"
onClick={() => setActiveTab('rules')}
>
<Icon icon="lucide:scroll-text" className="text-xl" />
</button>
</div>

{/* Bottom Menu Items */}
Expand Down
Loading
Loading