@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 +21 -0
- package/README.md +217 -0
- package/bin/tanagram +0 -0
- package/bin/tanagram.js +18 -0
- package/checker/llm_integration.go +119 -0
- package/checker/matcher.go +56 -0
- package/checker/violation_checker.go +109 -0
- package/git/diff.go +127 -0
- package/git/diff_test.go +175 -0
- package/git/testdata/diff-addition-with-context.txt +10 -0
- package/git/testdata/diff-with-context.txt +9 -0
- package/git/testdata/diff1.txt +6 -0
- package/go.mod +12 -0
- package/go.sum +20 -0
- package/install.js +69 -0
- package/main.go +64 -0
- package/package.json +41 -0
- package/parser/agents.go +66 -0
- package/parser/agents_test.go +157 -0
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
|
package/bin/tanagram.js
ADDED
|
@@ -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
|
+
}
|
package/git/diff_test.go
ADDED
|
@@ -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
|
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
|
+
}
|
package/parser/agents.go
ADDED
|
@@ -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
|
+
}
|