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.
- checksums.yaml +7 -0
- data/.github/workflows/ci.yml +96 -0
- data/.rubocop.yml +54 -0
- data/CHANGELOG.md +88 -0
- data/CLAUDE.md +115 -0
- data/CODE_OF_CONDUCT.md +132 -0
- data/README.md +354 -0
- data/app/assets/config/glancer_manifest.js +1 -0
- data/app/assets/javascripts/glancer/application.js +15 -0
- data/app/assets/javascripts/glancer/controllers/chat_controller.js +101 -0
- data/app/assets/javascripts/glancer/controllers/message_controller.js +1052 -0
- data/app/assets/javascripts/glancer/controllers/toast_controller.js +63 -0
- data/app/assets/stylesheets/glancer/application.css +350 -0
- data/app/assets/stylesheets/glancer/code-blocks.css +6 -0
- data/app/assets/stylesheets/glancer/list.css +31 -0
- data/app/assets/stylesheets/glancer/scrollbar.css +16 -0
- data/app/assets/stylesheets/glancer/table.css +97 -0
- data/app/controllers/glancer/application_controller.rb +33 -0
- data/app/controllers/glancer/chats_controller.rb +49 -0
- data/app/controllers/glancer/messages_controller.rb +144 -0
- data/app/controllers/glancer/schema_controller.rb +29 -0
- data/app/controllers/glancer/settings_controller.rb +23 -0
- data/app/helpers/glancer/application_helper.rb +17 -0
- data/app/jobs/glancer/application_job.rb +6 -0
- data/app/jobs/glancer/process_message_job.rb +38 -0
- data/app/models/glancer/audit.rb +12 -0
- data/app/models/glancer/chat.rb +8 -0
- data/app/models/glancer/code_version.rb +12 -0
- data/app/models/glancer/embedding.rb +6 -0
- data/app/models/glancer/message.rb +25 -0
- data/app/models/glancer/setting.rb +23 -0
- data/app/models/glancer/sql_version.rb +6 -0
- data/app/views/glancer/_data/_importmap.json.erb +7 -0
- data/app/views/glancer/chats/_chat_sidebar.html.erb +2 -0
- data/app/views/glancer/chats/_show.html.erb +52 -0
- data/app/views/glancer/chats/_sidebar_chat_list.html.erb +30 -0
- data/app/views/glancer/chats/index.html.erb +10 -0
- data/app/views/glancer/chats/show.html.erb +1 -0
- data/app/views/glancer/messages/_data_table.html.erb +268 -0
- data/app/views/glancer/messages/_execution_error.html.erb +26 -0
- data/app/views/glancer/messages/_form.html.erb +93 -0
- data/app/views/glancer/messages/_message.html.erb +206 -0
- data/app/views/glancer/messages/_message_info.html.erb +176 -0
- data/app/views/glancer/messages/_temp_form.html.erb +100 -0
- data/app/views/glancer/messages/create.turbo_stream.erb +25 -0
- data/app/views/glancer/schema/show.html.erb +123 -0
- data/app/views/glancer/settings/show.html.erb +306 -0
- data/app/views/glancer/shared/_icons.html.erb +126 -0
- data/app/views/layouts/glancer/application.html.erb +234 -0
- data/config/locales/glancer.en.yml +90 -0
- data/config/locales/glancer.es.yml +90 -0
- data/config/locales/glancer.pt-BR.yml +90 -0
- data/config/routes.rb +20 -0
- data/db/migrate/20250629212642_create_glancer_audits.rb +19 -0
- data/db/migrate/20250629212643_create_glancer_chats.rb +10 -0
- data/db/migrate/20250629212645_create_glancer_embeddings.rb +17 -0
- data/db/migrate/20250629212647_create_glancer_messages.rb +29 -0
- data/db/migrate/20260513204129_add_user_edited_sql_to_glancer_messages.rb +11 -0
- data/db/migrate/20260513210647_create_glancer_sql_versions.rb +18 -0
- data/db/migrate/20260513210648_add_message_id_to_glancer_audits.rb +8 -0
- data/db/migrate/20260513220000_create_glancer_settings.rb +12 -0
- data/db/migrate/20260514083509_add_llm_model_to_glancer_messages.rb +9 -0
- data/db/migrate/20260523120000_rename_code_columns_in_glancer_messages.rb +8 -0
- data/db/migrate/20260523120001_rename_code_column_in_glancer_audits.rb +7 -0
- data/db/migrate/20260523120002_add_code_type_to_glancer_tables.rb +10 -0
- data/db/migrate/20260523120003_rename_glancer_sql_versions_to_code_versions.rb +8 -0
- data/db/migrate/20260523130000_add_enriched_question_to_glancer_messages.rb +7 -0
- data/db/migrate/20260524100000_add_status_to_glancer_messages.rb +9 -0
- data/lib/generators/glancer/install/install_generator.rb +74 -0
- data/lib/generators/glancer/install/templates/glancer.rb +227 -0
- data/lib/generators/glancer/install/templates/llm_context.glancer.md +51 -0
- data/lib/glancer/async_runner.rb +50 -0
- data/lib/glancer/chart_analyzer.rb +230 -0
- data/lib/glancer/configuration.rb +372 -0
- data/lib/glancer/engine.rb +90 -0
- data/lib/glancer/indexer/context_indexer.rb +58 -0
- data/lib/glancer/indexer/model_indexer.rb +64 -0
- data/lib/glancer/indexer/schema_indexer.rb +171 -0
- data/lib/glancer/indexer.rb +50 -0
- data/lib/glancer/retriever.rb +114 -0
- data/lib/glancer/utils/logger.rb +83 -0
- data/lib/glancer/utils/markdown_helper.rb +56 -0
- data/lib/glancer/utils/result_formatter.rb +25 -0
- data/lib/glancer/utils/table_stats.rb +18 -0
- data/lib/glancer/utils/transaction.rb +59 -0
- data/lib/glancer/version.rb +5 -0
- data/lib/glancer/workflow/ar_executor.rb +104 -0
- data/lib/glancer/workflow/ar_extractor.rb +25 -0
- data/lib/glancer/workflow/ar_prompt_builder.rb +64 -0
- data/lib/glancer/workflow/ar_sanitizer.rb +88 -0
- data/lib/glancer/workflow/builder.rb +129 -0
- data/lib/glancer/workflow/cache.rb +55 -0
- data/lib/glancer/workflow/executor.rb +72 -0
- data/lib/glancer/workflow/llm.rb +123 -0
- data/lib/glancer/workflow/prompt_builder.rb +143 -0
- data/lib/glancer/workflow/query_enricher.rb +117 -0
- data/lib/glancer/workflow/sql_extractor.rb +42 -0
- data/lib/glancer/workflow/sql_sanitizer.rb +42 -0
- data/lib/glancer/workflow/sql_validator.rb +67 -0
- data/lib/glancer/workflow.rb +158 -0
- data/lib/glancer.rb +50 -0
- data/lib/tasks/glancer/tailwind.rake +8 -0
- data/lib/tasks/glancer.rake +99 -0
- data/spec/glancer_spec.rb +62 -0
- data/spec/lib/glancer/async_runner_spec.rb +133 -0
- data/spec/lib/glancer/chart_analyzer_spec.rb +296 -0
- data/spec/lib/glancer/configuration_spec.rb +858 -0
- data/spec/lib/glancer/engine_spec.rb +209 -0
- data/spec/lib/glancer/indexer/context_indexer_spec.rb +96 -0
- data/spec/lib/glancer/indexer/model_indexer_spec.rb +103 -0
- data/spec/lib/glancer/indexer/schema_indexer_spec.rb +382 -0
- data/spec/lib/glancer/indexer_spec.rb +95 -0
- data/spec/lib/glancer/retriever_spec.rb +179 -0
- data/spec/lib/glancer/utils/logger_spec.rb +85 -0
- data/spec/lib/glancer/utils/markdown_helper_spec.rb +92 -0
- data/spec/lib/glancer/utils/result_formatter_spec.rb +73 -0
- data/spec/lib/glancer/utils/table_stats_spec.rb +34 -0
- data/spec/lib/glancer/utils/transaction_spec.rb +73 -0
- data/spec/lib/glancer/workflow/ar_executor_spec.rb +155 -0
- data/spec/lib/glancer/workflow/ar_extractor_spec.rb +50 -0
- data/spec/lib/glancer/workflow/ar_prompt_builder_spec.rb +79 -0
- data/spec/lib/glancer/workflow/ar_sanitizer_spec.rb +175 -0
- data/spec/lib/glancer/workflow/builder_spec.rb +204 -0
- data/spec/lib/glancer/workflow/cache_spec.rb +142 -0
- data/spec/lib/glancer/workflow/executor_spec.rb +149 -0
- data/spec/lib/glancer/workflow/llm_spec.rb +124 -0
- data/spec/lib/glancer/workflow/prompt_builder_spec.rb +196 -0
- data/spec/lib/glancer/workflow/query_enricher_spec.rb +184 -0
- data/spec/lib/glancer/workflow/sql_extractor_spec.rb +82 -0
- data/spec/lib/glancer/workflow/sql_sanitizer_spec.rb +98 -0
- data/spec/lib/glancer/workflow/sql_validator_spec.rb +166 -0
- data/spec/lib/glancer/workflow_spec.rb +308 -0
- data/spec/models/glancer/audit_spec.rb +82 -0
- data/spec/models/glancer/chat_spec.rb +60 -0
- data/spec/models/glancer/code_version_spec.rb +71 -0
- data/spec/models/glancer/embedding_spec.rb +73 -0
- data/spec/models/glancer/message_spec.rb +144 -0
- data/spec/models/glancer/setting_spec.rb +88 -0
- data/spec/models/glancer/sql_version_spec.rb +4 -0
- data/spec/spec_helper.rb +128 -0
- data/spec/support/schema.rb +55 -0
- 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
|