agentcode 0.9.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 (70) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +59 -0
  3. data/lib/agentcode/blueprint/blueprint_parser.rb +198 -0
  4. data/lib/agentcode/blueprint/blueprint_validator.rb +209 -0
  5. data/lib/agentcode/blueprint/generators/factory_generator.rb +74 -0
  6. data/lib/agentcode/blueprint/generators/policy_generator.rb +154 -0
  7. data/lib/agentcode/blueprint/generators/seeder_generator.rb +160 -0
  8. data/lib/agentcode/blueprint/generators/test_generator.rb +291 -0
  9. data/lib/agentcode/blueprint/manifest_manager.rb +81 -0
  10. data/lib/agentcode/commands/base_command.rb +57 -0
  11. data/lib/agentcode/commands/blueprint_command.rb +549 -0
  12. data/lib/agentcode/commands/export_postman_command.rb +328 -0
  13. data/lib/agentcode/commands/generate_command.rb +563 -0
  14. data/lib/agentcode/commands/install_command.rb +441 -0
  15. data/lib/agentcode/commands/invitation_link_command.rb +107 -0
  16. data/lib/agentcode/concerns/belongs_to_organization.rb +49 -0
  17. data/lib/agentcode/concerns/has_agentcode.rb +93 -0
  18. data/lib/agentcode/concerns/has_audit_trail.rb +125 -0
  19. data/lib/agentcode/concerns/has_auto_scope.rb +91 -0
  20. data/lib/agentcode/concerns/has_permissions.rb +117 -0
  21. data/lib/agentcode/concerns/has_uuid.rb +26 -0
  22. data/lib/agentcode/concerns/has_validation.rb +250 -0
  23. data/lib/agentcode/concerns/hidable_columns.rb +180 -0
  24. data/lib/agentcode/configuration.rb +98 -0
  25. data/lib/agentcode/controllers/auth_controller.rb +242 -0
  26. data/lib/agentcode/controllers/invitations_controller.rb +231 -0
  27. data/lib/agentcode/controllers/resources_controller.rb +813 -0
  28. data/lib/agentcode/engine.rb +65 -0
  29. data/lib/agentcode/mailers/invitation_mailer.rb +22 -0
  30. data/lib/agentcode/middleware/resolve_organization_from_route.rb +72 -0
  31. data/lib/agentcode/models/agentcode_model.rb +387 -0
  32. data/lib/agentcode/models/audit_log.rb +17 -0
  33. data/lib/agentcode/models/organization_invitation.rb +57 -0
  34. data/lib/agentcode/policies/invitation_policy.rb +54 -0
  35. data/lib/agentcode/policies/resource_policy.rb +197 -0
  36. data/lib/agentcode/query_builder.rb +278 -0
  37. data/lib/agentcode/railtie.rb +11 -0
  38. data/lib/agentcode/resource_scope.rb +59 -0
  39. data/lib/agentcode/routes.rb +124 -0
  40. data/lib/agentcode/tasks/agentcode.rake +39 -0
  41. data/lib/agentcode/templates/agentcode.rb +71 -0
  42. data/lib/agentcode/templates/agentcode_model.rb +104 -0
  43. data/lib/agentcode/templates/audit_trail/create_audit_logs.rb.erb +26 -0
  44. data/lib/agentcode/templates/generate/factory.rb.erb +43 -0
  45. data/lib/agentcode/templates/generate/migration.rb.erb +26 -0
  46. data/lib/agentcode/templates/generate/model.rb.erb +55 -0
  47. data/lib/agentcode/templates/generate/policy.rb.erb +52 -0
  48. data/lib/agentcode/templates/generate/scope.rb.erb +31 -0
  49. data/lib/agentcode/templates/multi_tenant/factories/organizations.rb.erb +9 -0
  50. data/lib/agentcode/templates/multi_tenant/factories/roles.rb.erb +9 -0
  51. data/lib/agentcode/templates/multi_tenant/factories/user_roles.rb.erb +10 -0
  52. data/lib/agentcode/templates/multi_tenant/factories/users.rb.erb +9 -0
  53. data/lib/agentcode/templates/multi_tenant/migrations/create_organizations.rb.erb +15 -0
  54. data/lib/agentcode/templates/multi_tenant/migrations/create_roles.rb.erb +15 -0
  55. data/lib/agentcode/templates/multi_tenant/migrations/create_user_roles.rb.erb +16 -0
  56. data/lib/agentcode/templates/multi_tenant/migrations/create_users.rb.erb +15 -0
  57. data/lib/agentcode/templates/multi_tenant/models/organization.rb.erb +18 -0
  58. data/lib/agentcode/templates/multi_tenant/models/role.rb.erb +11 -0
  59. data/lib/agentcode/templates/multi_tenant/models/user.rb.erb +14 -0
  60. data/lib/agentcode/templates/multi_tenant/models/user_role.rb.erb +9 -0
  61. data/lib/agentcode/templates/multi_tenant/policies/organization_policy.rb.erb +6 -0
  62. data/lib/agentcode/templates/multi_tenant/policies/role_policy.rb.erb +6 -0
  63. data/lib/agentcode/templates/multi_tenant/seeders/organization_seeder.rb.erb +9 -0
  64. data/lib/agentcode/templates/multi_tenant/seeders/role_seeder.rb.erb +19 -0
  65. data/lib/agentcode/templates/routes.rb +13 -0
  66. data/lib/agentcode/version.rb +5 -0
  67. data/lib/agentcode/views/lumina/invitation_mailer/invite.html.erb +29 -0
  68. data/lib/agentcode-rails.rb +3 -0
  69. data/lib/agentcode.rb +26 -0
  70. metadata +281 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 156884253ac38e8338ed1a4dd25f0d6b07b490159981cf527a82767dd753a78c
4
+ data.tar.gz: 38425ac745bee34c114409082f47823d0bee5f0024b42ebc67e86f8c97f4cbd1
5
+ SHA512:
6
+ metadata.gz: 4826283c4e629c65742f910b2d53a5714e816830c7bcaefcfb305a88c75d825c03981d71390e2ee10290deea83de55244d4e6ec94e0f23d4dd3e1ee563661573
7
+ data.tar.gz: ee02e159498fc5e3c21467dea7e83ea3c20aa2b85ffea5b23b3ce061f212582d042a22fa9c0952bc3099f87631c045905e5c15e557a2b00e32be6bfdb29b3c94
data/README.md ADDED
@@ -0,0 +1,59 @@
1
+ # AgentCode — Rails
2
+
3
+ > Automatic REST API generation for Rails models with built-in security, validation, and advanced querying.
4
+
5
+ [![Ruby](https://img.shields.io/badge/ruby-3.1%2B-red)](https://www.ruby-lang.org/)
6
+ [![Rails](https://img.shields.io/badge/rails-7%2B-red)](https://rubyonrails.org/)
7
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
8
+
9
+ Register a model, get a full REST API instantly.
10
+
11
+ ## Features
12
+
13
+ | # | Feature | Description |
14
+ |---|---------|-------------|
15
+ | 1 | **Automatic CRUD Endpoints** | Generates `index`, `show`, `create`, `update`, `destroy` for every registered model. |
16
+ | 2 | **Authentication** | Login, logout, password recovery/reset, invitation-based registration. |
17
+ | 3 | **Authorization & Policies** | Pundit-based permission checks (`{slug}.{action}`), wildcard support. |
18
+ | 4 | **Role-Based Access Control** | Per-org roles via `user_roles` join table. |
19
+ | 5 | **Attribute-Level Permissions** | Control which fields each role can read and write. |
20
+ | 6 | **Validation** | Dual-layer: format rules + field presence. Supports role-keyed rules. |
21
+ | 7 | **Cross-Tenant FK Validation** | `exists:` rules auto-scoped to current org, even through indirect FK relationships. |
22
+ | 8 | **Filtering** | `?filter[field]=value` with AND/OR logic. |
23
+ | 9 | **Sorting** | `?sort=-created_at,title` — ascending and descending. |
24
+ | 10 | **Full-Text Search** | `?search=term` across configured fields, supports relationship dot notation. |
25
+ | 11 | **Pagination** | Header-based metadata (`X-Current-Page`, `X-Last-Page`, `X-Per-Page`, `X-Total`). |
26
+ | 12 | **Field Selection** | `?fields[posts]=id,title,status` to reduce payload. |
27
+ | 13 | **Eager Loading** | `?include=user,comments` with nested, Count/Exists suffixes, and auth per include. |
28
+ | 14 | **Multi-Tenancy** | Organization-based data isolation, auto-set `organization_id`, global scope. |
29
+ | 15 | **Nested Ownership** | Auto-detects org by walking `belongs_to` chains. |
30
+ | 16 | **Route Groups** | Multiple URL prefixes with different middleware/auth (`tenant`, `public`, custom). |
31
+ | 17 | **Soft Deletes** | Discard gem — trash, restore, force-delete endpoints with individual permissions. |
32
+ | 18 | **Audit Trail** | Logs all CRUD events with old/new values, user, IP, and org context. |
33
+ | 19 | **Nested Operations** | `POST /nested` for atomic multi-model transactions with `$N.field` references. |
34
+ | 20 | **Invitations** | Token-based invite system with create, resend, cancel, accept, and role assignment. |
35
+ | 21 | **Hidden Columns** | Base + model-level + policy-level dynamic column hiding per role. |
36
+ | 22 | **Auto-Scope Discovery** | Auto-registers scopes by naming convention. |
37
+ | 23 | **UUID Primary Keys** | `HasUuid` concern for auto-generated UUIDs. |
38
+ | 24 | **Middleware Support** | Global per model + per action middleware. |
39
+ | 25 | **Action Exclusion** | `except_actions` to disable specific CRUD routes. |
40
+ | 26 | **Generator CLI** | `agentcode:install`, `agentcode:generate`, `agentcode:blueprint`, `agentcode:export_postman`. |
41
+ | 27 | **Postman Export** | Auto-generated Postman Collection v2.1 with all endpoints. |
42
+ | 28 | **Blueprint System** | YAML-to-code generation for models, migrations, factories, policies, tests, and seeders. |
43
+
44
+ ## Quick Start
45
+
46
+ ```bash
47
+ bundle add agentcode
48
+ rails agentcode:install
49
+ ```
50
+
51
+ ## Documentation
52
+
53
+ For full documentation, guides, and API reference visit:
54
+
55
+ **[https://startsoft-dev.github.io/agentcode-docs/docs/getting-started](https://startsoft-dev.github.io/agentcode-docs/docs/getting-started)**
56
+
57
+ ## License
58
+
59
+ MIT — see [LICENSE](LICENSE) for details.
@@ -0,0 +1,198 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require "digest"
5
+
6
+ module AgentCode
7
+ module Blueprint
8
+ # Parses YAML blueprint files into normalized data structures.
9
+ # Port of agentcode-server BlueprintParser.php / agentcode-adonis-server blueprint_parser.ts.
10
+ class BlueprintParser
11
+ # Parse _roles.yaml file into normalized role definitions.
12
+ #
13
+ # @param file_path [String]
14
+ # @return [Hash<String, Hash>] e.g. { 'owner' => { name: 'Owner', description: '...' } }
15
+ def parse_roles(file_path)
16
+ content = read_file(file_path)
17
+
18
+ raise "Blueprint roles file is empty: #{file_path}" if content.strip.empty?
19
+
20
+ parsed = begin
21
+ YAML.safe_load(content, permitted_classes: [Symbol])
22
+ rescue Psych::SyntaxError
23
+ raise "Invalid YAML syntax in: #{file_path}"
24
+ end
25
+
26
+ raise "Invalid YAML structure in: #{file_path}" unless parsed.is_a?(Hash)
27
+ raise "Missing 'roles' key in: #{file_path}" unless parsed["roles"]
28
+
29
+ roles = {}
30
+
31
+ parsed["roles"].each do |slug, value|
32
+ raise "Invalid role definition for '#{slug}' — expected a hash" unless value.is_a?(Hash)
33
+
34
+ roles[slug] = {
35
+ name: value["name"] || slug_to_name(slug),
36
+ description: value["description"] || ""
37
+ }
38
+ end
39
+
40
+ roles
41
+ end
42
+
43
+ # Parse a model blueprint YAML file into normalized structure.
44
+ #
45
+ # @param file_path [String]
46
+ # @return [Hash] ParsedBlueprint hash
47
+ def parse_model(file_path)
48
+ content = read_file(file_path)
49
+
50
+ raise "Blueprint file is empty: #{file_path}" if content.strip.empty?
51
+
52
+ parsed = begin
53
+ YAML.safe_load(content, permitted_classes: [Symbol])
54
+ rescue Psych::SyntaxError
55
+ raise "Invalid YAML syntax in: #{file_path}"
56
+ end
57
+
58
+ raise "Invalid YAML structure in: #{file_path}" unless parsed.is_a?(Hash)
59
+ raise "Missing 'model' key in: #{file_path}" unless parsed["model"]
60
+
61
+ model_name = parsed["model"]
62
+ slug = parsed["slug"] || model_to_slug(model_name)
63
+ table = parsed["table"] || slug
64
+
65
+ source_file = File.basename(file_path)
66
+
67
+ {
68
+ model: model_name,
69
+ slug: slug,
70
+ table: table,
71
+ options: normalize_options(parsed["options"] || {}),
72
+ columns: normalize_columns(parsed["columns"] || {}),
73
+ relationships: parsed["relationships"] || [],
74
+ permissions: normalize_permissions(parsed["permissions"] || {}),
75
+ source_file: source_file
76
+ }
77
+ end
78
+
79
+ # Compute SHA-256 hash of a file's contents.
80
+ #
81
+ # @param file_path [String]
82
+ # @return [String] 64-char hex hash
83
+ def compute_file_hash(file_path)
84
+ Digest::SHA256.hexdigest(File.read(file_path))
85
+ end
86
+
87
+ # ──────────────────────────────────────────────
88
+ # Normalization helpers
89
+ # ──────────────────────────────────────────────
90
+
91
+ def normalize_options(options)
92
+ {
93
+ belongs_to_organization: options.fetch("belongs_to_organization", false),
94
+ soft_deletes: options.fetch("soft_deletes", true),
95
+ audit_trail: options.fetch("audit_trail", false),
96
+ owner: options.fetch("owner", nil),
97
+ except_actions: options.fetch("except_actions", []),
98
+ pagination: options.fetch("pagination", false),
99
+ per_page: options.fetch("per_page", 25)
100
+ }
101
+ end
102
+
103
+ def normalize_columns(columns)
104
+ result = []
105
+
106
+ columns.each do |name, value|
107
+ if value.is_a?(String)
108
+ # Short syntax: field_name: type
109
+ result << {
110
+ name: name,
111
+ type: value,
112
+ nullable: false,
113
+ unique: false,
114
+ index: false,
115
+ default: nil,
116
+ filterable: false,
117
+ sortable: false,
118
+ searchable: false,
119
+ precision: nil,
120
+ scale: nil,
121
+ foreign_model: nil
122
+ }
123
+ elsif value.is_a?(Hash)
124
+ result << {
125
+ name: name,
126
+ type: value.fetch("type", "string"),
127
+ nullable: value.fetch("nullable", false),
128
+ unique: value.fetch("unique", false),
129
+ index: value.fetch("index", false),
130
+ default: value.fetch("default", nil),
131
+ filterable: value.fetch("filterable", false),
132
+ sortable: value.fetch("sortable", false),
133
+ searchable: value.fetch("searchable", false),
134
+ precision: value.fetch("precision", nil),
135
+ scale: value.fetch("scale", nil),
136
+ foreign_model: value.fetch("foreign_model", nil)
137
+ }
138
+ end
139
+ end
140
+
141
+ result
142
+ end
143
+
144
+ def normalize_permissions(permissions)
145
+ result = {}
146
+
147
+ permissions.each do |role, value|
148
+ next unless value.is_a?(Hash)
149
+
150
+ result[role] = {
151
+ actions: value["actions"] || [],
152
+ show_fields: normalize_field_list(value["show_fields"]),
153
+ create_fields: normalize_field_list(value["create_fields"]),
154
+ update_fields: normalize_field_list(value["update_fields"]),
155
+ hidden_fields: normalize_field_list(value["hidden_fields"])
156
+ }
157
+ end
158
+
159
+ result
160
+ end
161
+
162
+ def normalize_field_list(value)
163
+ return [] if value.nil?
164
+ return ["*"] if value == "*"
165
+ return [value] if value.is_a?(String)
166
+ return value if value.is_a?(Array)
167
+
168
+ []
169
+ end
170
+
171
+ private
172
+
173
+ def read_file(file_path)
174
+ File.read(file_path)
175
+ rescue Errno::ENOENT
176
+ raise "File not found or unreadable: #{file_path}"
177
+ end
178
+
179
+ def slug_to_name(slug)
180
+ slug.split("_").map(&:capitalize).join(" ")
181
+ end
182
+
183
+ def model_to_slug(model_name)
184
+ # PascalCase → snake_case plural
185
+ snake = model_name.gsub(/([A-Z])/, '_\1').downcase.sub(/\A_/, "")
186
+
187
+ # Simple pluralization
188
+ if snake.end_with?("y") && !snake.match?(/[aeiou]y\z/)
189
+ snake[0..-2] + "ies"
190
+ elsif snake.match?(/(s|x|z|ch|sh)\z/)
191
+ snake + "es"
192
+ else
193
+ snake + "s"
194
+ end
195
+ end
196
+ end
197
+ end
198
+ end
@@ -0,0 +1,209 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AgentCode
4
+ module Blueprint
5
+ # Validates parsed blueprint data structures.
6
+ # Port of agentcode-server BlueprintValidator.php / agentcode-adonis-server blueprint_validator.ts.
7
+ class BlueprintValidator
8
+ VALID_COLUMN_TYPES = %w[
9
+ string text integer bigInteger boolean date datetime
10
+ timestamp decimal float json uuid foreignId
11
+ ].freeze
12
+
13
+ VALID_ACTIONS = %w[
14
+ index show store update destroy trashed restore forceDelete
15
+ ].freeze
16
+
17
+ # Validate role definitions.
18
+ #
19
+ # @param roles [Hash<String, Hash>]
20
+ # @return [Hash] { valid:, errors: }
21
+ def validate_roles(roles)
22
+ errors = []
23
+
24
+ if roles.empty?
25
+ errors << "At least one role is required"
26
+ return { valid: false, errors: errors }
27
+ end
28
+
29
+ roles.each do |slug, role|
30
+ unless slug.match?(/\A[a-z][a-z0-9_]*\z/)
31
+ errors << "Invalid role slug '#{slug}' — must match /^[a-z][a-z0-9_]*$/"
32
+ end
33
+
34
+ if role[:name].nil? || role[:name].strip.empty?
35
+ errors << "Role '#{slug}' must have a non-empty name"
36
+ end
37
+ end
38
+
39
+ { valid: errors.empty?, errors: errors }
40
+ end
41
+
42
+ # Validate a full model blueprint.
43
+ #
44
+ # @param blueprint [Hash]
45
+ # @param valid_roles [Hash] optional role definitions for cross-reference
46
+ # @return [Hash] { valid:, errors:, warnings: }
47
+ def validate_model(blueprint, valid_roles = {})
48
+ errors = []
49
+ warnings = []
50
+
51
+ # Model name
52
+ if blueprint[:model].nil? || blueprint[:model].strip.empty?
53
+ errors << "Model name is required"
54
+ elsif !blueprint[:model].match?(/\A[A-Z][a-zA-Z0-9]*\z/)
55
+ errors << "Invalid model name '#{blueprint[:model]}' — must be PascalCase (match /^[A-Z][a-zA-Z0-9]*$/)"
56
+ end
57
+
58
+ # Columns
59
+ errors.concat(validate_columns(blueprint[:columns]))
60
+
61
+ # Permissions
62
+ column_names = blueprint[:columns].map { |c| c[:name] }
63
+ perm_result = validate_permissions(blueprint[:permissions], valid_roles, column_names)
64
+ errors.concat(perm_result[:errors])
65
+ warnings.concat(perm_result[:warnings])
66
+
67
+ # Options
68
+ errors.concat(validate_options(blueprint[:options]))
69
+
70
+ # Relationships
71
+ errors.concat(validate_relationships(blueprint[:relationships]))
72
+
73
+ { valid: errors.empty?, errors: errors, warnings: warnings }
74
+ end
75
+
76
+ # Validate columns.
77
+ #
78
+ # @param columns [Array<Hash>]
79
+ # @return [Array<String>] errors
80
+ def validate_columns(columns)
81
+ errors = []
82
+ seen = Set.new
83
+
84
+ columns.each do |col|
85
+ if col[:name].nil? || col[:name].strip.empty?
86
+ errors << "Column name is required"
87
+ next
88
+ end
89
+
90
+ if seen.include?(col[:name])
91
+ errors << "Duplicate column name '#{col[:name]}'"
92
+ end
93
+ seen.add(col[:name])
94
+
95
+ unless VALID_COLUMN_TYPES.include?(col[:type])
96
+ errors << "Invalid column type '#{col[:type]}' for column '#{col[:name]}'"
97
+ end
98
+
99
+ if col[:type] == "foreignId" && col[:foreign_model].nil?
100
+ errors << "Column '#{col[:name]}' is foreignId but missing 'foreign_model'"
101
+ end
102
+ end
103
+
104
+ errors
105
+ end
106
+
107
+ # Validate permissions.
108
+ #
109
+ # @return [Hash] { errors:, warnings: }
110
+ def validate_permissions(permissions, valid_roles, column_names)
111
+ errors = []
112
+ warnings = []
113
+ has_roles = !valid_roles.empty?
114
+
115
+ permissions.each do |role, perm|
116
+ # Check role exists
117
+ if has_roles && !valid_roles.key?(role)
118
+ errors << "Unknown role '#{role}' in permissions"
119
+ end
120
+
121
+ # Check actions
122
+ perm[:actions].each do |action|
123
+ unless VALID_ACTIONS.include?(action)
124
+ errors << "Invalid action '#{action}' for role '#{role}'"
125
+ end
126
+ end
127
+
128
+ # Check field references
129
+ all_column_names = ["id"] + column_names
130
+ check_field_references(perm[:show_fields], all_column_names, role, "show_fields", warnings)
131
+ check_field_references(perm[:create_fields], all_column_names, role, "create_fields", warnings)
132
+ check_field_references(perm[:update_fields], all_column_names, role, "update_fields", warnings)
133
+
134
+ # Warn on conflicts
135
+ if perm[:hidden_fields].any? && perm[:show_fields].any?
136
+ perm[:hidden_fields].each do |field|
137
+ if perm[:show_fields].include?(field)
138
+ warnings << "Role '#{role}': field '#{field}' is in both show_fields and hidden_fields"
139
+ end
140
+ end
141
+ end
142
+
143
+ # Warn on create_fields without store action
144
+ if perm[:create_fields].any? && !perm[:create_fields].include?("*") &&
145
+ perm[:create_fields].any? { |f| f != "*" } && !perm[:actions].include?("store")
146
+ warnings << "Role '#{role}': has create_fields but no 'store' action"
147
+ end
148
+
149
+ # Warn on update_fields without update action
150
+ if perm[:update_fields].any? && !perm[:update_fields].include?("*") &&
151
+ perm[:update_fields].any? { |f| f != "*" } && !perm[:actions].include?("update")
152
+ warnings << "Role '#{role}': has update_fields but no 'update' action"
153
+ end
154
+ end
155
+
156
+ { errors: errors, warnings: warnings }
157
+ end
158
+
159
+ # Validate options.
160
+ def validate_options(options)
161
+ errors = []
162
+
163
+ if options[:except_actions]
164
+ options[:except_actions].each do |action|
165
+ unless VALID_ACTIONS.include?(action)
166
+ errors << "Invalid action '#{action}' in except_actions"
167
+ end
168
+ end
169
+ end
170
+
171
+ errors
172
+ end
173
+
174
+ # Validate relationships.
175
+ def validate_relationships(relationships)
176
+ errors = []
177
+ valid_types = %w[belongsTo hasMany hasOne belongsToMany]
178
+
179
+ relationships.each do |rel|
180
+ rel = rel.transform_keys(&:to_s) if rel.is_a?(Hash)
181
+
182
+ if rel["type"].nil?
183
+ errors << "Relationship is missing type"
184
+ elsif !valid_types.include?(rel["type"])
185
+ errors << "Invalid relationship type '#{rel["type"]}'"
186
+ end
187
+
188
+ if rel["model"].nil?
189
+ errors << "Relationship is missing model"
190
+ end
191
+ end
192
+
193
+ errors
194
+ end
195
+
196
+ private
197
+
198
+ def check_field_references(fields, column_names, role, field_key, warnings)
199
+ return if fields.empty? || (fields.length == 1 && fields[0] == "*")
200
+
201
+ fields.each do |field|
202
+ if field != "*" && !column_names.include?(field)
203
+ warnings << "Role '#{role}': unknown field '#{field}' in #{field_key}"
204
+ end
205
+ end
206
+ end
207
+ end
208
+ end
209
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AgentCode
4
+ module Blueprint
5
+ module Generators
6
+ # Generates FactoryBot factory files with smart Faker detection.
7
+ # Reuses faker mapping logic from GenerateCommand.
8
+ class FactoryGenerator
9
+ # Generate a FactoryBot factory file.
10
+ #
11
+ # @param blueprint [Hash] ParsedBlueprint
12
+ # @return [String] Ruby source code
13
+ def generate(blueprint)
14
+ model_name = blueprint[:model]
15
+ factory_name = model_name.underscore
16
+ columns = blueprint[:columns]
17
+
18
+ field_lines = columns.map do |col|
19
+ next if col[:name] == "organization_id" && blueprint[:options][:belongs_to_organization]
20
+
21
+ if col[:type] == "foreignId" || col[:type] == "references"
22
+ if col[:foreign_model]
23
+ relation = col[:name].sub(/_id\z/, "")
24
+ " association :#{relation}, factory: :#{col[:foreign_model].underscore}"
25
+ else
26
+ " #{col[:name]} { Faker::Number.between(from: 1, to: 10) }"
27
+ end
28
+ else
29
+ " #{col[:name]} { #{column_to_faker(col)} }"
30
+ end
31
+ end.compact
32
+
33
+ <<~RUBY
34
+ # frozen_string_literal: true
35
+
36
+ FactoryBot.define do
37
+ factory :#{factory_name} do
38
+ #{field_lines.join("\n")}
39
+ end
40
+ end
41
+ RUBY
42
+ end
43
+
44
+ private
45
+
46
+ def column_to_faker(column)
47
+ case column[:name]
48
+ when "name", "full_name" then "Faker::Name.name"
49
+ when "email" then "Faker::Internet.email"
50
+ when "title" then "Faker::Lorem.sentence(word_count: 3)"
51
+ when "description", "content", "body" then "Faker::Lorem.paragraph"
52
+ when "slug" then "Faker::Internet.slug"
53
+ when "phone", "phone_number" then "Faker::PhoneNumber.phone_number"
54
+ when "url", "website" then "Faker::Internet.url"
55
+ when /\Ais_/ then "[true, false].sample"
56
+ else
57
+ case column[:type]
58
+ when "string" then "Faker::Lorem.sentence(word_count: 3)"
59
+ when "text" then "Faker::Lorem.paragraph"
60
+ when "integer", "bigInteger" then "Faker::Number.between(from: 1, to: 100)"
61
+ when "boolean" then "[true, false].sample"
62
+ when "date" then "Faker::Date.between(from: 1.year.ago, to: Date.today)"
63
+ when "datetime", "timestamp" then "Faker::Time.between(from: 1.year.ago, to: Time.current)"
64
+ when "decimal", "float" then "Faker::Number.decimal(l_digits: 3, r_digits: 2)"
65
+ when "json" then "{}"
66
+ when "uuid" then "SecureRandom.uuid"
67
+ else "Faker::Lorem.word"
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end