@tanagram/cli 0.1.1 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,55 @@
1
+ package commands
2
+
3
+ import (
4
+ "fmt"
5
+
6
+ "github.com/tanagram/cli/storage"
7
+ )
8
+
9
+ // List displays all cached policies
10
+ func List() error {
11
+ // Find git root
12
+ gitRoot, err := storage.FindGitRoot()
13
+ if err != nil {
14
+ return fmt.Errorf("not in a git repository: %w", err)
15
+ }
16
+
17
+ // Load cache
18
+ cache, err := storage.LoadCache(gitRoot)
19
+ if err != nil {
20
+ return fmt.Errorf("failed to load cache: %w", err)
21
+ }
22
+
23
+ if len(cache.Policies) == 0 {
24
+ fmt.Println("No cached policies found. Run 'tanagram sync' first.")
25
+ return nil
26
+ }
27
+
28
+ totalPolicies := 0
29
+
30
+ fmt.Println("Cached Policies (All enforced via LLM):")
31
+
32
+ // Display policies grouped by file
33
+ for filepath, serializablePolicies := range cache.Policies {
34
+ if len(serializablePolicies) == 0 {
35
+ continue
36
+ }
37
+
38
+ fmt.Printf("📄 %s (%d policies)\n", filepath, len(serializablePolicies))
39
+ fmt.Println("─────────────────────────────────────────────────────────────")
40
+
41
+ for _, sp := range serializablePolicies {
42
+ totalPolicies++
43
+ fmt.Printf(" • %s: %s\n", sp.Name, sp.Message)
44
+ }
45
+ fmt.Println()
46
+ }
47
+
48
+ // Summary
49
+ fmt.Println("═════════════════════════════════════════════════════════════")
50
+ fmt.Printf("Total: %d policies\n", totalPolicies)
51
+ fmt.Printf("Files: %d\n", len(cache.Policies))
52
+ fmt.Println("\nAll policies are enforced using LLM-based semantic analysis")
53
+
54
+ return nil
55
+ }
@@ -0,0 +1,118 @@
1
+ package commands
2
+
3
+ import (
4
+ "context"
5
+ "fmt"
6
+ "os"
7
+
8
+ "github.com/tanagram/cli/checker"
9
+ "github.com/tanagram/cli/extractor"
10
+ "github.com/tanagram/cli/git"
11
+ "github.com/tanagram/cli/storage"
12
+ )
13
+
14
+ // Run executes the main policy check with auto-sync
15
+ func Run() error {
16
+ // Find git root
17
+ gitRoot, err := storage.FindGitRoot()
18
+ if err != nil {
19
+ return fmt.Errorf("not in a git repository: %w", err)
20
+ }
21
+
22
+ // Find all instruction files
23
+ instructionFiles, err := FindInstructionFiles(gitRoot)
24
+ if err != nil {
25
+ return err
26
+ }
27
+
28
+ if len(instructionFiles) == 0 {
29
+ return fmt.Errorf("no instruction files found (looking for AGENTS.md, POLICIES.md, etc.)")
30
+ }
31
+
32
+ // Load cache
33
+ cache, err := storage.LoadCache(gitRoot)
34
+ if err != nil {
35
+ return fmt.Errorf("failed to load cache: %w", err)
36
+ }
37
+
38
+ // Check each file for changes and auto-sync if needed
39
+ needsSync := false
40
+ for _, file := range instructionFiles {
41
+ changed, err := cache.HasChanged(file)
42
+ if err != nil {
43
+ return fmt.Errorf("failed to check if %s changed: %w", file, err)
44
+ }
45
+ if changed {
46
+ needsSync = true
47
+ break
48
+ }
49
+ }
50
+
51
+ // Auto-sync if any files changed
52
+ if needsSync {
53
+ fmt.Println("Instruction files changed, syncing with LLM...")
54
+ totalPolicies := 0
55
+ ctx := context.Background()
56
+
57
+ for _, file := range instructionFiles {
58
+ policies, err := extractor.ExtractPoliciesFromFile(ctx, file)
59
+ if err != nil {
60
+ return fmt.Errorf("failed to extract policies from %s: %w", file, err)
61
+ }
62
+
63
+ if err := cache.UpdateFile(file, policies); err != nil {
64
+ return fmt.Errorf("failed to update cache for %s: %w", file, err)
65
+ }
66
+
67
+ totalPolicies += len(policies)
68
+ }
69
+
70
+ if err := cache.Save(); err != nil {
71
+ return fmt.Errorf("failed to save cache: %w", err)
72
+ }
73
+
74
+ fmt.Printf("Synced %d policies from %d file(s)\n", totalPolicies, len(instructionFiles))
75
+ }
76
+
77
+ // Load all policies from cache
78
+ policies, err := cache.GetAllPolicies()
79
+ if err != nil {
80
+ return fmt.Errorf("failed to load policies from cache: %w", err)
81
+ }
82
+
83
+ if len(policies) == 0 {
84
+ fmt.Println("No enforceable policies found")
85
+ return nil
86
+ }
87
+
88
+ fmt.Printf("Loaded %d policies\n", len(policies))
89
+
90
+ // Get all changes (unstaged + staged)
91
+ fmt.Println("Checking all changes (unstaged + staged)...")
92
+ diffResult, err := git.GetAllChanges()
93
+ if err != nil {
94
+ return fmt.Errorf("error getting git diff: %w", err)
95
+ }
96
+
97
+ if len(diffResult.Changes) == 0 {
98
+ fmt.Println("No changes to check")
99
+ return nil
100
+ }
101
+
102
+ fmt.Printf("Scanning %d changed lines...\n\n", len(diffResult.Changes))
103
+
104
+ // Check changes against policies (both regex and LLM-based)
105
+ ctx := context.Background()
106
+ result := checker.CheckChanges(ctx, diffResult.Changes, policies)
107
+
108
+ // Output results
109
+ output := checker.FormatViolations(result)
110
+ fmt.Print(output)
111
+
112
+ // Exit with error if violations found
113
+ if len(result.Violations) > 0 {
114
+ os.Exit(1)
115
+ }
116
+
117
+ return nil
118
+ }
@@ -0,0 +1,124 @@
1
+ package commands
2
+
3
+ import (
4
+ "context"
5
+ "fmt"
6
+ "os"
7
+ "path/filepath"
8
+
9
+ "github.com/tanagram/cli/extractor"
10
+ "github.com/tanagram/cli/storage"
11
+ )
12
+
13
+ // Sync manually syncs all instruction files to the cache
14
+ func Sync() error {
15
+ // Find git root
16
+ gitRoot, err := storage.FindGitRoot()
17
+ if err != nil {
18
+ return fmt.Errorf("not in a git repository: %w", err)
19
+ }
20
+
21
+ // Find all instruction files
22
+ instructionFiles, err := FindInstructionFiles(gitRoot)
23
+ if err != nil {
24
+ return err
25
+ }
26
+
27
+ if len(instructionFiles) == 0 {
28
+ return fmt.Errorf("no instruction files found (looking for AGENTS.md, POLICIES.md, etc.)")
29
+ }
30
+
31
+ fmt.Printf("Found %d instruction file(s)\n", len(instructionFiles))
32
+
33
+ // Load or create cache
34
+ cache, err := storage.LoadCache(gitRoot)
35
+ if err != nil {
36
+ return fmt.Errorf("failed to load cache: %w", err)
37
+ }
38
+
39
+ // Parse and sync each file using LLM
40
+ totalPolicies := 0
41
+ ctx := context.Background()
42
+
43
+ for _, file := range instructionFiles {
44
+ relPath, _ := filepath.Rel(gitRoot, file)
45
+ fmt.Printf("Syncing %s (using LLM)...\n", relPath)
46
+
47
+ policies, err := extractor.ExtractPoliciesFromFile(ctx, file)
48
+ if err != nil {
49
+ return fmt.Errorf("failed to extract policies from %s: %w", file, err)
50
+ }
51
+
52
+ if err := cache.UpdateFile(file, policies); err != nil {
53
+ return fmt.Errorf("failed to update cache for %s: %w", file, err)
54
+ }
55
+
56
+ fmt.Printf(" ✓ Extracted %d policies\n", len(policies))
57
+ totalPolicies += len(policies)
58
+ }
59
+
60
+ // Save cache
61
+ if err := cache.Save(); err != nil {
62
+ return fmt.Errorf("failed to save cache: %w", err)
63
+ }
64
+
65
+ fmt.Printf("\nSync complete! Total: %d policies from %d file(s)\n", totalPolicies, len(instructionFiles))
66
+ return nil
67
+ }
68
+
69
+ // FindInstructionFiles searches for instruction files in the git repository
70
+ // Looks for: AGENTS.md, POLICIES.md, and .md files in .tanagram/policies/
71
+ func FindInstructionFiles(gitRoot string) ([]string, error) {
72
+ var files []string
73
+
74
+ // Common instruction file names to look for
75
+ commonNames := []string{"AGENTS.md", "POLICIES.md"}
76
+
77
+ // Directories to skip
78
+ skipDirs := map[string]bool{
79
+ ".git": true,
80
+ "node_modules": true,
81
+ "vendor": true,
82
+ ".venv": true,
83
+ "venv": true,
84
+ "__pycache__": true,
85
+ ".pytest_cache": true,
86
+ ".mypy_cache": true,
87
+ "dist": true,
88
+ "build": true,
89
+ ".tanagram": true,
90
+ ".conductor": true, // Skip conductor directories
91
+ "repos": true, // Skip cloned repos
92
+ "monorepo_clones": true, // Skip cloned repos
93
+ }
94
+
95
+ // Search from git root down
96
+ err := filepath.Walk(gitRoot, func(path string, info os.FileInfo, err error) error {
97
+ if err != nil {
98
+ return err
99
+ }
100
+
101
+ // Skip common directories
102
+ if info.IsDir() && skipDirs[info.Name()] {
103
+ return filepath.SkipDir
104
+ }
105
+
106
+ // Check if this is one of our instruction files
107
+ if !info.IsDir() {
108
+ for _, name := range commonNames {
109
+ if info.Name() == name {
110
+ files = append(files, path)
111
+ break
112
+ }
113
+ }
114
+ }
115
+
116
+ return nil
117
+ })
118
+
119
+ if err != nil {
120
+ return nil, fmt.Errorf("failed to search for instruction files: %w", err)
121
+ }
122
+
123
+ return files, nil
124
+ }
@@ -0,0 +1,106 @@
1
+ package extractor
2
+
3
+ import (
4
+ "context"
5
+ "encoding/json"
6
+ "fmt"
7
+
8
+ "github.com/tanagram/cli/llm"
9
+ "github.com/tanagram/cli/parser"
10
+ )
11
+
12
+ // ExtractedPolicy represents a policy extracted by the LLM
13
+ // Simplified: No types or patterns, all enforcement is LLM-based
14
+ type ExtractedPolicy struct {
15
+ Name string `json:"name"`
16
+ Message string `json:"message"`
17
+ OriginalText string `json:"original_text"`
18
+ }
19
+
20
+ // ExtractorResponse represents the JSON response from the LLM
21
+ type ExtractorResponse struct {
22
+ Policies []ExtractedPolicy `json:"policies"`
23
+ }
24
+
25
+ // ExtractPolicies uses LLM to extract policies from instruction file content
26
+ func ExtractPolicies(ctx context.Context, content string) ([]parser.Policy, error) {
27
+ client, err := llm.NewClient()
28
+ if err != nil {
29
+ return nil, err
30
+ }
31
+
32
+ prompt := buildExtractionPrompt(content)
33
+
34
+ response, err := client.SendMessage(ctx, prompt)
35
+ if err != nil {
36
+ return nil, fmt.Errorf("failed to extract policies: %w", err)
37
+ }
38
+
39
+ // Strip markdown code blocks if present
40
+ cleanedResponse := llm.StripMarkdownCodeBlocks(response)
41
+
42
+ // Parse JSON response
43
+ var extractorResponse ExtractorResponse
44
+ if err := json.Unmarshal([]byte(cleanedResponse), &extractorResponse); err != nil {
45
+ return nil, fmt.Errorf("failed to parse LLM response: %w\nResponse: %s", err, response)
46
+ }
47
+
48
+ // Convert to parser.Policy format
49
+ policies := make([]parser.Policy, 0, len(extractorResponse.Policies))
50
+ for _, ep := range extractorResponse.Policies {
51
+ policies = append(policies, parser.Policy{
52
+ Name: ep.Name,
53
+ Message: ep.Message,
54
+ OriginalText: ep.OriginalText,
55
+ })
56
+ }
57
+
58
+ return policies, nil
59
+ }
60
+
61
+ // buildExtractionPrompt creates the prompt for policy extraction
62
+ func buildExtractionPrompt(content string) string {
63
+ return fmt.Sprintf(`You are a code policy extraction system. Extract ONLY coding policies that apply when developers write and edit code.
64
+
65
+ Analyze the following instruction file and extract policies that define:
66
+ - Code structure and organization rules
67
+ - Coding standards and style guidelines
68
+ - Language-specific patterns and practices
69
+ - File naming and directory conventions
70
+ - Error handling requirements
71
+ - Security practices in code
72
+ - Testing requirements
73
+ - Performance optimization rules
74
+ - Import/dependency management
75
+
76
+ DO NOT extract:
77
+ - Agent behavior instructions (how AI agents should operate)
78
+ - Workflow or process instructions
79
+ - Tool usage instructions
80
+ - Communication style guidelines
81
+ - Project management policies
82
+ - Non-code related guidelines
83
+
84
+ For each code policy, provide:
85
+ 1. Name: Short descriptive name (2-5 words)
86
+ 2. Message: The full policy text or a clear summary
87
+ 3. OriginalText: The exact text from the file
88
+
89
+ Return ONLY valid JSON (no markdown, no backticks, no code fences):
90
+ {
91
+ "policies": [
92
+ {
93
+ "name": "Short policy name",
94
+ "message": "Clear policy description",
95
+ "original_text": "Original text from file"
96
+ }
97
+ ]
98
+ }
99
+
100
+ Instruction File Content:
101
+ %s
102
+
103
+ Remember: Return ONLY the JSON object, no other text, no markdown formatting.`, content)
104
+ }
105
+
106
+
@@ -0,0 +1,19 @@
1
+ package extractor
2
+
3
+ import (
4
+ "context"
5
+ "fmt"
6
+ "os"
7
+
8
+ "github.com/tanagram/cli/parser"
9
+ )
10
+
11
+ // ExtractPoliciesFromFile reads a file and extracts policies using LLM
12
+ func ExtractPoliciesFromFile(ctx context.Context, filepath string) ([]parser.Policy, error) {
13
+ content, err := os.ReadFile(filepath)
14
+ if err != nil {
15
+ return nil, fmt.Errorf("failed to read file: %w", err)
16
+ }
17
+
18
+ return ExtractPolicies(ctx, string(content))
19
+ }
@@ -0,0 +1,6 @@
1
+ # Demo Policies for Testing
2
+
3
+ - Don't use hard-coded color values; use theme colors instead
4
+ - Use ruff format for Python formatting, not black
5
+ - Always use async/await for database operations
6
+ - Avoid using var in JavaScript; use const or let
@@ -0,0 +1,6 @@
1
+ // Demo file to test policy violations
2
+ export function BadComponent() {
3
+ const color = "#FF5733"; // This violates the hardcoded color policy!
4
+
5
+ return <div style={{ backgroundColor: color }}>Hello</div>;
6
+ }
package/llm/client.go ADDED
@@ -0,0 +1,64 @@
1
+ package llm
2
+
3
+ import (
4
+ "context"
5
+ "fmt"
6
+ "os"
7
+
8
+ "github.com/anthropics/anthropic-sdk-go"
9
+ "github.com/anthropics/anthropic-sdk-go/option"
10
+ )
11
+
12
+ // Client handles communication with the Anthropic API
13
+ type Client struct {
14
+ client *anthropic.Client
15
+ }
16
+
17
+ // NewClient creates a new Anthropic API client
18
+ func NewClient() (*Client, error) {
19
+ apiKey := os.Getenv("ANTHROPIC_API_KEY")
20
+ if apiKey == "" {
21
+ return nil, fmt.Errorf("ANTHROPIC_API_KEY environment variable not set. Please set it to use LLM-based policy extraction")
22
+ }
23
+
24
+ client := anthropic.NewClient(
25
+ option.WithAPIKey(apiKey),
26
+ )
27
+
28
+ return &Client{
29
+ client: &client,
30
+ }, nil
31
+ }
32
+
33
+ // SendMessage sends a message to Claude and returns the response
34
+ func (c *Client) SendMessage(ctx context.Context, prompt string) (string, error) {
35
+ message, err := c.client.Messages.New(ctx, anthropic.MessageNewParams{
36
+ MaxTokens: 8192,
37
+ Messages: []anthropic.MessageParam{
38
+ anthropic.NewUserMessage(anthropic.NewTextBlock(prompt)),
39
+ },
40
+ Model: anthropic.ModelClaudeHaiku4_5_20251001,
41
+ })
42
+ if err != nil {
43
+ return "", fmt.Errorf("failed to send message: %w", err)
44
+ }
45
+
46
+ // Extract text from content blocks
47
+ if len(message.Content) == 0 {
48
+ return "", fmt.Errorf("empty response from API")
49
+ }
50
+
51
+ // Get text from all content blocks and concatenate
52
+ var result string
53
+ for _, block := range message.Content {
54
+ if block.Text != "" {
55
+ result += block.Text
56
+ }
57
+ }
58
+
59
+ if result == "" {
60
+ return "", fmt.Errorf("no text content in response")
61
+ }
62
+
63
+ return result, nil
64
+ }
package/llm/utils.go ADDED
@@ -0,0 +1,66 @@
1
+ package llm
2
+
3
+ import "strings"
4
+
5
+ // StripMarkdownCodeBlocks removes markdown code block markers (```json ... ```) from LLM responses
6
+ // This handles cases where Claude returns JSON wrapped in markdown formatting
7
+ // If multiple code blocks exist, extracts the last one (assumes LLM's final answer)
8
+ func StripMarkdownCodeBlocks(response string) string {
9
+ response = strings.TrimSpace(response)
10
+
11
+ // Find all code blocks and extract the last valid JSON one
12
+ // Pattern: ```json ... ``` or ``` ... ```
13
+ blocks := extractCodeBlocks(response)
14
+ if len(blocks) > 0 {
15
+ // Return the last block (LLM's final answer)
16
+ return blocks[len(blocks)-1]
17
+ }
18
+
19
+ // No code blocks found, return as-is
20
+ return response
21
+ }
22
+
23
+ // extractCodeBlocks finds all markdown code blocks in the response
24
+ func extractCodeBlocks(text string) []string {
25
+ var blocks []string
26
+ lines := strings.Split(text, "\n")
27
+
28
+ var currentBlock strings.Builder
29
+ inBlock := false
30
+
31
+ for _, line := range lines {
32
+ trimmed := strings.TrimSpace(line)
33
+
34
+ // Check if this line is a code block delimiter
35
+ if trimmed == "```json" || trimmed == "```" {
36
+ if inBlock {
37
+ // Closing a block
38
+ content := strings.TrimSpace(currentBlock.String())
39
+ if content != "" {
40
+ blocks = append(blocks, content)
41
+ }
42
+ currentBlock.Reset()
43
+ inBlock = false
44
+ } else {
45
+ // Opening a block
46
+ inBlock = true
47
+ }
48
+ } else if inBlock {
49
+ // Inside a block, accumulate content
50
+ if currentBlock.Len() > 0 {
51
+ currentBlock.WriteString("\n")
52
+ }
53
+ currentBlock.WriteString(line)
54
+ }
55
+ }
56
+
57
+ // Handle case where block wasn't closed
58
+ if inBlock && currentBlock.Len() > 0 {
59
+ content := strings.TrimSpace(currentBlock.String())
60
+ if content != "" {
61
+ blocks = append(blocks, content)
62
+ }
63
+ }
64
+
65
+ return blocks
66
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tanagram/cli",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "Tanagram - Catch sloppy code before it ships",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -30,12 +30,19 @@
30
30
  "files": [
31
31
  "bin/",
32
32
  "checker/",
33
+ "cli/",
34
+ "commands/",
35
+ "extractor/",
36
+ "fixtures/",
33
37
  "git/",
38
+ "llm/",
34
39
  "parser/",
40
+ "storage/",
35
41
  "main.go",
36
42
  "go.mod",
37
43
  "go.sum",
38
44
  "install.js",
39
- "README.md"
45
+ "README.md",
46
+ "LICENSE"
40
47
  ]
41
48
  }
@@ -0,0 +1,198 @@
1
+ package storage
2
+
3
+ import (
4
+ "crypto/md5"
5
+ "encoding/gob"
6
+ "fmt"
7
+ "io"
8
+ "os"
9
+ "path/filepath"
10
+
11
+ "github.com/tanagram/cli/parser"
12
+ )
13
+
14
+ const (
15
+ cacheDir = ".tanagram"
16
+ cacheFile = "cache.gob"
17
+ )
18
+
19
+ // SerializablePolicy is a version of parser.Policy that can be encoded with gob
20
+ // Simplified: No types or patterns needed for LLM-only detection
21
+ type SerializablePolicy struct {
22
+ Name string
23
+ Message string
24
+ OriginalText string
25
+ }
26
+
27
+ // Cache stores parsed policies and their MD5 hashes
28
+ type Cache struct {
29
+ FileMD5s map[string]string // filepath -> MD5 hash
30
+ Policies map[string][]SerializablePolicy // filepath -> policies
31
+ CachePath string // where the cache file is stored
32
+ }
33
+
34
+ // NewCache creates a new empty cache
35
+ func NewCache(cachePath string) *Cache {
36
+ return &Cache{
37
+ FileMD5s: make(map[string]string),
38
+ Policies: make(map[string][]SerializablePolicy),
39
+ CachePath: cachePath,
40
+ }
41
+ }
42
+
43
+ // LoadCache loads the cache from disk, returns empty cache if not found
44
+ func LoadCache(gitRoot string) (*Cache, error) {
45
+ cachePath := filepath.Join(gitRoot, cacheDir, cacheFile)
46
+
47
+ // If cache doesn't exist, return empty cache
48
+ if _, err := os.Stat(cachePath); os.IsNotExist(err) {
49
+ return NewCache(cachePath), nil
50
+ }
51
+
52
+ file, err := os.Open(cachePath)
53
+ if err != nil {
54
+ return nil, fmt.Errorf("failed to open cache: %w", err)
55
+ }
56
+ defer file.Close()
57
+
58
+ var cache Cache
59
+ decoder := gob.NewDecoder(file)
60
+ if err := decoder.Decode(&cache); err != nil {
61
+ return nil, fmt.Errorf("failed to decode cache: %w", err)
62
+ }
63
+
64
+ cache.CachePath = cachePath
65
+ return &cache, nil
66
+ }
67
+
68
+ // Save writes the cache to disk
69
+ func (c *Cache) Save() error {
70
+ // Create cache directory if it doesn't exist
71
+ dir := filepath.Dir(c.CachePath)
72
+ if err := os.MkdirAll(dir, 0755); err != nil {
73
+ return fmt.Errorf("failed to create cache directory: %w", err)
74
+ }
75
+
76
+ file, err := os.Create(c.CachePath)
77
+ if err != nil {
78
+ return fmt.Errorf("failed to create cache file: %w", err)
79
+ }
80
+ defer file.Close()
81
+
82
+ encoder := gob.NewEncoder(file)
83
+ if err := encoder.Encode(c); err != nil {
84
+ return fmt.Errorf("failed to encode cache: %w", err)
85
+ }
86
+
87
+ return nil
88
+ }
89
+
90
+ // CalculateMD5 computes the MD5 hash of a file
91
+ func CalculateMD5(filepath string) (string, error) {
92
+ file, err := os.Open(filepath)
93
+ if err != nil {
94
+ return "", fmt.Errorf("failed to open file: %w", err)
95
+ }
96
+ defer file.Close()
97
+
98
+ hash := md5.New()
99
+ if _, err := io.Copy(hash, file); err != nil {
100
+ return "", fmt.Errorf("failed to hash file: %w", err)
101
+ }
102
+
103
+ return fmt.Sprintf("%x", hash.Sum(nil)), nil
104
+ }
105
+
106
+ // HasChanged checks if a file's MD5 has changed since last sync
107
+ func (c *Cache) HasChanged(filepath string) (bool, error) {
108
+ currentMD5, err := CalculateMD5(filepath)
109
+ if err != nil {
110
+ return false, err
111
+ }
112
+
113
+ cachedMD5, exists := c.FileMD5s[filepath]
114
+ if !exists {
115
+ return true, nil // File not in cache, consider it changed
116
+ }
117
+
118
+ return currentMD5 != cachedMD5, nil
119
+ }
120
+
121
+ // UpdateFile updates the cache with new policies and MD5 for a file
122
+ func (c *Cache) UpdateFile(filepath string, policies []parser.Policy) error {
123
+ md5Hash, err := CalculateMD5(filepath)
124
+ if err != nil {
125
+ return err
126
+ }
127
+
128
+ // Convert policies to serializable format
129
+ serializablePolicies := make([]SerializablePolicy, len(policies))
130
+ for i, p := range policies {
131
+ serializablePolicies[i] = SerializablePolicy{
132
+ Name: p.Name,
133
+ Message: p.Message,
134
+ OriginalText: p.OriginalText,
135
+ }
136
+ }
137
+
138
+ c.FileMD5s[filepath] = md5Hash
139
+ c.Policies[filepath] = serializablePolicies
140
+
141
+ return nil
142
+ }
143
+
144
+ // GetPolicies retrieves cached policies for a file and converts them back to parser.Policy
145
+ func (c *Cache) GetPolicies(filepath string) ([]parser.Policy, error) {
146
+ serializablePolicies, exists := c.Policies[filepath]
147
+ if !exists {
148
+ return nil, fmt.Errorf("no cached policies for file: %s", filepath)
149
+ }
150
+
151
+ // Convert back to parser.Policy
152
+ policies := make([]parser.Policy, len(serializablePolicies))
153
+ for i, sp := range serializablePolicies {
154
+ policies[i] = parser.Policy{
155
+ Name: sp.Name,
156
+ Message: sp.Message,
157
+ OriginalText: sp.OriginalText,
158
+ }
159
+ }
160
+
161
+ return policies, nil
162
+ }
163
+
164
+ // GetAllPolicies returns all cached policies from all files combined
165
+ func (c *Cache) GetAllPolicies() ([]parser.Policy, error) {
166
+ var allPolicies []parser.Policy
167
+
168
+ for filepath := range c.Policies {
169
+ policies, err := c.GetPolicies(filepath)
170
+ if err != nil {
171
+ return nil, err
172
+ }
173
+ allPolicies = append(allPolicies, policies...)
174
+ }
175
+
176
+ return allPolicies, nil
177
+ }
178
+
179
+ // FindGitRoot walks up from the current directory to find the git root
180
+ func FindGitRoot() (string, error) {
181
+ dir, err := os.Getwd()
182
+ if err != nil {
183
+ return "", err
184
+ }
185
+
186
+ for {
187
+ gitPath := filepath.Join(dir, ".git")
188
+ if _, err := os.Stat(gitPath); err == nil {
189
+ return dir, nil
190
+ }
191
+
192
+ parent := filepath.Dir(dir)
193
+ if parent == dir {
194
+ return "", fmt.Errorf("not in a git repository")
195
+ }
196
+ dir = parent
197
+ }
198
+ }