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.
Files changed (48) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +177 -1
  3. data/CONTRIBUTING.md +1 -0
  4. data/docs/COMPATIBILITY.md +10 -1
  5. data/docs/SECURITY.md +11 -9
  6. data/docs/TOOLS.md +11 -10
  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 +54 -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 +115 -24
  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,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
- 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
+ # 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
- plan = @budget.spend { |left| @family.plan(@connection, text.sub(EXPLAIN_PREFIX, ""), timeout: left) } if planned
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.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
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
- 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
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
- names = called - PG_VOLATILE_ALLOWLIST
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 WHERE lower(proname) IN (#{quoted}) AND provolatile = 'v'"
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.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.0"
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.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
@@ -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).