@tanagram/cli 0.1.0

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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 Tanagram
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,217 @@
1
+ # Tanagram
2
+
3
+ A lightweight Go CLI that enforces policies from `AGENTS.md` files on your local git changes.
4
+
5
+ ## Quick Start
6
+
7
+ Run `tanagram` before committing to catch policy violations locally:
8
+
9
+ ```bash
10
+ $ tanagram
11
+
12
+ ✗ Found 1 policy violation(s):
13
+
14
+ webui/src/Button.tsx:42 - [No hardcoded colors] Don't use hard-coded color values; use theme colors instead
15
+ > background: "#FF5733"
16
+ ```
17
+
18
+ ## Installation
19
+
20
+ ### Via npm (Recommended)
21
+
22
+ ```bash
23
+ npm install -g @tanagram/cli
24
+ tanagram --help
25
+ ```
26
+
27
+ **Requirements:**
28
+ - Node.js >= 14.0.0
29
+ - Go >= 1.21 (for building the binary during installation)
30
+ - **Anthropic API Key** (required for LLM-based policy extraction)
31
+
32
+ The CLI is written in Go but distributed via npm for easier installation and version management.
33
+
34
+ ### API Key Setup
35
+
36
+ Tanagram uses Claude AI (via Anthropic API) to extract policies from your instruction files. You need to bring your own API key:
37
+
38
+ ```bash
39
+ # Set your Anthropic API key
40
+ export ANTHROPIC_API_KEY="sk-ant-..."
41
+
42
+ # Or add to your shell profile (~/.bashrc, ~/.zshrc, etc.)
43
+ echo 'export ANTHROPIC_API_KEY="sk-ant-..."' >> ~/.zshrc
44
+ ```
45
+
46
+ **Get an API key:**
47
+ 1. Sign up at [https://console.anthropic.com](https://console.anthropic.com)
48
+ 2. Create an API key in the dashboard
49
+ 3. Set the `ANTHROPIC_API_KEY` environment variable
50
+
51
+ ### Local Development
52
+
53
+ ```bash
54
+ cd cli
55
+ npm install # Builds the Go binary
56
+ ./bin/tanagram
57
+ ```
58
+
59
+ ### Install Locally for Testing
60
+
61
+ Install globally from the local directory to test as if it were published:
62
+
63
+ ```bash
64
+ cd /Users/molinar/tanagram/cli
65
+ npm install -g .
66
+ ```
67
+
68
+ Then run from anywhere:
69
+ ```bash
70
+ tanagram
71
+ ```
72
+
73
+ ## Usage
74
+
75
+ ```bash
76
+ # Check all changes (unstaged + staged) - automatically syncs if policies changed
77
+ tanagram
78
+ # or explicitly:
79
+ tanagram run
80
+
81
+ # Manually sync instruction files to cache
82
+ tanagram sync
83
+
84
+ # View all cached policies
85
+ tanagram list
86
+
87
+ # Show help
88
+ tanagram help
89
+ ```
90
+
91
+ **Smart Caching:** Policies are cached and automatically resynced when instruction files change (detected via MD5 hash).
92
+
93
+ ## Commands
94
+
95
+ - **`run`** (default) - Check git changes against policies with auto-sync
96
+ - **`sync`** - Manually sync all instruction files to cache
97
+ - **`list`** - View all cached policies (shows enforceable vs unenforceable)
98
+ - **`help`** - Show usage information
99
+
100
+ ## How It Works
101
+
102
+ 1. **Finds instruction files** - Searches for `AGENTS.md`, `POLICIES.md` in your git repository
103
+ 2. **Checks cache** - Loads cached policies and MD5 hashes from `.tanagram/`
104
+ 3. **Auto-syncs** - Detects file changes via MD5 and automatically resyncs if needed
105
+ 4. **LLM extraction** - Uses Claude AI to extract ALL policies from instruction files
106
+ 5. **Gets git diff** - Analyzes all your changes (unstaged + staged)
107
+ 6. **LLM detection** - Checks violations using intelligent semantic analysis
108
+ 7. **Reports results** - Terminal output with detailed reasoning for each violation
109
+
110
+ ### Cache Location
111
+
112
+ Policies are cached in `.tanagram/cache.gob` at your git repository root. Add this to your `.gitignore`:
113
+
114
+ ```gitignore
115
+ .tanagram/
116
+ ```
117
+
118
+ ## Fully LLM-Based Architecture
119
+
120
+ Tanagram uses **100% LLM-powered** policy extraction and enforcement:
121
+
122
+ ### Extraction Phase
123
+ Claude AI extracts **ALL** policies from instruction files:
124
+ - No classification needed (no MUST_NOT_USE, MUST_USE, etc.)
125
+ - No regex pattern generation
126
+ - Simple: Just extract policy names and descriptions
127
+ - Fast: Simpler prompts = faster responses
128
+
129
+ ### Detection Phase
130
+ Claude AI analyzes code changes against all policies:
131
+ - **Semantic understanding** - Not just pattern matching
132
+ - **Context-aware** - Understands code intent and structure
133
+ - **Language-agnostic** - Works with any programming language
134
+ - **Detailed reasoning** - Explains why code violates each policy
135
+
136
+ ### What Can Be Enforced
137
+
138
+ **Everything!** Because the LLM reads and understands code like a human:
139
+
140
+ **Simple patterns:**
141
+ - "Don't use hard-coded colors" → Detects `#FF5733`, `rgb()`, etc.
142
+ - "Use ruff format, not black" → Detects `black` usage
143
+ - "Always use === instead of ==" → Detects `==` operators
144
+
145
+ **Complex guidelines:**
146
+ - "Break down code into modular functions" → Analyzes function length and complexity
147
+ - "Don't deeply layer code" → Detects excessive nesting
148
+ - "Ensure no code smells" → Identifies common anti-patterns
149
+ - "Use structured logging with request IDs" → Checks logging patterns
150
+ - "Prefer async/await for I/O" → Understands async patterns
151
+
152
+ **Language-specific idioms:**
153
+ - Knows Go uses PascalCase for exports (not Python's snake_case)
154
+ - Won't flag Go code for missing Python type hints
155
+ - Understands JavaScript !== Python !== Go
156
+
157
+ ## Exit Codes
158
+
159
+ - `0` - No violations found
160
+ - `1` - Violations found (fails CI/CD if integrated)
161
+
162
+ ## Example
163
+
164
+ Create an `AGENTS.md` in your repo with policies:
165
+
166
+ ```markdown
167
+ # Development Policies
168
+
169
+ - Don't use hard-coded color values; use theme colors instead
170
+ - Use ruff format for Python formatting, not black
171
+ - Always use async/await for database operations
172
+ ```
173
+
174
+ Then run `tanagram` to enforce them locally!
175
+
176
+ ---
177
+
178
+ ## Contributing
179
+
180
+ Contributions are welcome! Please feel free to submit a Pull Request.
181
+
182
+ ### Development Setup
183
+
184
+ ```bash
185
+ # Clone the repository
186
+ git clone https://github.com/tanagram/cli.git
187
+ cd cli
188
+
189
+ # Install dependencies and build
190
+ npm install
191
+
192
+ # Run tests
193
+ npm test
194
+
195
+ # Build manually
196
+ go build -o bin/tanagram .
197
+ ```
198
+
199
+ ### Publishing to npm
200
+
201
+ To publish a new version:
202
+
203
+ ```bash
204
+ # Update version in package.json
205
+ npm version patch # or minor, or major
206
+
207
+ # Publish to npm
208
+ npm publish --access public
209
+
210
+ # Create git tag
211
+ git tag v$(node -p "require('./package.json').version")
212
+ git push origin --tags
213
+ ```
214
+
215
+ ## License
216
+
217
+ MIT
package/bin/tanagram ADDED
Binary file
@@ -0,0 +1,18 @@
1
+ #!/usr/bin/env node
2
+
3
+ const { spawn } = require('child_process');
4
+ const path = require('path');
5
+ const os = require('os');
6
+
7
+ const platform = os.platform();
8
+ const binaryName = platform === 'win32' ? 'tanagram.exe' : 'tanagram';
9
+ const binaryPath = path.join(__dirname, binaryName);
10
+
11
+ // Spawn the Go binary with all arguments
12
+ const child = spawn(binaryPath, process.argv.slice(2), {
13
+ stdio: 'inherit'
14
+ });
15
+
16
+ child.on('exit', (code) => {
17
+ process.exit(code || 0);
18
+ });
@@ -0,0 +1,119 @@
1
+ package checker
2
+
3
+ import (
4
+ "context"
5
+ "fmt"
6
+ "strings"
7
+
8
+ "github.com/tanagram/monorepo/cli/git"
9
+ "github.com/tanagram/monorepo/cli/parser"
10
+ )
11
+
12
+ // CheckChangesWithLLM checks code changes against ALL policies using LLM
13
+ func CheckChangesWithLLM(ctx context.Context, changes []git.ChangedLine, policies []parser.Policy) []Violation {
14
+ if len(policies) == 0 {
15
+ return []Violation{}
16
+ }
17
+
18
+ // Convert policies slice to map for O(1) lookup
19
+ policyMap := buildPolicyMap(policies)
20
+
21
+ // Group changes by file for efficient checking
22
+ changesByFile := groupChangesByFile(changes)
23
+
24
+ var allViolations []Violation
25
+ for file, fileChanges := range changesByFile {
26
+ violations := checkFileWithLLM(ctx, file, fileChanges, policies, policyMap)
27
+ allViolations = append(allViolations, violations...)
28
+ }
29
+
30
+ return allViolations
31
+ }
32
+
33
+ // buildPolicyMap converts a slice of policies to a map keyed by policy name for O(1) lookup
34
+ func buildPolicyMap(policies []parser.Policy) map[string]parser.Policy {
35
+ policyMap := make(map[string]parser.Policy, len(policies))
36
+ for _, policy := range policies {
37
+ policyMap[policy.Name] = policy
38
+ }
39
+ return policyMap
40
+ }
41
+
42
+ // groupChangesByFile organizes changed lines by their file path
43
+ func groupChangesByFile(changes []git.ChangedLine) map[string][]git.ChangedLine {
44
+ grouped := make(map[string][]git.ChangedLine)
45
+ for _, change := range changes {
46
+ grouped[change.File] = append(grouped[change.File], change)
47
+ }
48
+ return grouped
49
+ }
50
+
51
+ // checkFileWithLLM checks a single file's changes using the LLM
52
+ func checkFileWithLLM(ctx context.Context, file string, changes []git.ChangedLine, policies []parser.Policy, policyMap map[string]parser.Policy) []Violation {
53
+ // Format changes for LLM
54
+ codeChanges := formatChangesForLLM(changes)
55
+
56
+ // Call LLM to check violations
57
+ checks, err := CheckViolations(ctx, file, codeChanges, policies)
58
+ if err != nil {
59
+ // Log error but don't fail the whole check
60
+ fmt.Printf("Warning: LLM check failed for %s: %v\n", file, err)
61
+ return []Violation{}
62
+ }
63
+
64
+ // Convert LLM checks to Violations
65
+ var violations []Violation
66
+ for _, check := range checks {
67
+ if !check.Violated {
68
+ continue
69
+ }
70
+
71
+ // Find the policy details using O(1) map lookup
72
+ policy, found := policyMap[check.PolicyName]
73
+ if !found {
74
+ continue
75
+ }
76
+
77
+ // Use first changed line as the location (LLM doesn't provide specific line numbers yet)
78
+ firstLine := changes[0]
79
+
80
+ violations = append(violations, Violation{
81
+ File: file,
82
+ LineNumber: firstLine.LineNumber,
83
+ PolicyName: policy.Name,
84
+ Message: fmt.Sprintf("%s\n\nLLM Analysis: %s", policy.Message, check.Reason),
85
+ Code: formatChangesForDisplay(changes),
86
+ })
87
+ }
88
+
89
+ return violations
90
+ }
91
+
92
+ // formatChangesForLLM creates a readable representation of code changes for the LLM
93
+ func formatChangesForLLM(changes []git.ChangedLine) string {
94
+ var builder strings.Builder
95
+ for _, change := range changes {
96
+ builder.WriteString(fmt.Sprintf("Line %d: %s\n", change.LineNumber, change.Content))
97
+ }
98
+ return builder.String()
99
+ }
100
+
101
+ // formatChangesForDisplay creates a compact display of changed lines
102
+ func formatChangesForDisplay(changes []git.ChangedLine) string {
103
+ if len(changes) == 1 {
104
+ return strings.TrimSpace(changes[0].Content)
105
+ }
106
+
107
+ var builder strings.Builder
108
+ for i, change := range changes {
109
+ if i > 0 {
110
+ builder.WriteString("\n")
111
+ }
112
+ builder.WriteString(fmt.Sprintf("L%d: %s", change.LineNumber, strings.TrimSpace(change.Content)))
113
+ if i >= 2 { // Limit to first 3 lines
114
+ builder.WriteString("\n...")
115
+ break
116
+ }
117
+ }
118
+ return builder.String()
119
+ }
@@ -0,0 +1,56 @@
1
+ package checker
2
+
3
+ import (
4
+ "context"
5
+ "fmt"
6
+ "strings"
7
+
8
+ "github.com/tanagram/monorepo/cli/git"
9
+ "github.com/tanagram/monorepo/cli/parser"
10
+ )
11
+
12
+ // Violation represents a policy violation found in code
13
+ type Violation struct {
14
+ File string
15
+ LineNumber int
16
+ PolicyName string
17
+ Message string
18
+ Code string
19
+ }
20
+
21
+ // CheckResult contains all violations found
22
+ type CheckResult struct {
23
+ Violations []Violation
24
+ TotalChecked int
25
+ }
26
+
27
+ // CheckChanges checks all changed lines against policies using LLM-based detection
28
+ func CheckChanges(ctx context.Context, changes []git.ChangedLine, policies []parser.Policy) *CheckResult {
29
+ result := &CheckResult{
30
+ Violations: []Violation{},
31
+ TotalChecked: len(changes),
32
+ }
33
+
34
+ // Use LLM-based checking for all policies
35
+ llmViolations := CheckChangesWithLLM(ctx, changes, policies)
36
+ result.Violations = append(result.Violations, llmViolations...)
37
+
38
+ return result
39
+ }
40
+
41
+ // FormatViolations formats violations for terminal output
42
+ func FormatViolations(result *CheckResult) string {
43
+ if len(result.Violations) == 0 {
44
+ return "✓ No policy violations found"
45
+ }
46
+
47
+ var output strings.Builder
48
+ output.WriteString(fmt.Sprintf("✗ Found %d policy violation(s):\n\n", len(result.Violations)))
49
+
50
+ for _, v := range result.Violations {
51
+ output.WriteString(fmt.Sprintf("%s:%d - [%s] %s\n", v.File, v.LineNumber, v.PolicyName, v.Message))
52
+ output.WriteString(fmt.Sprintf(" > %s\n\n", v.Code))
53
+ }
54
+
55
+ return output.String()
56
+ }
@@ -0,0 +1,109 @@
1
+ package checker
2
+
3
+ import (
4
+ "context"
5
+ "encoding/json"
6
+ "fmt"
7
+ "strings"
8
+
9
+ "github.com/tanagram/monorepo/cli/llm"
10
+ "github.com/tanagram/monorepo/cli/parser"
11
+ )
12
+
13
+ // ViolationCheck represents a single policy violation check result from the LLM
14
+ type ViolationCheck struct {
15
+ PolicyName string `json:"policy_name"`
16
+ Violated bool `json:"violated"`
17
+ Reason string `json:"reason"`
18
+ }
19
+
20
+ // ViolationCheckResponse represents the LLM's response to a violation check request
21
+ type ViolationCheckResponse struct {
22
+ Violations []ViolationCheck `json:"violations"`
23
+ }
24
+
25
+ // CheckViolations uses LLM to check if code changes violate any policies
26
+ // Returns a list of violation checks with policy names and reasons
27
+ func CheckViolations(ctx context.Context, file string, codeChanges string, policies []parser.Policy) ([]ViolationCheck, error) {
28
+ if len(policies) == 0 {
29
+ return []ViolationCheck{}, nil
30
+ }
31
+
32
+ client, err := llm.NewClient()
33
+ if err != nil {
34
+ return nil, err
35
+ }
36
+
37
+ prompt := buildViolationCheckPrompt(file, codeChanges, policies)
38
+
39
+ response, err := client.SendMessage(ctx, prompt)
40
+ if err != nil {
41
+ return nil, fmt.Errorf("failed to check violations: %w", err)
42
+ }
43
+
44
+ // Parse response
45
+ cleanedResponse := llm.StripMarkdownCodeBlocks(response)
46
+
47
+ var checkResponse ViolationCheckResponse
48
+ if err := json.Unmarshal([]byte(cleanedResponse), &checkResponse); err != nil {
49
+ return nil, fmt.Errorf("failed to parse LLM response: %w\nResponse: %s", err, response)
50
+ }
51
+
52
+ return checkResponse.Violations, nil
53
+ }
54
+
55
+ // buildViolationCheckPrompt creates a focused prompt for LLM violation checking
56
+ func buildViolationCheckPrompt(file string, codeChanges string, policies []parser.Policy) string {
57
+ policiesText := formatPoliciesForPrompt(policies)
58
+
59
+ return fmt.Sprintf(`You are a code policy enforcement system. Check if code changes violate coding policies.
60
+
61
+ File: %s
62
+
63
+ Code Changes:
64
+ %s
65
+
66
+ Policies to Check:
67
+ %s
68
+
69
+ For each policy, determine if the code changes violate it. Consider:
70
+ - The intent and spirit of the policy, not just literal interpretation
71
+ - Whether the changes introduce new violations
72
+ - Best practices and code quality standards
73
+ - Language-specific conventions (e.g., Go uses PascalCase for exports, not Python type hints)
74
+
75
+ IMPORTANT: Only flag violations that make sense for this programming language.
76
+ - Don't apply Python-specific policies (like type hints) to Go code
77
+ - Don't apply JavaScript-specific policies to Python code
78
+ - Consider the language's idioms and conventions
79
+
80
+ Return ONLY valid JSON (no markdown, no backticks, no extra commentary):
81
+ {
82
+ "violations": [
83
+ {
84
+ "policy_name": "Exact policy name from 'Policy Name:' field above",
85
+ "violated": true,
86
+ "reason": "Brief explanation of why it violates the policy"
87
+ }
88
+ ]
89
+ }
90
+
91
+ IMPORTANT: The "policy_name" field must be the EXACT text from the "Policy Name:" field above.
92
+ Do not include numbers, asterisks, or any other formatting - just the plain policy name.
93
+
94
+ Only include policies that are ACTUALLY violated. If no violations, return empty violations array.
95
+ Be precise - only flag clear violations, not potential issues.`,
96
+ file,
97
+ codeChanges,
98
+ policiesText)
99
+ }
100
+
101
+ // formatPoliciesForPrompt formats policies into a readable list for the LLM prompt
102
+ // Uses plain policy names without formatting to ensure exact matching in responses
103
+ func formatPoliciesForPrompt(policies []parser.Policy) string {
104
+ var builder strings.Builder
105
+ for _, policy := range policies {
106
+ builder.WriteString(fmt.Sprintf("- Policy Name: %s\n Description: %s\n\n", policy.Name, policy.Message))
107
+ }
108
+ return builder.String()
109
+ }
package/git/diff.go ADDED
@@ -0,0 +1,127 @@
1
+ package git
2
+
3
+ import (
4
+ "bufio"
5
+ "fmt"
6
+ "os/exec"
7
+ "regexp"
8
+ "strconv"
9
+ "strings"
10
+ )
11
+
12
+ // ChangedLine represents a single line change from git diff
13
+ type ChangedLine struct {
14
+ File string
15
+ LineNumber int
16
+ Content string
17
+ ChangeType string // "+" for addition, "~" for modification
18
+ }
19
+
20
+ // DiffResult contains all changed lines from a git diff
21
+ type DiffResult struct {
22
+ Changes []ChangedLine
23
+ }
24
+
25
+ // GetUnstagedDiff gets the git diff for unstaged changes
26
+ func GetUnstagedDiff() (*DiffResult, error) {
27
+ cmd := exec.Command("git", "diff", "--unified=0")
28
+ output, err := cmd.Output()
29
+ if err != nil {
30
+ return nil, fmt.Errorf("failed to run git diff: %w", err)
31
+ }
32
+
33
+ return parseDiff(string(output))
34
+ }
35
+
36
+ // GetStagedDiff gets the git diff for staged changes
37
+ func GetStagedDiff() (*DiffResult, error) {
38
+ cmd := exec.Command("git", "diff", "--cached", "--unified=0")
39
+ output, err := cmd.Output()
40
+ if err != nil {
41
+ return nil, fmt.Errorf("failed to run git diff --cached: %w", err)
42
+ }
43
+
44
+ return parseDiff(string(output))
45
+ }
46
+
47
+ // GetAllChanges gets all changes (both unstaged and staged)
48
+ func GetAllChanges() (*DiffResult, error) {
49
+ // Get unstaged changes
50
+ unstaged, err := GetUnstagedDiff()
51
+ if err != nil {
52
+ return nil, fmt.Errorf("failed to get unstaged changes: %w", err)
53
+ }
54
+
55
+ // Get staged changes
56
+ staged, err := GetStagedDiff()
57
+ if err != nil {
58
+ return nil, fmt.Errorf("failed to get staged changes: %w", err)
59
+ }
60
+
61
+ // Combine both results
62
+ result := &DiffResult{
63
+ Changes: append(unstaged.Changes, staged.Changes...),
64
+ }
65
+
66
+ return result, nil
67
+ }
68
+
69
+ // parseDiff parses unified diff format and extracts changed lines
70
+ func parseDiff(diffText string) (*DiffResult, error) {
71
+ result := &DiffResult{
72
+ Changes: []ChangedLine{},
73
+ }
74
+
75
+ scanner := bufio.NewScanner(strings.NewReader(diffText))
76
+ var currentFile string
77
+ var currentLineNum int
78
+
79
+ // Regex patterns for diff parsing
80
+ filePattern := regexp.MustCompile(`^\+\+\+ b/(.+)$`)
81
+ hunkPattern := regexp.MustCompile(`^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@`)
82
+
83
+ for scanner.Scan() {
84
+ line := scanner.Text()
85
+
86
+ // Check for file marker
87
+ if matches := filePattern.FindStringSubmatch(line); matches != nil {
88
+ currentFile = matches[1]
89
+ continue
90
+ }
91
+
92
+ // Check for hunk header (gives us line number)
93
+ if matches := hunkPattern.FindStringSubmatch(line); matches != nil {
94
+ lineNum, err := strconv.Atoi(matches[1])
95
+ if err != nil {
96
+ continue
97
+ }
98
+ currentLineNum = lineNum
99
+ continue
100
+ }
101
+
102
+ // Process added/modified lines
103
+ if strings.HasPrefix(line, "+") && !strings.HasPrefix(line, "+++") {
104
+ // Added line
105
+ content := strings.TrimPrefix(line, "+")
106
+ result.Changes = append(result.Changes, ChangedLine{
107
+ File: currentFile,
108
+ LineNumber: currentLineNum,
109
+ Content: content,
110
+ ChangeType: "+",
111
+ })
112
+ currentLineNum++
113
+ } else if strings.HasPrefix(line, "-") && !strings.HasPrefix(line, "---") {
114
+ // Removed line - we don't check these for new violations
115
+ continue
116
+ } else if !strings.HasPrefix(line, "@") && !strings.HasPrefix(line, "\\") {
117
+ // Context line (no +/-)
118
+ currentLineNum++
119
+ }
120
+ }
121
+
122
+ if err := scanner.Err(); err != nil {
123
+ return nil, fmt.Errorf("error parsing diff: %w", err)
124
+ }
125
+
126
+ return result, nil
127
+ }
@@ -0,0 +1,175 @@
1
+ package git
2
+
3
+ import (
4
+ "os"
5
+ "testing"
6
+ )
7
+
8
+ func TestParseDiff_SimpleAddition(t *testing.T) {
9
+ // Read the example diff file
10
+ content, err := os.ReadFile("testdata/diff1.txt")
11
+ if err != nil {
12
+ t.Fatalf("Failed to read test file: %v", err)
13
+ }
14
+
15
+ result, err := parseDiff(string(content))
16
+ if err != nil {
17
+ t.Fatalf("parseDiff failed: %v", err)
18
+ }
19
+
20
+ // diff1.txt has one addition: print("hello") at line 71
21
+ if len(result.Changes) != 1 {
22
+ t.Fatalf("Expected 1 change, got %d", len(result.Changes))
23
+ }
24
+
25
+ change := result.Changes[0]
26
+ if change.File != "airflow/www/app.py" {
27
+ t.Errorf("Expected file 'airflow/www/app.py', got %q", change.File)
28
+ }
29
+ if change.LineNumber != 71 {
30
+ t.Errorf("Expected line number 71, got %d", change.LineNumber)
31
+ }
32
+ if change.Content != " print(\"hello\")" {
33
+ t.Errorf("Expected content ' print(\"hello\")', got %q", change.Content)
34
+ }
35
+ if change.ChangeType != "+" {
36
+ t.Errorf("Expected change type '+', got %q", change.ChangeType)
37
+ }
38
+ }
39
+
40
+ func TestParseDiff_ModificationWithContext(t *testing.T) {
41
+ // Read the example diff file
42
+ content, err := os.ReadFile("testdata/diff-with-context.txt")
43
+ if err != nil {
44
+ t.Fatalf("Failed to read test file: %v", err)
45
+ }
46
+
47
+ result, err := parseDiff(string(content))
48
+ if err != nil {
49
+ t.Fatalf("parseDiff failed: %v", err)
50
+ }
51
+
52
+ // diff-with-context.txt has one line changed (old_line -> new_line)
53
+ // We only track additions, not deletions
54
+ if len(result.Changes) != 1 {
55
+ t.Fatalf("Expected 1 change (the addition), got %d", len(result.Changes))
56
+ }
57
+
58
+ change := result.Changes[0]
59
+ if change.File != "example/app.py" {
60
+ t.Errorf("Expected file 'example/app.py', got %q", change.File)
61
+ }
62
+ if change.Content != " new_line = \"new_value\"" {
63
+ t.Errorf("Expected 'new_line' content, got %q", change.Content)
64
+ }
65
+ if change.ChangeType != "+" {
66
+ t.Errorf("Expected change type '+', got %q", change.ChangeType)
67
+ }
68
+ }
69
+
70
+ func TestParseDiff_AdditionWithContext(t *testing.T) {
71
+ // Read the example diff file
72
+ content, err := os.ReadFile("testdata/diff-addition-with-context.txt")
73
+ if err != nil {
74
+ t.Fatalf("Failed to read test file: %v", err)
75
+ }
76
+
77
+ result, err := parseDiff(string(content))
78
+ if err != nil {
79
+ t.Fatalf("parseDiff failed: %v", err)
80
+ }
81
+
82
+ // diff-addition-with-context.txt has one line added
83
+ if len(result.Changes) != 1 {
84
+ t.Fatalf("Expected 1 change, got %d", len(result.Changes))
85
+ }
86
+
87
+ change := result.Changes[0]
88
+ if change.File != "example/addition.py" {
89
+ t.Errorf("Expected file 'example/addition.py', got %q", change.File)
90
+ }
91
+ if change.Content != " new_functionality = True" {
92
+ t.Errorf("Expected 'new_functionality = True', got %q", change.Content)
93
+ }
94
+ if change.ChangeType != "+" {
95
+ t.Errorf("Expected change type '+', got %q", change.ChangeType)
96
+ }
97
+ }
98
+
99
+ func TestParseDiff_EmptyDiff(t *testing.T) {
100
+ result, err := parseDiff("")
101
+ if err != nil {
102
+ t.Fatalf("parseDiff failed: %v", err)
103
+ }
104
+
105
+ if len(result.Changes) != 0 {
106
+ t.Errorf("Expected 0 changes for empty diff, got %d", len(result.Changes))
107
+ }
108
+ }
109
+
110
+ func TestParseDiff_MultipleFiles(t *testing.T) {
111
+ diffText := `diff --git a/file1.py b/file1.py
112
+ index 1234567..abcdefg 100644
113
+ --- a/file1.py
114
+ +++ b/file1.py
115
+ @@ -10,0 +11 @@ def main():
116
+ + print("file1")
117
+ diff --git a/file2.py b/file2.py
118
+ index 7654321..gfedcba 100644
119
+ --- a/file2.py
120
+ +++ b/file2.py
121
+ @@ -20,0 +21 @@ def test():
122
+ + print("file2")
123
+ `
124
+
125
+ result, err := parseDiff(diffText)
126
+ if err != nil {
127
+ t.Fatalf("parseDiff failed: %v", err)
128
+ }
129
+
130
+ if len(result.Changes) != 2 {
131
+ t.Fatalf("Expected 2 changes, got %d", len(result.Changes))
132
+ }
133
+
134
+ // Check first file
135
+ if result.Changes[0].File != "file1.py" {
136
+ t.Errorf("Expected file 'file1.py', got %q", result.Changes[0].File)
137
+ }
138
+ if result.Changes[0].LineNumber != 11 {
139
+ t.Errorf("Expected line 11, got %d", result.Changes[0].LineNumber)
140
+ }
141
+
142
+ // Check second file
143
+ if result.Changes[1].File != "file2.py" {
144
+ t.Errorf("Expected file 'file2.py', got %q", result.Changes[1].File)
145
+ }
146
+ if result.Changes[1].LineNumber != 21 {
147
+ t.Errorf("Expected line 21, got %d", result.Changes[1].LineNumber)
148
+ }
149
+ }
150
+
151
+ func TestParseDiff_IgnoresDeletions(t *testing.T) {
152
+ diffText := `diff --git a/test.py b/test.py
153
+ index 1234567..abcdefg 100644
154
+ --- a/test.py
155
+ +++ b/test.py
156
+ @@ -5,2 +5,1 @@ def func():
157
+ - old_line_1 = "removed"
158
+ - old_line_2 = "also removed"
159
+ + new_line = "added"
160
+ `
161
+
162
+ result, err := parseDiff(diffText)
163
+ if err != nil {
164
+ t.Fatalf("parseDiff failed: %v", err)
165
+ }
166
+
167
+ // Should only track the addition, not the 2 deletions
168
+ if len(result.Changes) != 1 {
169
+ t.Fatalf("Expected 1 change (addition only), got %d", len(result.Changes))
170
+ }
171
+
172
+ if result.Changes[0].Content != " new_line = \"added\"" {
173
+ t.Errorf("Expected addition content, got %q", result.Changes[0].Content)
174
+ }
175
+ }
@@ -0,0 +1,10 @@
1
+ diff --git a/example/addition.py b/example/addition.py
2
+ index 1234567..abcdefg 100644
3
+ --- a/example/addition.py
4
+ +++ b/example/addition.py
5
+ @@ -8,4 +8,5 @@ def setup():
6
+ config = load_config()
7
+ print("Starting setup")
8
+
9
+ + new_functionality = True
10
+ return config
@@ -0,0 +1,9 @@
1
+ diff --git a/example/app.py b/example/app.py
2
+ index 1234567..abcdefg 100644
3
+ --- a/example/app.py
4
+ +++ b/example/app.py
5
+ @@ -10,3 +10,3 @@ def main():
6
+ print("before")
7
+ - old_line = "old_value"
8
+ + new_line = "new_value"
9
+ print("after")
@@ -0,0 +1,6 @@
1
+ diff --git a/airflow/www/app.py b/airflow/www/app.py
2
+ index 06a0e14de0..0649747401 100644
3
+ --- a/airflow/www/app.py
4
+ +++ b/airflow/www/app.py
5
+ @@ -70,0 +71 @@ def create_app(config=None, testing=False):
6
+ + print("hello")
package/go.mod ADDED
@@ -0,0 +1,12 @@
1
+ module github.com/tanagram/monorepo/cli
2
+
3
+ go 1.23.0
4
+
5
+ require github.com/anthropics/anthropic-sdk-go v1.17.0
6
+
7
+ require (
8
+ github.com/tidwall/gjson v1.18.0 // indirect
9
+ github.com/tidwall/match v1.1.1 // indirect
10
+ github.com/tidwall/pretty v1.2.1 // indirect
11
+ github.com/tidwall/sjson v1.2.5 // indirect
12
+ )
package/go.sum ADDED
@@ -0,0 +1,20 @@
1
+ github.com/anthropics/anthropic-sdk-go v1.17.0 h1:BwK8ApcmaAUkvZTiQE0yi3R9XneEFskDIjLTmOAFZxQ=
2
+ github.com/anthropics/anthropic-sdk-go v1.17.0/go.mod h1:WTz31rIUHUHqai2UslPpw5CwXrQP3geYBioRV4WOLvE=
3
+ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
4
+ github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
5
+ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
6
+ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
7
+ github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
8
+ github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
9
+ github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
10
+ github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
11
+ github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
12
+ github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
13
+ github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
14
+ github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
15
+ github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
16
+ github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
17
+ github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
18
+ github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
19
+ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
20
+ gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
package/install.js ADDED
@@ -0,0 +1,69 @@
1
+ #!/usr/bin/env node
2
+
3
+ const { execSync } = require('child_process');
4
+ const fs = require('fs');
5
+ const path = require('path');
6
+ const os = require('os');
7
+
8
+ // Check if Go is installed
9
+ function checkGo() {
10
+ try {
11
+ execSync('go version', { stdio: 'ignore' });
12
+ return true;
13
+ } catch (error) {
14
+ return false;
15
+ }
16
+ }
17
+
18
+ // Build the Go binary
19
+ function buildBinary() {
20
+ console.log('🔍 Building Tanagram CLI...');
21
+
22
+ const platform = os.platform();
23
+ const arch = os.arch();
24
+
25
+ // Map Node.js platform/arch to Go GOOS/GOARCH
26
+ const goos = platform === 'win32' ? 'windows' : platform;
27
+ const goarch = arch === 'x64' ? 'amd64' : arch === 'arm64' ? 'arm64' : arch;
28
+
29
+ const binaryName = platform === 'win32' ? 'tanagram.exe' : 'tanagram';
30
+ const binaryPath = path.join(__dirname, 'bin', binaryName);
31
+
32
+ // Ensure bin directory exists
33
+ const binDir = path.join(__dirname, 'bin');
34
+ if (!fs.existsSync(binDir)) {
35
+ fs.mkdirSync(binDir, { recursive: true });
36
+ }
37
+
38
+ try {
39
+ // Build the binary
40
+ execSync(`go build -o "${binaryPath}" .`, {
41
+ cwd: __dirname,
42
+ stdio: 'inherit',
43
+ env: {
44
+ ...process.env,
45
+ GOOS: goos,
46
+ GOARCH: goarch,
47
+ }
48
+ });
49
+
50
+ // Make it executable on Unix-like systems
51
+ if (platform !== 'win32') {
52
+ fs.chmodSync(binaryPath, '755');
53
+ }
54
+
55
+ console.log(`✓ Tanagram CLI built successfully at ${binaryPath}`);
56
+ } catch (error) {
57
+ console.error('Failed to build Tanagram CLI:', error.message);
58
+ process.exit(1);
59
+ }
60
+ }
61
+
62
+ // Main
63
+ if (!checkGo()) {
64
+ console.error('Error: Go is not installed or not in PATH');
65
+ console.error('Please install Go from https://golang.org/dl/');
66
+ process.exit(1);
67
+ }
68
+
69
+ buildBinary();
package/main.go ADDED
@@ -0,0 +1,64 @@
1
+ package main
2
+
3
+ import (
4
+ "fmt"
5
+ "os"
6
+
7
+ "github.com/tanagram/monorepo/cli/commands"
8
+ )
9
+
10
+ func main() {
11
+ // Get subcommand (default to "run" if none provided)
12
+ subcommand := "run"
13
+ if len(os.Args) > 1 {
14
+ subcommand = os.Args[1]
15
+ }
16
+
17
+ var err error
18
+ switch subcommand {
19
+ case "run":
20
+ err = commands.Run()
21
+ case "sync":
22
+ err = commands.Sync()
23
+ case "list":
24
+ err = commands.List()
25
+ case "help", "-h", "--help":
26
+ printHelp()
27
+ return
28
+ default:
29
+ fmt.Fprintf(os.Stderr, "Unknown command: %s\n\n", subcommand)
30
+ printHelp()
31
+ os.Exit(1)
32
+ }
33
+
34
+ if err != nil {
35
+ fmt.Fprintf(os.Stderr, "Error: %v\n", err)
36
+ os.Exit(1)
37
+ }
38
+ }
39
+
40
+ func printHelp() {
41
+ help := `Tanagram - Policy enforcement for git changes
42
+
43
+ USAGE:
44
+ tanagram [command]
45
+
46
+ COMMANDS:
47
+ run Check git changes against policies (default)
48
+ sync Manually sync instruction files to cache
49
+ list Show all cached policies
50
+ help Show this help message
51
+
52
+ EXAMPLES:
53
+ tanagram # Check changes (auto-syncs if files changed)
54
+ tanagram run # Same as above
55
+ tanagram sync # Manually sync policies
56
+ tanagram list # View all cached policies
57
+
58
+ INSTRUCTION FILES:
59
+ Tanagram looks for instruction files like AGENTS.md or POLICIES.md
60
+ in your git repository. Policies are cached and automatically resynced
61
+ when files change.
62
+ `
63
+ fmt.Print(help)
64
+ }
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "@tanagram/cli",
3
+ "version": "0.1.0",
4
+ "description": "Tanagram - Catch sloppy code before it ships",
5
+ "main": "index.js",
6
+ "bin": {
7
+ "tanagram": "./bin/tanagram.js"
8
+ },
9
+ "scripts": {
10
+ "postinstall": "node install.js",
11
+ "test": "go test ./..."
12
+ },
13
+ "keywords": [
14
+ "tanagram",
15
+ "policy",
16
+ "enforcement",
17
+ "cli",
18
+ "linter",
19
+ "code-quality"
20
+ ],
21
+ "author": "Tanagram",
22
+ "license": "MIT",
23
+ "repository": {
24
+ "type": "git",
25
+ "url": "https://github.com/tanagram/cli.git"
26
+ },
27
+ "engines": {
28
+ "node": ">=14.0.0"
29
+ },
30
+ "files": [
31
+ "bin/",
32
+ "checker/",
33
+ "git/",
34
+ "parser/",
35
+ "main.go",
36
+ "go.mod",
37
+ "go.sum",
38
+ "install.js",
39
+ "README.md"
40
+ ]
41
+ }
@@ -0,0 +1,66 @@
1
+ package parser
2
+
3
+ import (
4
+ "bufio"
5
+ "os"
6
+ "regexp"
7
+ "strings"
8
+ )
9
+
10
+ // Policy represents a policy rule from instruction files
11
+ // Simplified: No types or patterns, all detection is LLM-based
12
+ type Policy struct {
13
+ Name string
14
+ Message string
15
+ OriginalText string
16
+ }
17
+
18
+ // ParseAgents parses AGENTS.md content and extracts enforceable policies
19
+ func ParseAgents(content string) ([]Policy, error) {
20
+ var policies []Policy
21
+ scanner := bufio.NewScanner(strings.NewReader(content))
22
+
23
+ for scanner.Scan() {
24
+ line := strings.TrimSpace(scanner.Text())
25
+
26
+ // Skip empty lines and headers
27
+ if line == "" || strings.HasPrefix(line, "#") {
28
+ continue
29
+ }
30
+
31
+ // Look for bullet points or numbered lists
32
+ if strings.HasPrefix(line, "-") || strings.HasPrefix(line, "*") || regexp.MustCompile(`^\d+\.`).MatchString(line) {
33
+ // Remove list markers
34
+ line = regexp.MustCompile(`^[-*\d.]\s*`).ReplaceAllString(line, "")
35
+
36
+ if policy := parsePolicy(line); policy != nil {
37
+ policies = append(policies, *policy)
38
+ }
39
+ }
40
+ }
41
+
42
+ return policies, scanner.Err()
43
+ }
44
+
45
+ // ParseAgentsFile reads an AGENTS.md file and extracts enforceable policies
46
+ // NOTE: This uses the old regex-based extraction. Use extractor.ExtractPoliciesFromFile for LLM-based extraction.
47
+ func ParseAgentsFile(filepath string) ([]Policy, error) {
48
+ content, err := os.ReadFile(filepath)
49
+ if err != nil {
50
+ return nil, err
51
+ }
52
+
53
+ return ParseAgents(string(content))
54
+ }
55
+
56
+ // parsePolicy attempts to convert a policy statement into a policy
57
+ // Legacy regex-based parsing - kept for backwards compatibility
58
+ // Prefer using extractor.ExtractPoliciesFromFile for better accuracy
59
+ func parsePolicy(text string) *Policy {
60
+ return &Policy{
61
+ Name: text, // Use the policy text as the name
62
+ Message: text,
63
+ OriginalText: text,
64
+ }
65
+ }
66
+
@@ -0,0 +1,157 @@
1
+ package parser
2
+
3
+ import (
4
+ "testing"
5
+ )
6
+
7
+ func TestParseAgents_BasicPolicies(t *testing.T) {
8
+ content := `# Policies
9
+ - Don't use hard-coded color values
10
+ - Always use theme colors
11
+ `
12
+ policies, err := ParseAgents(content)
13
+ if err != nil {
14
+ t.Fatalf("ParseAgents failed: %v", err)
15
+ }
16
+
17
+ if len(policies) != 2 {
18
+ t.Fatalf("Expected 2 policies, got %d", len(policies))
19
+ }
20
+
21
+ // Check first policy was extracted
22
+ firstPolicy := policies[0]
23
+ if firstPolicy.Message == "" {
24
+ t.Error("Expected policy message to be non-empty")
25
+ }
26
+ if firstPolicy.OriginalText == "" {
27
+ t.Error("Expected original text to be non-empty")
28
+ }
29
+ }
30
+
31
+ func TestParseAgents_ExtractsMessages(t *testing.T) {
32
+ content := `# Rules
33
+ - Don't use eval
34
+ - Don't use document.write
35
+ `
36
+ policies, err := ParseAgents(content)
37
+ if err != nil {
38
+ t.Fatalf("ParseAgents failed: %v", err)
39
+ }
40
+
41
+ if len(policies) != 2 {
42
+ t.Fatalf("Expected 2 policies, got %d", len(policies))
43
+ }
44
+
45
+ // Verify policies have content
46
+ for i, policy := range policies {
47
+ if policy.Message == "" {
48
+ t.Errorf("Policy %d has empty message", i)
49
+ }
50
+ if policy.Name == "" {
51
+ t.Errorf("Policy %d has empty name", i)
52
+ }
53
+ }
54
+ }
55
+
56
+ func TestParseAgents_EmptyContent(t *testing.T) {
57
+ content := ``
58
+ policies, err := ParseAgents(content)
59
+ if err != nil {
60
+ t.Fatalf("ParseAgents failed: %v", err)
61
+ }
62
+
63
+ if len(policies) != 0 {
64
+ t.Fatalf("Expected 0 policies, got %d", len(policies))
65
+ }
66
+ }
67
+
68
+ func TestParseAgents_BulletPoints(t *testing.T) {
69
+ content := `# Policies
70
+ * Don't use hardcoded colors
71
+ - Don't use eval
72
+ 1. Don't use document.write
73
+ `
74
+ policies, err := ParseAgents(content)
75
+ if err != nil {
76
+ t.Fatalf("ParseAgents failed: %v", err)
77
+ }
78
+
79
+ if len(policies) != 3 {
80
+ t.Fatalf("Expected 3 policies, got %d", len(policies))
81
+ }
82
+
83
+ // All policies should have content
84
+ for _, policy := range policies {
85
+ if policy.Message == "" || policy.Name == "" {
86
+ t.Errorf("Policy missing required fields: %+v", policy)
87
+ }
88
+ }
89
+ }
90
+
91
+ func TestParseAgents_SkipsHeaders(t *testing.T) {
92
+ content := `# Main Policies
93
+ ## Subsection
94
+ ### Details
95
+ - Don't use eval
96
+ `
97
+ policies, err := ParseAgents(content)
98
+ if err != nil {
99
+ t.Fatalf("ParseAgents failed: %v", err)
100
+ }
101
+
102
+ if len(policies) != 1 {
103
+ t.Fatalf("Expected 1 policy, got %d", len(policies))
104
+ }
105
+ }
106
+
107
+ func TestParseAgents_PreservesOriginalText(t *testing.T) {
108
+ content := `# Python Policies
109
+ - Use ruff format instead of black
110
+ `
111
+ policies, err := ParseAgents(content)
112
+ if err != nil {
113
+ t.Fatalf("ParseAgents failed: %v", err)
114
+ }
115
+
116
+ if len(policies) != 1 {
117
+ t.Fatalf("Expected 1 policy, got %d", len(policies))
118
+ }
119
+
120
+ policy := policies[0]
121
+ if policy.OriginalText == "" {
122
+ t.Error("Expected OriginalText to be preserved")
123
+ }
124
+ if policy.Message == "" {
125
+ t.Error("Expected Message to be set")
126
+ }
127
+ if policy.Name == "" {
128
+ t.Error("Expected Name to be set")
129
+ }
130
+ }
131
+
132
+ func TestParseAgents_MultilinePolicies(t *testing.T) {
133
+ content := `# Database Policies
134
+ - Always use async when database operations
135
+ - Use connection pooling for better performance
136
+ `
137
+ policies, err := ParseAgents(content)
138
+ if err != nil {
139
+ t.Fatalf("ParseAgents failed: %v", err)
140
+ }
141
+
142
+ if len(policies) != 2 {
143
+ t.Fatalf("Expected 2 policies, got %d", len(policies))
144
+ }
145
+
146
+ for i, policy := range policies {
147
+ if policy.Name == "" {
148
+ t.Errorf("Policy %d missing name", i)
149
+ }
150
+ if policy.Message == "" {
151
+ t.Errorf("Policy %d missing message", i)
152
+ }
153
+ if policy.OriginalText == "" {
154
+ t.Errorf("Policy %d missing original text", i)
155
+ }
156
+ }
157
+ }