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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b40d9febee207cdff655e91d94f0f719188454fa98592a5cb9fa7b54521ed0c7
4
- data.tar.gz: 701294ca8f65152b881f6492caa4e51979e4de81dec22735989829ae87b8a015
3
+ metadata.gz: c8efdb5e1472295c0778a374828e8af7cb49e741a281d4d7558790863221ee0b
4
+ data.tar.gz: 99c547540505671159ace42750ba0ba6708aeb16f2888276a60426fc11316176
5
5
  SHA512:
6
- metadata.gz: 57dc53e9ff93189272b0a5ad5d203e9921f21376c1142db3f37d5b1074808dd92f57b4b2bc2181e43f4ca0df64a9c75e467f40efaf61b0b0bc380e4aa1830a7a
7
- data.tar.gz: 07a3b7c9588044077452430f1ebb9dba9a5f23443d141fcb7bb6b6b2ec4f32182cd1cd024178f9b87850aacdabe1d778ec9dc9f18d8ddd972ccaafa40993365f
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.1.1] - 2026-01-29
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
- - Switch to supporting ruby 3.4 and up. (84fdd9c)
13
- - Require ruby 3.4 and above (067ec87)
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
- ### Added
23
+ ### Fixed
16
24
 
17
- - Reissue for version and release support. (885fe28)
18
- - SimpleCov for coverage reporting (889d169)
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.0] - 2022-05-01
28
+ ## [0.1.2] - 2026-02-01
28
29
 
29
30
  ### Added
30
31
 
31
- - Initial release
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] #=> Set of words that appear at least 4 times
49
- concept[2] #=> larger Set of words that appear at least 2 times
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] #=> Set of concepts excluding any common modifiers
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
- warn "threshold is beyond the max count of #{max}" if threshold > max
13
- warn "threshold is below the min count of #{min}" if threshold < min
14
- clear
15
- @ideas = Set.new samples
16
- .select { |key, value|
17
- if value.to_i >= threshold
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 modifiers.map { Regexp.quote(it) }.join("|")
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
- .then do |string|
61
- if modifiers.empty?
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