@tanagram/cli 0.1.7 → 0.1.9
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/README.md +44 -3
- package/bin/tanagram +0 -0
- package/checker/matcher.go +50 -1
- package/checker/matcher_test.go +215 -0
- package/commands/run.go +14 -7
- package/commands/sync.go +12 -2
- package/extractor/file.go +12 -3
- package/main.go +5 -3
- package/package.json +1 -1
- package/parser/mdc.go +57 -0
- package/parser/mdc_test.go +137 -0
package/README.md
CHANGED
|
@@ -90,16 +90,40 @@ tanagram help
|
|
|
90
90
|
|
|
91
91
|
**Smart Caching:** Policies are cached and automatically resynced when instruction files change (detected via MD5 hash).
|
|
92
92
|
|
|
93
|
-
|
|
93
|
+
### Commands
|
|
94
94
|
|
|
95
95
|
- **`run`** (default) - Check git changes against policies with auto-sync
|
|
96
96
|
- **`sync`** - Manually sync all instruction files to cache
|
|
97
97
|
- **`list`** - View all cached policies (shows enforceable vs unenforceable)
|
|
98
98
|
- **`help`** - Show usage information
|
|
99
99
|
|
|
100
|
+
### Claude Code Hook
|
|
101
|
+
You can install the CLI as a Claude Code [hook](https://code.claude.com/docs/en/hooks) to have Claude automatically iterate on Tanagram's output. Add the following to your `~/.claude/settings.json` (user settings) or `.claude/settings.json` (project settings):
|
|
102
|
+
|
|
103
|
+
```json
|
|
104
|
+
{
|
|
105
|
+
"hooks": {
|
|
106
|
+
"PostToolUse": [
|
|
107
|
+
{
|
|
108
|
+
"matcher": "Edit|Write",
|
|
109
|
+
"hooks": [
|
|
110
|
+
{
|
|
111
|
+
"type": "command",
|
|
112
|
+
"command": "tanagram"
|
|
113
|
+
}
|
|
114
|
+
]
|
|
115
|
+
}
|
|
116
|
+
]
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
If you have existing hooks, you can merge this hook into your existing config.
|
|
122
|
+
|
|
123
|
+
|
|
100
124
|
## How It Works
|
|
101
125
|
|
|
102
|
-
1. **Finds instruction files** - Searches for `AGENTS.md`, `POLICIES.md`, `CLAUDE.md` in your git repository
|
|
126
|
+
1. **Finds instruction files** - Searches for `AGENTS.md`, `POLICIES.md`, `CLAUDE.md`, and `.cursor/rules/*.mdc` in your git repository
|
|
103
127
|
2. **Checks cache** - Loads cached policies and MD5 hashes from `.tanagram/`
|
|
104
128
|
3. **Auto-syncs** - Detects file changes via MD5 and automatically resyncs if needed
|
|
105
129
|
4. **LLM extraction** - Uses Claude AI to extract ALL policies from instruction files
|
|
@@ -157,7 +181,7 @@ Claude AI analyzes code changes against all policies:
|
|
|
157
181
|
## Exit Codes
|
|
158
182
|
|
|
159
183
|
- `0` - No violations found
|
|
160
|
-
- `
|
|
184
|
+
- `2` - Violations found (triggers Claude Code automatic fix behavior)
|
|
161
185
|
|
|
162
186
|
## Example
|
|
163
187
|
|
|
@@ -171,8 +195,25 @@ Create an `AGENTS.md` in your repo with policies:
|
|
|
171
195
|
- Always use async/await for database operations
|
|
172
196
|
```
|
|
173
197
|
|
|
198
|
+
Or use Cursor rules files in `.cursor/rules/`:
|
|
199
|
+
|
|
200
|
+
```markdown
|
|
201
|
+
---
|
|
202
|
+
description: TypeScript coding standards
|
|
203
|
+
globs: ["*.ts", "*.tsx"]
|
|
204
|
+
---
|
|
205
|
+
|
|
206
|
+
# TypeScript Standards
|
|
207
|
+
|
|
208
|
+
- Use strict type checking
|
|
209
|
+
- Avoid using 'any' type
|
|
210
|
+
- Prefer interfaces over type aliases
|
|
211
|
+
```
|
|
212
|
+
|
|
174
213
|
Then run `tanagram` to enforce them locally!
|
|
175
214
|
|
|
215
|
+
**Note:** For `.mdc` files, Tanagram extracts policies from the markdown content only (YAML frontmatter is used by Cursor and ignored during policy extraction).
|
|
216
|
+
|
|
176
217
|
---
|
|
177
218
|
|
|
178
219
|
## Contributing
|
package/bin/tanagram
CHANGED
|
Binary file
|
package/checker/matcher.go
CHANGED
|
@@ -21,7 +21,7 @@ type Violation struct {
|
|
|
21
21
|
|
|
22
22
|
// CheckResult contains all violations found
|
|
23
23
|
type CheckResult struct {
|
|
24
|
-
Violations
|
|
24
|
+
Violations []Violation
|
|
25
25
|
TotalChecked int
|
|
26
26
|
}
|
|
27
27
|
|
|
@@ -89,3 +89,52 @@ func FormatViolations(result *CheckResult) string {
|
|
|
89
89
|
|
|
90
90
|
return output.String()
|
|
91
91
|
}
|
|
92
|
+
|
|
93
|
+
// FormatClaudeInstructions formats violations as actionable instructions for Claude Code
|
|
94
|
+
// to automatically fix via exit code 2 hook behavior
|
|
95
|
+
func FormatClaudeInstructions(result *CheckResult) string {
|
|
96
|
+
if len(result.Violations) == 0 {
|
|
97
|
+
return ""
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Group violations by file
|
|
101
|
+
violationsByFile := make(map[string][]Violation)
|
|
102
|
+
for _, v := range result.Violations {
|
|
103
|
+
violationsByFile[v.File] = append(violationsByFile[v.File], v)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Sort files for consistent output
|
|
107
|
+
files := make([]string, 0, len(violationsByFile))
|
|
108
|
+
for file := range violationsByFile {
|
|
109
|
+
files = append(files, file)
|
|
110
|
+
}
|
|
111
|
+
sort.Strings(files)
|
|
112
|
+
|
|
113
|
+
var output strings.Builder
|
|
114
|
+
output.WriteString("POLICY VIOLATIONS DETECTED - PLEASE FIX\n\n")
|
|
115
|
+
output.WriteString(fmt.Sprintf("Found %d policy violation(s) that need to be fixed:\n\n", len(result.Violations)))
|
|
116
|
+
|
|
117
|
+
for _, file := range files {
|
|
118
|
+
violations := violationsByFile[file]
|
|
119
|
+
|
|
120
|
+
// Sort violations by line number
|
|
121
|
+
sort.Slice(violations, func(i, j int) bool {
|
|
122
|
+
return violations[i].LineNumber < violations[j].LineNumber
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
output.WriteString(fmt.Sprintf("File: %s\n", file))
|
|
126
|
+
|
|
127
|
+
for _, v := range violations {
|
|
128
|
+
output.WriteString(fmt.Sprintf("\n Line %d:\n", v.LineNumber))
|
|
129
|
+
output.WriteString(fmt.Sprintf(" Code: %s\n", v.Code))
|
|
130
|
+
output.WriteString(fmt.Sprintf(" Policy: %s\n", v.PolicyName))
|
|
131
|
+
output.WriteString(fmt.Sprintf(" Issue: %s\n", v.Message))
|
|
132
|
+
output.WriteString(fmt.Sprintf(" Action: Review and fix this code to comply with the policy.\n"))
|
|
133
|
+
}
|
|
134
|
+
output.WriteString("\n")
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
output.WriteString("Please fix all violations listed above and ensure the code complies with all policies.\n")
|
|
138
|
+
|
|
139
|
+
return output.String()
|
|
140
|
+
}
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
package checker
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"strings"
|
|
5
|
+
"testing"
|
|
6
|
+
)
|
|
7
|
+
|
|
8
|
+
func TestFormatViolations_NoViolations(t *testing.T) {
|
|
9
|
+
result := &CheckResult{
|
|
10
|
+
Violations: []Violation{},
|
|
11
|
+
TotalChecked: 10,
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
output := FormatViolations(result)
|
|
15
|
+
expected := "✓ No policy violations found"
|
|
16
|
+
|
|
17
|
+
if output != expected {
|
|
18
|
+
t.Errorf("Expected %q, got %q", expected, output)
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
func TestFormatViolations_WithViolations(t *testing.T) {
|
|
23
|
+
result := &CheckResult{
|
|
24
|
+
Violations: []Violation{
|
|
25
|
+
{
|
|
26
|
+
File: "main.go",
|
|
27
|
+
LineNumber: 10,
|
|
28
|
+
PolicyName: "No hardcoded secrets",
|
|
29
|
+
Message: "Potential hardcoded API key detected",
|
|
30
|
+
Code: "apiKey := \"sk-1234567890\"",
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
File: "config.go",
|
|
34
|
+
LineNumber: 5,
|
|
35
|
+
PolicyName: "Use environment variables",
|
|
36
|
+
Message: "Configuration should use environment variables",
|
|
37
|
+
Code: "var token = \"abc123\"",
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
TotalChecked: 50,
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
output := FormatViolations(result)
|
|
44
|
+
|
|
45
|
+
// Check that output contains expected elements
|
|
46
|
+
if !strings.Contains(output, "✗ Found 2 policy violation(s)") {
|
|
47
|
+
t.Errorf("Expected violation count in output, got: %s", output)
|
|
48
|
+
}
|
|
49
|
+
if !strings.Contains(output, "main.go") {
|
|
50
|
+
t.Errorf("Expected file name 'main.go' in output")
|
|
51
|
+
}
|
|
52
|
+
if !strings.Contains(output, "config.go") {
|
|
53
|
+
t.Errorf("Expected file name 'config.go' in output")
|
|
54
|
+
}
|
|
55
|
+
if !strings.Contains(output, "10:") {
|
|
56
|
+
t.Errorf("Expected line number 10 in output")
|
|
57
|
+
}
|
|
58
|
+
if !strings.Contains(output, "Potential hardcoded API key detected") {
|
|
59
|
+
t.Errorf("Expected violation message in output")
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
func TestFormatClaudeInstructions_NoViolations(t *testing.T) {
|
|
64
|
+
result := &CheckResult{
|
|
65
|
+
Violations: []Violation{},
|
|
66
|
+
TotalChecked: 10,
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
output := FormatClaudeInstructions(result)
|
|
70
|
+
|
|
71
|
+
if output != "" {
|
|
72
|
+
t.Errorf("Expected empty string for no violations, got: %s", output)
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
func TestFormatClaudeInstructions_WithViolations(t *testing.T) {
|
|
77
|
+
result := &CheckResult{
|
|
78
|
+
Violations: []Violation{
|
|
79
|
+
{
|
|
80
|
+
File: "main.go",
|
|
81
|
+
LineNumber: 10,
|
|
82
|
+
PolicyName: "No hardcoded secrets",
|
|
83
|
+
Message: "Potential hardcoded API key detected",
|
|
84
|
+
Code: "apiKey := \"sk-1234567890\"",
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
File: "main.go",
|
|
88
|
+
LineNumber: 15,
|
|
89
|
+
PolicyName: "No hardcoded secrets",
|
|
90
|
+
Message: "Potential hardcoded password detected",
|
|
91
|
+
Code: "password := \"secret123\"",
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
File: "config.go",
|
|
95
|
+
LineNumber: 5,
|
|
96
|
+
PolicyName: "Use environment variables",
|
|
97
|
+
Message: "Configuration should use environment variables",
|
|
98
|
+
Code: "var token = \"abc123\"",
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
TotalChecked: 50,
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
output := FormatClaudeInstructions(result)
|
|
105
|
+
|
|
106
|
+
// Check header
|
|
107
|
+
if !strings.Contains(output, "POLICY VIOLATIONS DETECTED - PLEASE FIX") {
|
|
108
|
+
t.Errorf("Expected header in output")
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Check violation count
|
|
112
|
+
if !strings.Contains(output, "Found 3 policy violation(s)") {
|
|
113
|
+
t.Errorf("Expected violation count in output, got: %s", output)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Check file names
|
|
117
|
+
if !strings.Contains(output, "File: main.go") {
|
|
118
|
+
t.Errorf("Expected 'File: main.go' in output")
|
|
119
|
+
}
|
|
120
|
+
if !strings.Contains(output, "File: config.go") {
|
|
121
|
+
t.Errorf("Expected 'File: config.go' in output")
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Check line numbers
|
|
125
|
+
if !strings.Contains(output, "Line 10:") {
|
|
126
|
+
t.Errorf("Expected 'Line 10:' in output")
|
|
127
|
+
}
|
|
128
|
+
if !strings.Contains(output, "Line 15:") {
|
|
129
|
+
t.Errorf("Expected 'Line 15:' in output")
|
|
130
|
+
}
|
|
131
|
+
if !strings.Contains(output, "Line 5:") {
|
|
132
|
+
t.Errorf("Expected 'Line 5:' in output")
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Check policy names
|
|
136
|
+
if !strings.Contains(output, "Policy: No hardcoded secrets") {
|
|
137
|
+
t.Errorf("Expected policy name in output")
|
|
138
|
+
}
|
|
139
|
+
if !strings.Contains(output, "Policy: Use environment variables") {
|
|
140
|
+
t.Errorf("Expected policy name in output")
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Check code snippets
|
|
144
|
+
if !strings.Contains(output, "Code: apiKey := \"sk-1234567890\"") {
|
|
145
|
+
t.Errorf("Expected code snippet in output")
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Check issue messages
|
|
149
|
+
if !strings.Contains(output, "Issue: Potential hardcoded API key detected") {
|
|
150
|
+
t.Errorf("Expected issue message in output")
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Check action instruction
|
|
154
|
+
if !strings.Contains(output, "Action: Review and fix this code to comply with the policy.") {
|
|
155
|
+
t.Errorf("Expected action instruction in output")
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Check footer
|
|
159
|
+
if !strings.Contains(output, "Please fix all violations listed above and ensure the code complies with all policies.") {
|
|
160
|
+
t.Errorf("Expected footer instruction in output")
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
func TestFormatClaudeInstructions_MultipleFilesGroupedAndSorted(t *testing.T) {
|
|
165
|
+
result := &CheckResult{
|
|
166
|
+
Violations: []Violation{
|
|
167
|
+
{
|
|
168
|
+
File: "z_file.go",
|
|
169
|
+
LineNumber: 20,
|
|
170
|
+
PolicyName: "Policy Z",
|
|
171
|
+
Message: "Issue Z",
|
|
172
|
+
Code: "code z",
|
|
173
|
+
},
|
|
174
|
+
{
|
|
175
|
+
File: "a_file.go",
|
|
176
|
+
LineNumber: 30,
|
|
177
|
+
PolicyName: "Policy A2",
|
|
178
|
+
Message: "Issue A2",
|
|
179
|
+
Code: "code a2",
|
|
180
|
+
},
|
|
181
|
+
{
|
|
182
|
+
File: "a_file.go",
|
|
183
|
+
LineNumber: 10,
|
|
184
|
+
PolicyName: "Policy A1",
|
|
185
|
+
Message: "Issue A1",
|
|
186
|
+
Code: "code a1",
|
|
187
|
+
},
|
|
188
|
+
},
|
|
189
|
+
TotalChecked: 50,
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
output := FormatClaudeInstructions(result)
|
|
193
|
+
|
|
194
|
+
// Files should be sorted alphabetically
|
|
195
|
+
aFileIndex := strings.Index(output, "File: a_file.go")
|
|
196
|
+
zFileIndex := strings.Index(output, "File: z_file.go")
|
|
197
|
+
|
|
198
|
+
if aFileIndex == -1 || zFileIndex == -1 {
|
|
199
|
+
t.Errorf("Expected both file names in output")
|
|
200
|
+
}
|
|
201
|
+
if aFileIndex >= zFileIndex {
|
|
202
|
+
t.Errorf("Expected files to be sorted alphabetically (a_file.go before z_file.go)")
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Within a file, violations should be sorted by line number
|
|
206
|
+
line10Index := strings.Index(output, "Line 10:")
|
|
207
|
+
line30Index := strings.Index(output, "Line 30:")
|
|
208
|
+
|
|
209
|
+
if line10Index == -1 || line30Index == -1 {
|
|
210
|
+
t.Errorf("Expected both line numbers in output")
|
|
211
|
+
}
|
|
212
|
+
if line10Index >= line30Index {
|
|
213
|
+
t.Errorf("Expected violations to be sorted by line number (10 before 30)")
|
|
214
|
+
}
|
|
215
|
+
}
|
package/commands/run.go
CHANGED
|
@@ -47,7 +47,7 @@ func Run() error {
|
|
|
47
47
|
}
|
|
48
48
|
|
|
49
49
|
if len(instructionFiles) == 0 {
|
|
50
|
-
return fmt.Errorf("no instruction files found (looking for AGENTS.md, POLICIES.md, CLAUDE.md)")
|
|
50
|
+
return fmt.Errorf("no instruction files found (looking for AGENTS.md, POLICIES.md, CLAUDE.md, .cursor/rules/*.mdc)")
|
|
51
51
|
}
|
|
52
52
|
|
|
53
53
|
// Load cache
|
|
@@ -196,14 +196,21 @@ func Run() error {
|
|
|
196
196
|
ctx := context.Background()
|
|
197
197
|
result := checker.CheckChanges(ctx, diffResult.Changes, policies)
|
|
198
198
|
|
|
199
|
-
//
|
|
200
|
-
output := checker.FormatViolations(result)
|
|
201
|
-
fmt.Print(output)
|
|
202
|
-
|
|
203
|
-
// Exit with error if violations found
|
|
199
|
+
// Handle results based on whether violations were found
|
|
204
200
|
if len(result.Violations) > 0 {
|
|
205
|
-
|
|
201
|
+
// Format and output violations to stderr
|
|
202
|
+
violationOutput := checker.FormatViolations(result)
|
|
203
|
+
fmt.Fprint(os.Stderr, violationOutput)
|
|
204
|
+
|
|
205
|
+
// Format and output Claude-friendly instructions to stderr
|
|
206
|
+
claudeInstructions := checker.FormatClaudeInstructions(result)
|
|
207
|
+
fmt.Fprint(os.Stderr, claudeInstructions)
|
|
208
|
+
|
|
209
|
+
// Exit with code 2 to trigger Claude Code hook behavior
|
|
210
|
+
os.Exit(2)
|
|
206
211
|
}
|
|
207
212
|
|
|
213
|
+
// No violations found - output success message
|
|
214
|
+
fmt.Println("✓ No policy violations found")
|
|
208
215
|
return nil
|
|
209
216
|
}
|
package/commands/sync.go
CHANGED
|
@@ -28,7 +28,7 @@ func Sync() error {
|
|
|
28
28
|
}
|
|
29
29
|
|
|
30
30
|
if len(instructionFiles) == 0 {
|
|
31
|
-
return fmt.Errorf("no instruction files found (looking for AGENTS.md, POLICIES.md, CLAUDE.md)")
|
|
31
|
+
return fmt.Errorf("no instruction files found (looking for AGENTS.md, POLICIES.md, CLAUDE.md, .cursor/rules/*.mdc)")
|
|
32
32
|
}
|
|
33
33
|
|
|
34
34
|
fmt.Printf("Found %d instruction file(s)\n", len(instructionFiles))
|
|
@@ -137,7 +137,7 @@ func Sync() error {
|
|
|
137
137
|
}
|
|
138
138
|
|
|
139
139
|
// FindInstructionFiles searches for instruction files in the git repository
|
|
140
|
-
// Looks for: AGENTS.md, POLICIES.md, CLAUDE.md
|
|
140
|
+
// Looks for: AGENTS.md, POLICIES.md, CLAUDE.md, and .cursor/rules/*.mdc
|
|
141
141
|
func FindInstructionFiles(gitRoot string) ([]string, error) {
|
|
142
142
|
var files []string
|
|
143
143
|
|
|
@@ -175,12 +175,22 @@ func FindInstructionFiles(gitRoot string) ([]string, error) {
|
|
|
175
175
|
|
|
176
176
|
// Check if this is one of our instruction files
|
|
177
177
|
if !info.IsDir() {
|
|
178
|
+
// Check for common instruction files (AGENTS.md, etc.)
|
|
178
179
|
for _, name := range commonNames {
|
|
179
180
|
if info.Name() == name {
|
|
180
181
|
files = append(files, path)
|
|
181
182
|
break
|
|
182
183
|
}
|
|
183
184
|
}
|
|
185
|
+
|
|
186
|
+
// Check for .mdc files in .cursor/rules/ directory
|
|
187
|
+
if filepath.Ext(info.Name()) == ".mdc" {
|
|
188
|
+
// Check if this file is inside .cursor/rules/
|
|
189
|
+
relPath, err := filepath.Rel(gitRoot, path)
|
|
190
|
+
if err == nil && filepath.HasPrefix(relPath, filepath.Join(".cursor", "rules")) {
|
|
191
|
+
files = append(files, path)
|
|
192
|
+
}
|
|
193
|
+
}
|
|
184
194
|
}
|
|
185
195
|
|
|
186
196
|
return nil
|
package/extractor/file.go
CHANGED
|
@@ -4,16 +4,25 @@ import (
|
|
|
4
4
|
"context"
|
|
5
5
|
"fmt"
|
|
6
6
|
"os"
|
|
7
|
+
"path/filepath"
|
|
8
|
+
"strings"
|
|
7
9
|
|
|
8
10
|
"github.com/tanagram/cli/parser"
|
|
9
11
|
)
|
|
10
12
|
|
|
11
13
|
// ExtractPoliciesFromFile reads a file and extracts policies using LLM
|
|
12
|
-
func ExtractPoliciesFromFile(ctx context.Context,
|
|
13
|
-
content, err := os.ReadFile(
|
|
14
|
+
func ExtractPoliciesFromFile(ctx context.Context, filePath string) ([]parser.Policy, error) {
|
|
15
|
+
content, err := os.ReadFile(filePath)
|
|
14
16
|
if err != nil {
|
|
15
17
|
return nil, fmt.Errorf("failed to read file: %w", err)
|
|
16
18
|
}
|
|
17
19
|
|
|
18
|
-
|
|
20
|
+
contentStr := string(content)
|
|
21
|
+
|
|
22
|
+
// If this is an .mdc file, strip YAML frontmatter
|
|
23
|
+
if strings.HasSuffix(strings.ToLower(filepath.Base(filePath)), ".mdc") {
|
|
24
|
+
contentStr = parser.StripMDCFrontmatter(contentStr)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return ExtractPolicies(ctx, contentStr)
|
|
19
28
|
}
|
package/main.go
CHANGED
|
@@ -56,9 +56,11 @@ EXAMPLES:
|
|
|
56
56
|
tanagram list # View all cached policies
|
|
57
57
|
|
|
58
58
|
INSTRUCTION FILES:
|
|
59
|
-
Tanagram looks for instruction files
|
|
60
|
-
|
|
61
|
-
|
|
59
|
+
Tanagram looks for instruction files in your git repository:
|
|
60
|
+
- AGENTS.md, POLICIES.md, CLAUDE.md
|
|
61
|
+
- Cursor rules: .cursor/rules/*.mdc
|
|
62
|
+
|
|
63
|
+
Policies are cached and automatically resynced when files change.
|
|
62
64
|
`
|
|
63
65
|
fmt.Print(help)
|
|
64
66
|
}
|
package/package.json
CHANGED
package/parser/mdc.go
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
package parser
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"bufio"
|
|
5
|
+
"strings"
|
|
6
|
+
)
|
|
7
|
+
|
|
8
|
+
// StripMDCFrontmatter removes YAML frontmatter from .mdc files
|
|
9
|
+
// .mdc files have optional YAML frontmatter between --- delimiters
|
|
10
|
+
// We only need the markdown content for policy extraction
|
|
11
|
+
func StripMDCFrontmatter(content string) string {
|
|
12
|
+
lines := strings.Split(content, "\n")
|
|
13
|
+
|
|
14
|
+
// Check if file starts with frontmatter delimiter
|
|
15
|
+
if len(lines) == 0 || strings.TrimSpace(lines[0]) != "---" {
|
|
16
|
+
// No frontmatter, return as-is
|
|
17
|
+
return content
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Find the closing frontmatter delimiter
|
|
21
|
+
scanner := bufio.NewScanner(strings.NewReader(content))
|
|
22
|
+
inFrontmatter := false
|
|
23
|
+
frontmatterEnded := false
|
|
24
|
+
lineNum := 0
|
|
25
|
+
|
|
26
|
+
var markdownLines []string
|
|
27
|
+
|
|
28
|
+
for scanner.Scan() {
|
|
29
|
+
line := scanner.Text()
|
|
30
|
+
lineNum++
|
|
31
|
+
|
|
32
|
+
// First line is opening ---
|
|
33
|
+
if lineNum == 1 && strings.TrimSpace(line) == "---" {
|
|
34
|
+
inFrontmatter = true
|
|
35
|
+
continue
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Look for closing ---
|
|
39
|
+
if inFrontmatter && strings.TrimSpace(line) == "---" {
|
|
40
|
+
inFrontmatter = false
|
|
41
|
+
frontmatterEnded = true
|
|
42
|
+
continue
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Skip frontmatter content
|
|
46
|
+
if inFrontmatter {
|
|
47
|
+
continue
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// After frontmatter ends, collect markdown lines
|
|
51
|
+
if frontmatterEnded || lineNum > 1 {
|
|
52
|
+
markdownLines = append(markdownLines, line)
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return strings.Join(markdownLines, "\n")
|
|
57
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
package parser
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"strings"
|
|
5
|
+
"testing"
|
|
6
|
+
)
|
|
7
|
+
|
|
8
|
+
func TestStripMDCFrontmatter_WithFrontmatter(t *testing.T) {
|
|
9
|
+
input := `---
|
|
10
|
+
description: TypeScript coding standards
|
|
11
|
+
globs: ["*.ts", "*.tsx"]
|
|
12
|
+
alwaysApply: false
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
# TypeScript Standards
|
|
16
|
+
|
|
17
|
+
- Use strict type checking
|
|
18
|
+
- Avoid using 'any' type`
|
|
19
|
+
|
|
20
|
+
expected := `
|
|
21
|
+
# TypeScript Standards
|
|
22
|
+
|
|
23
|
+
- Use strict type checking
|
|
24
|
+
- Avoid using 'any' type`
|
|
25
|
+
|
|
26
|
+
result := StripMDCFrontmatter(input)
|
|
27
|
+
|
|
28
|
+
if result != expected {
|
|
29
|
+
t.Errorf("Expected:\n%s\n\nGot:\n%s", expected, result)
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
func TestStripMDCFrontmatter_NoFrontmatter(t *testing.T) {
|
|
34
|
+
input := `# TypeScript Standards
|
|
35
|
+
|
|
36
|
+
- Use strict type checking
|
|
37
|
+
- Avoid using 'any' type`
|
|
38
|
+
|
|
39
|
+
result := StripMDCFrontmatter(input)
|
|
40
|
+
|
|
41
|
+
if result != input {
|
|
42
|
+
t.Errorf("Expected input to be unchanged when no frontmatter present")
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
func TestStripMDCFrontmatter_EmptyFile(t *testing.T) {
|
|
47
|
+
input := ""
|
|
48
|
+
result := StripMDCFrontmatter(input)
|
|
49
|
+
|
|
50
|
+
if result != input {
|
|
51
|
+
t.Errorf("Expected empty string for empty input")
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
func TestStripMDCFrontmatter_OnlyFrontmatter(t *testing.T) {
|
|
56
|
+
input := `---
|
|
57
|
+
description: Test rule
|
|
58
|
+
globs: ["*.ts"]
|
|
59
|
+
---`
|
|
60
|
+
|
|
61
|
+
result := StripMDCFrontmatter(input)
|
|
62
|
+
|
|
63
|
+
// Should return empty or minimal content after frontmatter
|
|
64
|
+
if strings.TrimSpace(result) != "" {
|
|
65
|
+
t.Errorf("Expected empty content after stripping frontmatter-only file, got: %s", result)
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
func TestStripMDCFrontmatter_ComplexFrontmatter(t *testing.T) {
|
|
70
|
+
input := `---
|
|
71
|
+
description: Multi-line description
|
|
72
|
+
that spans multiple lines
|
|
73
|
+
globs:
|
|
74
|
+
- "src/**/*.ts"
|
|
75
|
+
- "test/**/*.test.ts"
|
|
76
|
+
alwaysApply: false
|
|
77
|
+
priority: 10
|
|
78
|
+
dependencies:
|
|
79
|
+
- other-rule.mdc
|
|
80
|
+
---
|
|
81
|
+
|
|
82
|
+
# Complex Rule
|
|
83
|
+
|
|
84
|
+
## Section 1
|
|
85
|
+
- Policy 1
|
|
86
|
+
- Policy 2
|
|
87
|
+
|
|
88
|
+
## Section 2
|
|
89
|
+
- Policy 3`
|
|
90
|
+
|
|
91
|
+
result := StripMDCFrontmatter(input)
|
|
92
|
+
|
|
93
|
+
// Should contain markdown but not frontmatter
|
|
94
|
+
if !strings.Contains(result, "# Complex Rule") {
|
|
95
|
+
t.Errorf("Expected markdown content to be preserved")
|
|
96
|
+
}
|
|
97
|
+
if strings.Contains(result, "description:") {
|
|
98
|
+
t.Errorf("Expected frontmatter to be removed")
|
|
99
|
+
}
|
|
100
|
+
if strings.Contains(result, "globs:") {
|
|
101
|
+
t.Errorf("Expected frontmatter to be removed")
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
func TestStripMDCFrontmatter_MarkdownWithCodeBlocks(t *testing.T) {
|
|
106
|
+
input := `---
|
|
107
|
+
description: Code examples
|
|
108
|
+
---
|
|
109
|
+
|
|
110
|
+
# Rules
|
|
111
|
+
|
|
112
|
+
Example with --- in code:
|
|
113
|
+
|
|
114
|
+
` + "```" + `yaml
|
|
115
|
+
---
|
|
116
|
+
some: yaml
|
|
117
|
+
---
|
|
118
|
+
` + "```" + `
|
|
119
|
+
|
|
120
|
+
- Follow this policy`
|
|
121
|
+
|
|
122
|
+
result := StripMDCFrontmatter(input)
|
|
123
|
+
|
|
124
|
+
// Should preserve --- inside code blocks
|
|
125
|
+
if !strings.Contains(result, "```yaml") {
|
|
126
|
+
t.Errorf("Expected code blocks to be preserved")
|
|
127
|
+
}
|
|
128
|
+
if !strings.Contains(result, "- Follow this policy") {
|
|
129
|
+
t.Errorf("Expected policy content to be preserved")
|
|
130
|
+
}
|
|
131
|
+
// First frontmatter should be removed
|
|
132
|
+
lines := strings.Split(result, "\n")
|
|
133
|
+
if len(lines) > 0 && strings.TrimSpace(lines[0]) == "---" {
|
|
134
|
+
// This is tricky - the first --- should be gone, but ones in code blocks should remain
|
|
135
|
+
// For now, we accept this limitation as our parser is simple
|
|
136
|
+
}
|
|
137
|
+
}
|