klee 0.1.0 → 0.1.2

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 508af2506e1d6db7fbadde901c29284c219ee4cf7471dac68a21d34110b82a2d
4
- data.tar.gz: c9dcc725e9a626c9f1ff45c68e46868eaa25166ccf4e88d93bcd0009a3a7211e
3
+ metadata.gz: 6ad4060f69da403186a89fb198ddfdb917606eac5e3344dcb5b0cc58f3d1bdd8
4
+ data.tar.gz: 90b540e449684b48e71714194b3c622f9fd35ecae931cb4b94fb53545f338958
5
5
  SHA512:
6
- metadata.gz: e411a8b0c08b4304327663139192ccaed4a05d62048b94b151564065d71b5c3f574c780b3e278c127c54368744a2cd99ad5fd8ba514fb1f1545b65a1977fb17d
7
- data.tar.gz: dcc64449cbfc1d0f60243f4a2eeee661d5075a271b79c3906b476af37bcc9624b6d740424df96596103f13af663932003aa76b002bb453a82b8da413c68f8d3f
6
+ metadata.gz: 46ed4f0a4469aee13b165138f3ff04da7b30819e6e37b01524e750df591a52cf6da53dc2f082a8c353a1bfa6a47e37111814baf8bbcdd377f246797ea12ec283
7
+ data.tar.gz: 77af3a015eb3e3fd8267b78525e46f53bb51694b2160d12a04e05bf6c04734549ed628a9cb5a8955cf4c9d6d4ec3ac1a544b011556125fdd5d07d2e1b6b71037
data/CHANGELOG.md CHANGED
@@ -1,5 +1,41 @@
1
- ## [Unreleased]
1
+ # Changelog
2
2
 
3
- ## [0.1.0] - 2022-05-01
3
+ All notable changes to this project will be documented in this file.
4
4
 
5
- - Initial release
5
+ The format is based on [Keep a Changelog](http://keepachangelog.com/)
6
+ and this project adheres to [Semantic Versioning](http://semver.org/).
7
+
8
+ ## [0.1.2] - 2026-02-01
9
+
10
+ ### Added
11
+
12
+ - MCP server support via klee-mcp executable (requires gem install mcp) (08c2294)
13
+ - discover_vocabulary tool for extracting domain language (08c2294)
14
+ - find_concept_clusters tool for identifying related concepts (08c2294)
15
+ - explore_concept tool for deep-diving into specific concepts (08c2294)
16
+ - find_collaborators tool for finding co-occurring objects (08c2294)
17
+ - check_naming_consistency tool for detecting naming deviations (08c2294)
18
+ - codebase_summary tool for high-level vocabulary overview (08c2294)
19
+
20
+ ### Changed
21
+
22
+ - README now includes MCP server setup and available tools (f84bdc5)
23
+
24
+ ## [0.1.1] - 2026-01-29
25
+
26
+ ### Changed
27
+
28
+ - Switch to supporting ruby 3.4 and up. (84fdd9c)
29
+ - Require ruby 3.4 and above (067ec87)
30
+
31
+ ### Added
32
+
33
+ - Reissue for version and release support. (885fe28)
34
+ - SimpleCov for coverage reporting (889d169)
35
+ - Coverage comment from PRs (067ec87)
36
+ - Klee.scan() method for codebase-wide analysis with glob patterns (3e509a1)
37
+ - ConceptIndex for aggregating domain concepts across files (3e509a1)
38
+ - CollaboratorIndex for pairwise co-occurrence and clustering (3e509a1)
39
+ - FileAnalyzer using Prism AST to extract classes, methods, and collaborators (3e509a1)
40
+ - Configurable threshold and ignore list for filtering noise (3e509a1)
41
+ - Method-level scope option for tighter coupling analysis (3e509a1)
data/README.md CHANGED
@@ -52,6 +52,87 @@ filtered = Klee.object_concepts(Something, modifiers: %i[fill_in_ hover_over_ _m
52
52
  filtered[4] #=> Set of concepts excluding any common modifiers
53
53
  ```
54
54
 
55
+ ### Scan a codebase for domain concepts
56
+
57
+ Discover hidden vocabulary across your entire codebase by analyzing class and method names.
58
+
59
+ ```ruby
60
+ codebase = Klee.scan("app/**/*.rb", "lib/**/*.rb")
61
+
62
+ # See all concepts ranked by frequency
63
+ codebase.concepts.rank
64
+ # => {
65
+ # "user" => { classes: Set["User", "UserSession"], methods: Set["current_user", "find_user"] },
66
+ # "account" => { classes: Set["Account", "AccountManager"], methods: Set["account_balance"] },
67
+ # ...
68
+ # }
69
+
70
+ # Drill into a specific concept
71
+ codebase.concepts[:account]
72
+ # => { classes: Set["Account", "AccountManager"], methods: Set["account_balance", "close_account"] }
73
+
74
+ # Filter noise with ignore list and threshold
75
+ codebase = Klee.scan("app/**/*.rb",
76
+ ignore: %i[new create get set find all],
77
+ threshold: 3)
78
+ ```
79
+
80
+ ### Find collaborator clusters
81
+
82
+ Discover which objects frequently work together across your codebase.
83
+
84
+ ```ruby
85
+ codebase = Klee.scan("app/**/*.rb", threshold: 3)
86
+
87
+ # Pairwise co-occurrence (which objects appear together in files)
88
+ codebase.collaborators.pairs
89
+ # => { ["account", "user"] => 15, ["session", "user"] => 12, ... }
90
+
91
+ # Method-level co-occurrence (tighter coupling)
92
+ codebase.collaborators.pairs(scope: :method)
93
+ # => { ["account", "user"] => 8, ... }
94
+
95
+ # What collaborates with a specific object
96
+ codebase.collaborators.for(:user)
97
+ # => { "account" => 15, "session" => 12, "order" => 5 }
98
+
99
+ # Derived clusters (connected components)
100
+ codebase.collaborators.clusters
101
+ # => [Set["user", "account", "session"], Set["order", "payment", "invoice"]]
102
+ ```
103
+
104
+ ### MCP Server for Claude Code
105
+
106
+ Expose klee's vocabulary analysis to Claude Code or other MCP clients.
107
+
108
+ ```bash
109
+ # Install the mcp gem (optional dependency)
110
+ gem install mcp
111
+
112
+ # Run the server
113
+ klee-mcp
114
+ ```
115
+
116
+ Configure Claude Code in `~/.claude.json`:
117
+
118
+ ```json
119
+ {
120
+ "mcpServers": {
121
+ "klee": {
122
+ "command": "klee-mcp"
123
+ }
124
+ }
125
+ }
126
+ ```
127
+
128
+ Available tools:
129
+ - `discover_vocabulary` - Extract domain language from a codebase
130
+ - `find_concept_clusters` - Identify groups of related concepts
131
+ - `explore_concept` - Deep-dive into a specific concept
132
+ - `find_collaborators` - Find objects that work together
133
+ - `check_naming_consistency` - Detect naming pattern deviations
134
+ - `codebase_summary` - High-level vocabulary overview
135
+
55
136
  ## Development
56
137
 
57
138
  After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
data/exe/klee-mcp ADDED
@@ -0,0 +1,21 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ begin
5
+ require "mcp"
6
+ rescue LoadError
7
+ warn <<~ERROR
8
+ klee-mcp requires the 'mcp' gem for MCP server support.
9
+
10
+ Install it with:
11
+ gem install mcp
12
+
13
+ Then run klee-mcp again.
14
+ ERROR
15
+ exit 1
16
+ end
17
+
18
+ require "klee"
19
+ require "klee/mcp"
20
+
21
+ Klee::MCP::Server.new.run
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Klee
4
+ class Codebase
5
+ def initialize(*patterns, ignore: [], threshold: 2)
6
+ @patterns = patterns
7
+ @ignore = ignore
8
+ @threshold = threshold
9
+ end
10
+
11
+ def concepts
12
+ @concepts ||= build_concept_index
13
+ end
14
+
15
+ def collaborators
16
+ @collaborators ||= build_collaborator_index
17
+ end
18
+
19
+ private
20
+
21
+ def files
22
+ @patterns.flat_map { |p| Dir.glob(p) }
23
+ .uniq
24
+ .select { |f| File.file?(f) && f.end_with?(".rb") }
25
+ end
26
+
27
+ def analyzers
28
+ @analyzers ||= files.map { |path| FileAnalyzer.new(path) }
29
+ end
30
+
31
+ def build_concept_index
32
+ index = ConceptIndex.new(ignore: @ignore, threshold: @threshold)
33
+ analyzers.each { |a| index.add(a) }
34
+ index
35
+ end
36
+
37
+ def build_collaborator_index
38
+ index = CollaboratorIndex.new(ignore: @ignore, threshold: @threshold)
39
+ analyzers.each { |a| index.add(a) }
40
+ index
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Klee
4
+ class CollaboratorIndex
5
+ def initialize(ignore: [], threshold: 2)
6
+ @ignore = Set.new(ignore.map(&:to_s))
7
+ @threshold = threshold
8
+ @file_pairs = Hash.new(0)
9
+ @method_pairs = Hash.new(0)
10
+ end
11
+
12
+ def add(file_analyzer)
13
+ # File-level pairs
14
+ collabs = filter(file_analyzer.collaborators.uniq)
15
+ each_pair(collabs) { |pair| @file_pairs[pair] += 1 }
16
+
17
+ # Method-level pairs
18
+ file_analyzer.method_collaborators.each_value do |method_collabs|
19
+ filtered = filter(method_collabs.uniq)
20
+ each_pair(filtered) { |pair| @method_pairs[pair] += 1 }
21
+ end
22
+ end
23
+
24
+ def pairs(scope: :file)
25
+ source = (scope == :method) ? @method_pairs : @file_pairs
26
+ source.select { |_, count| count >= @threshold }
27
+ .sort_by { |_, count| -count }.to_h
28
+ end
29
+
30
+ def for(collaborator)
31
+ name = collaborator.to_s
32
+ pairs.select { |pair, _| pair.include?(name) }
33
+ .transform_keys { |pair| (pair - [name]).first }
34
+ end
35
+
36
+ def clusters
37
+ build_clusters(pairs)
38
+ end
39
+
40
+ private
41
+
42
+ def filter(names)
43
+ names.map(&:to_s).reject { |n| @ignore.include?(n) }
44
+ end
45
+
46
+ def each_pair(items)
47
+ items.combination(2).each { |pair| yield pair.sort }
48
+ end
49
+
50
+ def build_clusters(pair_data)
51
+ graph = Hash.new { |h, k| h[k] = Set.new }
52
+ pair_data.each_key do |(a, b)|
53
+ graph[a] << b
54
+ graph[b] << a
55
+ end
56
+
57
+ visited = Set.new
58
+ clusters = []
59
+
60
+ graph.each_key do |node|
61
+ next if visited.include?(node)
62
+
63
+ cluster = Set.new
64
+ stack = [node]
65
+
66
+ while stack.any?
67
+ current = stack.pop
68
+ next if visited.include?(current)
69
+
70
+ visited << current
71
+ cluster << current
72
+ stack.concat(graph[current].to_a)
73
+ end
74
+
75
+ clusters << cluster if cluster.size > 1
76
+ end
77
+
78
+ clusters.sort_by { |c| -c.size }
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ module Klee
6
+ class Collaborators
7
+ def initialize(const)
8
+ @const = const
9
+ end
10
+ attr_reader :const
11
+
12
+ def lexed
13
+ Prism.lex_file(Object.const_source_location(const.name).first)
14
+ end
15
+
16
+ def additional_processing_types = [:DOT, :BRACKET_LEFT]
17
+
18
+ def identifier_with_additional_processing
19
+ lexed.value.each_cons(2).filter_map do |(token, next_token)|
20
+ if token.first.type == :IDENTIFIER &&
21
+ additional_processing_types.include?(next_token.first.type)
22
+ token.first.value
23
+ end
24
+ end
25
+ end
26
+
27
+ def tally
28
+ identifier_with_additional_processing.tally
29
+ end
30
+
31
+ def rank
32
+ tally.group_by do |_, count|
33
+ count
34
+ end.sort.reverse.to_h.transform_values do |value|
35
+ value.map(&:first)
36
+ end.reject do |key, value|
37
+ value.empty? || key < 3
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Klee
4
+ class ConceptIndex
5
+ include Enumerable
6
+
7
+ def initialize(ignore: [], threshold: 2)
8
+ @ignore = Set.new(ignore.map(&:to_s))
9
+ @threshold = threshold
10
+ @data = Hash.new { |h, k| h[k] = {classes: Set.new, methods: Set.new} }
11
+ end
12
+
13
+ def add(file_analyzer)
14
+ file_analyzer.class_names.each do |name|
15
+ words_from(name).each { |word| @data[word][:classes] << name }
16
+ end
17
+
18
+ file_analyzer.method_names.each do |name|
19
+ words_from(name).each { |word| @data[word][:methods] << name }
20
+ end
21
+ end
22
+
23
+ def [](concept)
24
+ @data[concept.to_s]
25
+ end
26
+
27
+ def each(&block)
28
+ filtered.each(&block)
29
+ end
30
+
31
+ def rank
32
+ filtered.sort_by { |word, locs| -(locs[:classes].size + locs[:methods].size) }.to_h
33
+ end
34
+
35
+ private
36
+
37
+ def words_from(name)
38
+ name.to_s.gsub(/([a-z])([A-Z])/, '\1_\2')
39
+ .downcase.split("_")
40
+ .reject { |w| w.empty? || @ignore.include?(w) }
41
+ end
42
+
43
+ def filtered
44
+ @data.select { |_, locs| (locs[:classes].size + locs[:methods].size) >= @threshold }
45
+ end
46
+ end
47
+ end
data/lib/klee/concepts.rb CHANGED
@@ -26,7 +26,7 @@ module Klee
26
26
 
27
27
  def clear
28
28
  clearable = instance_variables - %i[@method_names @modifiers]
29
- clearable.each { send(:remove_instance_variable, _1) }
29
+ clearable.each { send(:remove_instance_variable, it) }
30
30
  end
31
31
 
32
32
  def each(&block)
@@ -34,7 +34,7 @@ module Klee
34
34
  end
35
35
 
36
36
  def samples
37
- @samples ||= method_names.flat_map{ words(_1) }.tally
37
+ @samples ||= method_names.flat_map { words(it) }.tally
38
38
  end
39
39
 
40
40
  def max
@@ -52,7 +52,7 @@ module Klee
52
52
  private
53
53
 
54
54
  def modifier_matcher
55
- @modifier_matcher ||= Regexp.new modifiers.map{ Regexp.quote(_1) }.join("|")
55
+ @modifier_matcher ||= Regexp.new modifiers.map { Regexp.quote(it) }.join("|")
56
56
  end
57
57
 
58
58
  def words(method_name)
@@ -66,7 +66,7 @@ module Klee
66
66
  end
67
67
  .gsub(/\s/, "")
68
68
  .split("_")
69
- .delete_if { _1.empty? }
69
+ .delete_if { it.empty? }
70
70
  end
71
71
  end
72
72
  end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ module Klee
6
+ class FileAnalyzer
7
+ attr_reader :path, :class_names, :method_names, :collaborators, :method_collaborators
8
+
9
+ def initialize(path)
10
+ @path = path
11
+ @class_names = []
12
+ @method_names = []
13
+ @collaborators = []
14
+ @method_collaborators = Hash.new { |h, k| h[k] = [] }
15
+ parse
16
+ end
17
+
18
+ private
19
+
20
+ def parse
21
+ result = Prism.parse_file(@path)
22
+ visit(result.value, current_method: nil)
23
+ end
24
+
25
+ def visit(node, current_method:)
26
+ case node
27
+ when Prism::ClassNode, Prism::ModuleNode
28
+ @class_names << node.name.to_s
29
+ when Prism::DefNode
30
+ @method_names << node.name.to_s
31
+ current_method = node.name.to_s
32
+ when Prism::CallNode
33
+ name = extract_collaborator(node)
34
+ if name
35
+ @collaborators << name
36
+ @method_collaborators[current_method] << name if current_method
37
+ end
38
+ end
39
+
40
+ node.child_nodes.compact.each { |child| visit(child, current_method: current_method) }
41
+ end
42
+
43
+ def extract_collaborator(call_node)
44
+ receiver = call_node.receiver
45
+ case receiver
46
+ when Prism::LocalVariableReadNode
47
+ receiver.name.to_s
48
+ when Prism::InstanceVariableReadNode
49
+ receiver.name.to_s.delete_prefix("@")
50
+ when Prism::CallNode
51
+ # Chain like user.account.name - extract the root
52
+ extract_collaborator(receiver)
53
+ end
54
+ end
55
+ end
56
+ end
data/lib/klee/gestalt.rb CHANGED
@@ -1,4 +1,3 @@
1
- require "set"
2
1
  module Klee
3
2
  class Gestalt
4
3
  def initialize(object, patterns:, ignored:)
@@ -49,15 +48,15 @@ module Klee
49
48
  end
50
49
 
51
50
  def prefixes
52
- plot.select { |key, _| patterns.prefixes.include?(key) }
51
+ plot.slice(*patterns.prefixes)
53
52
  end
54
53
 
55
54
  def infixes
56
- plot.select { |key, _| patterns.infixes.include?(key) }
55
+ plot.slice(*patterns.infixes)
57
56
  end
58
57
 
59
58
  def suffixes
60
- plot.select { |key, _| patterns.suffixes.include?(key) }
59
+ plot.slice(*patterns.suffixes)
61
60
  end
62
61
 
63
62
  def unusual?(*items)
@@ -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
data/lib/klee/patterns.rb CHANGED
@@ -9,7 +9,7 @@ module Klee
9
9
 
10
10
  def prefix(which)
11
11
  @patterns[__callee__].add which
12
- send("to_#{__callee__}", which).tap { match(_1) }
12
+ send("to_#{__callee__}", which).tap { match(it) }
13
13
  end
14
14
  alias_method :suffix, :prefix
15
15
  alias_method :infix, :prefix
@@ -40,7 +40,7 @@ module Klee
40
40
  end
41
41
 
42
42
  def key_for(pattern)
43
- keys.find { pattern =~ _1 }
43
+ keys.find { pattern =~ it }
44
44
  end
45
45
 
46
46
  private
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.0"
4
+ VERSION = "0.1.2"
5
5
  end
data/lib/klee.rb CHANGED
@@ -1,9 +1,15 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # require "classifier-reborn"
3
4
  require_relative "klee/version"
4
5
  require_relative "klee/patterns"
5
6
  require_relative "klee/gestalt"
6
7
  require_relative "klee/concepts"
8
+ require_relative "klee/collaborators"
9
+ require_relative "klee/file_analyzer"
10
+ require_relative "klee/concept_index"
11
+ require_relative "klee/collaborator_index"
12
+ require_relative "klee/codebase"
7
13
 
8
14
  module Klee
9
15
  class Error < StandardError; end
@@ -20,10 +26,14 @@ module Klee
20
26
  Patterns.new(&block)
21
27
  end
22
28
 
23
- def self.concepts(*methond_names, modifiers: [])
24
- Concepts.new(*methond_names, modifiers: [])
29
+ def self.concepts(*method_names, modifiers: [])
30
+ Concepts.new(*method_names, modifiers: [])
25
31
  end
26
32
 
33
+ # def self.classifier
34
+ # @classifier ||= ClassifierReborn::Bayes.new "Interesting", "Uninteresting"
35
+ # end
36
+
27
37
  def self.object_concepts(object, modifiers: [])
28
38
  names = if object.respond_to?(:public_instance_methods)
29
39
  object.public_instance_methods(false)
@@ -38,4 +48,12 @@ module Klee
38
48
  ignored: Class.instance_methods)
39
49
  Gestalt.new(object, patterns: patterns, ignored: ignored).trace(threshold: threshold)
40
50
  end
51
+
52
+ def self.collaborators(const)
53
+ Klee::Collaborators.new(const)
54
+ end
55
+
56
+ def self.scan(*patterns, ignore: [], threshold: 2)
57
+ Codebase.new(*patterns, ignore: ignore, threshold: threshold)
58
+ end
41
59
  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
metadata CHANGED
@@ -1,21 +1,35 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: klee
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.1.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jim Gay
8
- autorequire:
9
8
  bindir: exe
10
9
  cert_chain: []
11
- date: 2022-05-06 00:00:00.000000000 Z
12
- dependencies: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: prism
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '0'
13
26
  description: Evaluate the similarities and differences in your objects. Art does not
14
27
  reflect what is seen, rather it makes the hidden visible.
15
28
  email:
16
29
  - jim@saturnflyer.com
17
30
  executables:
18
31
  - klee
32
+ - klee-mcp
19
33
  extensions: []
20
34
  extra_rdoc_files: []
21
35
  files:
@@ -26,11 +40,28 @@ files:
26
40
  - bin/console
27
41
  - bin/setup
28
42
  - exe/klee
43
+ - exe/klee-mcp
29
44
  - lib/klee.rb
45
+ - lib/klee/codebase.rb
46
+ - lib/klee/collaborator_index.rb
47
+ - lib/klee/collaborators.rb
48
+ - lib/klee/concept_index.rb
30
49
  - lib/klee/concepts.rb
50
+ - lib/klee/file_analyzer.rb
31
51
  - lib/klee/gestalt.rb
52
+ - lib/klee/mcp.rb
53
+ - lib/klee/mcp/path_validator.rb
54
+ - lib/klee/mcp/server.rb
55
+ - lib/klee/mcp/tools/check_naming_consistency.rb
56
+ - lib/klee/mcp/tools/codebase_summary.rb
57
+ - lib/klee/mcp/tools/discover_vocabulary.rb
58
+ - lib/klee/mcp/tools/explore_concept.rb
59
+ - lib/klee/mcp/tools/find_collaborators.rb
60
+ - lib/klee/mcp/tools/find_concept_clusters.rb
61
+ - lib/klee/mcp/version.rb
32
62
  - lib/klee/patterns.rb
33
63
  - lib/klee/version.rb
64
+ - sig/klee.rbs
34
65
  homepage: https://github.com/saturnflyer/klee
35
66
  licenses:
36
67
  - MIT
@@ -38,7 +69,13 @@ metadata:
38
69
  homepage_uri: https://github.com/saturnflyer/klee
39
70
  source_code_uri: https://github.com/saturnflyer/klee
40
71
  changelog_uri: https://github.com/saturnflyer/klee/blob/main/CHANGELOG.md
41
- post_install_message:
72
+ post_install_message: |
73
+ klee installed successfully!
74
+
75
+ For MCP server support (Claude Code integration), also install:
76
+ gem install mcp
77
+
78
+ Then run: klee-mcp
42
79
  rdoc_options: []
43
80
  require_paths:
44
81
  - lib
@@ -46,15 +83,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
46
83
  requirements:
47
84
  - - ">="
48
85
  - !ruby/object:Gem::Version
49
- version: 2.7.0
86
+ version: 3.4.0
50
87
  required_rubygems_version: !ruby/object:Gem::Requirement
51
88
  requirements:
52
89
  - - ">="
53
90
  - !ruby/object:Gem::Version
54
91
  version: '0'
55
92
  requirements: []
56
- rubygems_version: 3.2.27
57
- signing_key:
93
+ rubygems_version: 4.0.5
58
94
  specification_version: 4
59
95
  summary: Evaluate the similarities and differences in your objects
60
96
  test_files: []