rails-ai-context 5.32.2 → 5.34.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 +1491 -0
- data/CONTEXT.md +27 -3
- data/README.md +16 -12
- data/Rakefile +6 -0
- data/app/controllers/rails_ai_context/mcp_controller.rb +112 -84
- data/config/routes.rb +2 -3
- data/docs/ARCHITECTURE.md +7 -5
- data/docs/CLI.md +79 -6
- data/docs/COMPATIBILITY.md +75 -1
- data/docs/CONFIGURATION.md +30 -11
- data/docs/CUSTOM_TOOLS.md +2 -1
- data/docs/FAQ.md +16 -6
- data/docs/GUIDE.md +117 -71
- data/docs/INTROSPECTORS.md +10 -8
- data/docs/QUICKSTART.md +3 -3
- data/docs/RECIPES.md +1 -1
- data/docs/SECURITY.md +109 -25
- data/docs/SETUP.md +30 -6
- data/docs/STANDALONE.md +10 -3
- data/docs/TOOLS.md +152 -34
- data/docs/TROUBLESHOOTING.md +90 -12
- data/docs/adr/0003-shared-tool-cache-semantics.md +8 -0
- data/docs/adr/0005-workspace-mcp-entries.md +39 -0
- data/exe/rails-ai-context +733 -104
- data/lib/generators/rails_ai_context/install/install_generator.rb +341 -125
- data/lib/rails_ai_context/app_kind.rb +18 -5
- data/lib/rails_ai_context/ast_cache.rb +7 -0
- data/lib/rails_ai_context/base_mixins.rb +8 -8
- data/lib/rails_ai_context/boot_manager.rb +22 -3
- data/lib/rails_ai_context/change_watch.rb +14 -11
- data/lib/rails_ai_context/cli/app_root.rb +401 -0
- data/lib/rails_ai_context/cli/entry_boot.rb +58 -22
- data/lib/rails_ai_context/cli/tool_runner.rb +47 -21
- data/lib/rails_ai_context/code_reloader.rb +115 -1
- data/lib/rails_ai_context/concern_membership.rb +3 -1
- data/lib/rails_ai_context/concern_paths.rb +8 -3
- data/lib/rails_ai_context/configuration.rb +41 -9
- data/lib/rails_ai_context/context_file_report.rb +19 -2
- data/lib/rails_ai_context/database_file.rb +26 -0
- data/lib/rails_ai_context/doctor.rb +1122 -184
- data/lib/rails_ai_context/engine.rb +18 -1
- data/lib/rails_ai_context/erb_source.rb +19 -0
- data/lib/rails_ai_context/executable.rb +21 -0
- data/lib/rails_ai_context/facts_formatter.rb +5 -2
- data/lib/rails_ai_context/file_walk.rb +61 -10
- data/lib/rails_ai_context/fingerprinter/snapshot.rb +234 -0
- data/lib/rails_ai_context/fingerprinter.rb +40 -12
- data/lib/rails_ai_context/gem_lock.rb +186 -27
- data/lib/rails_ai_context/git_ignore.rb +22 -0
- data/lib/rails_ai_context/install/ai_tool.rb +31 -5
- data/lib/rails_ai_context/install/cleanup.rb +21 -0
- data/lib/rails_ai_context/install/program.rb +209 -35
- data/lib/rails_ai_context/install/selection_record.rb +258 -97
- data/lib/rails_ai_context/install/surface.rb +6 -1
- data/lib/rails_ai_context/install/validation_hook.rb +282 -0
- data/lib/rails_ai_context/install/workspace.rb +166 -0
- data/lib/rails_ai_context/install_mode.rb +71 -13
- data/lib/rails_ai_context/introspectors/action_mailbox_introspector.rb +1 -1
- data/lib/rails_ai_context/introspectors/action_presence.rb +11 -6
- data/lib/rails_ai_context/introspectors/action_source.rb +182 -0
- data/lib/rails_ai_context/introspectors/action_text_introspector.rb +1 -2
- data/lib/rails_ai_context/introspectors/active_record_settings.rb +1 -1
- data/lib/rails_ai_context/introspectors/active_storage_introspector.rb +1 -3
- data/lib/rails_ai_context/introspectors/active_support_introspector.rb +3 -3
- data/lib/rails_ai_context/introspectors/admin_resources.rb +1 -1
- data/lib/rails_ai_context/introspectors/anyway_configs.rb +1 -1
- data/lib/rails_ai_context/introspectors/api_introspector.rb +3 -3
- data/lib/rails_ai_context/introspectors/auth_introspector.rb +15 -10
- data/lib/rails_ai_context/introspectors/call_site_expansion.rb +24 -1
- data/lib/rails_ai_context/introspectors/component_introspector.rb +3 -2
- data/lib/rails_ai_context/introspectors/controller_introspector.rb +387 -60
- data/lib/rails_ai_context/introspectors/convention_introspector.rb +1 -1
- data/lib/rails_ai_context/introspectors/database_stats_introspector.rb +15 -3
- data/lib/rails_ai_context/introspectors/devops_introspector.rb +3 -1
- data/lib/rails_ai_context/introspectors/eager_load.rb +201 -9
- data/lib/rails_ai_context/introspectors/engine_introspector.rb +1 -1
- data/lib/rails_ai_context/introspectors/env_config_introspector.rb +1 -1
- data/lib/rails_ai_context/introspectors/env_references.rb +10 -1
- data/lib/rails_ai_context/introspectors/frontend_framework_introspector.rb +1 -1
- data/lib/rails_ai_context/introspectors/gem_introspector.rb +23 -10
- data/lib/rails_ai_context/introspectors/grape_endpoints.rb +1 -1
- data/lib/rails_ai_context/introspectors/habtm_join_tables.rb +1 -1
- data/lib/rails_ai_context/introspectors/helper_names.rb +1 -1
- data/lib/rails_ai_context/introspectors/i18n_introspector.rb +3 -3
- data/lib/rails_ai_context/introspectors/job_introspector.rb +8 -6
- data/lib/rails_ai_context/introspectors/listeners/base_listener.rb +12 -8
- data/lib/rails_ai_context/introspectors/listeners/callbacks_listener.rb +78 -4
- data/lib/rails_ai_context/introspectors/listeners/config_assignment_listener.rb +1 -0
- data/lib/rails_ai_context/introspectors/listeners/env_access_listener.rb +10 -1
- data/lib/rails_ai_context/introspectors/listeners/foreign_evals_listener.rb +61 -0
- data/lib/rails_ai_context/introspectors/listeners/macros_listener.rb +13 -4
- data/lib/rails_ai_context/introspectors/listeners/migration_dsl_listener.rb +2 -1
- data/lib/rails_ai_context/introspectors/listeners/migration_replay_listener.rb +3 -2
- data/lib/rails_ai_context/introspectors/listeners/routes_dsl_listener.rb +29 -8
- data/lib/rails_ai_context/introspectors/listeners/schema_dsl_listener.rb +2 -2
- data/lib/rails_ai_context/introspectors/listeners/scopes_listener.rb +88 -12
- data/lib/rails_ai_context/introspectors/middleware_introspector.rb +2 -2
- data/lib/rails_ai_context/introspectors/migration_introspector.rb +27 -0
- data/lib/rails_ai_context/introspectors/migration_replay/helpers.rb +1 -1
- data/lib/rails_ai_context/introspectors/migration_replay/statements.rb +83 -27
- data/lib/rails_ai_context/introspectors/migration_replay.rb +15 -14
- data/lib/rails_ai_context/introspectors/model_introspector.rb +231 -23
- data/lib/rails_ai_context/introspectors/n_plus_one_scan.rb +620 -0
- data/lib/rails_ai_context/introspectors/performance_introspector.rb +11 -174
- data/lib/rails_ai_context/introspectors/route_introspector.rb +10 -1
- data/lib/rails_ai_context/introspectors/schema_conventions.rb +23 -3
- data/lib/rails_ai_context/introspectors/schema_introspector.rb +66 -16
- data/lib/rails_ai_context/introspectors/schema_reader.rb +4 -3
- data/lib/rails_ai_context/introspectors/seeds_introspector.rb +1 -1
- data/lib/rails_ai_context/introspectors/source_scan.rb +39 -9
- data/lib/rails_ai_context/introspectors/stimulus_introspector.rb +15 -6
- data/lib/rails_ai_context/introspectors/test_introspector.rb +1 -1
- data/lib/rails_ai_context/introspectors/turbo_introspector.rb +131 -12
- data/lib/rails_ai_context/introspectors/view_introspector.rb +4 -4
- data/lib/rails_ai_context/introspectors/view_template_introspector.rb +15 -1
- data/lib/rails_ai_context/legacy_cleanup.rb +17 -4
- data/lib/rails_ai_context/live_reload.rb +20 -20
- data/lib/rails_ai_context/mcp_config_generator.rb +916 -109
- data/lib/rails_ai_context/mcp_edge.rb +182 -32
- data/lib/rails_ai_context/middleware.rb +5 -1
- data/lib/rails_ai_context/migration_status.rb +88 -25
- data/lib/rails_ai_context/output_guard.rb +48 -2
- data/lib/rails_ai_context/package_json.rb +1 -1
- data/lib/rails_ai_context/path_resolver.rb +46 -13
- data/lib/rails_ai_context/payload.rb +92 -8
- data/lib/rails_ai_context/pending_migrations.rb +5 -1
- data/lib/rails_ai_context/portable_path.rb +3 -2
- data/lib/rails_ai_context/presets.rb +15 -11
- data/lib/rails_ai_context/redaction.rb +262 -51
- data/lib/rails_ai_context/resources.rb +55 -20
- data/lib/rails_ai_context/route_coverage.rb +8 -0
- data/lib/rails_ai_context/safe_file.rb +14 -1
- data/lib/rails_ai_context/safe_path.rb +101 -4
- data/lib/rails_ai_context/schema_adapter.rb +8 -0
- data/lib/rails_ai_context/schema_version.rb +9 -0
- data/lib/rails_ai_context/serializers/claude_rules_serializer.rb +88 -33
- data/lib/rails_ai_context/serializers/compact_serializer_helper.rb +36 -34
- data/lib/rails_ai_context/serializers/context_file_serializer.rb +21 -29
- data/lib/rails_ai_context/serializers/copilot_instructions_serializer.rb +8 -6
- data/lib/rails_ai_context/serializers/copilot_serializer.rb +2 -7
- data/lib/rails_ai_context/serializers/cursor_rules_serializer.rb +19 -16
- data/lib/rails_ai_context/serializers/guide_examples.rb +142 -0
- data/lib/rails_ai_context/serializers/markdown_serializer.rb +146 -30
- data/lib/rails_ai_context/serializers/opencode_rules_serializer.rb +38 -15
- data/lib/rails_ai_context/serializers/section_facts.rb +44 -1
- data/lib/rails_ai_context/serializers/section_marker_writer.rb +94 -12
- data/lib/rails_ai_context/serializers/stack_overview_helper.rb +24 -12
- data/lib/rails_ai_context/serializers/tool_guide_helper.rb +244 -129
- data/lib/rails_ai_context/server.rb +304 -71
- data/lib/rails_ai_context/tasks/rails_ai_context.rake +447 -372
- data/lib/rails_ai_context/tools/analyze_feature.rb +72 -18
- data/lib/rails_ai_context/tools/argument_schema.rb +49 -0
- data/lib/rails_ai_context/tools/base_tool.rb +367 -46
- data/lib/rails_ai_context/tools/brakeman_guard.rb +24 -0
- data/lib/rails_ai_context/tools/dependency_graph.rb +63 -28
- data/lib/rails_ai_context/tools/diagnose.rb +218 -11
- data/lib/rails_ai_context/tools/generate_test.rb +339 -39
- data/lib/rails_ai_context/tools/get_callbacks.rb +24 -13
- data/lib/rails_ai_context/tools/get_component_catalog.rb +1 -1
- data/lib/rails_ai_context/tools/get_concern.rb +91 -27
- data/lib/rails_ai_context/tools/get_config.rb +13 -4
- data/lib/rails_ai_context/tools/get_context.rb +71 -14
- data/lib/rails_ai_context/tools/get_controllers.rb +179 -44
- data/lib/rails_ai_context/tools/get_conventions.rb +28 -6
- data/lib/rails_ai_context/tools/get_edit_context.rb +27 -14
- data/lib/rails_ai_context/tools/get_env.rb +18 -9
- data/lib/rails_ai_context/tools/get_frontend_stack.rb +3 -1
- data/lib/rails_ai_context/tools/get_gems.rb +1 -1
- data/lib/rails_ai_context/tools/get_helper_methods.rb +10 -6
- data/lib/rails_ai_context/tools/get_i18n.rb +1 -1
- data/lib/rails_ai_context/tools/get_job_pattern.rb +3 -2
- data/lib/rails_ai_context/tools/get_mailers.rb +3 -3
- data/lib/rails_ai_context/tools/get_model_details.rb +30 -10
- data/lib/rails_ai_context/tools/get_partial_interface.rb +242 -23
- data/lib/rails_ai_context/tools/get_routes.rb +29 -8
- data/lib/rails_ai_context/tools/get_schema.rb +52 -19
- data/lib/rails_ai_context/tools/get_service_pattern.rb +24 -10
- data/lib/rails_ai_context/tools/get_stimulus.rb +37 -1
- data/lib/rails_ai_context/tools/get_test_info.rb +18 -4
- data/lib/rails_ai_context/tools/get_turbo_map.rb +221 -58
- data/lib/rails_ai_context/tools/get_view.rb +14 -14
- data/lib/rails_ai_context/tools/migration_advisor.rb +355 -48
- data/lib/rails_ai_context/tools/onboard.rb +52 -8
- data/lib/rails_ai_context/tools/performance_check.rb +5 -3
- data/lib/rails_ai_context/tools/query.rb +914 -86
- data/lib/rails_ai_context/tools/read_logs.rb +69 -15
- data/lib/rails_ai_context/tools/review_changes.rb +215 -53
- data/lib/rails_ai_context/tools/runtime_info.rb +51 -1
- data/lib/rails_ai_context/tools/safe_call.rb +35 -14
- data/lib/rails_ai_context/tools/search_code.rb +243 -67
- data/lib/rails_ai_context/tools/search_docs.rb +4 -4
- data/lib/rails_ai_context/tools/security_scan.rb +143 -33
- data/lib/rails_ai_context/tools/session_context.rb +39 -5
- data/lib/rails_ai_context/tools/validate.rb +259 -40
- data/lib/rails_ai_context/tools/validate_semantics.rb +130 -56
- data/lib/rails_ai_context/version.rb +1 -1
- data/lib/rails_ai_context/vfs.rb +67 -40
- data/lib/rails_ai_context/view_file.rb +41 -3
- data/lib/rails_ai_context/watcher.rb +51 -13
- data/lib/rails_ai_context.rb +21 -0
- data/server.json +1 -1
- metadata +14 -1
|
@@ -22,7 +22,9 @@ module RailsAiContext
|
|
|
22
22
|
# relative: the caller's path, relative to `under`. root: the directory
|
|
23
23
|
# the sensitive patterns are matched against (the app root for most tools).
|
|
24
24
|
# listed: the path came from a listing of `under`, so it is spelled as on disk.
|
|
25
|
-
|
|
25
|
+
# links: a path through a directory linked in from the repository holding
|
|
26
|
+
# `root` (linked_in) is one of `under`'s; off, only `under` itself holds.
|
|
27
|
+
def locate(relative, under:, root: under, max_size: nil, listed: false, links: true)
|
|
26
28
|
relative = relative.to_s
|
|
27
29
|
return refuse(:traversal) if traversal?(relative)
|
|
28
30
|
return refuse(:sensitive) if sensitive?(relative)
|
|
@@ -30,22 +32,31 @@ module RailsAiContext
|
|
|
30
32
|
path = File.join(under.to_s, relative)
|
|
31
33
|
real = listed ? real_file(path) : File.realpath(path)
|
|
32
34
|
real_under = real_base(under)
|
|
33
|
-
|
|
35
|
+
linked = links && !contained?(real, real_under) ? linked_in(path, real, under, root) : nil
|
|
36
|
+
return refuse(:outside) unless linked || contained?(real, real_under)
|
|
34
37
|
|
|
35
38
|
real_root = real_base(root)
|
|
39
|
+
# `under` can itself be a linked-in directory, a pack's app/views, so the
|
|
40
|
+
# root's spelling of the path is asked for the link too.
|
|
41
|
+
linked ||= links && !contained?(real, real_root) ? linked_in(path, real, root, root) : nil
|
|
36
42
|
root_relative = if real == real_root then ""
|
|
37
43
|
elsif contained?(real, real_root) then real.delete_prefix(dir_prefix(real_root))
|
|
44
|
+
# Named the way the app spells it: packs/billing/app/models/invoice.rb.
|
|
45
|
+
elsif linked then spelled_relative(path, root)
|
|
38
46
|
# A directory the caller trusts outside the root, such as the engine around a test/dummy.
|
|
39
47
|
else Pathname.new(real).relative_path_from(Pathname.new(real_root)).to_s
|
|
40
48
|
end
|
|
41
49
|
return refuse(:sensitive) if sensitive?(root_relative)
|
|
50
|
+
# Read where the file is too: a name inside the linked directory.
|
|
51
|
+
return refuse(:sensitive) if linked && sensitive?(real.delete_prefix(dir_prefix(linked)))
|
|
42
52
|
return refuse(:missing) unless File.file?(real)
|
|
43
53
|
|
|
44
54
|
limit = max_size || RailsAiContext.configuration.max_file_size
|
|
45
55
|
return refuse(:too_large, realpath: real, relative: root_relative) if File.size(real) > limit
|
|
46
56
|
|
|
47
57
|
Resolution.new(realpath: real, relative: root_relative, refusal: nil)
|
|
48
|
-
|
|
58
|
+
# EINVAL: Windows refuses a name holding `:`, `<`, `|` and the like.
|
|
59
|
+
rescue Errno::ENOENT, Errno::EACCES, Errno::ELOOP, Errno::ENAMETOOLONG, Errno::ENOTDIR, Errno::EINVAL
|
|
49
60
|
refuse(:missing)
|
|
50
61
|
end
|
|
51
62
|
|
|
@@ -63,6 +74,55 @@ module RailsAiContext
|
|
|
63
74
|
File.join(real_base(File.dirname(path)), File.basename(path))
|
|
64
75
|
end
|
|
65
76
|
|
|
77
|
+
# Where a directory link inside the app may lead and still be the app's:
|
|
78
|
+
# the git work tree holding `root`, so a monorepo's shared package counts
|
|
79
|
+
# and a home directory does not. Outside any repository nothing marks
|
|
80
|
+
# where the project ends, so only `root` itself.
|
|
81
|
+
def link_bound(root)
|
|
82
|
+
RunCache.fetch([ :safe_path_link_bound, root.to_s ]) do
|
|
83
|
+
real_root = real_base(root)
|
|
84
|
+
repo = git_root(real_root)
|
|
85
|
+
repo ? File.realpath(repo) : real_root
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# The real path of the linked-in directory that holds `real`: a directory
|
|
90
|
+
# link on `path`'s spelling below `base` that leads inside link_bound(root).
|
|
91
|
+
# Nil when no such link leads to it, as for a file linked out on its own.
|
|
92
|
+
# A spelled `..` is read lexically, so it cannot climb out through a link.
|
|
93
|
+
def linked_in(path, real, base, root)
|
|
94
|
+
spelled = File.expand_path(path.to_s)
|
|
95
|
+
prefix = dir_prefix(File.expand_path(base.to_s))
|
|
96
|
+
return nil unless spelled.start_with?(prefix)
|
|
97
|
+
|
|
98
|
+
bound = link_bound(root)
|
|
99
|
+
segments = spelled.delete_prefix(prefix).split(File::SEPARATOR)
|
|
100
|
+
segments.size.downto(1) do |depth|
|
|
101
|
+
linked = linked_dir(File.join(prefix, *segments.first(depth)), bound)
|
|
102
|
+
return linked if linked && contained?(real, linked)
|
|
103
|
+
end
|
|
104
|
+
nil
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# A directory link's real path when it leads inside `bound`, else nil.
|
|
108
|
+
def linked_dir(dir, bound)
|
|
109
|
+
RunCache.fetch([ :safe_path_linked_dir, dir, bound ]) do
|
|
110
|
+
real = File.symlink?(dir) && File.directory?(dir) && File.realpath(dir)
|
|
111
|
+
real if real && contained?(real, bound)
|
|
112
|
+
end
|
|
113
|
+
rescue SystemCallError
|
|
114
|
+
nil
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# `path` relative to `root` as both are spelled.
|
|
118
|
+
def spelled_relative(path, root)
|
|
119
|
+
spelled = File.expand_path(path.to_s)
|
|
120
|
+
spelled_root = File.expand_path(root.to_s)
|
|
121
|
+
return spelled.delete_prefix(dir_prefix(spelled_root)) if spelled.start_with?(dir_prefix(spelled_root))
|
|
122
|
+
|
|
123
|
+
Pathname.new(spelled).relative_path_from(Pathname.new(spelled_root)).to_s
|
|
124
|
+
end
|
|
125
|
+
|
|
66
126
|
def read(relative, under:, root: under, max_size: nil)
|
|
67
127
|
resolution = locate(relative, under: under, root: root, max_size: max_size)
|
|
68
128
|
return [ nil, resolution ] unless resolution.ok?
|
|
@@ -75,7 +135,21 @@ module RailsAiContext
|
|
|
75
135
|
# that resolve a directory rather than a file and so cannot use `locate`.
|
|
76
136
|
def traversal?(relative)
|
|
77
137
|
relative = relative.to_s
|
|
78
|
-
relative.include?("
|
|
138
|
+
relative.include?("\0") || relative.include?("..") || absolute?(relative)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Whether a path names its place whatever directory it is joined to:
|
|
142
|
+
# `/x` everywhere, and on Windows `C:/x`, `C:\x` and `\\server\share`,
|
|
143
|
+
# which File.absolute_path? answers there, as well as `\x` (the current
|
|
144
|
+
# drive's root) and `C:x` (that drive's current directory). A NUL byte
|
|
145
|
+
# names no file at all, and File.absolute_path? raises on one.
|
|
146
|
+
def absolute?(path)
|
|
147
|
+
path = path.to_s
|
|
148
|
+
return true if path.start_with?("/")
|
|
149
|
+
return false if path.include?("\0")
|
|
150
|
+
return true if File.absolute_path?(path)
|
|
151
|
+
|
|
152
|
+
!File::ALT_SEPARATOR.nil? && path.match?(/\A(?:\\|[A-Za-z]:)/)
|
|
79
153
|
end
|
|
80
154
|
|
|
81
155
|
PLACEHOLDER_SUFFIXES = %w[.example .sample .template .dist].freeze
|
|
@@ -102,6 +176,29 @@ module RailsAiContext
|
|
|
102
176
|
real == real_dir || real.start_with?(dir_prefix(real_dir))
|
|
103
177
|
end
|
|
104
178
|
|
|
179
|
+
# One spelling of a place whatever links lead to it, for comparing two
|
|
180
|
+
# paths: the real path, or for a path not there yet its nearest existing
|
|
181
|
+
# ancestor's real path with the rest appended. Stdlib only, so it serves
|
|
182
|
+
# before the gem entry loads too.
|
|
183
|
+
#
|
|
184
|
+
# Worked in bytes: a name that is not UTF-8 arrives as a broken UTF-8
|
|
185
|
+
# string, which Ruby 3.1's delete_prefix leaves whole, and realpath
|
|
186
|
+
# answers it as bytes, which File.join will not mix with a UTF-8 rest.
|
|
187
|
+
def canonical(path)
|
|
188
|
+
path = File.expand_path(path.to_s)
|
|
189
|
+
existing = path
|
|
190
|
+
existing = File.dirname(existing) until File.exist?(existing) || File.dirname(existing) == existing
|
|
191
|
+
rest = path.b.delete_prefix(existing.b)
|
|
192
|
+
real = File.realpath(existing)
|
|
193
|
+
return real if rest.empty?
|
|
194
|
+
|
|
195
|
+
joined = File.join(real.b, rest)
|
|
196
|
+
text = joined.dup.force_encoding(Encoding::UTF_8)
|
|
197
|
+
text.valid_encoding? ? text : joined
|
|
198
|
+
rescue SystemCallError
|
|
199
|
+
path
|
|
200
|
+
end
|
|
201
|
+
|
|
105
202
|
# The nearest directory at or above `dir` that holds `.git` (a directory, or a
|
|
106
203
|
# file in a worktree or submodule), or nil outside any repository.
|
|
107
204
|
def git_root(dir)
|
|
@@ -96,6 +96,14 @@ module RailsAiContext
|
|
|
96
96
|
database_label(primary)
|
|
97
97
|
end
|
|
98
98
|
|
|
99
|
+
# A secondary database's adapter: its database.yml entry, else the dialect
|
|
100
|
+
# its structure.sql is in. nil when neither says; schema.rb does not.
|
|
101
|
+
def secondary_label(context, name, schema)
|
|
102
|
+
multi = context.is_a?(Hash) ? context[:multi_database] : nil
|
|
103
|
+
databases = Tools::SectionFetch.usable?(multi) ? Array(multi[:databases]) : []
|
|
104
|
+
database_label(databases.find { |db| db.is_a?(Hash) && db[:name].to_s == name.to_s }) || from_dialect(schema)
|
|
105
|
+
end
|
|
106
|
+
|
|
99
107
|
# These are ActiveRecord adapter names (`postgresql`), not gem names; the two
|
|
100
108
|
# overlap only at sqlite3/mysql2/trilogy.
|
|
101
109
|
#
|
|
@@ -51,6 +51,15 @@ module RailsAiContext
|
|
|
51
51
|
format, path = Introspectors::SchemaDumpPath.present(root)
|
|
52
52
|
return nil unless format == :sql
|
|
53
53
|
|
|
54
|
+
recorded(format, path)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# What one dump records as applied: structure.sql's whole set, schema.rb's
|
|
58
|
+
# newest version. A secondary database's dump answers for that database.
|
|
59
|
+
def self.recorded(format, path)
|
|
60
|
+
return nil unless path && File.exist?(path)
|
|
61
|
+
return from_schema_rb(path) unless format == :sql
|
|
62
|
+
|
|
54
63
|
content = RailsAiContext::SafeFile.read(path, max_size: RailsAiContext.configuration.max_schema_file_size)
|
|
55
64
|
return nil unless content
|
|
56
65
|
|
|
@@ -16,6 +16,9 @@ module RailsAiContext
|
|
|
16
16
|
"rails-components.md" => { renderer: :render_components_reference, reason: "no view components" }
|
|
17
17
|
}.freeze
|
|
18
18
|
|
|
19
|
+
# Per database; the tools have the rest.
|
|
20
|
+
TABLES_SHOWN = 30
|
|
21
|
+
|
|
19
22
|
# @param output_dir [String] Rails root path
|
|
20
23
|
# @return [Hash] { written: [paths], skipped: [paths], not_applicable: { path => reason } }
|
|
21
24
|
def call(output_dir)
|
|
@@ -36,44 +39,78 @@ module RailsAiContext
|
|
|
36
39
|
lines.concat(overview_lines(gems: false, architecture: false, app_dirs: false))
|
|
37
40
|
|
|
38
41
|
lines << ""
|
|
39
|
-
lines << "ALWAYS use
|
|
40
|
-
lines << "Start with
|
|
42
|
+
lines << "ALWAYS use #{tools_noun} for context - do NOT read reference files directly."
|
|
43
|
+
lines << "Start with `#{param_text("detail", "summary")}`. Read files ONLY when you will Edit them."
|
|
41
44
|
|
|
42
45
|
lines.join("\n")
|
|
43
46
|
end
|
|
44
47
|
|
|
48
|
+
# Every database's tables, the primary's first. With more than one, each
|
|
49
|
+
# gets a heading, since a table name can be in two of them. A database
|
|
50
|
+
# a Rails framework keeps to itself (Rails 8's queue, cache and cable)
|
|
51
|
+
# is no part of it: its tables are the framework's, CLAUDE.md names it,
|
|
52
|
+
# and its dump says nothing this listing would help with.
|
|
45
53
|
def render_schema_reference
|
|
46
54
|
schema = Payload.section(context, :schema)
|
|
47
55
|
return nil unless schema
|
|
48
|
-
tables = schema[:tables] || {}
|
|
49
|
-
return nil if tables.empty?
|
|
50
56
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
"---",
|
|
59
|
-
"",
|
|
60
|
-
"# Database Tables (#{tables.size})",
|
|
61
|
-
""
|
|
62
|
-
]
|
|
63
|
-
lines.concat(SectionFacts.static_notice_lines(context))
|
|
64
|
-
lines << "_Snapshot - may be stale after migrations. Use `rails_get_schema(table:\"name\")` for live data._"
|
|
57
|
+
secondary = Payload.app_databases(schema)
|
|
58
|
+
databases = [ [ nil, schema[:tables] || {} ] ] + secondary.map { |name, db| [ name, db[:tables] ] }
|
|
59
|
+
databases.reject! { |_, tables| tables.empty? }
|
|
60
|
+
return nil if databases.empty?
|
|
61
|
+
|
|
62
|
+
lines = [ "---", "paths:", *schema_rule_paths(secondary).map { |path| " - \"#{path}\"" }, "---", "" ]
|
|
63
|
+
lines << "# Database Tables (#{databases.sum { |_, tables| tables.size }})"
|
|
65
64
|
lines << ""
|
|
65
|
+
lines.concat(SectionFacts.static_notice_lines(context))
|
|
66
|
+
live = " Use #{tool_ref("rails_get_schema", 'table:"name"', "table=name")} for live data." if served?("rails_get_schema")
|
|
67
|
+
lines << "_Snapshot - may be stale after migrations.#{live}_"
|
|
68
|
+
|
|
69
|
+
databases.each do |name, tables|
|
|
70
|
+
lines << ""
|
|
71
|
+
lines.concat(database_heading_lines(name, tables, secondary)) if secondary.any?
|
|
72
|
+
lines.concat(table_reference_lines(tables, name, schema))
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
lines.join("\n")
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# The dumps and migrations the tables are read from, each database's, so
|
|
79
|
+
# opening any of them brings the rule in. The primary's has to name the
|
|
80
|
+
# dump the app committed, or the rule never triggers on a :sql app:
|
|
81
|
+
# db/schema.rb is never opened there.
|
|
82
|
+
def schema_rule_paths(secondary)
|
|
83
|
+
paths = [ schema_dump_path, "db/migrate/**" ]
|
|
84
|
+
secondary.each_value do |db|
|
|
85
|
+
paths << db[:dump] if db[:dump]
|
|
86
|
+
Array(db[:migrations_paths]).each { |dir| paths << "#{dir}/**" }
|
|
87
|
+
end
|
|
88
|
+
paths.uniq
|
|
89
|
+
end
|
|
66
90
|
|
|
91
|
+
def database_heading_lines(name, tables, secondary)
|
|
92
|
+
relations = Introspectors::SchemaConventions.relations_phrase(tables)
|
|
93
|
+
return [ "## primary (#{SchemaAdapter.label(context)}, #{relations})", "" ] unless name
|
|
94
|
+
|
|
95
|
+
db = secondary[name]
|
|
96
|
+
adapter = SchemaAdapter.secondary_label(context, name, db)
|
|
97
|
+
lines = [ "## #{name} (#{[ adapter, relations ].compact.join(', ')})", "" ]
|
|
98
|
+
# Where the snapshot comes from: a booted run reads the primary live, this from its files.
|
|
99
|
+
lines.push("_#{db[:note]}._", "") if db[:note]
|
|
100
|
+
lines
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def table_reference_lines(tables, database, schema)
|
|
104
|
+
lines = []
|
|
67
105
|
skip_cols = %w[id created_at updated_at]
|
|
68
106
|
keep_cols = %w[type deleted_at discarded_at]
|
|
69
107
|
# Get enum values from models introspection if available
|
|
70
108
|
models = Payload.models(context)
|
|
71
109
|
|
|
72
|
-
tables.keys.sort.first(
|
|
110
|
+
tables.keys.sort.first(TABLES_SHOWN).each do |name|
|
|
73
111
|
data = tables[name]
|
|
74
112
|
columns = data[:columns] || []
|
|
75
113
|
col_count = columns.size
|
|
76
|
-
pk_display = Introspectors::SchemaConventions.primary_key_label(data[:primary_key])
|
|
77
114
|
|
|
78
115
|
# Show column names WITH types for key columns
|
|
79
116
|
# Skip standard Rails FK columns (like user_id, account_id) but keep
|
|
@@ -118,8 +155,7 @@ module RailsAiContext
|
|
|
118
155
|
lines << "- **#{name}** (#{count_phrase(col_count, 'col')})#{col_str}#{fk_str}#{idx_str}"
|
|
119
156
|
|
|
120
157
|
# Include enum values if model has them
|
|
121
|
-
|
|
122
|
-
model_data = models[model_name]
|
|
158
|
+
model_data = table_model(models, name, database, schema)
|
|
123
159
|
if model_data.is_a?(Hash) && model_data[:enums]&.any?
|
|
124
160
|
model_data[:enums].each do |attr, values|
|
|
125
161
|
lines << " #{attr}: #{SectionFacts.enum_values(values)}"
|
|
@@ -127,11 +163,24 @@ module RailsAiContext
|
|
|
127
163
|
end
|
|
128
164
|
end
|
|
129
165
|
|
|
130
|
-
if tables.size >
|
|
131
|
-
|
|
166
|
+
if tables.size > TABLES_SHOWN
|
|
167
|
+
use = " (use #{tool_named("rails_get_schema")})" if served?("rails_get_schema")
|
|
168
|
+
lines << "- ...#{count_phrase(tables.size - TABLES_SHOWN, "more table")}#{use}"
|
|
132
169
|
end
|
|
133
170
|
|
|
134
|
-
lines
|
|
171
|
+
lines
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# The model named after the table. Another database's table of that name
|
|
175
|
+
# is some other model's: the one that reads from that database.
|
|
176
|
+
def table_model(models, table, database, schema)
|
|
177
|
+
named = models[table.classify]
|
|
178
|
+
return named unless database
|
|
179
|
+
return named if named.is_a?(Hash) && Payload.model_databases(schema, named).include?(database)
|
|
180
|
+
|
|
181
|
+
models.keys.sort.map { |name| models[name] }.find do |data|
|
|
182
|
+
data.is_a?(Hash) && data[:table_name] == table && Payload.model_databases(schema, data).include?(database)
|
|
183
|
+
end
|
|
135
184
|
end
|
|
136
185
|
|
|
137
186
|
def render_models_reference
|
|
@@ -148,7 +197,11 @@ module RailsAiContext
|
|
|
148
197
|
""
|
|
149
198
|
]
|
|
150
199
|
lines.concat(SectionFacts.static_notice_lines(context))
|
|
151
|
-
lines <<
|
|
200
|
+
lines << if served?("rails_get_model_details")
|
|
201
|
+
"_Quick reference - use #{tool_ref("rails_get_model_details", 'model:"Name"', "model=Name")} for live data with resolved concerns and callbacks._"
|
|
202
|
+
else
|
|
203
|
+
"_Quick reference._"
|
|
204
|
+
end
|
|
152
205
|
lines << ""
|
|
153
206
|
|
|
154
207
|
models.keys.sort.each do |name|
|
|
@@ -161,8 +214,9 @@ module RailsAiContext
|
|
|
161
214
|
assocs = (data[:associations] || []).size
|
|
162
215
|
vals = (data[:validations] || []).size
|
|
163
216
|
table = data[:table_name]
|
|
217
|
+
note = SectionFacts.table_note(Payload.section(context, :schema), data)
|
|
164
218
|
line = "- #{name}"
|
|
165
|
-
line += " (table: #{table})" if table
|
|
219
|
+
line += note ? " (table #{note})" : (" (table: #{table})" if table).to_s
|
|
166
220
|
line += " - #{count_phrase(assocs, "assoc")}, #{count_phrase(vals, "validation")}"
|
|
167
221
|
lines << line
|
|
168
222
|
|
|
@@ -218,11 +272,9 @@ module RailsAiContext
|
|
|
218
272
|
""
|
|
219
273
|
]
|
|
220
274
|
lines.concat(SectionFacts.static_notice_lines(context))
|
|
221
|
-
lines.
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
""
|
|
225
|
-
])
|
|
275
|
+
lines << "ViewComponent and Phlex components available for reuse."
|
|
276
|
+
lines << "Use #{tool_ref("rails_get_component_catalog", 'component:"Name"', "component=Name")} for full details." if served?("rails_get_component_catalog")
|
|
277
|
+
lines << ""
|
|
226
278
|
|
|
227
279
|
components.each do |c|
|
|
228
280
|
slots = (c[:slots] || []).map { |s| s[:name] }
|
|
@@ -235,8 +287,11 @@ module RailsAiContext
|
|
|
235
287
|
lines.join("\n")
|
|
236
288
|
end
|
|
237
289
|
|
|
290
|
+
# Always loaded beside CLAUDE.md, which carries the guide whenever root
|
|
291
|
+
# files are on; without CLAUDE.md this is the only place for the guide.
|
|
238
292
|
def render_mcp_tools_reference
|
|
239
|
-
|
|
293
|
+
guide = RailsAiContext.configuration.generate_root_files ? render_tools_reference("CLAUDE.md") : render_tools_guide
|
|
294
|
+
guide.join("\n")
|
|
240
295
|
end
|
|
241
296
|
end
|
|
242
297
|
end
|
|
@@ -4,9 +4,10 @@ module RailsAiContext
|
|
|
4
4
|
module Serializers
|
|
5
5
|
# Shared rendering methods for compact-mode serializers (Claude, OpenCode).
|
|
6
6
|
# Include in any serializer that has a `context` reader and includes
|
|
7
|
-
# StackOverviewHelper
|
|
7
|
+
# StackOverviewHelper and TestCommandDetection.
|
|
8
8
|
module CompactSerializerHelper
|
|
9
9
|
include CountPhrase
|
|
10
|
+
include ToolGuideHelper
|
|
10
11
|
|
|
11
12
|
private
|
|
12
13
|
|
|
@@ -40,11 +41,7 @@ module RailsAiContext
|
|
|
40
41
|
end
|
|
41
42
|
|
|
42
43
|
routes = Payload.section(context, :routes)
|
|
43
|
-
if routes
|
|
44
|
-
lines << "- Routes: #{count_phrase(RouteCoverage.app_route_count(routes), "app route")} across " \
|
|
45
|
-
"#{RouteCoverage.controller_phrase(routes)} " \
|
|
46
|
-
"(#{routes[:total_routes]} total incl. framework#{RouteCoverage.suffix(routes)})"
|
|
47
|
-
end
|
|
44
|
+
lines << "- Routes: #{RouteCoverage.summary(routes)}" if routes
|
|
48
45
|
|
|
49
46
|
jobs = Payload.section(context, :jobs)
|
|
50
47
|
if jobs
|
|
@@ -60,11 +57,8 @@ module RailsAiContext
|
|
|
60
57
|
lines << "- Async: #{parts.join(', ')}" if parts.any?
|
|
61
58
|
end
|
|
62
59
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
line = "- Migrations: #{migrations[:total]} total"
|
|
66
|
-
line += ", #{Payload.pending_migrations(context).size} pending" if migrations.key?(:pending)
|
|
67
|
-
lines << line
|
|
60
|
+
if (migrations_line = SectionFacts.migrations_line(context))
|
|
61
|
+
lines << migrations_line
|
|
68
62
|
end
|
|
69
63
|
|
|
70
64
|
lines.concat(full_preset_stack_lines)
|
|
@@ -97,7 +91,10 @@ module RailsAiContext
|
|
|
97
91
|
extras = model_extras_line(data)
|
|
98
92
|
lines << extras if extras
|
|
99
93
|
end
|
|
100
|
-
|
|
94
|
+
if models.size > max_show
|
|
95
|
+
use = " (use #{tool_named("rails_get_model_details")})" if served?("rails_get_model_details")
|
|
96
|
+
lines << "- _...#{models.size - max_show} more#{use}_"
|
|
97
|
+
end
|
|
101
98
|
lines << ""
|
|
102
99
|
lines
|
|
103
100
|
end
|
|
@@ -130,23 +127,28 @@ module RailsAiContext
|
|
|
130
127
|
SectionFacts.warnings(context)
|
|
131
128
|
end
|
|
132
129
|
|
|
133
|
-
|
|
130
|
+
# Where the cut detail is: the tools, as the mode reaches them.
|
|
131
|
+
def trim_note
|
|
132
|
+
"_Context trimmed. Use #{tools_noun} for full details._"
|
|
133
|
+
end
|
|
134
134
|
|
|
135
|
-
# Counts non-blank physical lines. `
|
|
136
|
-
#
|
|
137
|
-
|
|
135
|
+
# Counts non-blank physical lines. `head` (the title block) and `keep`
|
|
136
|
+
# (the trailing how-to-behave sections: commands, warnings, rules, the
|
|
137
|
+
# tools guide and its protocol) are never cut. An over-budget file
|
|
138
|
+
# loses the data between them, which the tools answer in full, down to
|
|
139
|
+
# none of it, so a budget smaller than the kept sections leaves just
|
|
140
|
+
# them: the guide is what makes a compact file worth reading.
|
|
141
|
+
def enforce_max_lines(lines, head: [], keep: [])
|
|
138
142
|
# The BEGIN/END markers the writer adds count against the budget.
|
|
139
143
|
max = RailsAiContext.configuration.claude_max_lines - SectionMarkerWriter::MARKER_LINES
|
|
144
|
+
head = physical(head)
|
|
140
145
|
body = physical(lines)
|
|
141
146
|
tail = physical(keep)
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
# A hard cap: a tail too big to fit is trimmed from the end like any other overflow.
|
|
145
|
-
budget = max - 1 - content_count(tail)
|
|
146
|
-
return truncate(body + tail, max - 1).join("\n") if budget <= 0
|
|
147
|
+
fixed = content_count(head) + content_count(tail)
|
|
148
|
+
return (head + body + tail).join("\n") if fixed + content_count(body) <= max
|
|
147
149
|
|
|
148
150
|
separator = tail.empty? ? [] : [ "" ]
|
|
149
|
-
(truncate(body,
|
|
151
|
+
(head + truncate(body, max - 1 - fixed) + separator + tail).join("\n")
|
|
150
152
|
end
|
|
151
153
|
|
|
152
154
|
def physical(lines)
|
|
@@ -176,7 +178,7 @@ module RailsAiContext
|
|
|
176
178
|
# nested headings and separators included.
|
|
177
179
|
kept.pop while kept.any? && (kept.last.strip.empty? || heading?(kept.last))
|
|
178
180
|
kept << "" if kept.any?
|
|
179
|
-
kept <<
|
|
181
|
+
kept << trim_note
|
|
180
182
|
end
|
|
181
183
|
|
|
182
184
|
def heading?(line)
|
|
@@ -202,11 +204,17 @@ module RailsAiContext
|
|
|
202
204
|
|
|
203
205
|
# The scan decides, so services under a configured extra path show wherever
|
|
204
206
|
# the conventions walk looked.
|
|
207
|
+
names = []
|
|
205
208
|
services = service_names
|
|
206
|
-
|
|
207
|
-
|
|
209
|
+
names << "**Services:** #{services.join(', ')}" if services.any?
|
|
208
210
|
jobs = job_names
|
|
209
|
-
|
|
211
|
+
names << "**Jobs:** #{jobs.join(', ')}" if jobs.any?
|
|
212
|
+
|
|
213
|
+
# The blank line sets the names apart from a pattern list. With no list
|
|
214
|
+
# above them - the static tier reads no conventions - it left the
|
|
215
|
+
# heading standing over nothing.
|
|
216
|
+
lines << "" if lines.any? && names.any?
|
|
217
|
+
lines.concat(names)
|
|
210
218
|
return [] if lines.empty?
|
|
211
219
|
|
|
212
220
|
[ "## Architecture", *lines, "" ]
|
|
@@ -246,15 +254,9 @@ module RailsAiContext
|
|
|
246
254
|
|
|
247
255
|
# The compact pipeline behind CLAUDE.md, AGENTS.md, .cursorrules and copilot-instructions.md.
|
|
248
256
|
def render_compact_rules
|
|
249
|
-
|
|
250
|
-
lines.concat(render_header)
|
|
251
|
-
lines.concat(render_stack_overview)
|
|
252
|
-
lines.concat(render_key_models)
|
|
253
|
-
lines.concat(render_notable_gems)
|
|
254
|
-
lines.concat(render_architecture)
|
|
255
|
-
|
|
257
|
+
data = render_stack_overview + render_key_models + render_notable_gems + render_architecture
|
|
256
258
|
instructions = render_commands + render_warnings + render_footer + render_tools_guide_compact
|
|
257
|
-
enforce_max_lines(
|
|
259
|
+
enforce_max_lines(data, head: render_header, keep: instructions)
|
|
258
260
|
end
|
|
259
261
|
end
|
|
260
262
|
end
|
|
@@ -55,14 +55,19 @@ module RailsAiContext
|
|
|
55
55
|
end
|
|
56
56
|
|
|
57
57
|
# Callers that pay for an introspection before writing check the format
|
|
58
|
-
# first, so a name nothing can write is refused before the work.
|
|
58
|
+
# first, so a name nothing can write is refused before the work. The
|
|
59
|
+
# names come in the order the CLI's help lists them, and a single name
|
|
60
|
+
# (a command's --format or argument) may also be `all`; a list, which
|
|
61
|
+
# is config.ai_tools, may not.
|
|
59
62
|
def self.validate_format!(format)
|
|
60
63
|
return if format.nil? || format == :all
|
|
61
64
|
|
|
62
65
|
unknown = Array(format).reject { |fmt| ALL_FORMATS.include?(fmt) }
|
|
63
66
|
return if unknown.empty?
|
|
64
67
|
|
|
65
|
-
|
|
68
|
+
valid = (Install::AiTool.all.map(&:key) & ALL_FORMATS) | ALL_FORMATS
|
|
69
|
+
valid += [ :all ] unless format.is_a?(Array)
|
|
70
|
+
raise ArgumentError, "Unknown format: #{unknown.first}. Valid formats: #{valid.join(', ')}"
|
|
66
71
|
end
|
|
67
72
|
|
|
68
73
|
# Write context files, skipping unchanged ones.
|
|
@@ -85,9 +90,7 @@ module RailsAiContext
|
|
|
85
90
|
# - a different path from a boot that started and failed.
|
|
86
91
|
output_dir = RailsAiContext.configuration.output_dir_for(RailsAiContext.default_app)
|
|
87
92
|
generate_root = RailsAiContext.configuration.generate_root_files
|
|
88
|
-
|
|
89
|
-
skipped = []
|
|
90
|
-
not_applicable = {}
|
|
93
|
+
result = { written: [], skipped: [], not_applicable: {} }
|
|
91
94
|
|
|
92
95
|
seen_root_files = Set.new
|
|
93
96
|
|
|
@@ -105,7 +108,7 @@ module RailsAiContext
|
|
|
105
108
|
# generate_root_files = false is a deliberate omission, so it is
|
|
106
109
|
# reported rather than dropped. The JSON dump is no tool's root file.
|
|
107
110
|
unless generate_root || fmt == :json
|
|
108
|
-
not_applicable[filepath] = "root files disabled"
|
|
111
|
+
result[:not_applicable][filepath] = "root files disabled"
|
|
109
112
|
next
|
|
110
113
|
end
|
|
111
114
|
|
|
@@ -113,16 +116,16 @@ module RailsAiContext
|
|
|
113
116
|
content = serialize(fmt)
|
|
114
117
|
|
|
115
118
|
if fmt == :json
|
|
116
|
-
write_plain(filepath, content,
|
|
119
|
+
write_plain(filepath, content, result)
|
|
117
120
|
else
|
|
118
|
-
write_with_markers(filepath, content,
|
|
121
|
+
SectionMarkerWriter.report(SectionMarkerWriter.write_with_markers(filepath, content), filepath, result)
|
|
119
122
|
end
|
|
120
123
|
end
|
|
121
124
|
|
|
122
125
|
# Split rules are always generated regardless of generate_root_files
|
|
123
|
-
generate_split_rules(formats, output_dir,
|
|
126
|
+
generate_split_rules(formats, output_dir, result)
|
|
124
127
|
|
|
125
|
-
|
|
128
|
+
result
|
|
126
129
|
end
|
|
127
130
|
|
|
128
131
|
private
|
|
@@ -132,12 +135,12 @@ module RailsAiContext
|
|
|
132
135
|
end
|
|
133
136
|
|
|
134
137
|
# JSON and other formats that don't support HTML comments
|
|
135
|
-
def write_plain(filepath, content,
|
|
138
|
+
def write_plain(filepath, content, result)
|
|
136
139
|
if File.exist?(filepath) && same_payload?(File.read(filepath), content)
|
|
137
|
-
skipped << filepath
|
|
140
|
+
result[:skipped] << filepath
|
|
138
141
|
else
|
|
139
142
|
RailsAiContext::SafeFile.atomic_write(filepath, content)
|
|
140
|
-
written << filepath
|
|
143
|
+
result[:written] << filepath
|
|
141
144
|
end
|
|
142
145
|
end
|
|
143
146
|
|
|
@@ -154,25 +157,14 @@ module RailsAiContext
|
|
|
154
157
|
false
|
|
155
158
|
end
|
|
156
159
|
|
|
157
|
-
|
|
158
|
-
# Delegates to SectionMarkerWriter (also used by CursorRulesSerializer
|
|
159
|
-
# for .cursorrules) so the marker contract is implemented in exactly
|
|
160
|
-
# one place.
|
|
161
|
-
def write_with_markers(filepath, content, written, skipped)
|
|
162
|
-
case SectionMarkerWriter.write_with_markers(filepath, content)
|
|
163
|
-
when :written then written << filepath
|
|
164
|
-
when :skipped then skipped << filepath
|
|
165
|
-
end
|
|
166
|
-
end
|
|
167
|
-
|
|
168
|
-
def generate_split_rules(formats, output_dir, written, skipped, not_applicable)
|
|
160
|
+
def generate_split_rules(formats, output_dir, result)
|
|
169
161
|
serializers = formats.filter_map { |fmt| RULES_SERIALIZERS[fmt] }.uniq
|
|
170
162
|
|
|
171
163
|
serializers.each do |serializer|
|
|
172
|
-
|
|
173
|
-
written.concat(
|
|
174
|
-
skipped.concat(
|
|
175
|
-
not_applicable.merge!(
|
|
164
|
+
rules = serializer.new(context).call(output_dir)
|
|
165
|
+
result[:written].concat(rules[:written])
|
|
166
|
+
result[:skipped].concat(rules[:skipped])
|
|
167
|
+
result[:not_applicable].merge!(rules[:not_applicable] || {})
|
|
176
168
|
end
|
|
177
169
|
end
|
|
178
170
|
end
|