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 +4 -4
- data/CHANGELOG.md +39 -3
- data/README.md +81 -0
- 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 +41 -0
- data/lib/klee/concept_index.rb +47 -0
- data/lib/klee/concepts.rb +4 -4
- data/lib/klee/file_analyzer.rb +56 -0
- data/lib/klee/gestalt.rb +3 -4
- 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/patterns.rb +2 -2
- data/lib/klee/version.rb +1 -1
- data/lib/klee.rb +20 -2
- data/sig/klee.rbs +4 -0
- metadata +44 -8
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6ad4060f69da403186a89fb198ddfdb917606eac5e3344dcb5b0cc58f3d1bdd8
|
|
4
|
+
data.tar.gz: 90b540e449684b48e71714194b3c622f9fd35ecae931cb4b94fb53545f338958
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 46ed4f0a4469aee13b165138f3ff04da7b30819e6e37b01524e750df591a52cf6da53dc2f082a8c353a1bfa6a47e37111814baf8bbcdd377f246797ea12ec283
|
|
7
|
+
data.tar.gz: 77af3a015eb3e3fd8267b78525e46f53bb51694b2160d12a04e05bf6c04734549ed628a9cb5a8955cf4c9d6d4ec3ac1a544b011556125fdd5d07d2e1b6b71037
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,41 @@
|
|
|
1
|
-
|
|
1
|
+
# Changelog
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
4
|
|
|
5
|
-
|
|
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,
|
|
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(
|
|
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(
|
|
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 {
|
|
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.
|
|
51
|
+
plot.slice(*patterns.prefixes)
|
|
53
52
|
end
|
|
54
53
|
|
|
55
54
|
def infixes
|
|
56
|
-
plot.
|
|
55
|
+
plot.slice(*patterns.infixes)
|
|
57
56
|
end
|
|
58
57
|
|
|
59
58
|
def suffixes
|
|
60
|
-
plot.
|
|
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
|
data/lib/klee/mcp.rb
ADDED
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(
|
|
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 =~
|
|
43
|
+
keys.find { pattern =~ it }
|
|
44
44
|
end
|
|
45
45
|
|
|
46
46
|
private
|
data/lib/klee/version.rb
CHANGED
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(*
|
|
24
|
-
Concepts.new(*
|
|
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
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.
|
|
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:
|
|
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:
|
|
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:
|
|
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: []
|