rails-ai-context 5.35.2 → 5.36.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +177 -1
- data/CONTRIBUTING.md +1 -0
- data/docs/COMPATIBILITY.md +10 -1
- data/docs/SECURITY.md +11 -9
- data/docs/TOOLS.md +11 -10
- data/docs/TROUBLESHOOTING.md +1 -1
- data/lib/rails_ai_context/database_family/mysql_runtime.rb +17 -2
- data/lib/rails_ai_context/database_family/postgresql_plan.rb +6 -2
- data/lib/rails_ai_context/database_family/postgresql_runtime.rb +41 -6
- data/lib/rails_ai_context/database_family/sqlite_runtime.rb +9 -3
- data/lib/rails_ai_context/database_family.rb +27 -5
- data/lib/rails_ai_context/introspector.rb +12 -0
- data/lib/rails_ai_context/introspectors/eager_load.rb +12 -2
- data/lib/rails_ai_context/introspectors/job_introspector.rb +54 -14
- data/lib/rails_ai_context/introspectors/model_introspector.rb +13 -11
- data/lib/rails_ai_context/introspectors/schema_introspector.rb +12 -1
- data/lib/rails_ai_context/payload.rb +2 -3
- data/lib/rails_ai_context/secret_columns.rb +59 -37
- data/lib/rails_ai_context/sql_gate.rb +115 -24
- data/lib/rails_ai_context/tools/get_conventions.rb +10 -5
- data/lib/rails_ai_context/tools/get_schema.rb +35 -11
- data/lib/rails_ai_context/tools/query.rb +12 -2
- data/lib/rails_ai_context/tools/runtime_info.rb +15 -10
- data/lib/rails_ai_context/version.rb +1 -1
- metadata +1 -23
- data/.rspec +0 -3
- data/.rubocop.yml +0 -16
- data/.ruby-version +0 -1
- data/AGENTS.md +0 -15
- data/CLAUDE.md +0 -3
- data/CODE_OF_CONDUCT.md +0 -44
- data/CONTEXT.md +0 -242
- data/Rakefile +0 -80
- data/docs/RELEASING.md +0 -72
- data/docs/_config.yml +0 -15
- data/docs/_includes/head-custom.html +0 -16
- data/docs/adr/0001-own-listener-registration.md +0 -9
- data/docs/adr/0002-static-tier-declared-not-detected.md +0 -5
- data/docs/adr/0003-shared-tool-cache-semantics.md +0 -31
- data/docs/adr/0004-install-e2e-stays-example-rich.md +0 -28
- data/docs/adr/0008-query-runs-only-where-it-can-guard.md +0 -38
- data/docs/adr/0009-dbhub-shapes-in-ruby.md +0 -26
- data/docs/adr/0010-one-config-tool-with-sections.md +0 -32
- data/docs/agents/domain.md +0 -38
- data/docs/agents/issue-tracker.md +0 -45
- data/docs/agents/triage-labels.md +0 -15
- data/server.json +0 -21
|
@@ -121,7 +121,17 @@ module RailsAiContext
|
|
|
121
121
|
integrity_check quick_check user_version application_id schema_version page_size page_count freelist_count
|
|
122
122
|
encoding auto_vacuum journal_mode compile_options pragma_list
|
|
123
123
|
].to_set.freeze
|
|
124
|
-
|
|
124
|
+
SQLITE_COUNTER_CALL = /(?<![\w$])["'`\[]?(changes|total_changes|last_insert_rowid)["'`\]]?\s*\(/i
|
|
125
|
+
# A pragma function's name, bare or quoted, as one token.
|
|
126
|
+
SQLITE_PRAGMA_NAME = /\A["'`\[]?pragma_(\w+)["'`\]]?\z/i
|
|
127
|
+
# The clauses after which a comma at the same depth starts another table of a FROM list.
|
|
128
|
+
FROM_LIST_CLAUSES = %w[from join on using].freeze
|
|
129
|
+
CLAUSE_WORDS = %w[select from join on where group order having limit values using returning set union intersect except].freeze
|
|
130
|
+
|
|
131
|
+
# How a statement reads a session setting by name: SHOW, @@, @@SESSION. or @@LOCAL. (backquoted or not), current_setting('..').
|
|
132
|
+
SETTING_READ = /(?:\bshow\s+(?!global\b)|@@(?!global\s*\.)(?:(?:session|local)\s*\.\s*)?|current_setting\s*\(\s*')`?(\w+)/i
|
|
133
|
+
# A list of the session's settings, which names each in its rows or in the filter the statement puts on it.
|
|
134
|
+
SETTINGS_LIST = /\A\s*show\s+(?:(?:session|local)\s+)?variables\b|\b["`]?information_schema["`]?\s*\.\s*["`]?session_variables\b/i
|
|
125
135
|
|
|
126
136
|
# Catalogs refused when a statement names them. A view over one runs, and the plan checks hold the
|
|
127
137
|
# column that carries the credential (SecretColumns::CATALOG_CREDENTIALS) as a secret column.
|
|
@@ -176,6 +186,7 @@ module RailsAiContext
|
|
|
176
186
|
pg_is_in_recovery pg_is_wal_replay_paused pg_get_wal_replay_pause_state
|
|
177
187
|
pg_current_wal_lsn pg_current_wal_insert_lsn pg_current_wal_flush_lsn
|
|
178
188
|
pg_last_wal_receive_lsn pg_last_wal_replay_lsn pg_last_xact_replay_timestamp
|
|
189
|
+
pg_sequence_last_value
|
|
179
190
|
].freeze
|
|
180
191
|
|
|
181
192
|
# Any character past ASCII is an identifier character to SQLite and MySQL, a no-break space included.
|
|
@@ -228,6 +239,13 @@ module RailsAiContext
|
|
|
228
239
|
# Every other statement is planned, as is whatever a leading EXPLAIN would run.
|
|
229
240
|
NO_PLAN = /\A\s*(?:SHOW|DESCRIBE|DESC)\b/i
|
|
230
241
|
EXPLAIN_PREFIX = /\A\s*EXPLAIN\s+(?:\([^)]*\)\s*)?(?:ANALYZE\s+)?(?:VERBOSE\s+)?/i
|
|
242
|
+
# A generic plan leaves $1 unbound, so the statement under it is planned only with the option. The server reads
|
|
243
|
+
# the option's last value, quoted or not, and anything but a false spelling of it is true.
|
|
244
|
+
EXPLAIN_OPTIONS = /\A\s*EXPLAIN\s*\(([^)]*)\)/i
|
|
245
|
+
GENERIC_PLAN_OPTION = /\bGENERIC_PLAN(?:\s+'?(\w*)'?)?\s*(?=,|\z)/i
|
|
246
|
+
FALSE_SPELLING = /\A(?:f(?:a(?:l(?:se?)?)?)?|no?|off?|0)\z/i
|
|
247
|
+
# The handler a sampling method names is never called by the statement, and the plan prints its arguments.
|
|
248
|
+
SAMPLING_METHOD = /\bTABLESAMPLE\s+(?:\w+\s*\.\s*)?\w+(?=\s*\()/i
|
|
231
249
|
|
|
232
250
|
# A statement's text under a pointer. A lookbehind sees the text before the pointer, and where each dollar
|
|
233
251
|
# tag last stands is read in one pass: a search for its closer from every opener is quadratic.
|
|
@@ -274,7 +292,9 @@ module RailsAiContext
|
|
|
274
292
|
text, refusal = text_verdict(sql)
|
|
275
293
|
return refuse(refusal) if refusal
|
|
276
294
|
planned = @family.plan_checks? && !text.match?(NO_PLAN)
|
|
277
|
-
|
|
295
|
+
if planned
|
|
296
|
+
plan = @budget.spend { |left| @family.plan(@connection, text.sub(EXPLAIN_PREFIX, ""), timeout: left, generic: generic_plan?(text)) }
|
|
297
|
+
end
|
|
278
298
|
if planned && plan.nil?
|
|
279
299
|
return refuse("Blocked: the query is not run because its plan could not be read. On PostgreSQL " \
|
|
280
300
|
"the plan shows which columns a query returns, so no query runs without one.")
|
|
@@ -310,6 +330,33 @@ module RailsAiContext
|
|
|
310
330
|
out.strip
|
|
311
331
|
end
|
|
312
332
|
|
|
333
|
+
# The session settings a statement reads: by name where the name is code, outside a quoted span and a comment,
|
|
334
|
+
# and every name a list of the session's settings shows in its rows or its filter.
|
|
335
|
+
def self.settings_read(sql, family, rows)
|
|
336
|
+
text = comment_free(sql, family)
|
|
337
|
+
code = mask_quoted(text, family) { true }
|
|
338
|
+
# MySQL reads a double-quoted span as a string, or under ANSI_QUOTES as a name, which only a bare word can be here.
|
|
339
|
+
strings_blanked = mask_quoted(text, family) do |kind, span|
|
|
340
|
+
kind == :string || (family.mysql? && span.start_with?('"') && !span.match?(/\A"\w+"\z/))
|
|
341
|
+
end
|
|
342
|
+
read = text.enum_for(:scan, SETTING_READ).filter_map { $~[1] if code[$~.begin(0)] == text[$~.begin(0)] }
|
|
343
|
+
read += text.scan(/\w+/) + rows.flatten.map(&:to_s) if strings_blanked.match?(SETTINGS_LIST)
|
|
344
|
+
read.map(&:downcase)
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
# Every quoted span the block picks blanked to spaces of the same length, so positions still line up.
|
|
348
|
+
def self.mask_quoted(text, family, backslash: family.backslash_escapes?)
|
|
349
|
+
scanner = Scanner.new(text)
|
|
350
|
+
out = +""
|
|
351
|
+
|
|
352
|
+
until scanner.eos?
|
|
353
|
+
kind, span = quoted(scanner, family, backslash: backslash)
|
|
354
|
+
out << (span.nil? ? scanner.scan(UNQUOTED) || scanner.getch : yield(kind, span) ? " " * span.length : span)
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
out
|
|
358
|
+
end
|
|
359
|
+
|
|
313
360
|
# The quoted span that opens where the scanner stands, as the family reads it: [:string or :quoted, the span], or nil.
|
|
314
361
|
# brackets: whether [name] quotes a name in this text. backslash: whether one escapes the next character of a string.
|
|
315
362
|
def self.quoted(scanner, family, brackets: family.sqlite?, backslash: family.backslash_escapes?)
|
|
@@ -429,8 +476,8 @@ module RailsAiContext
|
|
|
429
476
|
end
|
|
430
477
|
table, why = @secrets.unread_table_in(cleaned)
|
|
431
478
|
return "Blocked: `#{table}` is not read: #{why}." if table
|
|
432
|
-
if (state = connection_state(cleaned))
|
|
433
|
-
return "Blocked: `#{state}` tells of the connection that runs it. rails_query runs a statement on a connection of its own and " \
|
|
479
|
+
if (state, view = connection_state(cleaned))
|
|
480
|
+
return "Blocked: `#{state}`#{", which the view `#{view}` reads," if view} tells of the connection that runs it. rails_query runs a statement on a connection of its own and " \
|
|
434
481
|
"sets some of that connection's values itself, so the answer would not be the app's. A pragma function that describes the " \
|
|
435
482
|
"schema or the database file runs, for example `pragma_table_info`."
|
|
436
483
|
end
|
|
@@ -551,14 +598,55 @@ module RailsAiContext
|
|
|
551
598
|
"#{" To read one setting, run SHOW with its name." if name.match?(/settings/i)}"
|
|
552
599
|
end
|
|
553
600
|
|
|
601
|
+
# [name, view] for the first function that tells of the connection in the statement, or in a view it reaches,
|
|
602
|
+
# which SQLite runs on the same connection.
|
|
554
603
|
def connection_state(cleaned)
|
|
555
604
|
return nil unless @family.sqlite?
|
|
556
605
|
|
|
557
|
-
cleaned
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
606
|
+
state = connection_state_in(cleaned)
|
|
607
|
+
return [ state, nil ] if state
|
|
608
|
+
|
|
609
|
+
@secrets.view_definitions_in(cleaned).each { |view, definition| (state = connection_state_in(definition)) and return [ state, view ] }
|
|
610
|
+
nil
|
|
611
|
+
end
|
|
612
|
+
|
|
613
|
+
# A counter is told by its call, a pragma function by its call or where a table stands (after FROM, JOIN, IN, a
|
|
614
|
+
# comma of a FROM list, a parenthesis that opens one, or a schema and a dot there); anywhere else it is a column.
|
|
615
|
+
def connection_state_in(cleaned)
|
|
616
|
+
# A table named like a counter does not hide the function, as one named like a pragma function does.
|
|
617
|
+
counter = cleaned[SQLITE_COUNTER_CALL, 1]
|
|
618
|
+
return counter.downcase if counter
|
|
619
|
+
|
|
620
|
+
list = tokens(cleaned)
|
|
621
|
+
clauses = [ nil ]
|
|
622
|
+
at_table = []
|
|
623
|
+
table_next = false
|
|
624
|
+
list.each_with_index do |(kind, text), i|
|
|
625
|
+
at_table << table_next
|
|
626
|
+
pragma = text.to_s[SQLITE_PRAGMA_NAME, 1]&.downcase if %i[word quoted string].include?(kind)
|
|
627
|
+
name = "pragma_#{pragma}" if pragma && !SQLITE_FILE_PRAGMAS.include?(pragma)
|
|
628
|
+
return name if name && (table_next || list[i + 1] == [ :punct, "(" ]) && !@secrets.knows?(name)
|
|
629
|
+
|
|
630
|
+
word = text.downcase if kind == :word
|
|
631
|
+
case [ kind, text ]
|
|
632
|
+
when [ :punct, "(" ]
|
|
633
|
+
clauses.push(table_next == :from ? "from" : nil)
|
|
634
|
+
table_next = table_next == :from && :from
|
|
635
|
+
when [ :punct, ")" ]
|
|
636
|
+
clauses.pop if clauses.size > 1
|
|
637
|
+
table_next = false
|
|
638
|
+
when [ :punct, "," ] then table_next = FROM_LIST_CLAUSES.include?(clauses.last) && :from
|
|
639
|
+
when [ :punct, "." ] then table_next = i.positive? && at_table[i - 1]
|
|
640
|
+
else table_next = (%w[from join].include?(word) && :from) || (word == "in" && :in)
|
|
641
|
+
end
|
|
642
|
+
clauses[-1] = word if CLAUSE_WORDS.include?(word)
|
|
643
|
+
end
|
|
644
|
+
nil
|
|
645
|
+
end
|
|
646
|
+
|
|
647
|
+
def generic_plan?(text)
|
|
648
|
+
value = text[EXPLAIN_OPTIONS, 1]&.scan(GENERIC_PLAN_OPTION)&.last or return false
|
|
649
|
+
value.first.to_s.empty? || !value.first.match?(FALSE_SPELLING)
|
|
562
650
|
end
|
|
563
651
|
|
|
564
652
|
def dump_refusal(function)
|
|
@@ -634,15 +722,7 @@ module RailsAiContext
|
|
|
634
722
|
# only and positions still line up. only: the one kind to blank, :string or :quoted. With a block, the spans it picks.
|
|
635
723
|
def mask_quoted(text, only = nil, &picks)
|
|
636
724
|
picks ||= ->(kind, _span) { only.nil? || kind == only }
|
|
637
|
-
|
|
638
|
-
out = +""
|
|
639
|
-
|
|
640
|
-
until scanner.eos?
|
|
641
|
-
kind, span = self.class.quoted(scanner, @family, backslash: @backslash)
|
|
642
|
-
out << (span.nil? ? scanner.scan(UNQUOTED) || scanner.getch : picks.call(kind, span) ? " " * span.length : span)
|
|
643
|
-
end
|
|
644
|
-
|
|
645
|
-
out
|
|
725
|
+
self.class.mask_quoted(text, @family, backslash: @backslash, &picks)
|
|
646
726
|
end
|
|
647
727
|
|
|
648
728
|
# [kind, text] for each token of comment-free text: :word (bare, a keyword or a name), :quoted (a name in
|
|
@@ -853,7 +933,7 @@ module RailsAiContext
|
|
|
853
933
|
foreign = plan.nodes.select { |node| node["Remote SQL"] }
|
|
854
934
|
conds.concat(foreign.map { |node| remote_uses(node) }, local_names(foreign))
|
|
855
935
|
# EXPLAIN prints no row of a VALUES list and no LIMIT, so the functions the text names are read too.
|
|
856
|
-
called = function_names(raw.map { |expr| expr.gsub(PLAN_LITERAL, "''") } + [ mask_sql_literals(text) ])
|
|
936
|
+
called = function_names(raw.map { |expr| expr.gsub(PLAN_LITERAL, "''") } + [ mask_sql_literals(text).gsub(SAMPLING_METHOD, "TABLESAMPLE ") ])
|
|
857
937
|
|
|
858
938
|
# A view reaches a relation or a function the text check did not see.
|
|
859
939
|
sources = plan.nodes.flat_map { |node| node.values_at("Relation Name", "Function Name") }.compact + called.to_a
|
|
@@ -882,7 +962,10 @@ module RailsAiContext
|
|
|
882
962
|
"or set operation whose output the tool cannot map to a column, so it could leave " \
|
|
883
963
|
"unredacted. Name the other columns you need; `#{col}` is never returned."
|
|
884
964
|
end
|
|
885
|
-
if (fn = volatile_function(called))
|
|
965
|
+
if (fn = volatile_function(called)) && PG_VOLATILE_ALLOWLIST.include?(fn)
|
|
966
|
+
"Blocked: the database holds a VOLATILE function named #{fn} that rails_query cannot vouch for, so rails_query runs no " \
|
|
967
|
+
"call of that name. Its list of read-only VOLATILE functions trusts the name for the server's own function or an extension's C function alone."
|
|
968
|
+
elsif fn
|
|
886
969
|
"Blocked: function #{fn} is VOLATILE (pg_proc.provolatile = 'v') and not on " \
|
|
887
970
|
"rails_query's list of read-only VOLATILE functions, so it is not run. Some VOLATILE " \
|
|
888
971
|
"functions change server or session state in ways a read-only transaction does not " \
|
|
@@ -966,6 +1049,11 @@ module RailsAiContext
|
|
|
966
1049
|
# pin it. A column handed to a node that reads by name and names it nowhere (`count(*)`) is read by nothing.
|
|
967
1050
|
def laundered_secret_column(plan)
|
|
968
1051
|
top = secret_base_columns(plan, plan.output.flatten)
|
|
1052
|
+
# A set operation compares every column its inputs hand up, so a row of guesses tests a secret value.
|
|
1053
|
+
plan.nodes.select { |node| node["Node Type"] == "SetOp" }.each do |node|
|
|
1054
|
+
handed = ([ node ] + Array(node["Plans"])).flat_map { |held| Array(held["Output"]) }
|
|
1055
|
+
(col = @secrets.in_schema.find { |name| names?(handed, name) }) and return col
|
|
1056
|
+
end
|
|
969
1057
|
plan.nodes.each do |node|
|
|
970
1058
|
reader = plan.reader(node)
|
|
971
1059
|
secret_base_columns(plan, Array(node["Output"])).each do |var|
|
|
@@ -990,15 +1078,18 @@ module RailsAiContext
|
|
|
990
1078
|
exprs.each_with_object(Set.new) { |expr, names| expr.scan(/([A-Za-z_]\w*)\s*\(/) { |(fn)| names << fn.downcase } }
|
|
991
1079
|
end
|
|
992
1080
|
|
|
1081
|
+
# A name on the read-only list vouches for the server's own function and C code alone: while the database holds a
|
|
1082
|
+
# VOLATILE function of that name in another language outside pg_catalog, every call of the name is refused.
|
|
993
1083
|
def volatile_function(called)
|
|
994
|
-
|
|
995
|
-
return nil if names.empty?
|
|
1084
|
+
return nil if called.empty?
|
|
996
1085
|
|
|
997
|
-
quoted = names.map { |n| @connection.quote(n) }.join(", ")
|
|
1086
|
+
quoted, listed = [ called, PG_VOLATILE_ALLOWLIST ].map { |names| names.map { |n| @connection.quote(n) }.join(", ") }
|
|
998
1087
|
@budget.spend do |left|
|
|
999
1088
|
@family.bounded(@connection, timeout: left) do
|
|
1000
1089
|
@connection.select_values(
|
|
1001
|
-
"SELECT DISTINCT lower(proname) FROM pg_proc
|
|
1090
|
+
"SELECT DISTINCT lower(p.proname) FROM pg_proc p JOIN pg_language l ON l.oid = p.prolang " \
|
|
1091
|
+
"WHERE lower(p.proname) IN (#{quoted}) AND p.provolatile = 'v' AND (lower(p.proname) NOT IN (#{listed}) " \
|
|
1092
|
+
"OR (p.pronamespace <> 'pg_catalog'::regnamespace AND l.lanname NOT IN ('c', 'internal')))"
|
|
1002
1093
|
).first
|
|
1003
1094
|
end
|
|
1004
1095
|
end
|
|
@@ -284,19 +284,19 @@ module RailsAiContext
|
|
|
284
284
|
|
|
285
285
|
if auth_checks.any? || auth_denials.any?
|
|
286
286
|
sections << "" << "### Authorization"
|
|
287
|
-
auth_checks.uniq
|
|
288
|
-
auth_denials.uniq
|
|
287
|
+
sections.concat(capped_list(auth_checks.uniq) { |c| "- Check: `#{c}`" })
|
|
288
|
+
sections.concat(capped_list(auth_denials.uniq) { |d| "- Deny: #{d}" })
|
|
289
289
|
end
|
|
290
290
|
|
|
291
291
|
if flash_notices.any? || flash_alerts.any?
|
|
292
292
|
sections << "" << "### Flash Messages"
|
|
293
|
-
flash_notices.uniq
|
|
294
|
-
flash_alerts.uniq
|
|
293
|
+
sections.concat(capped_list(flash_notices.uniq) { |n| "- Success: notice: \"#{n}\"" })
|
|
294
|
+
sections.concat(capped_list(flash_alerts.uniq) { |a| "- Failure: alert: \"#{a}\"" })
|
|
295
295
|
end
|
|
296
296
|
|
|
297
297
|
if not_found_patterns.any?
|
|
298
298
|
sections << "" << "### Error Handling"
|
|
299
|
-
|
|
299
|
+
sections.concat(capped_list(not_found_patterns) { |p| "- Not found: #{p}" })
|
|
300
300
|
end
|
|
301
301
|
|
|
302
302
|
if create_flows.any?
|
|
@@ -402,6 +402,11 @@ module RailsAiContext
|
|
|
402
402
|
RailsAiContext.debug_fail(e, [], label: "detect_app_patterns")
|
|
403
403
|
end
|
|
404
404
|
|
|
405
|
+
private_class_method def self.capped_list(items, cap = 5, &line)
|
|
406
|
+
shown = items.first(cap).map(&line)
|
|
407
|
+
items.size > cap ? shown << "- _...and #{items.size - cap} more._" : shown
|
|
408
|
+
end
|
|
409
|
+
|
|
405
410
|
private_class_method def self.detect_locale_info
|
|
406
411
|
info = []
|
|
407
412
|
locales_dir = rails_app.root.join("config", "locales").to_s
|
|
@@ -81,6 +81,8 @@ module RailsAiContext
|
|
|
81
81
|
# `order_comments` are the same request.
|
|
82
82
|
declared = table_key.to_s.underscore
|
|
83
83
|
if declared_not_connected(schema).include?(declared)
|
|
84
|
+
return text_response("Table '#{declared}' is declared in #{dump_name(schema)}. #{shared_dump_note(schema, [ declared ])}") if schema[:shared_dump]
|
|
85
|
+
|
|
84
86
|
return text_response(
|
|
85
87
|
"Table '#{declared}' is declared in #{schema[:declared_in] || "db/schema.rb"} and missing from the connected database. " \
|
|
86
88
|
"#{pending_for_table(schema, declared)}" \
|
|
@@ -401,6 +403,25 @@ module RailsAiContext
|
|
|
401
403
|
schema[:declared_in] || schema[:note].to_s[/\AParsed from (\S+)/, 1] || "db/schema.rb"
|
|
402
404
|
end
|
|
403
405
|
|
|
406
|
+
# A table the shared dump declares that the connected database lacks may be the other database's,
|
|
407
|
+
# so it is no sign the connected one is behind.
|
|
408
|
+
private_class_method def self.shared_dump_lines(schema)
|
|
409
|
+
absent = schema[:connected_tables]&.zero? ? (schema[:tables] || {}).keys.map(&:to_s) : declared_not_connected(schema)
|
|
410
|
+
return [] if absent.empty?
|
|
411
|
+
|
|
412
|
+
[ "_#{shared_dump_note(schema, absent)}_" ]
|
|
413
|
+
end
|
|
414
|
+
|
|
415
|
+
private_class_method def self.shared_dump_note(schema, absent)
|
|
416
|
+
shared = schema[:shared_dump]
|
|
417
|
+
"#{shared[:primary]} and #{shared[:other]} both dump to #{dump_name(schema)}, so it may describe #{shared[:other]}. " \
|
|
418
|
+
"Not in #{shared[:primary]}: #{names_phrase(absent)}."
|
|
419
|
+
end
|
|
420
|
+
|
|
421
|
+
private_class_method def self.names_phrase(names)
|
|
422
|
+
"#{names.sort.first(5).join(', ')}#{" (+#{names.size - 5} more)" if names.size > 5}"
|
|
423
|
+
end
|
|
424
|
+
|
|
404
425
|
private_class_method def self.static_source_lines(schema)
|
|
405
426
|
lines = note_lines(schema)
|
|
406
427
|
lines << "**Dialect:** #{schema[:dialect]}" if schema[:dialect] && schema[:dialect] != "unknown"
|
|
@@ -409,17 +430,20 @@ module RailsAiContext
|
|
|
409
430
|
# db/schema.rb, and nothing joined the two: at that migration the app
|
|
410
431
|
# has the table the database is missing.
|
|
411
432
|
missing = declared_not_connected(schema)
|
|
412
|
-
if
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
433
|
+
if schema[:shared_dump]
|
|
434
|
+
lines.concat(shared_dump_lines(schema))
|
|
435
|
+
else
|
|
436
|
+
if missing.any?
|
|
437
|
+
declared_total = Array(schema[:declared_tables]).size
|
|
438
|
+
connected_total = Introspectors::SchemaConventions.table_count(schema[:tables] || {})
|
|
439
|
+
lines << "_#{schema[:declared_in] || "db/schema.rb"} declares #{count_phrase(declared_total, "table")}; the connected database has " \
|
|
440
|
+
"#{connected_total}. Missing: #{names_phrase(missing)}. Run `rails db:migrate`._"
|
|
441
|
+
end
|
|
442
|
+
# A database that holds none of the tables the dump lists is created
|
|
443
|
+
# but not migrated, and the listing above is the dump's.
|
|
444
|
+
if schema[:connected_tables]&.zero? && (declared_total = Introspectors::SchemaConventions.table_count(schema[:tables] || {})).positive?
|
|
445
|
+
lines << "_#{dump_name(schema)} declares #{count_phrase(declared_total, "table")}; the connected database has none of them. Run `rails db:migrate`._"
|
|
446
|
+
end
|
|
423
447
|
end
|
|
424
448
|
if schema[:pending_unknown]
|
|
425
449
|
lines << "**Pending migrations:** not known: #{schema[:pending_unknown]}"
|
|
@@ -198,10 +198,11 @@ module RailsAiContext
|
|
|
198
198
|
# a parser still reads the block); the unbounded note stays off CSV.
|
|
199
199
|
cut = !schema_metadata && result.respond_to?(:truncated) && result.truncated
|
|
200
200
|
truncation = cut ? truncation_note([ row_limit, HARD_ROW_CAP ].min, format) : ""
|
|
201
|
+
settings = settings_note(family.own_settings(connection, SqlGate.settings_read(sql, family, result.rows)), format)
|
|
201
202
|
output = if format == "csv"
|
|
202
|
-
format_csv(redacted) + truncation
|
|
203
|
+
format_csv(redacted) + truncation + settings
|
|
203
204
|
else
|
|
204
|
-
(definition ? format_definition(redacted) : format_table(redacted)) + truncation + unbounded_note(result)
|
|
205
|
+
(definition ? format_definition(redacted) : format_table(redacted)) + truncation + settings + unbounded_note(result)
|
|
205
206
|
end
|
|
206
207
|
|
|
207
208
|
text_response(output)
|
|
@@ -230,6 +231,15 @@ module RailsAiContext
|
|
|
230
231
|
RailsAiContext.debug_fail(e, "", label: "held_elsewhere")
|
|
231
232
|
end
|
|
232
233
|
|
|
234
|
+
# A statement runs with settings rails_query sets for it, which a statement that reads them answers.
|
|
235
|
+
private_class_method def self.settings_note(own, format)
|
|
236
|
+
return "" if own.empty?
|
|
237
|
+
|
|
238
|
+
note = "#{own.keys.join(" and ")} here #{own.size > 1 ? "are the values" : "is the value"} rails_query sets for each " \
|
|
239
|
+
"statement it runs. The session's own: #{own.map { |name, value| "#{name} #{value}" }.join(", ")}."
|
|
240
|
+
format == "csv" ? "\n\n#{note}" : "\n\n_#{note}_"
|
|
241
|
+
end
|
|
242
|
+
|
|
233
243
|
private_class_method def self.unbounded_note(result)
|
|
234
244
|
result.respond_to?(:unbounded) && result.unbounded ? UNBOUNDED_SQLITE_NOTE : ""
|
|
235
245
|
end
|
|
@@ -370,8 +370,21 @@ module RailsAiContext
|
|
|
370
370
|
end
|
|
371
371
|
lines << "**Adapter:** #{adapter_name}"
|
|
372
372
|
|
|
373
|
-
|
|
374
|
-
|
|
373
|
+
sidekiq_adapter = adapter_name == "sidekiq" && defined?(Sidekiq)
|
|
374
|
+
unless sidekiq_adapter
|
|
375
|
+
if adapter_name.start_with?("not ")
|
|
376
|
+
lines << "_Queue stats unavailable: no queue adapter detected._"
|
|
377
|
+
else
|
|
378
|
+
# The adapter's name says the app has a queue backend, one the tool reads no stats from.
|
|
379
|
+
lines << "_Live queue stats are only implemented for Sidekiq; this app uses the #{adapter_name} adapter._"
|
|
380
|
+
end
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
# Sidekiq's counts are the app's jobs under its adapter, else only its own Sidekiq::Job workers.
|
|
384
|
+
if sidekiq_adapter || (defined?(Sidekiq) && Payload.workers(cached_context).any?)
|
|
385
|
+
unless sidekiq_adapter
|
|
386
|
+
lines.push("", "### Sidekiq", "_Counts for the Sidekiq workers the app runs outside Active Job; its #{adapter_name} jobs are not in them._")
|
|
387
|
+
end
|
|
375
388
|
begin
|
|
376
389
|
require "sidekiq/api"
|
|
377
390
|
stats = Sidekiq::Stats.new
|
|
@@ -396,14 +409,6 @@ module RailsAiContext
|
|
|
396
409
|
rescue => e
|
|
397
410
|
lines << "_Sidekiq stats error: #{e.message}_"
|
|
398
411
|
end
|
|
399
|
-
else
|
|
400
|
-
# Name the adapter the app actually uses so "no stats" reads as a
|
|
401
|
-
# tool limitation, not as the app having no queue backend.
|
|
402
|
-
if adapter_name.start_with?("not ")
|
|
403
|
-
lines << "_Queue stats unavailable: no queue adapter detected._"
|
|
404
|
-
else
|
|
405
|
-
lines << "_Live queue stats are only implemented for Sidekiq; this app uses the #{adapter_name} adapter._"
|
|
406
|
-
end
|
|
407
412
|
end
|
|
408
413
|
|
|
409
414
|
lines << ""
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: rails-ai-context
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 5.
|
|
4
|
+
version: 5.36.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- crisnahine
|
|
@@ -223,18 +223,10 @@ executables:
|
|
|
223
223
|
extensions: []
|
|
224
224
|
extra_rdoc_files: []
|
|
225
225
|
files:
|
|
226
|
-
- ".rspec"
|
|
227
|
-
- ".rubocop.yml"
|
|
228
|
-
- ".ruby-version"
|
|
229
|
-
- AGENTS.md
|
|
230
226
|
- CHANGELOG.md
|
|
231
|
-
- CLAUDE.md
|
|
232
|
-
- CODE_OF_CONDUCT.md
|
|
233
|
-
- CONTEXT.md
|
|
234
227
|
- CONTRIBUTING.md
|
|
235
228
|
- LICENSE
|
|
236
229
|
- README.md
|
|
237
|
-
- Rakefile
|
|
238
230
|
- SECURITY.md
|
|
239
231
|
- app/controllers/rails_ai_context/mcp_controller.rb
|
|
240
232
|
- config/routes.rb
|
|
@@ -249,27 +241,14 @@ files:
|
|
|
249
241
|
- docs/PROMPTS.md
|
|
250
242
|
- docs/QUICKSTART.md
|
|
251
243
|
- docs/RECIPES.md
|
|
252
|
-
- docs/RELEASING.md
|
|
253
244
|
- docs/SECURITY.md
|
|
254
245
|
- docs/SETUP.md
|
|
255
246
|
- docs/STANDALONE.md
|
|
256
247
|
- docs/TOOLS.md
|
|
257
248
|
- docs/TROUBLESHOOTING.md
|
|
258
|
-
- docs/_config.yml
|
|
259
|
-
- docs/_includes/head-custom.html
|
|
260
|
-
- docs/adr/0001-own-listener-registration.md
|
|
261
|
-
- docs/adr/0002-static-tier-declared-not-detected.md
|
|
262
|
-
- docs/adr/0003-shared-tool-cache-semantics.md
|
|
263
|
-
- docs/adr/0004-install-e2e-stays-example-rich.md
|
|
264
249
|
- docs/adr/0005-workspace-mcp-entries.md
|
|
265
250
|
- docs/adr/0006-strong-params-resolve-as-ruby-runs.md
|
|
266
251
|
- docs/adr/0007-every-install-question-has-a-flag.md
|
|
267
|
-
- docs/adr/0008-query-runs-only-where-it-can-guard.md
|
|
268
|
-
- docs/adr/0009-dbhub-shapes-in-ruby.md
|
|
269
|
-
- docs/adr/0010-one-config-tool-with-sections.md
|
|
270
|
-
- docs/agents/domain.md
|
|
271
|
-
- docs/agents/issue-tracker.md
|
|
272
|
-
- docs/agents/triage-labels.md
|
|
273
252
|
- docs/index.md
|
|
274
253
|
- exe/rails-ai-context
|
|
275
254
|
- lib/generators/rails_ai_context/install/install_generator.rb
|
|
@@ -585,7 +564,6 @@ files:
|
|
|
585
564
|
- lib/rails_ai_context/vfs.rb
|
|
586
565
|
- lib/rails_ai_context/view_file.rb
|
|
587
566
|
- lib/rails_ai_context/watcher.rb
|
|
588
|
-
- server.json
|
|
589
567
|
homepage: https://github.com/crisnahine/rails-ai-context
|
|
590
568
|
licenses:
|
|
591
569
|
- MIT
|
data/.rspec
DELETED
data/.rubocop.yml
DELETED
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
inherit_gem:
|
|
2
|
-
rubocop-rails-omakase: rubocop.yml
|
|
3
|
-
|
|
4
|
-
AllCops:
|
|
5
|
-
TargetRubyVersion: 3.1
|
|
6
|
-
NewCops: enable
|
|
7
|
-
Exclude:
|
|
8
|
-
- "spec/fixtures/**/*"
|
|
9
|
-
- "vendor/**/*"
|
|
10
|
-
|
|
11
|
-
Style/Documentation:
|
|
12
|
-
Enabled: false
|
|
13
|
-
|
|
14
|
-
# rubocop-rails-omakase disables the whole Metrics department. Setting Max on a
|
|
15
|
-
# disabled cop does not re-enable it, so size limits here would enforce nothing.
|
|
16
|
-
# Turning it on would need ~40 existing files brought under the limit first.
|
data/.ruby-version
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
3.4.9
|
data/AGENTS.md
DELETED
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
# rails-ai-context
|
|
2
|
-
|
|
3
|
-
## Agent skills
|
|
4
|
-
|
|
5
|
-
### Issue tracker
|
|
6
|
-
|
|
7
|
-
Issues live as GitHub issues on `crisnahine/rails-ai-context`, driven with the `gh` CLI. See `docs/agents/issue-tracker.md`.
|
|
8
|
-
|
|
9
|
-
### Triage labels
|
|
10
|
-
|
|
11
|
-
The five canonical roles, each label string equal to its name (`needs-triage`, `needs-info`, `ready-for-agent`, `ready-for-human`, `wontfix`). See `docs/agents/triage-labels.md`.
|
|
12
|
-
|
|
13
|
-
### Domain docs
|
|
14
|
-
|
|
15
|
-
Single-context: `CONTEXT.md` and `docs/adr/` at the repo root. See `docs/agents/domain.md`.
|
data/CLAUDE.md
DELETED
data/CODE_OF_CONDUCT.md
DELETED
|
@@ -1,44 +0,0 @@
|
|
|
1
|
-
# Contributor Covenant Code of Conduct
|
|
2
|
-
|
|
3
|
-
## Our Pledge
|
|
4
|
-
|
|
5
|
-
We as members, contributors, and leaders pledge to make participation in our
|
|
6
|
-
community a harassment-free experience for everyone, regardless of age, body
|
|
7
|
-
size, visible or invisible disability, ethnicity, sex characteristics, gender
|
|
8
|
-
identity and expression, level of experience, education, socio-economic status,
|
|
9
|
-
nationality, personal appearance, race, caste, color, religion, or sexual
|
|
10
|
-
identity and orientation.
|
|
11
|
-
|
|
12
|
-
We pledge to act and interact in ways that contribute to an open, welcoming,
|
|
13
|
-
diverse, inclusive, and healthy community.
|
|
14
|
-
|
|
15
|
-
## Our Standards
|
|
16
|
-
|
|
17
|
-
Examples of behavior that contributes to a positive environment:
|
|
18
|
-
|
|
19
|
-
* Using welcoming and inclusive language
|
|
20
|
-
* Being respectful of differing viewpoints and experiences
|
|
21
|
-
* Gracefully accepting constructive criticism
|
|
22
|
-
* Focusing on what is best for the community
|
|
23
|
-
* Showing empathy towards other community members
|
|
24
|
-
|
|
25
|
-
Examples of unacceptable behavior:
|
|
26
|
-
|
|
27
|
-
* The use of sexualized language or imagery, and sexual attention or advances of any kind
|
|
28
|
-
* Trolling, insulting or derogatory comments, and personal or political attacks
|
|
29
|
-
* Public or private harassment
|
|
30
|
-
* Publishing others' private information without explicit permission
|
|
31
|
-
* Other conduct which could reasonably be considered inappropriate in a professional setting
|
|
32
|
-
|
|
33
|
-
## Enforcement
|
|
34
|
-
|
|
35
|
-
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
|
36
|
-
reported to the project maintainer at **crisjosephnahine@gmail.com**.
|
|
37
|
-
|
|
38
|
-
All complaints will be reviewed and investigated promptly and fairly.
|
|
39
|
-
|
|
40
|
-
## Attribution
|
|
41
|
-
|
|
42
|
-
This Code of Conduct is adapted from the [Contributor Covenant](https://www.contributor-covenant.org),
|
|
43
|
-
version 2.1, available at
|
|
44
|
-
[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html](https://www.contributor-covenant.org/version/2/1/code_of_conduct.html).
|