glancer 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.
Files changed (142) hide show
  1. checksums.yaml +7 -0
  2. data/.github/workflows/ci.yml +96 -0
  3. data/.rubocop.yml +54 -0
  4. data/CHANGELOG.md +88 -0
  5. data/CLAUDE.md +115 -0
  6. data/CODE_OF_CONDUCT.md +132 -0
  7. data/README.md +354 -0
  8. data/app/assets/config/glancer_manifest.js +1 -0
  9. data/app/assets/javascripts/glancer/application.js +15 -0
  10. data/app/assets/javascripts/glancer/controllers/chat_controller.js +101 -0
  11. data/app/assets/javascripts/glancer/controllers/message_controller.js +1052 -0
  12. data/app/assets/javascripts/glancer/controllers/toast_controller.js +63 -0
  13. data/app/assets/stylesheets/glancer/application.css +350 -0
  14. data/app/assets/stylesheets/glancer/code-blocks.css +6 -0
  15. data/app/assets/stylesheets/glancer/list.css +31 -0
  16. data/app/assets/stylesheets/glancer/scrollbar.css +16 -0
  17. data/app/assets/stylesheets/glancer/table.css +97 -0
  18. data/app/controllers/glancer/application_controller.rb +33 -0
  19. data/app/controllers/glancer/chats_controller.rb +49 -0
  20. data/app/controllers/glancer/messages_controller.rb +144 -0
  21. data/app/controllers/glancer/schema_controller.rb +29 -0
  22. data/app/controllers/glancer/settings_controller.rb +23 -0
  23. data/app/helpers/glancer/application_helper.rb +17 -0
  24. data/app/jobs/glancer/application_job.rb +6 -0
  25. data/app/jobs/glancer/process_message_job.rb +38 -0
  26. data/app/models/glancer/audit.rb +12 -0
  27. data/app/models/glancer/chat.rb +8 -0
  28. data/app/models/glancer/code_version.rb +12 -0
  29. data/app/models/glancer/embedding.rb +6 -0
  30. data/app/models/glancer/message.rb +25 -0
  31. data/app/models/glancer/setting.rb +23 -0
  32. data/app/models/glancer/sql_version.rb +6 -0
  33. data/app/views/glancer/_data/_importmap.json.erb +7 -0
  34. data/app/views/glancer/chats/_chat_sidebar.html.erb +2 -0
  35. data/app/views/glancer/chats/_show.html.erb +52 -0
  36. data/app/views/glancer/chats/_sidebar_chat_list.html.erb +30 -0
  37. data/app/views/glancer/chats/index.html.erb +10 -0
  38. data/app/views/glancer/chats/show.html.erb +1 -0
  39. data/app/views/glancer/messages/_data_table.html.erb +268 -0
  40. data/app/views/glancer/messages/_execution_error.html.erb +26 -0
  41. data/app/views/glancer/messages/_form.html.erb +93 -0
  42. data/app/views/glancer/messages/_message.html.erb +206 -0
  43. data/app/views/glancer/messages/_message_info.html.erb +176 -0
  44. data/app/views/glancer/messages/_temp_form.html.erb +100 -0
  45. data/app/views/glancer/messages/create.turbo_stream.erb +25 -0
  46. data/app/views/glancer/schema/show.html.erb +123 -0
  47. data/app/views/glancer/settings/show.html.erb +306 -0
  48. data/app/views/glancer/shared/_icons.html.erb +126 -0
  49. data/app/views/layouts/glancer/application.html.erb +234 -0
  50. data/config/locales/glancer.en.yml +90 -0
  51. data/config/locales/glancer.es.yml +90 -0
  52. data/config/locales/glancer.pt-BR.yml +90 -0
  53. data/config/routes.rb +20 -0
  54. data/db/migrate/20250629212642_create_glancer_audits.rb +19 -0
  55. data/db/migrate/20250629212643_create_glancer_chats.rb +10 -0
  56. data/db/migrate/20250629212645_create_glancer_embeddings.rb +17 -0
  57. data/db/migrate/20250629212647_create_glancer_messages.rb +29 -0
  58. data/db/migrate/20260513204129_add_user_edited_sql_to_glancer_messages.rb +11 -0
  59. data/db/migrate/20260513210647_create_glancer_sql_versions.rb +18 -0
  60. data/db/migrate/20260513210648_add_message_id_to_glancer_audits.rb +8 -0
  61. data/db/migrate/20260513220000_create_glancer_settings.rb +12 -0
  62. data/db/migrate/20260514083509_add_llm_model_to_glancer_messages.rb +9 -0
  63. data/db/migrate/20260523120000_rename_code_columns_in_glancer_messages.rb +8 -0
  64. data/db/migrate/20260523120001_rename_code_column_in_glancer_audits.rb +7 -0
  65. data/db/migrate/20260523120002_add_code_type_to_glancer_tables.rb +10 -0
  66. data/db/migrate/20260523120003_rename_glancer_sql_versions_to_code_versions.rb +8 -0
  67. data/db/migrate/20260523130000_add_enriched_question_to_glancer_messages.rb +7 -0
  68. data/db/migrate/20260524100000_add_status_to_glancer_messages.rb +9 -0
  69. data/lib/generators/glancer/install/install_generator.rb +74 -0
  70. data/lib/generators/glancer/install/templates/glancer.rb +227 -0
  71. data/lib/generators/glancer/install/templates/llm_context.glancer.md +51 -0
  72. data/lib/glancer/async_runner.rb +50 -0
  73. data/lib/glancer/chart_analyzer.rb +230 -0
  74. data/lib/glancer/configuration.rb +372 -0
  75. data/lib/glancer/engine.rb +90 -0
  76. data/lib/glancer/indexer/context_indexer.rb +58 -0
  77. data/lib/glancer/indexer/model_indexer.rb +64 -0
  78. data/lib/glancer/indexer/schema_indexer.rb +171 -0
  79. data/lib/glancer/indexer.rb +50 -0
  80. data/lib/glancer/retriever.rb +114 -0
  81. data/lib/glancer/utils/logger.rb +83 -0
  82. data/lib/glancer/utils/markdown_helper.rb +56 -0
  83. data/lib/glancer/utils/result_formatter.rb +25 -0
  84. data/lib/glancer/utils/table_stats.rb +18 -0
  85. data/lib/glancer/utils/transaction.rb +59 -0
  86. data/lib/glancer/version.rb +5 -0
  87. data/lib/glancer/workflow/ar_executor.rb +104 -0
  88. data/lib/glancer/workflow/ar_extractor.rb +25 -0
  89. data/lib/glancer/workflow/ar_prompt_builder.rb +64 -0
  90. data/lib/glancer/workflow/ar_sanitizer.rb +88 -0
  91. data/lib/glancer/workflow/builder.rb +129 -0
  92. data/lib/glancer/workflow/cache.rb +55 -0
  93. data/lib/glancer/workflow/executor.rb +72 -0
  94. data/lib/glancer/workflow/llm.rb +123 -0
  95. data/lib/glancer/workflow/prompt_builder.rb +143 -0
  96. data/lib/glancer/workflow/query_enricher.rb +117 -0
  97. data/lib/glancer/workflow/sql_extractor.rb +42 -0
  98. data/lib/glancer/workflow/sql_sanitizer.rb +42 -0
  99. data/lib/glancer/workflow/sql_validator.rb +67 -0
  100. data/lib/glancer/workflow.rb +158 -0
  101. data/lib/glancer.rb +50 -0
  102. data/lib/tasks/glancer/tailwind.rake +8 -0
  103. data/lib/tasks/glancer.rake +99 -0
  104. data/spec/glancer_spec.rb +62 -0
  105. data/spec/lib/glancer/async_runner_spec.rb +133 -0
  106. data/spec/lib/glancer/chart_analyzer_spec.rb +296 -0
  107. data/spec/lib/glancer/configuration_spec.rb +858 -0
  108. data/spec/lib/glancer/engine_spec.rb +209 -0
  109. data/spec/lib/glancer/indexer/context_indexer_spec.rb +96 -0
  110. data/spec/lib/glancer/indexer/model_indexer_spec.rb +103 -0
  111. data/spec/lib/glancer/indexer/schema_indexer_spec.rb +382 -0
  112. data/spec/lib/glancer/indexer_spec.rb +95 -0
  113. data/spec/lib/glancer/retriever_spec.rb +179 -0
  114. data/spec/lib/glancer/utils/logger_spec.rb +85 -0
  115. data/spec/lib/glancer/utils/markdown_helper_spec.rb +92 -0
  116. data/spec/lib/glancer/utils/result_formatter_spec.rb +73 -0
  117. data/spec/lib/glancer/utils/table_stats_spec.rb +34 -0
  118. data/spec/lib/glancer/utils/transaction_spec.rb +73 -0
  119. data/spec/lib/glancer/workflow/ar_executor_spec.rb +155 -0
  120. data/spec/lib/glancer/workflow/ar_extractor_spec.rb +50 -0
  121. data/spec/lib/glancer/workflow/ar_prompt_builder_spec.rb +79 -0
  122. data/spec/lib/glancer/workflow/ar_sanitizer_spec.rb +175 -0
  123. data/spec/lib/glancer/workflow/builder_spec.rb +204 -0
  124. data/spec/lib/glancer/workflow/cache_spec.rb +142 -0
  125. data/spec/lib/glancer/workflow/executor_spec.rb +149 -0
  126. data/spec/lib/glancer/workflow/llm_spec.rb +124 -0
  127. data/spec/lib/glancer/workflow/prompt_builder_spec.rb +196 -0
  128. data/spec/lib/glancer/workflow/query_enricher_spec.rb +184 -0
  129. data/spec/lib/glancer/workflow/sql_extractor_spec.rb +82 -0
  130. data/spec/lib/glancer/workflow/sql_sanitizer_spec.rb +98 -0
  131. data/spec/lib/glancer/workflow/sql_validator_spec.rb +166 -0
  132. data/spec/lib/glancer/workflow_spec.rb +308 -0
  133. data/spec/models/glancer/audit_spec.rb +82 -0
  134. data/spec/models/glancer/chat_spec.rb +60 -0
  135. data/spec/models/glancer/code_version_spec.rb +71 -0
  136. data/spec/models/glancer/embedding_spec.rb +73 -0
  137. data/spec/models/glancer/message_spec.rb +144 -0
  138. data/spec/models/glancer/setting_spec.rb +88 -0
  139. data/spec/models/glancer/sql_version_spec.rb +4 -0
  140. data/spec/spec_helper.rb +128 -0
  141. data/spec/support/schema.rb +55 -0
  142. metadata +255 -0
@@ -0,0 +1,382 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ RSpec.describe Glancer::Indexer::SchemaIndexer do
6
+ let(:schema_path) { Pathname.new("/fake/root/db/schema.rb") }
7
+ let(:schema_content) do
8
+ # NOTE: The schema split regex is /^ create_table / (with 2 leading spaces).
9
+ # Indentation must NOT be stripped (do not use <<~RUBY — use <<RUBY or
10
+ # explicit leading spaces so each create_table block is indented correctly).
11
+ "ActiveRecord::Schema[7.0].define(version: 2024_01_01) do\n" \
12
+ " create_table \"users\", force: :cascade do |t|\n" \
13
+ " t.string \"name\"\n" \
14
+ " t.string \"email\"\n" \
15
+ " t.timestamps\n" \
16
+ " end\n\n" \
17
+ " create_table \"orders\", force: :cascade do |t|\n" \
18
+ " t.integer \"user_id\"\n" \
19
+ " t.decimal \"total\"\n" \
20
+ " t.timestamps\n" \
21
+ " end\n\n" \
22
+ " add_foreign_key \"orders\", \"users\", column: \"user_id\"\n" \
23
+ "end\n"
24
+ end
25
+
26
+ let(:inflections_path) { Pathname.new("/fake/root/config/initializers/inflections.rb") }
27
+
28
+ before do
29
+ allow(Rails).to receive(:root).and_return(Pathname.new("/fake/root"))
30
+ allow(Rails).to receive(:application).and_return(double("app", eager_load!: nil))
31
+ allow(File).to receive(:exist?).with(schema_path).and_return(true)
32
+ allow(File).to receive(:exist?).with(inflections_path).and_return(false)
33
+ allow(File).to receive(:read).with(schema_path).and_return(schema_content)
34
+ # Prevent AR descendants from polluting results
35
+ allow(ActiveRecord::Base).to receive(:descendants).and_return([])
36
+ end
37
+
38
+ # ── index! ────────────────────────────────────────────────────────────────
39
+
40
+ describe ".index!" do
41
+ it "returns an array of chunk hashes" do
42
+ result = described_class.index!
43
+ expect(result).to be_an(Array)
44
+ expect(result).not_to be_empty
45
+ end
46
+
47
+ it "includes a chunk for each recognized create_table block" do
48
+ result = described_class.index!
49
+ table_chunks = result.select do |c|
50
+ c[:source_type] == "schema" &&
51
+ !c[:source_path].end_with?("#foreign_keys") &&
52
+ (c[:source_path].include?("#users") || c[:source_path].include?("#orders"))
53
+ end
54
+ # Schema has 2 tables (users, orders).
55
+ expect(table_chunks.size).to eq(2)
56
+ end
57
+
58
+ it "sets source_type to 'schema' on all chunks" do
59
+ result = described_class.index!
60
+ expect(result.map { |c| c[:source_type] }).to all(eq("schema"))
61
+ end
62
+
63
+ it "includes the table name in the source_path" do
64
+ result = described_class.index!
65
+ paths = result.map { |c| c[:source_path] }
66
+ expect(paths.any? { |p| p.include?("#users") }).to be(true)
67
+ expect(paths.any? { |p| p.include?("#orders") }).to be(true)
68
+ end
69
+
70
+ it "includes a foreign keys chunk when add_foreign_key lines exist" do
71
+ result = described_class.index!
72
+ fk_chunk = result.find { |c| c[:source_path].to_s.end_with?("#foreign_keys") }
73
+ expect(fk_chunk).not_to be_nil
74
+ end
75
+
76
+ it "includes the FK relationship in the foreign keys chunk content" do
77
+ result = described_class.index!
78
+ fk_chunk = result.find { |c| c[:source_path].to_s.end_with?("#foreign_keys") }
79
+ expect(fk_chunk[:content]).to include("orders")
80
+ expect(fk_chunk[:content]).to include("users")
81
+ end
82
+
83
+ it "returns [] when the schema file does not exist" do
84
+ allow(File).to receive(:exist?).and_return(false)
85
+ expect(described_class.index!).to eq([])
86
+ end
87
+
88
+ it "silently skips chunks whose table name cannot be extracted" do
89
+ # Use {nonstandard} so extract_table_name regex cannot match [a-zA-Z0-9_]+
90
+ schema_without_name = " create_table {nonstandard} do |t|\n t.string :foo\n end\n"
91
+ allow(File).to receive(:read).with(schema_path).and_return(schema_without_name)
92
+ result = described_class.index!
93
+ table_chunks = result.reject { |c| c[:source_path].to_s.end_with?("#foreign_keys") }
94
+ expect(table_chunks).to be_empty
95
+ end
96
+
97
+ it "raises Glancer::Error when File.read raises" do
98
+ allow(File).to receive(:read).and_raise(IOError, "disk failure")
99
+ expect { described_class.index! }.to raise_error(Glancer::Error, /Schema indexing failed/)
100
+ end
101
+ end
102
+
103
+ # ── split_into_chunks ─────────────────────────────────────────────────────
104
+
105
+ describe ".split_into_chunks" do
106
+ it "splits a schema string into create_table chunks" do
107
+ # schema_content has 2 tables; the preamble before the first ` create_table`
108
+ # is a separate (non-table) chunk — we only care that there are >= 2 table chunks.
109
+ chunks = described_class.split_into_chunks(schema_content)
110
+ table_chunks = chunks.select { |c| c =~ /create_table "(users|orders)"/ }
111
+ expect(table_chunks.size).to eq(2)
112
+ end
113
+
114
+ it "each chunk starts with 'create_table'" do
115
+ chunks = described_class.split_into_chunks(schema_content)
116
+ chunks.each { |c| expect(c).to start_with("create_table") }
117
+ end
118
+
119
+ it "returns a single non-table chunk for schema text with no create_table blocks" do
120
+ # The split produces one element (the whole text prepended with 'create_table')
121
+ # when no ` create_table ` lines are present.
122
+ result = described_class.split_into_chunks("# empty schema\n")
123
+ # All chunks start with create_table, but none match a real table name
124
+ expect(result.any? { |c| c =~ /create_table "/ }).to be(false)
125
+ end
126
+ end
127
+
128
+ # ── extract_table_name ────────────────────────────────────────────────────
129
+
130
+ describe ".extract_table_name" do
131
+ it "extracts a double-quoted table name" do
132
+ chunk = 'create_table "users", force: :cascade do |t|'
133
+ expect(described_class.extract_table_name(chunk)).to eq("users")
134
+ end
135
+
136
+ it "extracts a single-quoted table name" do
137
+ chunk = "create_table 'products' do |t|"
138
+ expect(described_class.extract_table_name(chunk)).to eq("products")
139
+ end
140
+
141
+ it "extracts an unquoted table name" do
142
+ chunk = "create_table orders do |t|"
143
+ expect(described_class.extract_table_name(chunk)).to eq("orders")
144
+ end
145
+
146
+ it "returns nil for a chunk that has no create_table pattern" do
147
+ expect(described_class.extract_table_name("add_index :users, :email")).to be_nil
148
+ end
149
+ end
150
+
151
+ # ── extract_foreign_keys ──────────────────────────────────────────────────
152
+
153
+ describe ".extract_foreign_keys" do
154
+ it "returns nil when no add_foreign_key lines are present" do
155
+ text = "create_table users do; end\n"
156
+ expect(described_class.extract_foreign_keys(text, schema_path)).to be_nil
157
+ end
158
+
159
+ it "returns a hash with content, source_type, and source_path" do
160
+ result = described_class.extract_foreign_keys(schema_content, schema_path)
161
+ expect(result).to be_a(Hash)
162
+ expect(result[:source_type]).to eq("schema")
163
+ expect(result[:source_path].to_s).to include("#foreign_keys")
164
+ end
165
+
166
+ it "describes the relationship in the content" do
167
+ result = described_class.extract_foreign_keys(schema_content, schema_path)
168
+ expect(result[:content]).to include("orders.user_id → users.id")
169
+ end
170
+
171
+ it "infers the column name when no explicit column is given" do
172
+ text = <<~RUBY
173
+ add_foreign_key "order_items", "orders"
174
+ RUBY
175
+ result = described_class.extract_foreign_keys(text, schema_path)
176
+ expect(result[:content]).to include("order_items.order_id → orders.id")
177
+ end
178
+ end
179
+
180
+ # ── eager_load_models! ────────────────────────────────────────────────────
181
+
182
+ describe ".eager_load_models!" do
183
+ it "calls Rails.application.eager_load!" do
184
+ app = double("app")
185
+ allow(Rails).to receive(:application).and_return(app)
186
+ expect(app).to receive(:eager_load!)
187
+ described_class.eager_load_models!
188
+ end
189
+
190
+ it "does not raise when eager_load! raises" do
191
+ allow(Rails).to receive(:application).and_return(double("app", eager_load!: nil).tap do |a|
192
+ allow(a).to receive(:eager_load!).and_raise(StandardError, "boom")
193
+ end)
194
+ expect { described_class.eager_load_models! }.not_to raise_error
195
+ end
196
+ end
197
+
198
+ # ── find_model_for_table ──────────────────────────────────────────────────
199
+
200
+ describe ".find_model_for_table" do
201
+ let(:fake_model) do
202
+ Class.new(ActiveRecord::Base) do
203
+ def self.name = "FakeUser"
204
+ def self.table_name = "users"
205
+ def self.abstract_class? = false
206
+ end
207
+ end
208
+
209
+ it "returns nil when no descendants match" do
210
+ allow(ActiveRecord::Base).to receive(:descendants).and_return([])
211
+ expect(described_class.find_model_for_table("users")).to be_nil
212
+ end
213
+
214
+ it "returns the model whose table_name matches" do
215
+ allow(ActiveRecord::Base).to receive(:descendants).and_return([fake_model])
216
+ expect(described_class.find_model_for_table("users")).to eq(fake_model)
217
+ end
218
+
219
+ it "excludes Glancer-namespaced models" do
220
+ glancer_model = Class.new(ActiveRecord::Base) do
221
+ def self.name = "Glancer::Embedding"
222
+ def self.table_name = "users"
223
+ def self.abstract_class? = false
224
+ end
225
+ allow(ActiveRecord::Base).to receive(:descendants).and_return([glancer_model])
226
+ expect(described_class.find_model_for_table("users")).to be_nil
227
+ end
228
+
229
+ it "returns nil when descendants raises" do
230
+ allow(ActiveRecord::Base).to receive(:descendants).and_raise(StandardError, "fail")
231
+ expect(described_class.find_model_for_table("users")).to be_nil
232
+ end
233
+ end
234
+
235
+ # ── model_associations_block ──────────────────────────────────────────────
236
+
237
+ describe ".model_associations_block" do
238
+ it "returns empty string when no model found for table" do
239
+ allow(described_class).to receive(:find_model_for_table).and_return(nil)
240
+ expect(described_class.model_associations_block("users")).to eq("")
241
+ end
242
+
243
+ it "returns empty string when model has no associations" do
244
+ model = Class.new(ActiveRecord::Base) do
245
+ def self.name = "FakeUser"
246
+ def self.reflect_on_all_associations = []
247
+ end
248
+ allow(described_class).to receive(:find_model_for_table).and_return(model)
249
+ expect(described_class.model_associations_block("users")).to eq("")
250
+ end
251
+
252
+ it "returns a formatted associations block when associations exist" do
253
+ assoc = double("assoc",
254
+ macro: :has_many,
255
+ name: :orders,
256
+ class_name: "Order",
257
+ foreign_key: "user_id",
258
+ options: {})
259
+ model = double("FakeUser", name: "FakeUser", reflect_on_all_associations: [assoc])
260
+ allow(described_class).to receive(:find_model_for_table).and_return(model)
261
+ result = described_class.model_associations_block("users")
262
+ expect(result).to include("ActiveRecord Associations (FakeUser)")
263
+ expect(result).to include("has_many :orders")
264
+ end
265
+ end
266
+
267
+ # ── format_association ────────────────────────────────────────────────────
268
+
269
+ describe ".format_association" do
270
+ def make_assoc(macro:, name:, class_name:, foreign_key:, options: {})
271
+ double("assoc", macro: macro, name: name, class_name: class_name,
272
+ foreign_key: foreign_key, options: options)
273
+ end
274
+
275
+ it "formats a simple belongs_to" do
276
+ assoc = make_assoc(macro: :belongs_to, name: :user, class_name: "User", foreign_key: "user_id")
277
+ result = described_class.format_association(assoc)
278
+ expect(result).to include("belongs_to :user")
279
+ expect(result).to include('class_name: "User"')
280
+ expect(result).to include('foreign_key: "user_id"')
281
+ end
282
+
283
+ it "includes through option when present" do
284
+ assoc = make_assoc(macro: :has_many, name: :products, class_name: "Product",
285
+ foreign_key: "id", options: { through: :line_items })
286
+ result = described_class.format_association(assoc)
287
+ expect(result).to include("through: :line_items")
288
+ end
289
+
290
+ it "includes polymorphic: true when set" do
291
+ assoc = make_assoc(macro: :belongs_to, name: :imageable, class_name: "Imageable",
292
+ foreign_key: "imageable_id", options: { polymorphic: true })
293
+ result = described_class.format_association(assoc)
294
+ expect(result).to include("polymorphic: true")
295
+ end
296
+
297
+ it "includes dependent option when present" do
298
+ assoc = make_assoc(macro: :has_many, name: :posts, class_name: "Post",
299
+ foreign_key: "user_id", options: { dependent: :destroy })
300
+ result = described_class.format_association(assoc)
301
+ expect(result).to include("dependent: :destroy")
302
+ end
303
+
304
+ it "silently skips an association that raises during formatting" do
305
+ bad_assoc = double("assoc")
306
+ allow(bad_assoc).to receive(:macro).and_raise(StandardError, "broken")
307
+ good_assoc = make_assoc(macro: :has_many, name: :orders, class_name: "Order", foreign_key: "user_id")
308
+ model = double("Model", name: "User", reflect_on_all_associations: [bad_assoc, good_assoc])
309
+ allow(described_class).to receive(:find_model_for_table).and_return(model)
310
+ result = described_class.model_associations_block("users")
311
+ expect(result).to include("has_many :orders")
312
+ end
313
+ end
314
+
315
+ # ── extract_inflections ───────────────────────────────────────────────────
316
+
317
+ describe ".extract_inflections" do
318
+ it "returns nil when inflections file does not exist" do
319
+ allow(File).to receive(:exist?).with(inflections_path).and_return(false)
320
+ expect(described_class.extract_inflections).to be_nil
321
+ end
322
+
323
+ it "returns nil when file exists but has no inflect.* rules" do
324
+ allow(File).to receive(:exist?).with(inflections_path).and_return(true)
325
+ allow(File).to receive(:read).with(inflections_path).and_return("# empty\n")
326
+ expect(described_class.extract_inflections).to be_nil
327
+ end
328
+
329
+ it "returns a chunk hash when inflect rules are present" do
330
+ raw = "ActiveSupport::Inflector.inflections(:en) do |inflect|\n inflect.irregular 'person', 'people'\nend\n"
331
+ allow(File).to receive(:exist?).with(inflections_path).and_return(true)
332
+ allow(File).to receive(:read).with(inflections_path).and_return(raw)
333
+ result = described_class.extract_inflections
334
+ expect(result).to be_a(Hash)
335
+ expect(result[:source_type]).to eq("schema")
336
+ expect(result[:content]).to include("Custom Rails Inflections")
337
+ expect(result[:content]).to include("inflect.irregular")
338
+ end
339
+
340
+ it "returns nil when File.read raises" do
341
+ allow(File).to receive(:exist?).with(inflections_path).and_return(true)
342
+ allow(File).to receive(:read).with(inflections_path).and_raise(IOError, "no read")
343
+ expect(described_class.extract_inflections).to be_nil
344
+ end
345
+ end
346
+
347
+ # ── index! enrichment ─────────────────────────────────────────────────────
348
+
349
+ describe ".index! with associations and inflections" do
350
+ it "appends associations block to table chunks when a model is found" do
351
+ assoc = double("assoc", macro: :has_many, name: :orders, class_name: "Order",
352
+ foreign_key: "user_id", options: {})
353
+ model = double("User", name: "User", reflect_on_all_associations: [assoc])
354
+ allow(described_class).to receive(:find_model_for_table).and_return(nil)
355
+ allow(described_class).to receive(:find_model_for_table).with("users").and_return(model)
356
+
357
+ result = described_class.index!
358
+ users_chunk = result.find { |c| c[:source_path].to_s.include?("#users") }
359
+ expect(users_chunk[:content]).to include("ActiveRecord Associations (User)")
360
+ end
361
+
362
+ it "appends inflections chunk when inflections file contains rules" do
363
+ raw = "ActiveSupport::Inflector.inflections(:en) do |inflect|\n inflect.irregular 'ox', 'oxen'\nend\n"
364
+ allow(File).to receive(:exist?).with(inflections_path).and_return(true)
365
+ allow(File).to receive(:read).with(inflections_path).and_return(raw)
366
+
367
+ result = described_class.index!
368
+ inflect_chunk = result.find { |c| c[:source_path].to_s.include?("inflections.rb") }
369
+ expect(inflect_chunk).not_to be_nil
370
+ expect(inflect_chunk[:content]).to include("inflect.irregular")
371
+ end
372
+
373
+ it "does not append inflections chunk when file has no rules" do
374
+ allow(File).to receive(:exist?).with(inflections_path).and_return(true)
375
+ allow(File).to receive(:read).with(inflections_path).and_return("# nothing here\n")
376
+
377
+ result = described_class.index!
378
+ inflect_chunk = result.find { |c| c[:source_path].to_s.include?("inflections.rb") }
379
+ expect(inflect_chunk).to be_nil
380
+ end
381
+ end
382
+ end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ RSpec.describe Glancer::Indexer do
6
+ let(:schema_chunk) { { content: "create_table users", source_type: "schema", source_path: "db/schema.rb#users" } }
7
+ let(:model_chunk) { { content: "class User", source_type: "models", source_path: "app/models/user.rb" } }
8
+ let(:context_chunk) { { content: "# Context", source_type: "context", source_path: "config/context.md" } }
9
+
10
+ before do
11
+ allow(Glancer::Retriever).to receive(:store_documents)
12
+ allow(Glancer::Indexer::SchemaIndexer).to receive(:index!).and_return([schema_chunk])
13
+ allow(Glancer::Indexer::ModelIndexer).to receive(:index!).and_return([model_chunk])
14
+ allow(Glancer::Indexer::ContextIndexer).to receive(:index!).and_return([context_chunk])
15
+ end
16
+
17
+ describe ".rebuild_all!" do
18
+ context "when schema_permission is false (default)" do
19
+ it "does not call SchemaIndexer.index!" do
20
+ expect(Glancer::Indexer::SchemaIndexer).not_to receive(:index!)
21
+ described_class.rebuild_all!
22
+ end
23
+ end
24
+
25
+ context "when schema_permission is true" do
26
+ before { Glancer.configuration.schema_permission = true }
27
+
28
+ it "calls SchemaIndexer.index!" do
29
+ expect(Glancer::Indexer::SchemaIndexer).to receive(:index!).and_return([schema_chunk])
30
+ described_class.rebuild_all!
31
+ end
32
+
33
+ it "includes schema chunks in the result" do
34
+ chunks = described_class.rebuild_all!
35
+ expect(chunks).to include(schema_chunk)
36
+ end
37
+ end
38
+
39
+ context "when models_permission is false (default)" do
40
+ it "does not call ModelIndexer.index!" do
41
+ expect(Glancer::Indexer::ModelIndexer).not_to receive(:index!)
42
+ described_class.rebuild_all!
43
+ end
44
+ end
45
+
46
+ context "when models_permission is true" do
47
+ before { Glancer.configuration.models_permission = true }
48
+
49
+ it "calls ModelIndexer.index!" do
50
+ expect(Glancer::Indexer::ModelIndexer).to receive(:index!).and_return([model_chunk])
51
+ described_class.rebuild_all!
52
+ end
53
+ end
54
+
55
+ context "when context_file_path is set (default is set)" do
56
+ it "calls ContextIndexer.index!" do
57
+ expect(Glancer::Indexer::ContextIndexer).to receive(:index!).and_return([context_chunk])
58
+ described_class.rebuild_all!
59
+ end
60
+
61
+ it "includes context chunks in the result" do
62
+ chunks = described_class.rebuild_all!
63
+ expect(chunks).to include(context_chunk)
64
+ end
65
+ end
66
+
67
+ context "when context_file_path is nil" do
68
+ before { allow(Glancer.configuration).to receive(:context_file_path).and_return(nil) }
69
+
70
+ it "skips ContextIndexer.index!" do
71
+ expect(Glancer::Indexer::ContextIndexer).not_to receive(:index!)
72
+ described_class.rebuild_all!
73
+ end
74
+ end
75
+
76
+ it "calls Retriever.store_documents with accumulated chunks" do
77
+ Glancer.configuration.schema_permission = true
78
+ Glancer.configuration.models_permission = true
79
+ expect(Glancer::Retriever).to receive(:store_documents).with(array_including(schema_chunk, model_chunk, context_chunk))
80
+ described_class.rebuild_all!
81
+ end
82
+
83
+ it "returns the combined chunks array" do
84
+ Glancer.configuration.schema_permission = true
85
+ Glancer.configuration.models_permission = true
86
+ chunks = described_class.rebuild_all!
87
+ expect(chunks).to be_an(Array)
88
+ end
89
+
90
+ it "raises Glancer::Error when an indexer raises" do
91
+ allow(Glancer::Indexer::ContextIndexer).to receive(:index!).and_raise(StandardError, "disk error")
92
+ expect { described_class.rebuild_all! }.to raise_error(Glancer::Error, /Index rebuilding failed/)
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,179 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ RSpec.describe Glancer::Retriever do
6
+ let(:fake_embed_response) { double("EmbedResponse", vectors: [0.1, 0.2, 0.3]) }
7
+
8
+ before do
9
+ allow(RubyLLM).to receive(:embed).and_return(fake_embed_response)
10
+ end
11
+
12
+ # ── store_documents ───────────────────────────────────────────────────────
13
+
14
+ describe ".store_documents" do
15
+ let(:chunks) do
16
+ [
17
+ { content: "create_table users ...", source_type: "schema", source_path: "db/schema.rb#users" },
18
+ { content: "class User < ApplicationRecord", source_type: "models", source_path: "app/models/user.rb" }
19
+ ]
20
+ end
21
+
22
+ it "creates an Embedding record for each chunk" do
23
+ expect { described_class.store_documents(chunks) }.to change(Glancer::Embedding, :count).by(2)
24
+ end
25
+
26
+ it "stores the content from each chunk" do
27
+ described_class.store_documents(chunks)
28
+ contents = Glancer::Embedding.pluck(:content)
29
+ expect(contents).to include("create_table users ...", "class User < ApplicationRecord")
30
+ end
31
+
32
+ it "stores the source_type from each chunk" do
33
+ described_class.store_documents(chunks)
34
+ types = Glancer::Embedding.pluck(:source_type)
35
+ expect(types).to include("schema", "models")
36
+ end
37
+
38
+ it "stores the source_path from each chunk" do
39
+ described_class.store_documents(chunks)
40
+ paths = Glancer::Embedding.pluck(:source_path)
41
+ expect(paths).to include("db/schema.rb#users", "app/models/user.rb")
42
+ end
43
+
44
+ it "stores the embedding vector returned by RubyLLM.embed" do
45
+ described_class.store_documents([chunks.first])
46
+ emb = Glancer::Embedding.last
47
+ expect(emb.embedding.map(&:to_f)).to eq([0.1, 0.2, 0.3])
48
+ end
49
+
50
+ it "calls RubyLLM.embed once per chunk" do
51
+ expect(RubyLLM).to receive(:embed).exactly(2).times.and_return(fake_embed_response)
52
+ described_class.store_documents(chunks)
53
+ end
54
+
55
+ it "raises Glancer::Error when RubyLLM.embed fails" do
56
+ allow(RubyLLM).to receive(:embed).and_raise(StandardError, "API down")
57
+ expect { described_class.store_documents(chunks) }.to raise_error(Glancer::Error, /Document storage failed/)
58
+ end
59
+ end
60
+
61
+ # ── search ────────────────────────────────────────────────────────────────
62
+
63
+ describe ".search" do
64
+ before do
65
+ Glancer::Embedding.create!(
66
+ content: "create_table users ...", embedding: [0.1, 0.2, 0.3],
67
+ source_type: "schema", source_path: "db/schema.rb#users"
68
+ )
69
+ end
70
+
71
+ it "returns an array of Embedding records" do
72
+ results = described_class.search("count users")
73
+ expect(results).to all(be_a(Glancer::Embedding))
74
+ end
75
+
76
+ it "attaches a score singleton method to each result" do
77
+ results = described_class.search("something")
78
+ expect(results.first).to respond_to(:score)
79
+ end
80
+
81
+ it "calls RubyLLM.embed with the query" do
82
+ expect(RubyLLM).to receive(:embed).with("find users", anything).and_return(fake_embed_response)
83
+ described_class.search("find users")
84
+ end
85
+ end
86
+
87
+ # ── perform_ruby_search ───────────────────────────────────────────────────
88
+
89
+ describe ".perform_ruby_search" do
90
+ before do
91
+ Glancer::Embedding.create!(
92
+ content: "schema doc", embedding: [1.0, 0.0, 0.0],
93
+ source_type: "schema", source_path: "schema.rb#t"
94
+ )
95
+ Glancer::Embedding.create!(
96
+ content: "model doc", embedding: [0.0, 1.0, 0.0],
97
+ source_type: "models", source_path: "user.rb"
98
+ )
99
+ end
100
+
101
+ it "returns results filtered by min_score" do
102
+ Glancer.configuration.min_score = 0.99
103
+ results = described_class.perform_ruby_search([1.0, 0.0, 0.0])
104
+ expect(results.size).to eq(1)
105
+ end
106
+
107
+ it "limits results to k embeddings" do
108
+ Glancer.configuration.k = 1
109
+ Glancer.configuration.min_score = 0.0
110
+ results = described_class.perform_ruby_search([1.0, 0.0, 0.0])
111
+ expect(results.size).to eq(1)
112
+ end
113
+
114
+ it "applies schema weight (1.3) to schema embeddings" do
115
+ Glancer.configuration.min_score = 0.0
116
+ results = described_class.perform_ruby_search([1.0, 0.0, 0.0])
117
+ schema_result = results.find { |r| r.source_type == "schema" }
118
+ # Score for a perfect match should be ~1.3 (1.0 cosine * 1.3 weight)
119
+ expect(schema_result.score).to be_within(0.01).of(1.3)
120
+ end
121
+
122
+ it "falls back to top-k results when no embedding exceeds min_score" do
123
+ Glancer.configuration.min_score = 0.99
124
+ # Query vector orthogonal to both stored embeddings → cosine similarity ≈ 0
125
+ results = described_class.perform_ruby_search([0.0, 0.0, 1.0])
126
+ expect(results).not_to be_empty
127
+ end
128
+ end
129
+
130
+ # ── cosine_similarity ─────────────────────────────────────────────────────
131
+
132
+ describe ".cosine_similarity" do
133
+ it "returns 1.0 for identical vectors" do
134
+ vec = [1.0, 2.0, 3.0]
135
+ expect(described_class.cosine_similarity(vec, vec)).to be_within(1e-9).of(1.0)
136
+ end
137
+
138
+ it "returns 0.0 for orthogonal vectors" do
139
+ expect(described_class.cosine_similarity([1.0, 0.0], [0.0, 1.0])).to eq(0.0)
140
+ end
141
+
142
+ it "returns 0.0 when the first vector is all zeros" do
143
+ expect(described_class.cosine_similarity([0.0, 0.0], [1.0, 2.0])).to eq(0.0)
144
+ end
145
+
146
+ it "returns 0.0 when the second vector is all zeros" do
147
+ expect(described_class.cosine_similarity([1.0, 2.0], [0.0, 0.0])).to eq(0.0)
148
+ end
149
+
150
+ it "is symmetric" do
151
+ a = [0.3, 0.7, 0.1]
152
+ b = [0.5, 0.2, 0.8]
153
+ expect(described_class.cosine_similarity(a, b)).to eq(described_class.cosine_similarity(b, a))
154
+ end
155
+ end
156
+
157
+ # ── weight_for ────────────────────────────────────────────────────────────
158
+
159
+ describe ".weight_for" do
160
+ it "returns schema_documents_weight for 'schema'" do
161
+ Glancer.configuration.schema_documents_weight = 1.3
162
+ expect(described_class.weight_for("schema")).to eq(1.3)
163
+ end
164
+
165
+ it "returns context_documents_weight for 'context'" do
166
+ Glancer.configuration.context_documents_weight = 1.2
167
+ expect(described_class.weight_for("context")).to eq(1.2)
168
+ end
169
+
170
+ it "returns models_documents_weight for 'models'" do
171
+ Glancer.configuration.models_documents_weight = 1.1
172
+ expect(described_class.weight_for("models")).to eq(1.1)
173
+ end
174
+
175
+ it "returns 1.0 for any other source type" do
176
+ expect(described_class.weight_for("unknown")).to eq(1.0)
177
+ end
178
+ end
179
+ end