Skip to content

Commit 00da87d

Browse files
committed
feat: add kind field to AI agent config files
Add a "kind" field to each detected configuration file in the AI agent config collectors, classifying files as either "configuration" (settings, JSON configs) or "instruction" (markdown rules, agents, commands). Ref: PFM-4969 Signed-off-by: Jose I. Paris <jiparis@chainloop.dev>
1 parent 2c2e9ec commit 00da87d

8 files changed

Lines changed: 181 additions & 86 deletions

File tree

internal/aiagentconfig/aiagentconfig.go

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,22 @@
1515

1616
package aiagentconfig
1717

18+
// ConfigFileKind classifies the purpose of a discovered configuration file.
19+
type ConfigFileKind string
20+
21+
const (
22+
// ConfigFileKindConfiguration is for settings and JSON config files.
23+
ConfigFileKindConfiguration ConfigFileKind = "configuration"
24+
// ConfigFileKindInstruction is for markdown instruction/rules files.
25+
ConfigFileKindInstruction ConfigFileKind = "instruction"
26+
)
27+
28+
// DiscoveredFile represents a file found during discovery, before reading its content.
29+
type DiscoveredFile struct {
30+
Path string
31+
Kind ConfigFileKind
32+
}
33+
1834
// Agent identifies the AI agent provider
1935
type Agent struct {
2036
Name string `json:"name"`
@@ -30,10 +46,11 @@ type GitContext struct {
3046

3147
// ConfigFile represents a single discovered configuration file
3248
type ConfigFile struct {
33-
Path string `json:"path"`
34-
SHA256 string `json:"sha256"`
35-
Size int64 `json:"size"`
36-
Content string `json:"content"`
49+
Path string `json:"path"`
50+
Kind ConfigFileKind `json:"kind"`
51+
SHA256 string `json:"sha256"`
52+
Size int64 `json:"size"`
53+
Content string `json:"content"`
3754
}
3855

3956
// Evidence is the AI agent configuration payload

internal/aiagentconfig/builder.go

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -30,20 +30,21 @@ import (
3030
)
3131

3232
// Build reads discovered files and constructs the AI agent config payload.
33-
// basePath is the base directory, filePaths are relative to basePath.
33+
// basePath is the base directory, discovered contains files relative to basePath with their kinds.
3434
// agentName identifies the AI agent (e.g. "claude", "cursor").
3535
// gitCtx may be nil if not in a git repository.
36-
func Build(basePath string, filePaths []string, agentName string, gitCtx *GitContext) (*Evidence, error) {
36+
func Build(basePath string, discovered []DiscoveredFile, agentName string, gitCtx *GitContext) (*Evidence, error) {
3737
// Resolve basePath to its real path so symlink comparisons are reliable
3838
realRoot, err := filepath.EvalSymlinks(basePath)
3939
if err != nil {
4040
return nil, fmt.Errorf("resolving root dir: %w", err)
4141
}
4242

43-
configFiles := make([]ConfigFile, 0, len(filePaths))
44-
hashes := make([]string, 0, len(filePaths))
43+
configFiles := make([]ConfigFile, 0, len(discovered))
44+
hashes := make([]string, 0, len(discovered))
4545

46-
for _, relPath := range filePaths {
46+
for _, df := range discovered {
47+
relPath := df.Path
4748
absPath := filepath.Join(basePath, relPath)
4849

4950
// Resolve the full path through any symlinks (covers both symlinked
@@ -73,6 +74,7 @@ func Build(basePath string, filePaths []string, agentName string, gitCtx *GitCon
7374

7475
configFiles = append(configFiles, ConfigFile{
7576
Path: relPath,
77+
Kind: df.Kind,
7678
SHA256: hexHash,
7779
Size: info.Size(),
7880
Content: base64.StdEncoding.EncodeToString(content),

internal/aiagentconfig/builder_test.go

Lines changed: 32 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,10 @@ func TestBuild(t *testing.T) {
4747
CommitSHA: "abc123",
4848
}
4949

50-
data, err := Build(rootDir, []string{"CLAUDE.md", ".claude/settings.json"}, "claude", gitCtx)
50+
data, err := Build(rootDir, []DiscoveredFile{
51+
{Path: "CLAUDE.md", Kind: ConfigFileKindInstruction},
52+
{Path: ".claude/settings.json", Kind: ConfigFileKindConfiguration},
53+
}, "claude", gitCtx)
5154
require.NoError(t, err)
5255

5356
assert.Equal(t, "0.1", data.SchemaVersion)
@@ -65,13 +68,15 @@ func TestBuild(t *testing.T) {
6568

6669
cf1 := data.ConfigFiles[0]
6770
assert.Equal(t, "CLAUDE.md", cf1.Path)
71+
assert.Equal(t, ConfigFileKindInstruction, cf1.Kind)
6872
assert.Equal(t, int64(len(file1Content)), cf1.Size)
6973
hash1 := sha256.Sum256(file1Content)
7074
assert.Equal(t, hex.EncodeToString(hash1[:]), cf1.SHA256)
7175
assert.Equal(t, base64.StdEncoding.EncodeToString(file1Content), cf1.Content)
7276

7377
cf2 := data.ConfigFiles[1]
7478
assert.Equal(t, ".claude/settings.json", cf2.Path)
79+
assert.Equal(t, ConfigFileKindConfiguration, cf2.Kind)
7580
hash2 := sha256.Sum256(file2Content)
7681
assert.Equal(t, hex.EncodeToString(hash2[:]), cf2.SHA256)
7782

@@ -91,19 +96,24 @@ func TestBuildWithCursorAgent(t *testing.T) {
9196
require.NoError(t, os.MkdirAll(filepath.Join(rootDir, ".cursor", "rules"), 0o755))
9297
require.NoError(t, os.WriteFile(filepath.Join(rootDir, ".cursor", "rules", "coding.md"), []byte("rules"), 0o600))
9398

94-
data, err := Build(rootDir, []string{".cursor/rules/coding.md"}, "cursor", nil)
99+
data, err := Build(rootDir, []DiscoveredFile{
100+
{Path: ".cursor/rules/coding.md", Kind: ConfigFileKindInstruction},
101+
}, "cursor", nil)
95102
require.NoError(t, err)
96103

97104
assert.Equal(t, "cursor", data.Agent.Name)
98105
require.Len(t, data.ConfigFiles, 1)
99106
assert.Equal(t, ".cursor/rules/coding.md", data.ConfigFiles[0].Path)
107+
assert.Equal(t, ConfigFileKindInstruction, data.ConfigFiles[0].Kind)
100108
}
101109

102110
func TestBuildWithoutGitContext(t *testing.T) {
103111
rootDir := t.TempDir()
104112
require.NoError(t, os.WriteFile(filepath.Join(rootDir, "CLAUDE.md"), []byte("content"), 0o600))
105113

106-
data, err := Build(rootDir, []string{"CLAUDE.md"}, "claude", nil)
114+
data, err := Build(rootDir, []DiscoveredFile{
115+
{Path: "CLAUDE.md", Kind: ConfigFileKindInstruction},
116+
}, "claude", nil)
107117
require.NoError(t, err)
108118

109119
assert.Nil(t, data.GitContext)
@@ -114,7 +124,9 @@ func TestBuildJSONFormat(t *testing.T) {
114124
rootDir := t.TempDir()
115125
require.NoError(t, os.WriteFile(filepath.Join(rootDir, "CLAUDE.md"), []byte("content"), 0o600))
116126

117-
data, err := Build(rootDir, []string{"CLAUDE.md"}, "claude", nil)
127+
data, err := Build(rootDir, []DiscoveredFile{
128+
{Path: "CLAUDE.md", Kind: ConfigFileKindInstruction},
129+
}, "claude", nil)
118130
require.NoError(t, err)
119131

120132
// Verify it marshals to valid JSON with top-level fields (no envelope)
@@ -132,6 +144,12 @@ func TestBuildJSONFormat(t *testing.T) {
132144
// Ensure no envelope fields
133145
assert.Nil(t, raw["chainloop.material.evidence.id"])
134146
assert.Nil(t, raw["data"])
147+
148+
// Verify kind is present in config_files
149+
files := raw["config_files"].([]any)
150+
require.Len(t, files, 1)
151+
file := files[0].(map[string]any)
152+
assert.Equal(t, "instruction", file["kind"])
135153
}
136154

137155
func TestBuildRejectsSymlinksEscapingRoot(t *testing.T) {
@@ -142,7 +160,9 @@ func TestBuildRejectsSymlinksEscapingRoot(t *testing.T) {
142160
require.NoError(t, os.WriteFile(filepath.Join(outsideDir, "secret.txt"), []byte("secret"), 0o600))
143161
require.NoError(t, os.Symlink(filepath.Join(outsideDir, "secret.txt"), filepath.Join(rootDir, "CLAUDE.md")))
144162

145-
_, err := Build(rootDir, []string{"CLAUDE.md"}, "claude", nil)
163+
_, err := Build(rootDir, []DiscoveredFile{
164+
{Path: "CLAUDE.md", Kind: ConfigFileKindInstruction},
165+
}, "claude", nil)
146166
require.Error(t, err)
147167
assert.Contains(t, err.Error(), "path escapes root directory via symlink")
148168
}
@@ -159,15 +179,17 @@ func TestBuildRejectsSymlinkedParentDir(t *testing.T) {
159179
// Symlink .claude -> outside directory
160180
require.NoError(t, os.Symlink(outsideClaude, filepath.Join(rootDir, ".claude")))
161181

162-
_, err := Build(rootDir, []string{".claude/settings.json"}, "claude", nil)
182+
_, err := Build(rootDir, []DiscoveredFile{
183+
{Path: ".claude/settings.json", Kind: ConfigFileKindConfiguration},
184+
}, "claude", nil)
163185
require.Error(t, err)
164186
assert.Contains(t, err.Error(), "path escapes root directory via symlink")
165187
}
166188

167189
func TestBuildEmptyFileList(t *testing.T) {
168190
rootDir := t.TempDir()
169191

170-
data, err := Build(rootDir, []string{}, "claude", nil)
192+
data, err := Build(rootDir, []DiscoveredFile{}, "claude", nil)
171193
require.NoError(t, err)
172194
assert.Empty(t, data.ConfigFiles)
173195
assert.NotEmpty(t, data.ConfigHash)
@@ -186,7 +208,9 @@ func TestBuildAllowsRegularFilesInRoot(t *testing.T) {
186208
rootDir := t.TempDir()
187209
require.NoError(t, os.WriteFile(filepath.Join(rootDir, "CLAUDE.md"), []byte("content"), 0o600))
188210

189-
data, err := Build(rootDir, []string{"CLAUDE.md"}, "claude", nil)
211+
data, err := Build(rootDir, []DiscoveredFile{
212+
{Path: "CLAUDE.md", Kind: ConfigFileKindInstruction},
213+
}, "claude", nil)
190214
require.NoError(t, err)
191215
assert.Len(t, data.ConfigFiles, 1)
192216
}

internal/aiagentconfig/discover.go

Lines changed: 39 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -20,51 +20,57 @@ import (
2020
"sort"
2121
)
2222

23+
// patternDef pairs a glob pattern with the kind of file it matches.
24+
type patternDef struct {
25+
pattern string
26+
kind ConfigFileKind
27+
}
28+
2329
// agentDef defines an AI agent and its exclusive file patterns.
2430
type agentDef struct {
2531
name string
26-
patterns []string
32+
patterns []patternDef
2733
}
2834

2935
// agents is the registry of supported AI agents and their exclusive file patterns.
3036
var agents = []agentDef{
31-
{name: "claude", patterns: []string{
32-
"CLAUDE.md",
33-
".claude/CLAUDE.md",
34-
".claude/settings.json",
35-
".claude/rules/*.md",
36-
".claude/agents/*.md",
37-
".claude/commands/*.md",
38-
".claude/skills/*/SKILL.md",
37+
{name: "claude", patterns: []patternDef{
38+
{"CLAUDE.md", ConfigFileKindInstruction},
39+
{".claude/CLAUDE.md", ConfigFileKindInstruction},
40+
{".claude/settings.json", ConfigFileKindConfiguration},
41+
{".claude/rules/*.md", ConfigFileKindInstruction},
42+
{".claude/agents/*.md", ConfigFileKindInstruction},
43+
{".claude/commands/*.md", ConfigFileKindInstruction},
44+
{".claude/skills/*/SKILL.md", ConfigFileKindInstruction},
3945
}},
40-
{name: "cursor", patterns: []string{
41-
".cursor/rules/*.md",
42-
".cursor/rules/*.mdc",
43-
".cursor/rules/*/*.md",
44-
".cursor/rules/*/*.mdc",
45-
".cursor/skills/*/SKILL.md",
46-
".cursor/agents/*.md",
46+
{name: "cursor", patterns: []patternDef{
47+
{".cursor/rules/*.md", ConfigFileKindInstruction},
48+
{".cursor/rules/*.mdc", ConfigFileKindInstruction},
49+
{".cursor/rules/*/*.md", ConfigFileKindInstruction},
50+
{".cursor/rules/*/*.mdc", ConfigFileKindInstruction},
51+
{".cursor/skills/*/SKILL.md", ConfigFileKindInstruction},
52+
{".cursor/agents/*.md", ConfigFileKindInstruction},
4753
}},
4854
}
4955

5056
// sharedPatterns are file patterns not exclusive to any agent.
5157
// They are included in every agent's evidence when that agent has exclusive files.
52-
var sharedPatterns = []string{
53-
".mcp.json",
54-
"AGENTS.md",
58+
var sharedPatterns = []patternDef{
59+
{".mcp.json", ConfigFileKindConfiguration},
60+
{"AGENTS.md", ConfigFileKindInstruction},
5561
}
5662

5763
// DiscoverAll searches basePath for AI agent configuration files and groups them by agent.
5864
// Only agents with at least one exclusive file match are included.
5965
// Shared files are appended to each detected agent's file list.
60-
// Returns a map of agent name → sorted, deduplicated relative paths.
61-
func DiscoverAll(basePath string) (map[string][]string, error) {
66+
// Returns a map of agent name → sorted, deduplicated discovered files.
67+
func DiscoverAll(basePath string) (map[string][]DiscoveredFile, error) {
6268
sharedFiles, err := matchPatterns(basePath, sharedPatterns)
6369
if err != nil {
6470
return nil, err
6571
}
6672

67-
result := make(map[string][]string)
73+
result := make(map[string][]DiscoveredFile)
6874

6975
for _, agent := range agents {
7076
files, err := matchPatterns(basePath, agent.patterns)
@@ -78,32 +84,32 @@ func DiscoverAll(basePath string) (map[string][]string, error) {
7884

7985
// Merge shared files into this agent's list, deduplicating
8086
seen := make(map[string]struct{}, len(files)+len(sharedFiles))
81-
merged := make([]string, 0, len(files)+len(sharedFiles))
87+
merged := make([]DiscoveredFile, 0, len(files)+len(sharedFiles))
8288
for _, f := range files {
83-
seen[f] = struct{}{}
89+
seen[f.Path] = struct{}{}
8490
merged = append(merged, f)
8591
}
8692
for _, f := range sharedFiles {
87-
if _, ok := seen[f]; !ok {
93+
if _, ok := seen[f.Path]; !ok {
8894
merged = append(merged, f)
8995
}
9096
}
9197

92-
sort.Strings(merged)
98+
sort.Slice(merged, func(i, j int) bool { return merged[i].Path < merged[j].Path })
9399
result[agent.name] = merged
94100
}
95101

96102
return result, nil
97103
}
98104

99105
// matchPatterns expands glob patterns relative to basePath and returns
100-
// deduplicated, sorted relative paths.
101-
func matchPatterns(basePath string, patterns []string) ([]string, error) {
106+
// deduplicated, sorted discovered files.
107+
func matchPatterns(basePath string, patterns []patternDef) ([]DiscoveredFile, error) {
102108
seen := make(map[string]struct{})
103-
var results []string
109+
var results []DiscoveredFile
104110

105-
for _, pattern := range patterns {
106-
absPattern := filepath.Join(basePath, pattern)
111+
for _, pd := range patterns {
112+
absPattern := filepath.Join(basePath, pd.pattern)
107113
matches, err := filepath.Glob(absPattern)
108114
if err != nil {
109115
return nil, err
@@ -117,12 +123,12 @@ func matchPatterns(basePath string, patterns []string) ([]string, error) {
117123

118124
if _, ok := seen[rel]; !ok {
119125
seen[rel] = struct{}{}
120-
results = append(results, rel)
126+
results = append(results, DiscoveredFile{Path: rel, Kind: pd.kind})
121127
}
122128
}
123129
}
124130

125-
sort.Strings(results)
131+
sort.Slice(results, func(i, j int) bool { return results[i].Path < results[j].Path })
126132

127133
return results, nil
128134
}

0 commit comments

Comments
 (0)