klee 0.1.1 → 1.0.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +26 -15
- data/README.md +63 -3
- data/exe/klee-mcp +21 -0
- data/lib/klee/codebase.rb +43 -0
- data/lib/klee/collaborator_index.rb +81 -0
- data/lib/klee/collaborators.rb +31 -0
- data/lib/klee/concept_index.rb +110 -0
- data/lib/klee/concepts.rb +27 -25
- data/lib/klee/file_analyzer.rb +148 -0
- data/lib/klee/gestalt.rb +3 -1
- data/lib/klee/mcp/path_validator.rb +32 -0
- data/lib/klee/mcp/server.rb +37 -0
- data/lib/klee/mcp/tools/check_naming_consistency.rb +141 -0
- data/lib/klee/mcp/tools/codebase_summary.rb +72 -0
- data/lib/klee/mcp/tools/discover_vocabulary.rb +59 -0
- data/lib/klee/mcp/tools/explore_concept.rb +80 -0
- data/lib/klee/mcp/tools/find_collaborators.rb +71 -0
- data/lib/klee/mcp/tools/find_concept_clusters.rb +53 -0
- data/lib/klee/mcp/version.rb +7 -0
- data/lib/klee/mcp.rb +10 -0
- data/lib/klee/profile.rb +33 -0
- data/lib/klee/version.rb +1 -1
- data/lib/klee/words.rb +20 -0
- data/lib/klee.rb +7 -4
- data/sig/klee.rbs +4 -0
- metadata +29 -2
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Klee
|
|
4
|
+
module MCP
|
|
5
|
+
class PathValidator
|
|
6
|
+
def initialize(allowed_roots: [Dir.pwd])
|
|
7
|
+
@allowed_roots = allowed_roots.map { |r| File.expand_path(r) }
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def validate!(patterns)
|
|
11
|
+
patterns.each do |pattern|
|
|
12
|
+
expanded = expand_pattern_root(pattern)
|
|
13
|
+
unless allowed?(expanded)
|
|
14
|
+
raise SecurityError, "Pattern '#{pattern}' resolves to '#{expanded}' which is outside allowed roots: #{@allowed_roots.join(", ")}"
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def expand_pattern_root(pattern)
|
|
22
|
+
base = pattern.split(/[*?\[{]/).first || "."
|
|
23
|
+
base = "." if base.empty?
|
|
24
|
+
File.expand_path(base)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def allowed?(path)
|
|
28
|
+
@allowed_roots.any? { |root| path.start_with?(root) }
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "mcp"
|
|
4
|
+
require_relative "tools/discover_vocabulary"
|
|
5
|
+
require_relative "tools/find_concept_clusters"
|
|
6
|
+
require_relative "tools/explore_concept"
|
|
7
|
+
require_relative "tools/find_collaborators"
|
|
8
|
+
require_relative "tools/check_naming_consistency"
|
|
9
|
+
require_relative "tools/codebase_summary"
|
|
10
|
+
|
|
11
|
+
module Klee
|
|
12
|
+
module MCP
|
|
13
|
+
class Server
|
|
14
|
+
TOOLS = [
|
|
15
|
+
Tools::DiscoverVocabulary,
|
|
16
|
+
Tools::FindConceptClusters,
|
|
17
|
+
Tools::ExploreConcept,
|
|
18
|
+
Tools::FindCollaborators,
|
|
19
|
+
Tools::CheckNamingConsistency,
|
|
20
|
+
Tools::CodebaseSummary
|
|
21
|
+
].freeze
|
|
22
|
+
|
|
23
|
+
def initialize
|
|
24
|
+
@server = ::MCP::Server.new(
|
|
25
|
+
name: "klee",
|
|
26
|
+
version: Klee::MCP::VERSION,
|
|
27
|
+
tools: TOOLS
|
|
28
|
+
)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def run
|
|
32
|
+
transport = ::MCP::Server::Transports::StdioTransport.new(@server)
|
|
33
|
+
transport.open
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Klee
|
|
4
|
+
module MCP
|
|
5
|
+
module Tools
|
|
6
|
+
class CheckNamingConsistency < ::MCP::Tool
|
|
7
|
+
description "Find methods that deviate from established naming patterns. Identifies unusual names and suggests alternatives based on common conventions in the codebase."
|
|
8
|
+
|
|
9
|
+
input_schema(
|
|
10
|
+
properties: {
|
|
11
|
+
patterns: {
|
|
12
|
+
type: "array",
|
|
13
|
+
items: {type: "string"},
|
|
14
|
+
description: "Glob patterns to match Ruby files, e.g. ['app/**/*.rb']"
|
|
15
|
+
},
|
|
16
|
+
conventions: {
|
|
17
|
+
type: "object",
|
|
18
|
+
properties: {
|
|
19
|
+
prefixes: {
|
|
20
|
+
type: "array",
|
|
21
|
+
items: {type: "string"},
|
|
22
|
+
description: "Expected method prefixes, e.g. ['find_', 'create_', 'update_', 'delete_']"
|
|
23
|
+
},
|
|
24
|
+
suffixes: {
|
|
25
|
+
type: "array",
|
|
26
|
+
items: {type: "string"},
|
|
27
|
+
description: "Expected method suffixes, e.g. ['?', '!', '_at', '_by']"
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
description: "Naming conventions to check against"
|
|
31
|
+
},
|
|
32
|
+
threshold: {
|
|
33
|
+
type: "integer",
|
|
34
|
+
description: "Levenshtein distance threshold for similarity suggestions (default: 6)"
|
|
35
|
+
},
|
|
36
|
+
ignore: {
|
|
37
|
+
type: "array",
|
|
38
|
+
items: {type: "string"},
|
|
39
|
+
description: "Method names to ignore"
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
required: ["patterns"]
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
class << self
|
|
46
|
+
def call(patterns:, conventions: {}, threshold: 6, ignore: [], server_context: nil)
|
|
47
|
+
validator = Klee::MCP::PathValidator.new
|
|
48
|
+
validator.validate!(patterns)
|
|
49
|
+
|
|
50
|
+
codebase = Klee.scan(*patterns, ignore: ignore, threshold: 1)
|
|
51
|
+
all_methods = codebase.concepts.flat_map { |_, locs| locs[:methods].to_a }.uniq
|
|
52
|
+
|
|
53
|
+
prefixes = conventions["prefixes"] || conventions[:prefixes] || []
|
|
54
|
+
suffixes = conventions["suffixes"] || conventions[:suffixes] || []
|
|
55
|
+
|
|
56
|
+
conforming = {}
|
|
57
|
+
unusual = []
|
|
58
|
+
|
|
59
|
+
prefixes.each do |prefix|
|
|
60
|
+
matched = all_methods.select { |m| m.start_with?(prefix) }
|
|
61
|
+
conforming["#{prefix}*"] = matched.sort if matched.any?
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
suffixes.each do |suffix|
|
|
65
|
+
matched = all_methods.select { |m| m.end_with?(suffix) }
|
|
66
|
+
conforming["*#{suffix}"] = matched.sort if matched.any?
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
conforming_methods = conforming.values.flatten.uniq
|
|
70
|
+
non_conforming = all_methods - conforming_methods
|
|
71
|
+
|
|
72
|
+
non_conforming.each do |method|
|
|
73
|
+
suggestion = find_similar(method, conforming_methods, threshold)
|
|
74
|
+
unusual << if suggestion
|
|
75
|
+
{
|
|
76
|
+
method: method,
|
|
77
|
+
suggestion: suggestion[:name],
|
|
78
|
+
similarity: suggestion[:distance]
|
|
79
|
+
}
|
|
80
|
+
else
|
|
81
|
+
{method: method, suggestion: nil, similarity: nil}
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
unusual.sort_by! { |u| u[:similarity] || Float::INFINITY }
|
|
86
|
+
|
|
87
|
+
result = {
|
|
88
|
+
conforming: conforming,
|
|
89
|
+
unusual: unusual.first(50)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
::MCP::Tool::Response.new([{
|
|
93
|
+
type: "text",
|
|
94
|
+
text: JSON.pretty_generate(result)
|
|
95
|
+
}])
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
private
|
|
99
|
+
|
|
100
|
+
def find_similar(method, candidates, max_distance)
|
|
101
|
+
best = nil
|
|
102
|
+
best_distance = max_distance + 1
|
|
103
|
+
|
|
104
|
+
candidates.each do |candidate|
|
|
105
|
+
distance = levenshtein_distance(method, candidate)
|
|
106
|
+
if distance < best_distance
|
|
107
|
+
best = candidate
|
|
108
|
+
best_distance = distance
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
best ? {name: best, distance: best_distance} : nil
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def levenshtein_distance(a, b)
|
|
116
|
+
return b.length if a.empty?
|
|
117
|
+
return a.length if b.empty?
|
|
118
|
+
|
|
119
|
+
matrix = Array.new(a.length + 1) { Array.new(b.length + 1) }
|
|
120
|
+
|
|
121
|
+
(0..a.length).each { |i| matrix[i][0] = i }
|
|
122
|
+
(0..b.length).each { |j| matrix[0][j] = j }
|
|
123
|
+
|
|
124
|
+
(1..a.length).each do |i|
|
|
125
|
+
(1..b.length).each do |j|
|
|
126
|
+
cost = (a[i - 1] == b[j - 1]) ? 0 : 1
|
|
127
|
+
matrix[i][j] = [
|
|
128
|
+
matrix[i - 1][j] + 1,
|
|
129
|
+
matrix[i][j - 1] + 1,
|
|
130
|
+
matrix[i - 1][j - 1] + cost
|
|
131
|
+
].min
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
matrix[a.length][b.length]
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Klee
|
|
4
|
+
module MCP
|
|
5
|
+
module Tools
|
|
6
|
+
class CodebaseSummary < ::MCP::Tool
|
|
7
|
+
description "Get a high-level vocabulary overview of a Ruby codebase for quick onboarding. Shows top domain concepts, concept clusters, and vocabulary metrics."
|
|
8
|
+
|
|
9
|
+
input_schema(
|
|
10
|
+
properties: {
|
|
11
|
+
patterns: {
|
|
12
|
+
type: "array",
|
|
13
|
+
items: {type: "string"},
|
|
14
|
+
description: "Glob patterns to match Ruby files, e.g. ['app/**/*.rb', 'lib/**/*.rb']"
|
|
15
|
+
},
|
|
16
|
+
threshold: {
|
|
17
|
+
type: "integer",
|
|
18
|
+
description: "Minimum occurrences for concepts (default: 2)"
|
|
19
|
+
},
|
|
20
|
+
ignore: {
|
|
21
|
+
type: "array",
|
|
22
|
+
items: {type: "string"},
|
|
23
|
+
description: "Words to ignore"
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
required: ["patterns"]
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
class << self
|
|
30
|
+
def call(patterns:, threshold: 2, ignore: [], server_context: nil)
|
|
31
|
+
validator = Klee::MCP::PathValidator.new
|
|
32
|
+
validator.validate!(patterns)
|
|
33
|
+
|
|
34
|
+
codebase = Klee.scan(*patterns, ignore: ignore, threshold: threshold)
|
|
35
|
+
|
|
36
|
+
ranked = codebase.concepts.rank
|
|
37
|
+
top_concepts = ranked.first(15).map(&:first)
|
|
38
|
+
|
|
39
|
+
clusters = codebase.collaborators.clusters
|
|
40
|
+
|
|
41
|
+
total_classes = Set.new
|
|
42
|
+
total_methods = Set.new
|
|
43
|
+
total_identifiers = 0
|
|
44
|
+
|
|
45
|
+
codebase.concepts.each do |_, locs|
|
|
46
|
+
total_classes.merge(locs[:classes])
|
|
47
|
+
total_methods.merge(locs[:methods])
|
|
48
|
+
total_identifiers += locs[:classes].size + locs[:methods].size
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
unique_concepts = ranked.keys.size
|
|
52
|
+
vocabulary_richness = total_identifiers.zero? ? 0 : (unique_concepts.to_f / total_identifiers).round(2)
|
|
53
|
+
|
|
54
|
+
result = {
|
|
55
|
+
top_concepts: top_concepts,
|
|
56
|
+
concept_clusters: clusters.size,
|
|
57
|
+
total_classes: total_classes.size,
|
|
58
|
+
total_methods: total_methods.size,
|
|
59
|
+
vocabulary_richness: vocabulary_richness,
|
|
60
|
+
cluster_preview: clusters.first(3).map { |c| c.to_a.sort }
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
::MCP::Tool::Response.new([{
|
|
64
|
+
type: "text",
|
|
65
|
+
text: JSON.pretty_generate(result)
|
|
66
|
+
}])
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Klee
|
|
4
|
+
module MCP
|
|
5
|
+
module Tools
|
|
6
|
+
class DiscoverVocabulary < ::MCP::Tool
|
|
7
|
+
description "Extract the domain language vocabulary from a Ruby codebase. Returns words that appear frequently in class and method names, revealing the key domain concepts."
|
|
8
|
+
|
|
9
|
+
input_schema(
|
|
10
|
+
properties: {
|
|
11
|
+
patterns: {
|
|
12
|
+
type: "array",
|
|
13
|
+
items: {type: "string"},
|
|
14
|
+
description: "Glob patterns to match Ruby files, e.g. ['app/**/*.rb', 'lib/**/*.rb']"
|
|
15
|
+
},
|
|
16
|
+
threshold: {
|
|
17
|
+
type: "integer",
|
|
18
|
+
description: "Minimum occurrences for a word to be included (default: 3)"
|
|
19
|
+
},
|
|
20
|
+
limit: {
|
|
21
|
+
type: "integer",
|
|
22
|
+
description: "Maximum number of vocabulary terms to return (default: 30)"
|
|
23
|
+
},
|
|
24
|
+
ignore: {
|
|
25
|
+
type: "array",
|
|
26
|
+
items: {type: "string"},
|
|
27
|
+
description: "Words to ignore, e.g. common terms like 'get', 'set', 'new'"
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
required: ["patterns"]
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
class << self
|
|
34
|
+
def call(patterns:, threshold: 3, limit: 30, ignore: [], server_context: nil)
|
|
35
|
+
validator = Klee::MCP::PathValidator.new
|
|
36
|
+
validator.validate!(patterns)
|
|
37
|
+
|
|
38
|
+
codebase = Klee.scan(*patterns, ignore: ignore, threshold: threshold)
|
|
39
|
+
ranked = codebase.concepts.rank
|
|
40
|
+
|
|
41
|
+
vocabulary = ranked.first(limit).map do |word, locations|
|
|
42
|
+
{
|
|
43
|
+
word: word,
|
|
44
|
+
frequency: locations[:classes].size + locations[:methods].size,
|
|
45
|
+
in_classes: locations[:classes].to_a,
|
|
46
|
+
in_methods: locations[:methods].to_a
|
|
47
|
+
}
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
::MCP::Tool::Response.new([{
|
|
51
|
+
type: "text",
|
|
52
|
+
text: JSON.pretty_generate({vocabulary: vocabulary})
|
|
53
|
+
}])
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Klee
|
|
4
|
+
module MCP
|
|
5
|
+
module Tools
|
|
6
|
+
class ExploreConcept < ::MCP::Tool
|
|
7
|
+
description "Deep-dive into a specific domain concept to see everywhere it appears in the codebase, what it co-occurs with, and naming patterns used."
|
|
8
|
+
|
|
9
|
+
input_schema(
|
|
10
|
+
properties: {
|
|
11
|
+
patterns: {
|
|
12
|
+
type: "array",
|
|
13
|
+
items: {type: "string"},
|
|
14
|
+
description: "Glob patterns to match Ruby files, e.g. ['app/**/*.rb']"
|
|
15
|
+
},
|
|
16
|
+
concept: {
|
|
17
|
+
type: "string",
|
|
18
|
+
description: "The domain concept word to explore, e.g. 'subscription', 'user', 'order'"
|
|
19
|
+
},
|
|
20
|
+
threshold: {
|
|
21
|
+
type: "integer",
|
|
22
|
+
description: "Minimum occurrences threshold (default: 1)"
|
|
23
|
+
},
|
|
24
|
+
ignore: {
|
|
25
|
+
type: "array",
|
|
26
|
+
items: {type: "string"},
|
|
27
|
+
description: "Words to ignore"
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
required: ["patterns", "concept"]
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
class << self
|
|
34
|
+
def call(patterns:, concept:, threshold: 1, ignore: [], server_context: nil)
|
|
35
|
+
validator = Klee::MCP::PathValidator.new
|
|
36
|
+
validator.validate!(patterns)
|
|
37
|
+
|
|
38
|
+
codebase = Klee.scan(*patterns, ignore: ignore, threshold: threshold)
|
|
39
|
+
concept_data = codebase.concepts[concept]
|
|
40
|
+
|
|
41
|
+
collaborator_pairs = codebase.collaborators.for(concept)
|
|
42
|
+
co_occurs_with = collaborator_pairs.keys.sort_by { |k| -collaborator_pairs[k] }
|
|
43
|
+
|
|
44
|
+
methods = concept_data[:methods].to_a
|
|
45
|
+
naming_patterns = methods.group_by { |m| extract_pattern(m, concept) }
|
|
46
|
+
.transform_values(&:sort)
|
|
47
|
+
|
|
48
|
+
result = {
|
|
49
|
+
concept: concept,
|
|
50
|
+
appears_in: {
|
|
51
|
+
classes: concept_data[:classes].to_a.sort,
|
|
52
|
+
methods: methods.sort
|
|
53
|
+
},
|
|
54
|
+
co_occurs_with: co_occurs_with,
|
|
55
|
+
naming_patterns: naming_patterns
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
::MCP::Tool::Response.new([{
|
|
59
|
+
type: "text",
|
|
60
|
+
text: JSON.pretty_generate(result)
|
|
61
|
+
}])
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
def extract_pattern(method_name, concept)
|
|
67
|
+
case method_name
|
|
68
|
+
when /^#{concept}_/ then "#{concept}_*"
|
|
69
|
+
when /_#{concept}$/ then "*_#{concept}"
|
|
70
|
+
when /_#{concept}_/ then "*_#{concept}_*"
|
|
71
|
+
when /#{concept}\?$/ then "#{concept}?"
|
|
72
|
+
when /#{concept}!$/ then "#{concept}!"
|
|
73
|
+
else "other"
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Klee
|
|
4
|
+
module MCP
|
|
5
|
+
module Tools
|
|
6
|
+
class FindCollaborators < ::MCP::Tool
|
|
7
|
+
description "Discover which objects conceptually belong together by analyzing co-occurrence patterns. Shows what a given object typically works with."
|
|
8
|
+
|
|
9
|
+
input_schema(
|
|
10
|
+
properties: {
|
|
11
|
+
patterns: {
|
|
12
|
+
type: "array",
|
|
13
|
+
items: {type: "string"},
|
|
14
|
+
description: "Glob patterns to match Ruby files, e.g. ['app/**/*.rb']"
|
|
15
|
+
},
|
|
16
|
+
object: {
|
|
17
|
+
type: "string",
|
|
18
|
+
description: "The object/variable name to find collaborators for, e.g. 'User', 'order', 'cart'"
|
|
19
|
+
},
|
|
20
|
+
threshold: {
|
|
21
|
+
type: "integer",
|
|
22
|
+
description: "Minimum co-occurrences to be considered a collaborator (default: 2)"
|
|
23
|
+
},
|
|
24
|
+
scope: {
|
|
25
|
+
type: "string",
|
|
26
|
+
enum: ["file", "method"],
|
|
27
|
+
description: "Scope for finding collaborators: 'file' (default) or 'method' level"
|
|
28
|
+
},
|
|
29
|
+
ignore: {
|
|
30
|
+
type: "array",
|
|
31
|
+
items: {type: "string"},
|
|
32
|
+
description: "Object names to ignore"
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
required: ["patterns", "object"]
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
class << self
|
|
39
|
+
def call(patterns:, object:, threshold: 2, scope: "file", ignore: [], server_context: nil)
|
|
40
|
+
validator = Klee::MCP::PathValidator.new
|
|
41
|
+
validator.validate!(patterns)
|
|
42
|
+
|
|
43
|
+
codebase = Klee.scan(*patterns, ignore: ignore, threshold: threshold)
|
|
44
|
+
pairs = codebase.collaborators.pairs(scope: scope.to_sym)
|
|
45
|
+
|
|
46
|
+
relevant_pairs = pairs.select { |pair, _| pair.include?(object) }
|
|
47
|
+
|
|
48
|
+
collaborators = relevant_pairs.map do |pair, count|
|
|
49
|
+
other = (pair - [object]).first
|
|
50
|
+
{
|
|
51
|
+
name: other,
|
|
52
|
+
co_occurrences: count,
|
|
53
|
+
scope: scope
|
|
54
|
+
}
|
|
55
|
+
end.sort_by { |c| -c[:co_occurrences] }
|
|
56
|
+
|
|
57
|
+
result = {
|
|
58
|
+
object: object,
|
|
59
|
+
collaborators: collaborators
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
::MCP::Tool::Response.new([{
|
|
63
|
+
type: "text",
|
|
64
|
+
text: JSON.pretty_generate(result)
|
|
65
|
+
}])
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Klee
|
|
4
|
+
module MCP
|
|
5
|
+
module Tools
|
|
6
|
+
class FindConceptClusters < ::MCP::Tool
|
|
7
|
+
description "Identify groups of domain concepts that frequently appear together in code, suggesting logical module boundaries or related functionality."
|
|
8
|
+
|
|
9
|
+
input_schema(
|
|
10
|
+
properties: {
|
|
11
|
+
patterns: {
|
|
12
|
+
type: "array",
|
|
13
|
+
items: {type: "string"},
|
|
14
|
+
description: "Glob patterns to match Ruby files, e.g. ['app/**/*.rb']"
|
|
15
|
+
},
|
|
16
|
+
threshold: {
|
|
17
|
+
type: "integer",
|
|
18
|
+
description: "Minimum co-occurrences for concepts to be considered related (default: 2)"
|
|
19
|
+
},
|
|
20
|
+
ignore: {
|
|
21
|
+
type: "array",
|
|
22
|
+
items: {type: "string"},
|
|
23
|
+
description: "Collaborator names to ignore"
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
required: ["patterns"]
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
class << self
|
|
30
|
+
def call(patterns:, threshold: 2, ignore: [], server_context: nil)
|
|
31
|
+
validator = Klee::MCP::PathValidator.new
|
|
32
|
+
validator.validate!(patterns)
|
|
33
|
+
|
|
34
|
+
codebase = Klee.scan(*patterns, ignore: ignore, threshold: threshold)
|
|
35
|
+
raw_clusters = codebase.collaborators.clusters
|
|
36
|
+
|
|
37
|
+
clusters = raw_clusters.map do |cluster_set|
|
|
38
|
+
{
|
|
39
|
+
concepts: cluster_set.to_a.sort,
|
|
40
|
+
size: cluster_set.size
|
|
41
|
+
}
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
::MCP::Tool::Response.new([{
|
|
45
|
+
type: "text",
|
|
46
|
+
text: JSON.pretty_generate({clusters: clusters})
|
|
47
|
+
}])
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
data/lib/klee/mcp.rb
ADDED
data/lib/klee/profile.rb
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Klee
|
|
4
|
+
class Profile
|
|
5
|
+
RAILS_GLOBS = %w[app/models/**/*.rb lib/**/*.rb].freeze
|
|
6
|
+
|
|
7
|
+
STOPWORDS = %w[
|
|
8
|
+
a an the and or not to for from of in on at by as
|
|
9
|
+
is are was were been being have has had do does did
|
|
10
|
+
will would could should may might must can
|
|
11
|
+
this that these those it its they them we our you your
|
|
12
|
+
if else then when
|
|
13
|
+
with without via into over per vs all any
|
|
14
|
+
id ids key keys current value values
|
|
15
|
+
].freeze
|
|
16
|
+
|
|
17
|
+
def self.resolve(name, patterns:, ignore:, threshold:)
|
|
18
|
+
case name&.to_sym
|
|
19
|
+
when nil
|
|
20
|
+
{patterns: patterns, ignore: Array(ignore), threshold: threshold}
|
|
21
|
+
when :rails
|
|
22
|
+
globs = patterns.empty? ? RAILS_GLOBS : patterns
|
|
23
|
+
{
|
|
24
|
+
patterns: globs,
|
|
25
|
+
ignore: (STOPWORDS + Array(ignore).map(&:to_s)).uniq,
|
|
26
|
+
threshold: threshold
|
|
27
|
+
}
|
|
28
|
+
else
|
|
29
|
+
raise ArgumentError, "unknown profile #{name.inspect}"
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
data/lib/klee/version.rb
CHANGED
data/lib/klee/words.rb
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Klee
|
|
4
|
+
module Words
|
|
5
|
+
def self.from(name, ignore: [])
|
|
6
|
+
ignored = ignore.map(&:to_s)
|
|
7
|
+
tokenize(name).reject { |word| word.empty? || ignored.include?(word) }
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def self.tokenize(name)
|
|
11
|
+
name.to_s
|
|
12
|
+
.gsub("::", "_")
|
|
13
|
+
.gsub(/([a-z\d])([A-Z])/, '\1_\2')
|
|
14
|
+
.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
|
|
15
|
+
.downcase
|
|
16
|
+
.gsub(/[?!=]/, "")
|
|
17
|
+
.split(/[_\s]+/)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
data/lib/klee.rb
CHANGED
|
@@ -2,13 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
# require "classifier-reborn"
|
|
4
4
|
require_relative "klee/version"
|
|
5
|
+
require_relative "klee/words"
|
|
5
6
|
require_relative "klee/patterns"
|
|
6
7
|
require_relative "klee/gestalt"
|
|
7
8
|
require_relative "klee/concepts"
|
|
8
|
-
require_relative "klee/collaborators"
|
|
9
9
|
require_relative "klee/file_analyzer"
|
|
10
|
+
require_relative "klee/collaborators"
|
|
10
11
|
require_relative "klee/concept_index"
|
|
11
12
|
require_relative "klee/collaborator_index"
|
|
13
|
+
require_relative "klee/profile"
|
|
12
14
|
require_relative "klee/codebase"
|
|
13
15
|
|
|
14
16
|
module Klee
|
|
@@ -27,7 +29,7 @@ module Klee
|
|
|
27
29
|
end
|
|
28
30
|
|
|
29
31
|
def self.concepts(*method_names, modifiers: [])
|
|
30
|
-
Concepts.new(*method_names, modifiers:
|
|
32
|
+
Concepts.new(*method_names, modifiers: modifiers)
|
|
31
33
|
end
|
|
32
34
|
|
|
33
35
|
# def self.classifier
|
|
@@ -53,7 +55,8 @@ module Klee
|
|
|
53
55
|
Klee::Collaborators.new(const)
|
|
54
56
|
end
|
|
55
57
|
|
|
56
|
-
def self.scan(*patterns, ignore: [], threshold: 2)
|
|
57
|
-
|
|
58
|
+
def self.scan(*patterns, ignore: [], threshold: 2, profile: nil)
|
|
59
|
+
spec = Profile.resolve(profile, patterns: patterns, ignore: ignore, threshold: threshold)
|
|
60
|
+
Codebase.new(*spec[:patterns], ignore: spec[:ignore], threshold: spec[:threshold])
|
|
58
61
|
end
|
|
59
62
|
end
|
data/sig/klee.rbs
ADDED