klee 0.1.1 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +26 -15
- data/README.md +63 -3
- data/exe/klee-mcp +21 -0
- data/lib/klee/codebase.rb +43 -0
- data/lib/klee/collaborator_index.rb +81 -0
- data/lib/klee/collaborators.rb +31 -0
- data/lib/klee/concept_index.rb +110 -0
- data/lib/klee/concepts.rb +27 -25
- data/lib/klee/file_analyzer.rb +148 -0
- data/lib/klee/gestalt.rb +3 -1
- data/lib/klee/mcp/path_validator.rb +32 -0
- data/lib/klee/mcp/server.rb +37 -0
- data/lib/klee/mcp/tools/check_naming_consistency.rb +141 -0
- data/lib/klee/mcp/tools/codebase_summary.rb +72 -0
- data/lib/klee/mcp/tools/discover_vocabulary.rb +59 -0
- data/lib/klee/mcp/tools/explore_concept.rb +80 -0
- data/lib/klee/mcp/tools/find_collaborators.rb +71 -0
- data/lib/klee/mcp/tools/find_concept_clusters.rb +53 -0
- data/lib/klee/mcp/version.rb +7 -0
- data/lib/klee/mcp.rb +10 -0
- data/lib/klee/profile.rb +33 -0
- data/lib/klee/version.rb +1 -1
- data/lib/klee/words.rb +20 -0
- data/lib/klee.rb +7 -4
- data/sig/klee.rbs +4 -0
- metadata +29 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c8efdb5e1472295c0778a374828e8af7cb49e741a281d4d7558790863221ee0b
|
|
4
|
+
data.tar.gz: 99c547540505671159ace42750ba0ba6708aeb16f2888276a60426fc11316176
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 4009d050d3439f95c2d451f7ec9a5c56f407c0fe95b269fb802335c2262886cb2bb0e159d71f7068aaa4f35c87836c366ee8745421d55e425e5791d110530df0
|
|
7
|
+
data.tar.gz: 68ffb23b66ae05b66874e677ca47bc3ed96274f681d9d0e9f3f1c84726db35851cb2f3657404690b80189284c8b1460a96d57339b9106175146f3200dae97016
|
data/CHANGELOG.md
CHANGED
|
@@ -5,27 +5,38 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](http://keepachangelog.com/)
|
|
6
6
|
and this project adheres to [Semantic Versioning](http://semver.org/).
|
|
7
7
|
|
|
8
|
-
## [0.
|
|
8
|
+
## [1.0.0] - 2026-09-19
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- Shared Words tokenizer with acronym splitting and predicate stripping (acff7d2)
|
|
13
|
+
- Nested class names, attr_* methods, and constant collaborators (18d27de)
|
|
14
|
+
- Klee.scan(profile: :rails) for models/lib globs and English stopwords (801fab2)
|
|
15
|
+
- ConceptIndex#catalog, #gaps, and #units (801fab2)
|
|
9
16
|
|
|
10
17
|
### Changed
|
|
11
18
|
|
|
12
|
-
-
|
|
13
|
-
-
|
|
19
|
+
- Concepts#[] returns a Hash of concept words to method names (acff7d2)
|
|
20
|
+
- FileAnalyzer records each message receiver, not only locals and ivars (18d27de)
|
|
21
|
+
- Klee::Collaborators wraps FileAnalyzer instead of lexing the file (18d27de)
|
|
14
22
|
|
|
15
|
-
###
|
|
23
|
+
### Fixed
|
|
16
24
|
|
|
17
|
-
-
|
|
18
|
-
-
|
|
19
|
-
- Coverage comment from PRs (067ec87)
|
|
20
|
-
- Klee.scan() method for codebase-wide analysis with glob patterns (3e509a1)
|
|
21
|
-
- ConceptIndex for aggregating domain concepts across files (3e509a1)
|
|
22
|
-
- CollaboratorIndex for pairwise co-occurrence and clustering (3e509a1)
|
|
23
|
-
- FileAnalyzer using Prism AST to extract classes, methods, and collaborators (3e509a1)
|
|
24
|
-
- Configurable threshold and ignore list for filtering noise (3e509a1)
|
|
25
|
-
- Method-level scope option for tighter coupling analysis (3e509a1)
|
|
25
|
+
- Klee.concepts now forwards the modifiers: keyword (acff7d2)
|
|
26
|
+
- Operator messages, block params, and conversion chains are ignored (18d27de)
|
|
26
27
|
|
|
27
|
-
## [0.1.
|
|
28
|
+
## [0.1.2] - 2026-02-01
|
|
28
29
|
|
|
29
30
|
### Added
|
|
30
31
|
|
|
31
|
-
-
|
|
32
|
+
- MCP server support via klee-mcp executable (requires gem install mcp) (08c2294)
|
|
33
|
+
- discover_vocabulary tool for extracting domain language (08c2294)
|
|
34
|
+
- find_concept_clusters tool for identifying related concepts (08c2294)
|
|
35
|
+
- explore_concept tool for deep-diving into specific concepts (08c2294)
|
|
36
|
+
- find_collaborators tool for finding co-occurring objects (08c2294)
|
|
37
|
+
- check_naming_consistency tool for detecting naming deviations (08c2294)
|
|
38
|
+
- codebase_summary tool for high-level vocabulary overview (08c2294)
|
|
39
|
+
|
|
40
|
+
### Changed
|
|
41
|
+
|
|
42
|
+
- README now includes MCP server setup and available tools (f84bdc5)
|
data/README.md
CHANGED
|
@@ -45,11 +45,14 @@ end
|
|
|
45
45
|
|
|
46
46
|
concept = Klee.object_concepts(Something)
|
|
47
47
|
|
|
48
|
-
concept[4]
|
|
49
|
-
|
|
48
|
+
concept[4]
|
|
49
|
+
# => { banana: [:banana_berry, :banana_boat, :banana_bunch, :banana_hammock], ... }
|
|
50
|
+
|
|
51
|
+
concept[3][:address]
|
|
52
|
+
# => [:address_informal_name, :address_street, :address_zip]
|
|
50
53
|
|
|
51
54
|
filtered = Klee.object_concepts(Something, modifiers: %i[fill_in_ hover_over_ _message])
|
|
52
|
-
filtered[4]
|
|
55
|
+
filtered[4] # modifiers stripped before words are counted
|
|
53
56
|
```
|
|
54
57
|
|
|
55
58
|
### Scan a codebase for domain concepts
|
|
@@ -77,6 +80,31 @@ codebase = Klee.scan("app/**/*.rb",
|
|
|
77
80
|
threshold: 3)
|
|
78
81
|
```
|
|
79
82
|
|
|
83
|
+
### Rails profile
|
|
84
|
+
|
|
85
|
+
Skip views, drop English function words, and get the extract-type read model:
|
|
86
|
+
|
|
87
|
+
```ruby
|
|
88
|
+
cb = Klee.scan(profile: :rails) # app/models/**/*.rb + lib/**/*.rb from cwd
|
|
89
|
+
|
|
90
|
+
cb.concepts.catalog.first
|
|
91
|
+
# => #<data Klee::ConceptIndex::Entry word="intensity" class_count=71 method_count=29 bias=:type>
|
|
92
|
+
|
|
93
|
+
cb.concepts.gaps
|
|
94
|
+
# => { "seconds" => { classes: 0, methods: 95, names: #<Set: {"trailing_rest_seconds", ...}> }, ... }
|
|
95
|
+
|
|
96
|
+
cb.concepts.units
|
|
97
|
+
# => { "seconds" => ["cap_clock_seconds", "trailing_rest_seconds", ...], "minutes" => [...], "kg" => [...] }
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
`catalog` bias is `:type` (class-heavy), `:verb` (`build`/`calculate`/`extract`/…), `:gap` (many methods, almost no classes), or `:mixed`.
|
|
101
|
+
|
|
102
|
+
Pass explicit globs with the profile to keep the stopwords:
|
|
103
|
+
|
|
104
|
+
```ruby
|
|
105
|
+
Klee.scan("app/models/**/*.rb", profile: :rails, threshold: 4)
|
|
106
|
+
```
|
|
107
|
+
|
|
80
108
|
### Find collaborator clusters
|
|
81
109
|
|
|
82
110
|
Discover which objects frequently work together across your codebase.
|
|
@@ -101,6 +129,38 @@ codebase.collaborators.clusters
|
|
|
101
129
|
# => [Set["user", "account", "session"], Set["order", "payment", "invoice"]]
|
|
102
130
|
```
|
|
103
131
|
|
|
132
|
+
### MCP Server for Claude Code
|
|
133
|
+
|
|
134
|
+
Expose klee's vocabulary analysis to Claude Code or other MCP clients.
|
|
135
|
+
|
|
136
|
+
```bash
|
|
137
|
+
# Install the mcp gem (optional dependency)
|
|
138
|
+
gem install mcp
|
|
139
|
+
|
|
140
|
+
# Run the server
|
|
141
|
+
klee-mcp
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
Configure Claude Code in `~/.claude.json`:
|
|
145
|
+
|
|
146
|
+
```json
|
|
147
|
+
{
|
|
148
|
+
"mcpServers": {
|
|
149
|
+
"klee": {
|
|
150
|
+
"command": "klee-mcp"
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
Available tools:
|
|
157
|
+
- `discover_vocabulary` - Extract domain language from a codebase
|
|
158
|
+
- `find_concept_clusters` - Identify groups of related concepts
|
|
159
|
+
- `explore_concept` - Deep-dive into a specific concept
|
|
160
|
+
- `find_collaborators` - Find objects that work together
|
|
161
|
+
- `check_naming_consistency` - Detect naming pattern deviations
|
|
162
|
+
- `codebase_summary` - High-level vocabulary overview
|
|
163
|
+
|
|
104
164
|
## Development
|
|
105
165
|
|
|
106
166
|
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,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Klee
|
|
4
|
+
class Collaborators
|
|
5
|
+
def initialize(const)
|
|
6
|
+
unless const.is_a?(Module)
|
|
7
|
+
raise ArgumentError, "expected a class or module, got #{const.inspect}"
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
path = Object.const_source_location(const.name)&.first
|
|
11
|
+
unless path && File.file?(path)
|
|
12
|
+
raise Error, "cannot locate source for #{const}"
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
@const = const
|
|
16
|
+
@analyzer = FileAnalyzer.new(path)
|
|
17
|
+
end
|
|
18
|
+
attr_reader :const
|
|
19
|
+
|
|
20
|
+
def tally
|
|
21
|
+
@analyzer.collaborators.tally
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def rank
|
|
25
|
+
tally.group_by { |_, count| count }
|
|
26
|
+
.sort.reverse.to_h
|
|
27
|
+
.transform_values { |value| value.map(&:first) }
|
|
28
|
+
.reject { |key, _value| key < 3 }
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "words"
|
|
4
|
+
|
|
5
|
+
module Klee
|
|
6
|
+
class ConceptIndex
|
|
7
|
+
include Enumerable
|
|
8
|
+
|
|
9
|
+
Entry = Data.define(:word, :class_count, :method_count, :class_names, :method_names, :bias)
|
|
10
|
+
|
|
11
|
+
VERBS = %w[
|
|
12
|
+
build calculate extract apply compute detect update create find get set
|
|
13
|
+
add ensure render save destroy validate serialize
|
|
14
|
+
parse process fetch refresh call fill infer display generate
|
|
15
|
+
normalize coerce prepare predict split sanitize mark
|
|
16
|
+
].freeze
|
|
17
|
+
|
|
18
|
+
UNIT_PATTERNS = {
|
|
19
|
+
"seconds" => /_seconds\z/,
|
|
20
|
+
"minutes" => /_minutes\z/,
|
|
21
|
+
"kg" => /_kg\z/,
|
|
22
|
+
"m" => /_m\z/,
|
|
23
|
+
"reps" => /_reps\z/
|
|
24
|
+
}.freeze
|
|
25
|
+
|
|
26
|
+
def initialize(ignore: [], threshold: 2)
|
|
27
|
+
@ignore = Set.new(ignore.map(&:to_s))
|
|
28
|
+
@threshold = threshold
|
|
29
|
+
@data = Hash.new { |h, k| h[k] = {classes: Set.new, methods: Set.new} }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def add(file_analyzer)
|
|
33
|
+
file_analyzer.class_names.each do |name|
|
|
34
|
+
words_from(name).each { |word| @data[word][:classes] << name }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
file_analyzer.method_names.each do |name|
|
|
38
|
+
words_from(name).each { |word| @data[word][:methods] << name }
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def [](concept)
|
|
43
|
+
@data[concept.to_s]
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def each(&block)
|
|
47
|
+
filtered.each(&block)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def rank
|
|
51
|
+
filtered.sort_by { |word, locs| -(locs[:classes].size + locs[:methods].size) }.to_h
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def catalog
|
|
55
|
+
rank.map do |word, locs|
|
|
56
|
+
Entry.new(
|
|
57
|
+
word: word,
|
|
58
|
+
class_count: locs[:classes].size,
|
|
59
|
+
method_count: locs[:methods].size,
|
|
60
|
+
class_names: locs[:classes],
|
|
61
|
+
method_names: locs[:methods],
|
|
62
|
+
bias: bias_for(word, locs)
|
|
63
|
+
)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def gaps(min_methods: 8, max_classes: 3)
|
|
68
|
+
filtered.each_with_object({}) do |(word, locs), acc|
|
|
69
|
+
next if VERBS.include?(word)
|
|
70
|
+
next if locs[:methods].size < min_methods
|
|
71
|
+
next if locs[:classes].size > max_classes
|
|
72
|
+
|
|
73
|
+
acc[word] = {
|
|
74
|
+
classes: locs[:classes].size,
|
|
75
|
+
methods: locs[:methods].size,
|
|
76
|
+
names: locs[:methods]
|
|
77
|
+
}
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def units
|
|
82
|
+
names = @data.each_value.flat_map { |locs| locs[:methods].to_a }.uniq
|
|
83
|
+
UNIT_PATTERNS.each_with_object({}) do |(unit, pattern), acc|
|
|
84
|
+
matched = names.grep(pattern)
|
|
85
|
+
acc[unit] = matched.sort if matched.size >= @threshold
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
private
|
|
90
|
+
|
|
91
|
+
def words_from(name)
|
|
92
|
+
Words.from(name, ignore: @ignore)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def filtered
|
|
96
|
+
@data.select { |_, locs| (locs[:classes].size + locs[:methods].size) >= @threshold }
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def bias_for(word, locs)
|
|
100
|
+
return :verb if VERBS.include?(word)
|
|
101
|
+
|
|
102
|
+
class_count = locs[:classes].size
|
|
103
|
+
method_count = locs[:methods].size
|
|
104
|
+
return :type if class_count > method_count
|
|
105
|
+
return :gap if method_count >= 8 && class_count <= 3
|
|
106
|
+
|
|
107
|
+
:mixed
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
data/lib/klee/concepts.rb
CHANGED
|
@@ -1,26 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "words"
|
|
4
|
+
|
|
1
5
|
module Klee
|
|
2
6
|
class Concepts
|
|
3
7
|
include Enumerable
|
|
4
8
|
|
|
5
9
|
def initialize(*method_names, modifiers: [])
|
|
6
10
|
@method_names = method_names
|
|
7
|
-
@modifiers = modifiers
|
|
11
|
+
@modifiers = Array(modifiers).map(&:to_s)
|
|
8
12
|
end
|
|
9
13
|
attr_reader :method_names, :modifiers
|
|
10
14
|
|
|
11
15
|
def call(threshold)
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
key
|
|
19
|
-
end
|
|
20
|
-
}
|
|
21
|
-
.compact
|
|
22
|
-
.transform_keys(&:to_sym)
|
|
23
|
-
.keys
|
|
16
|
+
unless samples.empty?
|
|
17
|
+
warn "threshold is beyond the max count of #{max}" if threshold > max
|
|
18
|
+
warn "threshold is below the min count of #{min}" if threshold < min
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
method_groups.select { |_, methods| methods.size >= threshold }
|
|
24
22
|
end
|
|
25
23
|
alias_method :[], :call
|
|
26
24
|
|
|
@@ -38,10 +36,14 @@ module Klee
|
|
|
38
36
|
end
|
|
39
37
|
|
|
40
38
|
def max
|
|
39
|
+
return 0 if samples.empty?
|
|
40
|
+
|
|
41
41
|
max_by { |_, v| v }.last
|
|
42
42
|
end
|
|
43
43
|
|
|
44
44
|
def min
|
|
45
|
+
return 0 if samples.empty?
|
|
46
|
+
|
|
45
47
|
min_by { |_, v| v }.last
|
|
46
48
|
end
|
|
47
49
|
|
|
@@ -51,22 +53,22 @@ module Klee
|
|
|
51
53
|
|
|
52
54
|
private
|
|
53
55
|
|
|
56
|
+
def method_groups
|
|
57
|
+
groups = Hash.new { |h, k| h[k] = [] }
|
|
58
|
+
method_names.each do |name|
|
|
59
|
+
words(name).uniq.each { |word| groups[word.to_sym] << name.to_sym }
|
|
60
|
+
end
|
|
61
|
+
groups
|
|
62
|
+
end
|
|
63
|
+
|
|
54
64
|
def modifier_matcher
|
|
55
|
-
@modifier_matcher ||= Regexp.new
|
|
65
|
+
@modifier_matcher ||= Regexp.new(modifiers.map { Regexp.quote(it) }.join("|"))
|
|
56
66
|
end
|
|
57
67
|
|
|
58
68
|
def words(method_name)
|
|
59
|
-
method_name.to_s
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
string
|
|
63
|
-
else
|
|
64
|
-
string.gsub(modifier_matcher, "")
|
|
65
|
-
end
|
|
66
|
-
end
|
|
67
|
-
.gsub(/\s/, "")
|
|
68
|
-
.split("_")
|
|
69
|
-
.delete_if { it.empty? }
|
|
69
|
+
string = method_name.to_s
|
|
70
|
+
string = string.gsub(modifier_matcher, "") unless modifiers.empty?
|
|
71
|
+
Words.from(string)
|
|
70
72
|
end
|
|
71
73
|
end
|
|
72
74
|
end
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
module Klee
|
|
6
|
+
class FileAnalyzer
|
|
7
|
+
ATTR_METHODS = %i[attr attr_reader attr_writer attr_accessor].freeze
|
|
8
|
+
INDEX_OPERATORS = Set[:[], :[]=].freeze
|
|
9
|
+
OPERATORS = Set.new(
|
|
10
|
+
%i[! != !~ + +@ - -@ * / % ** << >> & | ^ ~ <=> > >= < <= == === =~] +
|
|
11
|
+
INDEX_OPERATORS.to_a
|
|
12
|
+
).freeze
|
|
13
|
+
CHAIN_NOISE = %w[
|
|
14
|
+
to_s to_str to_i to_int to_a to_ary to_h to_hash to_f to_sym to_proc
|
|
15
|
+
intern itself dup clone freeze tap new
|
|
16
|
+
where includes joins order limit offset eager_load preload
|
|
17
|
+
unscope merge distinct group having lock reselect reorder
|
|
18
|
+
first last take count map select collect compact flatten uniq
|
|
19
|
+
sort sort_by reverse each
|
|
20
|
+
Hash Array Set Rails Data Logger Kernel Object
|
|
21
|
+
].to_set.freeze
|
|
22
|
+
|
|
23
|
+
attr_reader :path, :class_names, :method_names, :collaborators, :method_collaborators
|
|
24
|
+
|
|
25
|
+
def initialize(path)
|
|
26
|
+
@path = path
|
|
27
|
+
@class_names = []
|
|
28
|
+
@method_names = []
|
|
29
|
+
@collaborators = []
|
|
30
|
+
@method_collaborators = Hash.new { |h, k| h[k] = [] }
|
|
31
|
+
parse
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def parse
|
|
37
|
+
result = Prism.parse_file(@path)
|
|
38
|
+
visit(result.value, current_method: nil, namespace: [], block_params: Set.new)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def visit(node, current_method:, namespace:, block_params:)
|
|
42
|
+
case node
|
|
43
|
+
when Prism::ClassNode, Prism::ModuleNode
|
|
44
|
+
full_name = qualified_name(node, namespace)
|
|
45
|
+
@class_names << full_name
|
|
46
|
+
nested = full_name.split("::")
|
|
47
|
+
node.child_nodes.compact.each do |child|
|
|
48
|
+
visit(child, current_method: current_method, namespace: nested, block_params: block_params)
|
|
49
|
+
end
|
|
50
|
+
return
|
|
51
|
+
when Prism::DefNode
|
|
52
|
+
@method_names << node.name.to_s
|
|
53
|
+
current_method = node.name.to_s
|
|
54
|
+
when Prism::BlockNode, Prism::LambdaNode
|
|
55
|
+
nested_params = block_params | Array(node.locals).map(&:to_s)
|
|
56
|
+
node.child_nodes.compact.each do |child|
|
|
57
|
+
visit(child, current_method: current_method, namespace: namespace, block_params: nested_params)
|
|
58
|
+
end
|
|
59
|
+
return
|
|
60
|
+
when Prism::CallNode
|
|
61
|
+
# Walk the receiver first so `user.account` records left-to-right.
|
|
62
|
+
if (receiver = node.receiver)
|
|
63
|
+
visit(receiver, current_method: current_method, namespace: namespace, block_params: block_params)
|
|
64
|
+
end
|
|
65
|
+
record_call(node, current_method, block_params)
|
|
66
|
+
node.child_nodes.compact.each do |child|
|
|
67
|
+
next if child.equal?(receiver)
|
|
68
|
+
|
|
69
|
+
visit(child, current_method: current_method, namespace: namespace, block_params: block_params)
|
|
70
|
+
end
|
|
71
|
+
return
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
node.child_nodes.compact.each do |child|
|
|
75
|
+
visit(child, current_method: current_method, namespace: namespace, block_params: block_params)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def record_call(node, current_method, block_params)
|
|
80
|
+
if attr_call?(node)
|
|
81
|
+
@method_names.concat(attr_names(node))
|
|
82
|
+
return
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
return if skip_operator_call?(node)
|
|
86
|
+
|
|
87
|
+
name = extract_collaborator(node)
|
|
88
|
+
return if skip_collaborator?(name, block_params)
|
|
89
|
+
|
|
90
|
+
@collaborators << name
|
|
91
|
+
@method_collaborators[current_method] << name if current_method
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def skip_operator_call?(node)
|
|
95
|
+
OPERATORS.include?(node.name) && !INDEX_OPERATORS.include?(node.name)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def skip_collaborator?(name, block_params)
|
|
99
|
+
return true if name.nil? || block_params.include?(name)
|
|
100
|
+
|
|
101
|
+
key = name.to_sym
|
|
102
|
+
OPERATORS.include?(key) || CHAIN_NOISE.include?(name)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def attr_call?(node)
|
|
106
|
+
node.receiver.nil? && ATTR_METHODS.include?(node.name)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def attr_names(node)
|
|
110
|
+
args = node.arguments&.arguments || []
|
|
111
|
+
args.filter_map { |arg| attr_name_from(arg) }.map do |name|
|
|
112
|
+
(node.name == :attr_writer) ? "#{name}=" : name
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def attr_name_from(arg)
|
|
117
|
+
case arg
|
|
118
|
+
when Prism::SymbolNode, Prism::StringNode
|
|
119
|
+
arg.unescaped
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# A collaborator is the immediate receiver of a non-operator message.
|
|
124
|
+
def extract_collaborator(call_node)
|
|
125
|
+
case (receiver = call_node.receiver)
|
|
126
|
+
when Prism::LocalVariableReadNode
|
|
127
|
+
receiver.name.to_s
|
|
128
|
+
when Prism::InstanceVariableReadNode
|
|
129
|
+
receiver.name.to_s.delete_prefix("@")
|
|
130
|
+
when Prism::CallNode
|
|
131
|
+
receiver.name.to_s
|
|
132
|
+
when Prism::ConstantReadNode, Prism::ConstantPathNode
|
|
133
|
+
constant_name(receiver)
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def qualified_name(node, namespace)
|
|
138
|
+
path = constant_name(node.constant_path)
|
|
139
|
+
return path if node.constant_path.is_a?(Prism::ConstantPathNode)
|
|
140
|
+
|
|
141
|
+
[*namespace, path].join("::")
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def constant_name(node)
|
|
145
|
+
node.slice.delete_prefix("::")
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
data/lib/klee/gestalt.rb
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
module Klee
|
|
2
4
|
class Gestalt
|
|
3
5
|
def initialize(object, patterns:, ignored:)
|
|
@@ -33,7 +35,7 @@ module Klee
|
|
|
33
35
|
end
|
|
34
36
|
|
|
35
37
|
plot["unusual"].merge(unusual_set(threshold))
|
|
36
|
-
plot["concepts"].merge(concepts(modifiers: modifiers, threshold: concept_threshold))
|
|
38
|
+
plot["concepts"].merge(concepts(modifiers: modifiers, threshold: concept_threshold).keys)
|
|
37
39
|
|
|
38
40
|
self
|
|
39
41
|
end
|