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.
@@ -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
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Klee
4
+ module MCP
5
+ VERSION = "0.1.0"
6
+ end
7
+ end
data/lib/klee/mcp.rb ADDED
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "mcp/version"
4
+ require_relative "mcp/path_validator"
5
+ require_relative "mcp/server"
6
+
7
+ module Klee
8
+ module MCP
9
+ end
10
+ end
@@ -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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Klee
4
- VERSION = "0.1.1"
4
+ VERSION = "1.0.0"
5
5
  end
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
- Codebase.new(*patterns, ignore: ignore, threshold: threshold)
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
@@ -0,0 +1,4 @@
1
+ module Klee
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ end