rails-ai-context 5.35.2 → 5.36.1

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 (48) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +197 -1
  3. data/CONTRIBUTING.md +1 -0
  4. data/docs/COMPATIBILITY.md +14 -1
  5. data/docs/SECURITY.md +12 -10
  6. data/docs/TOOLS.md +12 -11
  7. data/docs/TROUBLESHOOTING.md +1 -1
  8. data/lib/rails_ai_context/database_family/mysql_runtime.rb +17 -2
  9. data/lib/rails_ai_context/database_family/postgresql_plan.rb +6 -2
  10. data/lib/rails_ai_context/database_family/postgresql_runtime.rb +41 -6
  11. data/lib/rails_ai_context/database_family/sqlite_runtime.rb +9 -3
  12. data/lib/rails_ai_context/database_family.rb +27 -5
  13. data/lib/rails_ai_context/introspector.rb +12 -0
  14. data/lib/rails_ai_context/introspectors/eager_load.rb +12 -2
  15. data/lib/rails_ai_context/introspectors/job_introspector.rb +57 -14
  16. data/lib/rails_ai_context/introspectors/model_introspector.rb +13 -11
  17. data/lib/rails_ai_context/introspectors/schema_introspector.rb +12 -1
  18. data/lib/rails_ai_context/payload.rb +2 -3
  19. data/lib/rails_ai_context/secret_columns.rb +59 -37
  20. data/lib/rails_ai_context/sql_gate.rb +118 -25
  21. data/lib/rails_ai_context/tools/get_conventions.rb +10 -5
  22. data/lib/rails_ai_context/tools/get_schema.rb +35 -11
  23. data/lib/rails_ai_context/tools/query.rb +12 -2
  24. data/lib/rails_ai_context/tools/runtime_info.rb +15 -10
  25. data/lib/rails_ai_context/version.rb +1 -1
  26. metadata +1 -23
  27. data/.rspec +0 -3
  28. data/.rubocop.yml +0 -16
  29. data/.ruby-version +0 -1
  30. data/AGENTS.md +0 -15
  31. data/CLAUDE.md +0 -3
  32. data/CODE_OF_CONDUCT.md +0 -44
  33. data/CONTEXT.md +0 -242
  34. data/Rakefile +0 -80
  35. data/docs/RELEASING.md +0 -72
  36. data/docs/_config.yml +0 -15
  37. data/docs/_includes/head-custom.html +0 -16
  38. data/docs/adr/0001-own-listener-registration.md +0 -9
  39. data/docs/adr/0002-static-tier-declared-not-detected.md +0 -5
  40. data/docs/adr/0003-shared-tool-cache-semantics.md +0 -31
  41. data/docs/adr/0004-install-e2e-stays-example-rich.md +0 -28
  42. data/docs/adr/0008-query-runs-only-where-it-can-guard.md +0 -38
  43. data/docs/adr/0009-dbhub-shapes-in-ruby.md +0 -26
  44. data/docs/adr/0010-one-config-tool-with-sections.md +0 -32
  45. data/docs/agents/domain.md +0 -38
  46. data/docs/agents/issue-tracker.md +0 -45
  47. data/docs/agents/triage-labels.md +0 -15
  48. data/server.json +0 -21
@@ -121,7 +121,19 @@ 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
- SQLITE_CONNECTION_STATE = /\bpragma_(\w+)|\b(changes|total_changes|last_insert_rowid)\s*\(/i
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
+ # A double-quoted bare word, which MySQL under ANSI_QUOTES reads as a name and otherwise as a string.
132
+ BARE_QUOTED_WORD = /\A"\w+"\z/
133
+ # How a statement reads a session setting by name: SHOW, @@, @@SESSION. or @@LOCAL. (backquoted or not), current_setting('..').
134
+ SETTING_READ = /(?:\bshow\s+(?!global\b)|@@(?!global\s*\.)(?:(?:session|local)\s*\.\s*)?|current_setting\s*\(\s*')`?(\w+)/i
135
+ # A list of the session's settings, which names each in its rows or in the filter the statement puts on it.
136
+ SETTINGS_LIST = /\A\s*show\s+(?:(?:session|local)\s+)?variables\b|\b["`]?information_schema["`]?\s*\.\s*["`]?session_variables\b/i
125
137
 
126
138
  # Catalogs refused when a statement names them. A view over one runs, and the plan checks hold the
127
139
  # column that carries the credential (SecretColumns::CATALOG_CREDENTIALS) as a secret column.
@@ -176,6 +188,7 @@ module RailsAiContext
176
188
  pg_is_in_recovery pg_is_wal_replay_paused pg_get_wal_replay_pause_state
177
189
  pg_current_wal_lsn pg_current_wal_insert_lsn pg_current_wal_flush_lsn
178
190
  pg_last_wal_receive_lsn pg_last_wal_replay_lsn pg_last_xact_replay_timestamp
191
+ pg_sequence_last_value
179
192
  ].freeze
180
193
 
181
194
  # Any character past ASCII is an identifier character to SQLite and MySQL, a no-break space included.
@@ -228,6 +241,13 @@ module RailsAiContext
228
241
  # Every other statement is planned, as is whatever a leading EXPLAIN would run.
229
242
  NO_PLAN = /\A\s*(?:SHOW|DESCRIBE|DESC)\b/i
230
243
  EXPLAIN_PREFIX = /\A\s*EXPLAIN\s+(?:\([^)]*\)\s*)?(?:ANALYZE\s+)?(?:VERBOSE\s+)?/i
244
+ # A generic plan leaves $1 unbound, so the statement under it is planned only with the option. The server reads
245
+ # the option's last value, quoted or not, and anything but a false spelling of it is true.
246
+ EXPLAIN_OPTIONS = /\A\s*EXPLAIN\s*\(([^)]*)\)/i
247
+ GENERIC_PLAN_OPTION = /\bGENERIC_PLAN(?:\s+'?(\w*)'?)?\s*(?=,|\z)/i
248
+ FALSE_SPELLING = /\A(?:f(?:a(?:l(?:se?)?)?)?|no?|off?|0)\z/i
249
+ # The handler a sampling method names is never called by the statement, and the plan prints its arguments.
250
+ SAMPLING_METHOD = /\bTABLESAMPLE\s+(?:\w+\s*\.\s*)?\w+(?=\s*\()/i
231
251
 
232
252
  # A statement's text under a pointer. A lookbehind sees the text before the pointer, and where each dollar
233
253
  # tag last stands is read in one pass: a search for its closer from every opener is quadratic.
@@ -274,7 +294,9 @@ module RailsAiContext
274
294
  text, refusal = text_verdict(sql)
275
295
  return refuse(refusal) if refusal
276
296
  planned = @family.plan_checks? && !text.match?(NO_PLAN)
277
- plan = @budget.spend { |left| @family.plan(@connection, text.sub(EXPLAIN_PREFIX, ""), timeout: left) } if planned
297
+ if planned
298
+ plan = @budget.spend { |left| @family.plan(@connection, text.sub(EXPLAIN_PREFIX, ""), timeout: left, generic: generic_plan?(text)) }
299
+ end
278
300
  if planned && plan.nil?
279
301
  return refuse("Blocked: the query is not run because its plan could not be read. On PostgreSQL " \
280
302
  "the plan shows which columns a query returns, so no query runs without one.")
@@ -310,6 +332,33 @@ module RailsAiContext
310
332
  out.strip
311
333
  end
312
334
 
335
+ # The session settings a statement reads: by name where the name is code, outside a quoted span and a comment,
336
+ # and every name a list of the session's settings shows in its rows or its filter.
337
+ def self.settings_read(sql, family, rows)
338
+ text = comment_free(sql, family)
339
+ code = mask_quoted(text, family) { true }
340
+ # On MySQL a double-quoted span other than a bare word is a string.
341
+ strings_blanked = mask_quoted(text, family) do |kind, span|
342
+ kind == :string || (family.mysql? && span.start_with?('"') && !span.match?(BARE_QUOTED_WORD))
343
+ end
344
+ read = text.enum_for(:scan, SETTING_READ).filter_map { $~[1] if code[$~.begin(0)] == text[$~.begin(0)] }
345
+ read += text.scan(/\w+/) + rows.flatten.map(&:to_s) if strings_blanked.match?(SETTINGS_LIST)
346
+ read.map(&:downcase)
347
+ end
348
+
349
+ # Every quoted span the block picks blanked to spaces of the same length, so positions still line up.
350
+ def self.mask_quoted(text, family, backslash: family.backslash_escapes?)
351
+ scanner = Scanner.new(text)
352
+ out = +""
353
+
354
+ until scanner.eos?
355
+ kind, span = quoted(scanner, family, backslash: backslash)
356
+ out << (span.nil? ? scanner.scan(UNQUOTED) || scanner.getch : yield(kind, span) ? " " * span.length : span)
357
+ end
358
+
359
+ out
360
+ end
361
+
313
362
  # The quoted span that opens where the scanner stands, as the family reads it: [:string or :quoted, the span], or nil.
314
363
  # brackets: whether [name] quotes a name in this text. backslash: whether one escapes the next character of a string.
315
364
  def self.quoted(scanner, family, brackets: family.sqlite?, backslash: family.backslash_escapes?)
@@ -420,7 +469,7 @@ module RailsAiContext
420
469
  return never_read_refusal(m.captures.compact.first)
421
470
  end
422
471
  if (m = listed(SYSTEM_SCHEMAS, mask_quoted(cleaned, :string)))
423
- text_only = ->(kind, span) { kind == :string || (span.start_with?('"') && !span.match?(/\A"\w+"\z/)) }
472
+ text_only = ->(kind, span) { kind == :string || (span.start_with?('"') && !span.match?(BARE_QUOTED_WORD)) }
424
473
  return "#{never_read_refusal(m[1])} A name before a dot is read as that schema, so an alias named `#{m[1]}` needs another name." if
425
474
  listed(SYSTEM_SCHEMAS, mask_quoted(cleaned, &text_only))
426
475
 
@@ -429,8 +478,8 @@ module RailsAiContext
429
478
  end
430
479
  table, why = @secrets.unread_table_in(cleaned)
431
480
  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 " \
481
+ if (state, view = connection_state(cleaned))
482
+ 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
483
  "sets some of that connection's values itself, so the answer would not be the app's. A pragma function that describes the " \
435
484
  "schema or the database file runs, for example `pragma_table_info`."
436
485
  end
@@ -551,14 +600,55 @@ module RailsAiContext
551
600
  "#{" To read one setting, run SHOW with its name." if name.match?(/settings/i)}"
552
601
  end
553
602
 
603
+ # [name, view] for the first function that tells of the connection in the statement, or in a view it reaches,
604
+ # which SQLite runs on the same connection.
554
605
  def connection_state(cleaned)
555
606
  return nil unless @family.sqlite?
556
607
 
557
- cleaned.to_enum(:scan, SQLITE_CONNECTION_STATE).filter_map do
558
- pragma, counter = Regexp.last_match.captures
559
- name = counter&.downcase || ("pragma_#{pragma.downcase}" unless SQLITE_FILE_PRAGMAS.include?(pragma.downcase))
560
- name unless name.nil? || @secrets.knows?(name)
561
- end.first
608
+ state = connection_state_in(cleaned)
609
+ return [ state, nil ] if state
610
+
611
+ @secrets.view_definitions_in(cleaned).each { |view, definition| (state = connection_state_in(definition)) and return [ state, view ] }
612
+ nil
613
+ end
614
+
615
+ # A counter is told by its call, a pragma function by its call or where a table stands (after FROM, JOIN, IN, a
616
+ # comma of a FROM list, a parenthesis that opens one, or a schema and a dot there); anywhere else it is a column.
617
+ def connection_state_in(cleaned)
618
+ # A table named like a counter does not hide the function, as one named like a pragma function does.
619
+ counter = cleaned[SQLITE_COUNTER_CALL, 1]
620
+ return counter.downcase if counter
621
+
622
+ list = tokens(cleaned)
623
+ clauses = [ nil ]
624
+ at_table = []
625
+ table_next = false
626
+ list.each_with_index do |(kind, text), i|
627
+ at_table << table_next
628
+ pragma = text.to_s[SQLITE_PRAGMA_NAME, 1]&.downcase if %i[word quoted string].include?(kind)
629
+ name = "pragma_#{pragma}" if pragma && !SQLITE_FILE_PRAGMAS.include?(pragma)
630
+ return name if name && (table_next || list[i + 1] == [ :punct, "(" ]) && !@secrets.knows?(name)
631
+
632
+ word = text.downcase if kind == :word
633
+ case [ kind, text ]
634
+ when [ :punct, "(" ]
635
+ clauses.push(table_next == :from ? "from" : nil)
636
+ table_next = table_next == :from && :from
637
+ when [ :punct, ")" ]
638
+ clauses.pop if clauses.size > 1
639
+ table_next = false
640
+ when [ :punct, "," ] then table_next = FROM_LIST_CLAUSES.include?(clauses.last) && :from
641
+ when [ :punct, "." ] then table_next = i.positive? && at_table[i - 1]
642
+ else table_next = (%w[from join].include?(word) && :from) || (word == "in" && :in)
643
+ end
644
+ clauses[-1] = word if CLAUSE_WORDS.include?(word)
645
+ end
646
+ nil
647
+ end
648
+
649
+ def generic_plan?(text)
650
+ value = text[EXPLAIN_OPTIONS, 1]&.scan(GENERIC_PLAN_OPTION)&.last or return false
651
+ value.first.to_s.empty? || !value.first.match?(FALSE_SPELLING)
562
652
  end
563
653
 
564
654
  def dump_refusal(function)
@@ -634,15 +724,7 @@ module RailsAiContext
634
724
  # only and positions still line up. only: the one kind to blank, :string or :quoted. With a block, the spans it picks.
635
725
  def mask_quoted(text, only = nil, &picks)
636
726
  picks ||= ->(kind, _span) { only.nil? || kind == only }
637
- scanner = Scanner.new(text)
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
727
+ self.class.mask_quoted(text, @family, backslash: @backslash, &picks)
646
728
  end
647
729
 
648
730
  # [kind, text] for each token of comment-free text: :word (bare, a keyword or a name), :quoted (a name in
@@ -853,7 +935,7 @@ module RailsAiContext
853
935
  foreign = plan.nodes.select { |node| node["Remote SQL"] }
854
936
  conds.concat(foreign.map { |node| remote_uses(node) }, local_names(foreign))
855
937
  # 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) ])
938
+ called = function_names(raw.map { |expr| expr.gsub(PLAN_LITERAL, "''") } + [ mask_sql_literals(text).gsub(SAMPLING_METHOD, "TABLESAMPLE ") ])
857
939
 
858
940
  # A view reaches a relation or a function the text check did not see.
859
941
  sources = plan.nodes.flat_map { |node| node.values_at("Relation Name", "Function Name") }.compact + called.to_a
@@ -882,7 +964,10 @@ module RailsAiContext
882
964
  "or set operation whose output the tool cannot map to a column, so it could leave " \
883
965
  "unredacted. Name the other columns you need; `#{col}` is never returned."
884
966
  end
885
- if (fn = volatile_function(called))
967
+ if (fn = volatile_function(called)) && PG_VOLATILE_ALLOWLIST.include?(fn)
968
+ "Blocked: the database holds a VOLATILE function named #{fn} that rails_query cannot vouch for, so rails_query runs no " \
969
+ "call of that name. Its list of read-only VOLATILE functions trusts the name for the server's own function or a C function alone."
970
+ elsif fn
886
971
  "Blocked: function #{fn} is VOLATILE (pg_proc.provolatile = 'v') and not on " \
887
972
  "rails_query's list of read-only VOLATILE functions, so it is not run. Some VOLATILE " \
888
973
  "functions change server or session state in ways a read-only transaction does not " \
@@ -966,6 +1051,11 @@ module RailsAiContext
966
1051
  # pin it. A column handed to a node that reads by name and names it nowhere (`count(*)`) is read by nothing.
967
1052
  def laundered_secret_column(plan)
968
1053
  top = secret_base_columns(plan, plan.output.flatten)
1054
+ # A set operation compares every column its inputs hand up, so a row of guesses tests a secret value.
1055
+ plan.nodes.select { |node| node["Node Type"] == "SetOp" }.each do |node|
1056
+ handed = ([ node ] + Array(node["Plans"])).flat_map { |held| Array(held["Output"]) }
1057
+ (col = @secrets.in_schema.find { |name| names?(handed, name) }) and return col
1058
+ end
969
1059
  plan.nodes.each do |node|
970
1060
  reader = plan.reader(node)
971
1061
  secret_base_columns(plan, Array(node["Output"])).each do |var|
@@ -990,15 +1080,18 @@ module RailsAiContext
990
1080
  exprs.each_with_object(Set.new) { |expr, names| expr.scan(/([A-Za-z_]\w*)\s*\(/) { |(fn)| names << fn.downcase } }
991
1081
  end
992
1082
 
1083
+ # A name on the read-only list vouches for the server's own function or a C function alone: while the database holds a
1084
+ # VOLATILE function of that name in another language outside pg_catalog, every call of the name is refused.
993
1085
  def volatile_function(called)
994
- names = called - PG_VOLATILE_ALLOWLIST
995
- return nil if names.empty?
1086
+ return nil if called.empty?
996
1087
 
997
- quoted = names.map { |n| @connection.quote(n) }.join(", ")
1088
+ quoted, listed = [ called, PG_VOLATILE_ALLOWLIST ].map { |names| names.map { |n| @connection.quote(n) }.join(", ") }
998
1089
  @budget.spend do |left|
999
1090
  @family.bounded(@connection, timeout: left) do
1000
1091
  @connection.select_values(
1001
- "SELECT DISTINCT lower(proname) FROM pg_proc WHERE lower(proname) IN (#{quoted}) AND provolatile = 'v'"
1092
+ "SELECT DISTINCT lower(p.proname) FROM pg_proc p JOIN pg_language l ON l.oid = p.prolang " \
1093
+ "WHERE lower(p.proname) IN (#{quoted}) AND p.provolatile = 'v' AND (lower(p.proname) NOT IN (#{listed}) " \
1094
+ "OR (p.pronamespace <> 'pg_catalog'::regnamespace AND l.lanname NOT IN ('c', 'internal')))"
1002
1095
  ).first
1003
1096
  end
1004
1097
  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.first(5).each { |c| sections << "- Check: `#{c}`" }
288
- auth_denials.uniq.first(5).each { |d| sections << "- Deny: #{d}" }
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.first(5).each { |n| sections << "- Success: notice: \"#{n}\"" }
294
- flash_alerts.uniq.first(5).each { |a| sections << "- Failure: alert: \"#{a}\"" }
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
- not_found_patterns.first(5).each { |p| sections << "- Not found: #{p}" }
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 missing.any?
413
- declared_total = Array(schema[:declared_tables]).size
414
- connected_total = Introspectors::SchemaConventions.table_count(schema[:tables] || {})
415
- lines << "_#{schema[:declared_in] || "db/schema.rb"} declares #{count_phrase(declared_total, "table")}; the connected database has " \
416
- "#{connected_total}. Missing: #{missing.sort.first(5).join(', ')}" \
417
- "#{missing.size > 5 ? " (+#{missing.size - 5} more)" : ""}. Run `rails db:migrate`._"
418
- end
419
- # A database that holds none of the tables the dump lists is created
420
- # but not migrated, and the listing above is the dump's.
421
- if schema[:connected_tables]&.zero? && (declared_total = Introspectors::SchemaConventions.table_count(schema[:tables] || {})).positive?
422
- lines << "_#{dump_name(schema)} declares #{count_phrase(declared_total, "table")}; the connected database has none of them. Run `rails db:migrate`._"
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
- # Sidekiq stats (if available)
374
- if defined?(Sidekiq)
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 << ""
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RailsAiContext
4
- VERSION = "5.35.2"
4
+ VERSION = "5.36.1"
5
5
  end
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.35.2
4
+ version: 5.36.1
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
@@ -1,3 +0,0 @@
1
- --require spec_helper
2
- --format documentation
3
- --color
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
@@ -1,3 +0,0 @@
1
- # rails-ai-context
2
-
3
- Project instructions live in `AGENTS.md`, which is committed and shared. Read it.
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).