mxrb 0.1.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 (237) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.de-DE.md +108 -0
  4. data/README.md +172 -0
  5. data/README.pt-BR.md +109 -0
  6. data/bin/mxrb +1613 -0
  7. data/docs/README.md +10 -0
  8. data/docs/de-DE/README.md +24 -0
  9. data/docs/de-DE/architectural-patterns.md +26 -0
  10. data/docs/de-DE/architectural-standard.md +48 -0
  11. data/docs/de-DE/architecture.md +76 -0
  12. data/docs/de-DE/compiler.md +162 -0
  13. data/docs/de-DE/conventions.md +19 -0
  14. data/docs/de-DE/design-system.md +52 -0
  15. data/docs/de-DE/entity-dsl.md +53 -0
  16. data/docs/de-DE/native-runtime-quality-report.md +74 -0
  17. data/docs/de-DE/oql-sql.md +211 -0
  18. data/docs/de-DE/platform-api-integration.md +36 -0
  19. data/docs/de-DE/platform-operations.md +243 -0
  20. data/docs/de-DE/project-structure.md +32 -0
  21. data/docs/de-DE/ruby-first-roadmap.md +116 -0
  22. data/docs/de-DE/scaffolds.md +59 -0
  23. data/docs/de-DE/semantic-refactoring.md +56 -0
  24. data/docs/de-DE/team-server.md +33 -0
  25. data/docs/de-DE/validation-matrix.md +64 -0
  26. data/docs/de-DE/vetclinic-acceptance.md +24 -0
  27. data/docs/de-DE/writing.md +138 -0
  28. data/docs/en-US/README.md +24 -0
  29. data/docs/en-US/architectural-patterns.md +151 -0
  30. data/docs/en-US/architectural-standard.md +89 -0
  31. data/docs/en-US/architecture.md +170 -0
  32. data/docs/en-US/compiler.md +157 -0
  33. data/docs/en-US/conventions.md +82 -0
  34. data/docs/en-US/design-system.md +163 -0
  35. data/docs/en-US/entity-dsl.md +53 -0
  36. data/docs/en-US/native-runtime-quality-report.md +73 -0
  37. data/docs/en-US/oql-sql.md +201 -0
  38. data/docs/en-US/platform-api-integration.md +36 -0
  39. data/docs/en-US/platform-operations.md +244 -0
  40. data/docs/en-US/project-structure.md +49 -0
  41. data/docs/en-US/ruby-first-roadmap.md +186 -0
  42. data/docs/en-US/scaffolds.md +57 -0
  43. data/docs/en-US/semantic-refactoring.md +198 -0
  44. data/docs/en-US/team-server.md +32 -0
  45. data/docs/en-US/validation-matrix.md +257 -0
  46. data/docs/en-US/vetclinic-acceptance.md +23 -0
  47. data/docs/en-US/writing.md +655 -0
  48. data/docs/pt-BR/README.md +24 -0
  49. data/docs/pt-BR/architectural-patterns.md +25 -0
  50. data/docs/pt-BR/architectural-standard.md +533 -0
  51. data/docs/pt-BR/architecture.md +300 -0
  52. data/docs/pt-BR/compiler.md +162 -0
  53. data/docs/pt-BR/conventions.md +19 -0
  54. data/docs/pt-BR/design-system.md +65 -0
  55. data/docs/pt-BR/entity-dsl.md +116 -0
  56. data/docs/pt-BR/native-runtime-quality-report.md +73 -0
  57. data/docs/pt-BR/oql-sql.md +208 -0
  58. data/docs/pt-BR/platform-api-integration.md +36 -0
  59. data/docs/pt-BR/platform-operations.md +260 -0
  60. data/docs/pt-BR/project-structure.md +32 -0
  61. data/docs/pt-BR/ruby-first-roadmap.md +239 -0
  62. data/docs/pt-BR/scaffolds.md +104 -0
  63. data/docs/pt-BR/semantic-refactoring.md +55 -0
  64. data/docs/pt-BR/team-server.md +59 -0
  65. data/docs/pt-BR/validation-matrix.md +86 -0
  66. data/docs/pt-BR/vetclinic-acceptance.md +50 -0
  67. data/docs/pt-BR/writing.md +177 -0
  68. data/examples/sudoku_evaluation.rb +14 -0
  69. data/examples/sudoku_functional_test.rb +28 -0
  70. data/lib/mxrb/architecture/graph.rb +124 -0
  71. data/lib/mxrb/architecture/validator.rb +98 -0
  72. data/lib/mxrb/benchmark.rb +33 -0
  73. data/lib/mxrb/compare.rb +390 -0
  74. data/lib/mxrb/compiler/adapter.rb +96 -0
  75. data/lib/mxrb/compiler/artifact_document_compiler.rb +102 -0
  76. data/lib/mxrb/compiler/artifact_materializer.rb +53 -0
  77. data/lib/mxrb/compiler/client_model_materializer.rb +42 -0
  78. data/lib/mxrb/compiler/code_action_document_compiler.rb +64 -0
  79. data/lib/mxrb/compiler/code_action_materializer.rb +35 -0
  80. data/lib/mxrb/compiler/code_action_type_compiler.rb +32 -0
  81. data/lib/mxrb/compiler/combo_box_bundle_compiler.rb +314 -0
  82. data/lib/mxrb/compiler/compatibility_analyzer.rb +169 -0
  83. data/lib/mxrb/compiler/constants_materializer.rb +111 -0
  84. data/lib/mxrb/compiler/data_grid_bundle_compiler.rb +290 -0
  85. data/lib/mxrb/compiler/database_connector_action_compiler.rb +280 -0
  86. data/lib/mxrb/compiler/deployment_asset_copier.rb +60 -0
  87. data/lib/mxrb/compiler/deployment_bootstrapper.rb +208 -0
  88. data/lib/mxrb/compiler/deployment_materializer.rb +43 -0
  89. data/lib/mxrb/compiler/deployment_metadata.rb +64 -0
  90. data/lib/mxrb/compiler/domain_document_compiler.rb +153 -0
  91. data/lib/mxrb/compiler/domain_model_materializer.rb +35 -0
  92. data/lib/mxrb/compiler/domain_security_compiler.rb +82 -0
  93. data/lib/mxrb/compiler/gallery_bundle_compiler.rb +190 -0
  94. data/lib/mxrb/compiler/generic_widget_bundle_compiler.rb +281 -0
  95. data/lib/mxrb/compiler/image_bundle_compiler.rb +216 -0
  96. data/lib/mxrb/compiler/java_proxy_generator.rb +509 -0
  97. data/lib/mxrb/compiler/legacy_data_grid_compiler.rb +267 -0
  98. data/lib/mxrb/compiler/legacy_page_builder.rb +287 -0
  99. data/lib/mxrb/compiler/mda.rb +99 -0
  100. data/lib/mxrb/compiler/microflow_document_compiler.rb +85 -0
  101. data/lib/mxrb/compiler/microflow_materializer.rb +48 -0
  102. data/lib/mxrb/compiler/microflow_node_compiler.rb +250 -0
  103. data/lib/mxrb/compiler/model_package.rb +116 -0
  104. data/lib/mxrb/compiler/model_values.rb +67 -0
  105. data/lib/mxrb/compiler/nanoflow_program_compiler.rb +483 -0
  106. data/lib/mxrb/compiler/navigation_document_compiler.rb +93 -0
  107. data/lib/mxrb/compiler/packager.rb +100 -0
  108. data/lib/mxrb/compiler/page_bundle_builder.rb +56 -0
  109. data/lib/mxrb/compiler/page_bundle_compiler.rb +2004 -0
  110. data/lib/mxrb/compiler/page_document_compiler.rb +77 -0
  111. data/lib/mxrb/compiler/portable_packager.rb +356 -0
  112. data/lib/mxrb/compiler/project_jar_archive.rb +84 -0
  113. data/lib/mxrb/compiler/project_jar_builder.rb +121 -0
  114. data/lib/mxrb/compiler/project_materializer.rb +139 -0
  115. data/lib/mxrb/compiler/project_model_orderer.rb +52 -0
  116. data/lib/mxrb/compiler/runtime_data_types.rb +31 -0
  117. data/lib/mxrb/compiler/runtime_model_schema.rb +90 -0
  118. data/lib/mxrb/compiler/schemas/runtime-10.24.0.73019.json +1052 -0
  119. data/lib/mxrb/compiler/schemas/runtime-11.json +1215 -0
  120. data/lib/mxrb/compiler/schemas/runtime-6.10.8.json +1148 -0
  121. data/lib/mxrb/compiler/schemas/runtime-7.17.0.json +1144 -0
  122. data/lib/mxrb/compiler/schemas/runtime-7.5.0.json +1215 -0
  123. data/lib/mxrb/compiler/schemas/runtime-9.6.1.29396.json +868 -0
  124. data/lib/mxrb/compiler/schemas/system-model-10.24.0.73019.b64 +1135 -0
  125. data/lib/mxrb/compiler/schemas/system-model-11.12.1.b64 +815 -0
  126. data/lib/mxrb/compiler/schemas/system-model-6.10.8.b64 +497 -0
  127. data/lib/mxrb/compiler/schemas/system-model-7.17.0.b64 +519 -0
  128. data/lib/mxrb/compiler/schemas/system-model-7.5.0.b64 +504 -0
  129. data/lib/mxrb/compiler/schemas/system-model-9.6.1.29396.b64 +816 -0
  130. data/lib/mxrb/compiler/security_materializer.rb +104 -0
  131. data/lib/mxrb/compiler/settings_document_compiler.rb +45 -0
  132. data/lib/mxrb/compiler/settings_materializer.rb +25 -0
  133. data/lib/mxrb/compiler/source_model.rb +128 -0
  134. data/lib/mxrb/compiler/system_model_seed.rb +88 -0
  135. data/lib/mxrb/compiler/system_queue_materializer.rb +54 -0
  136. data/lib/mxrb/compiler/system_text_materializer.rb +49 -0
  137. data/lib/mxrb/compiler/translation_materializer.rb +95 -0
  138. data/lib/mxrb/compiler/web_bundle_builder.rb +155 -0
  139. data/lib/mxrb/compiler/web_list_data_source.rb +139 -0
  140. data/lib/mxrb/compiler/web_operation_compiler.rb +614 -0
  141. data/lib/mxrb/compiler/web_shell_materializer.rb +194 -0
  142. data/lib/mxrb/compiler/widget_package_extractor.rb +63 -0
  143. data/lib/mxrb/doctor.rb +116 -0
  144. data/lib/mxrb/dsl/builder.rb +1636 -0
  145. data/lib/mxrb/errors.rb +18 -0
  146. data/lib/mxrb/evaluation.rb +131 -0
  147. data/lib/mxrb/exporter.rb +1716 -0
  148. data/lib/mxrb/frontend/migrator.rb +775 -0
  149. data/lib/mxrb/functional.rb +307 -0
  150. data/lib/mxrb/github/annotator.rb +161 -0
  151. data/lib/mxrb/initializer.rb +257 -0
  152. data/lib/mxrb/integrity/validator.rb +136 -0
  153. data/lib/mxrb/io/bson_codec.rb +145 -0
  154. data/lib/mxrb/io/mpr_file.rb +558 -0
  155. data/lib/mxrb/io/mxunit_codec.rb +42 -0
  156. data/lib/mxrb/marketplace.rb +380 -0
  157. data/lib/mxrb/model/association.rb +98 -0
  158. data/lib/mxrb/model/attribute.rb +100 -0
  159. data/lib/mxrb/model/connector.rb +17 -0
  160. data/lib/mxrb/model/design_materializer.rb +133 -0
  161. data/lib/mxrb/model/design_migration.rb +126 -0
  162. data/lib/mxrb/model/design_system.rb +111 -0
  163. data/lib/mxrb/model/domain_model.rb +51 -0
  164. data/lib/mxrb/model/entity.rb +180 -0
  165. data/lib/mxrb/model/menu.rb +38 -0
  166. data/lib/mxrb/model/microflow.rb +100 -0
  167. data/lib/mxrb/model/module.rb +142 -0
  168. data/lib/mxrb/model/navigation.rb +127 -0
  169. data/lib/mxrb/model/page.rb +410 -0
  170. data/lib/mxrb/model/project.rb +187 -0
  171. data/lib/mxrb/model/unit.rb +48 -0
  172. data/lib/mxrb/module_initializer.rb +75 -0
  173. data/lib/mxrb/official_marketplace/content_api.rb +292 -0
  174. data/lib/mxrb/official_marketplace/dependency_resolver.rb +329 -0
  175. data/lib/mxrb/official_marketplace/lifecycle.rb +367 -0
  176. data/lib/mxrb/official_marketplace/module_package_importer.rb +318 -0
  177. data/lib/mxrb/official_marketplace/widget_package_installer.rb +414 -0
  178. data/lib/mxrb/official_marketplace.rb +720 -0
  179. data/lib/mxrb/oql/analyzer.rb +155 -0
  180. data/lib/mxrb/oql/index_advisor.rb +85 -0
  181. data/lib/mxrb/oql/plan_analyzer.rb +185 -0
  182. data/lib/mxrb/oql/server.rb +132 -0
  183. data/lib/mxrb/oql/sql_server_plan_analyzer.rb +190 -0
  184. data/lib/mxrb/oql/sql_server_workload_analyzer.rb +80 -0
  185. data/lib/mxrb/oql/workload_analyzer.rb +175 -0
  186. data/lib/mxrb/oql/workload_baseline.rb +60 -0
  187. data/lib/mxrb/oql.rb +424 -0
  188. data/lib/mxrb/project_lifecycle.rb +76 -0
  189. data/lib/mxrb/protocols/plan.rb +86 -0
  190. data/lib/mxrb/protocols.rb +123 -0
  191. data/lib/mxrb/runtime/database_workspace.rb +677 -0
  192. data/lib/mxrb/runtime/docker_executor.rb +41 -0
  193. data/lib/mxrb/runtime/docker_workspace.rb +29 -0
  194. data/lib/mxrb/runtime/executor.rb +169 -0
  195. data/lib/mxrb/runtime/java_locator.rb +26 -0
  196. data/lib/mxrb/runtime/native.rb +474 -0
  197. data/lib/mxrb/runtime/sql_server_database.rb +146 -0
  198. data/lib/mxrb/runtime/toolchain.rb +82 -0
  199. data/lib/mxrb/scaffold/cli.rb +113 -0
  200. data/lib/mxrb/scaffold/generator.rb +246 -0
  201. data/lib/mxrb/scaffold/help.rb +80 -0
  202. data/lib/mxrb/scaffold/page_templates.rb +54 -0
  203. data/lib/mxrb/scaffold/recipes.rb +198 -0
  204. data/lib/mxrb/scaffold/registry.rb +82 -0
  205. data/lib/mxrb/scaffold/templates.rb +723 -0
  206. data/lib/mxrb/scaffold/transaction.rb +84 -0
  207. data/lib/mxrb/schema/tables.rb +55 -0
  208. data/lib/mxrb/semantic/analyzer.rb +473 -0
  209. data/lib/mxrb/semantic/batch_plan.rb +68 -0
  210. data/lib/mxrb/semantic/domain_mutator.rb +419 -0
  211. data/lib/mxrb/semantic/embedder.rb +51 -0
  212. data/lib/mxrb/semantic/extractor.rb +290 -0
  213. data/lib/mxrb/semantic/index.rb +623 -0
  214. data/lib/mxrb/semantic/inliner.rb +216 -0
  215. data/lib/mxrb/semantic/mover.rb +153 -0
  216. data/lib/mxrb/semantic/onnx_embedder.rb +34 -0
  217. data/lib/mxrb/semantic/remover.rb +70 -0
  218. data/lib/mxrb/semantic/renamer.rb +211 -0
  219. data/lib/mxrb/semantic/tfidf_embedder.rb +46 -0
  220. data/lib/mxrb/semantic/vec_store.rb +75 -0
  221. data/lib/mxrb/team_server.rb +390 -0
  222. data/lib/mxrb/templates/project/10.24.0.73019.json +32 -0
  223. data/lib/mxrb/templates/project/11.12.1.json +32 -0
  224. data/lib/mxrb/templates/project/6.10.8.json +50 -0
  225. data/lib/mxrb/templates/project/7.17.0.json +50 -0
  226. data/lib/mxrb/templates/project/7.5.0.json +50 -0
  227. data/lib/mxrb/templates/project/9.6.1.29396.json +50 -0
  228. data/lib/mxrb/version.rb +5 -0
  229. data/lib/mxrb/widget_package.rb +338 -0
  230. data/lib/mxrb/widget_synchronizer.rb +31 -0
  231. data/lib/mxrb/writer.rb +3775 -0
  232. data/lib/mxrb.rb +188 -0
  233. data/marketplace/catalog.json +10 -0
  234. data/marketplace/modules/shared-kernel/domain/README.md +4 -0
  235. data/marketplace/modules/shared-kernel/module.rb +5 -0
  236. data/marketplace/modules/shared-kernel/mxrb-module.json +9 -0
  237. metadata +447 -0
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mxrb
4
+ module Scaffold
5
+ # Applies a set of file creations and replacements with rollback on failure.
6
+ class Transaction
7
+ Change = Data.define(:path, :content, :original, :mode)
8
+
9
+ attr_reader :created, :updated
10
+
11
+ def initialize
12
+ @changes = {}
13
+ @created = []
14
+ @updated = []
15
+ end
16
+
17
+ def content(path)
18
+ return @changes.fetch(path).content if @changes.key?(path)
19
+ return File.binread(path) if File.file?(path)
20
+
21
+ nil
22
+ end
23
+
24
+ def create(path, content)
25
+ abort "#{path}: file already exists" if File.exist?(path) || @changes.key?(path)
26
+
27
+ @created << path
28
+ @changes[path] = Change.new(path, content, nil, 0o644)
29
+ end
30
+
31
+ def write(path, new_content)
32
+ original = content(path)
33
+ return create(path, new_content) unless original
34
+ return if original == new_content
35
+
36
+ @updated << path unless @updated.include?(path)
37
+ mode = File.stat(path).mode
38
+ @changes[path] = Change.new(path, new_content, File.binread(path), mode)
39
+ end
40
+
41
+ def commit
42
+ applied = []
43
+ @changes.each_value do |change|
44
+ apply(change)
45
+ applied << change
46
+ end
47
+ rescue StandardError
48
+ applied.reverse_each { rollback(_1) }
49
+ raise
50
+ ensure
51
+ cleanup_staging
52
+ end
53
+
54
+ private
55
+
56
+ def apply(change)
57
+ FileUtils.mkdir_p(File.dirname(change.path))
58
+ staging = staging_path(change.path)
59
+ @staging_paths ||= []
60
+ @staging_paths << staging
61
+ File.binwrite(staging, change.content)
62
+ File.chmod(change.mode, staging)
63
+ File.rename(staging, change.path)
64
+ end
65
+
66
+ def rollback(change)
67
+ if change.original
68
+ File.binwrite(change.path, change.original)
69
+ File.chmod(change.mode, change.path)
70
+ else
71
+ FileUtils.rm_f(change.path)
72
+ end
73
+ end
74
+
75
+ def staging_path(path)
76
+ File.join(File.dirname(path), ".#{File.basename(path)}.mxrb-#{Process.pid}-#{@changes.size}")
77
+ end
78
+
79
+ def cleanup_staging
80
+ Array(@staging_paths).each { FileUtils.rm_f(_1) }
81
+ end
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mxrb
4
+ module Schema
5
+ # Known .mpr SQLite tables and their purpose.
6
+ # Reverse-engineered from Mendix 9/10 projects + mxcli source inspection.
7
+ # Columns marked (?) are inferred / version-dependent.
8
+ TABLES = {
9
+ "_MetaData" => {
10
+ desc: "Single-row table with project-level metadata",
11
+ columns: %w[MetaDataID MendixVersion ProjectID ProjectName],
12
+ },
13
+ "Unit" => {
14
+ desc: "Every Mendix artefact (module, entity, page, microflow…) is a Unit",
15
+ columns: %w[UnitID ContainerID ContainmentName UnitTypeID ContentsHash Contents],
16
+ },
17
+ "UnitType" => {
18
+ desc: "Lookup table: numeric ID → qualified type name",
19
+ columns: %w[UnitTypeID Name],
20
+ },
21
+ "PreferredHash" => {
22
+ desc: "Stores preferred/last-known hash per unit for change detection",
23
+ columns: %w[UnitID Hash],
24
+ },
25
+ }.freeze
26
+
27
+ # Known UnitType names (qualified Mendix metamodel class names).
28
+ # These appear in UnitType.Name and drive how Contents is decoded.
29
+ UNIT_TYPES = %w[
30
+ Mxmodels.Projects.Project
31
+ Mxmodels.Projects.Module
32
+ Mxmodels.DomainModels.DomainModel
33
+ Mxmodels.DomainModels.Entity
34
+ Mxmodels.DomainModels.Attribute
35
+ Mxmodels.DomainModels.Association
36
+ Mxmodels.Pages.Page
37
+ Mxmodels.Pages.Layout
38
+ Mxmodels.Pages.Snippet
39
+ Mxmodels.Microflows.Microflow
40
+ Mxmodels.Microflows.Nanoflow
41
+ Mxmodels.Microflows.Rule
42
+ Mxmodels.Settings.ProjectSettings
43
+ Mxmodels.Settings.RuntimeSettings
44
+ Mxmodels.Security.ProjectSecurity
45
+ Mxmodels.Security.ModuleSecurity
46
+ Mxmodels.Texts.SystemTextCollection
47
+ ].freeze
48
+
49
+ # Attribute types as used in the serialized Contents blob.
50
+ ATTRIBUTE_TYPES = %w[
51
+ AutoNumber Boolean Currency DateTime Decimal
52
+ Enum Float HashString Integer Long String
53
+ ].freeze
54
+ end
55
+ end
@@ -0,0 +1,473 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mxrb
4
+ module Semantic
5
+ Diagnostic = Data.define(:rule, :severity, :message, :artifacts, :metadata)
6
+ CallCycle = Data.define(:artifacts, :references)
7
+ ModuleDependency = Data.define(:from, :to, :references, :source_artifacts)
8
+
9
+ Report = Data.define(
10
+ :diagnostics, :unreferenced, :unresolved_references, :call_cycles,
11
+ :module_dependencies
12
+ ) do
13
+ def errors = diagnostics.select { _1.severity == :error }
14
+ def warnings = diagnostics.select { _1.severity == :warning }
15
+ def valid? = errors.empty?
16
+ end
17
+
18
+ # Static analysis for an opened Mendix project. The result is plain,
19
+ # immutable Ruby data and can be filtered or extended without a query DSL.
20
+ class Analyzer
21
+ DEFAULT_UNREFERENCED_KINDS = %i[microflow nanoflow workflow page].freeze
22
+
23
+ def initialize(project)
24
+ @project = project
25
+ @index = project.semantic_index
26
+ end
27
+
28
+ def analyze(unreferenced_kinds: DEFAULT_UNREFERENCED_KINDS, rules: [])
29
+ cycles = call_cycles
30
+ unreferenced = unreferenced_artifacts(unreferenced_kinds)
31
+ unresolved = unresolved_references
32
+ diagnostics = cycle_diagnostics(cycles) +
33
+ unresolved_diagnostics(unresolved) +
34
+ unreferenced_diagnostics(unreferenced) +
35
+ quality_diagnostics +
36
+ custom_diagnostics(rules)
37
+ Report.new(
38
+ diagnostics.sort_by { [_1.severity == :error ? 0 : 1, _1.message] }.freeze,
39
+ unreferenced.freeze,
40
+ unresolved.freeze,
41
+ cycles.freeze,
42
+ module_dependencies.freeze
43
+ )
44
+ end
45
+
46
+ private
47
+
48
+ def unreferenced_artifacts(kinds)
49
+ selected_kinds = kinds.map(&:to_sym)
50
+ incoming = @index.references.each_with_object(Set.new) { |ref, ids| ids << ref.target.id }
51
+ @index.artifacts.select do |artifact|
52
+ selected_kinds.include?(artifact.kind) &&
53
+ !incoming.include?(artifact.id) &&
54
+ !entry_point?(artifact)
55
+ end.sort_by(&:qualified_name)
56
+ end
57
+
58
+ def entry_point?(artifact)
59
+ artifact.metadata[:excluded] == true ||
60
+ artifact.metadata[:mark_as_used] == true ||
61
+ artifact.metadata[:exposed] == true
62
+ end
63
+
64
+ def unresolved_references
65
+ return [] unless @index.respond_to?(:unresolved_references)
66
+
67
+ @index.unresolved_references.reject do |reference|
68
+ platform_reference?(reference) || reference.source.metadata[:excluded] == true
69
+ end.sort_by do |reference|
70
+ [reference.source.qualified_name, reference.path, reference.qualified_name]
71
+ end
72
+ end
73
+
74
+ def platform_reference?(reference)
75
+ reference.qualified_name.start_with?("System.")
76
+ end
77
+
78
+ def call_cycles
79
+ call_references = @index.references.select { _1.relation == :calls }
80
+ adjacency = Hash.new { |hash, key| hash[key] = [] }
81
+ artifacts = {}
82
+ call_references.each do |reference|
83
+ adjacency[reference.source.id] << reference.target.id
84
+ artifacts[reference.source.id] = reference.source
85
+ artifacts[reference.target.id] = reference.target
86
+ end
87
+
88
+ strongly_connected(adjacency, artifacts.keys).filter_map do |ids|
89
+ cyclic = ids.length > 1 || adjacency[ids.first].include?(ids.first)
90
+ next unless cyclic
91
+
92
+ id_set = ids.to_set
93
+ references = call_references.select do |reference|
94
+ id_set.include?(reference.source.id) && id_set.include?(reference.target.id)
95
+ end
96
+ CallCycle.new(
97
+ ids.map { artifacts.fetch(_1) }.sort_by(&:qualified_name).freeze,
98
+ references.freeze
99
+ )
100
+ end.sort_by { _1.artifacts.map(&:qualified_name) }
101
+ end
102
+
103
+ def strongly_connected(adjacency, nodes)
104
+ index = 0
105
+ indexes = {}
106
+ low_links = {}
107
+ stack = []
108
+ on_stack = Set.new
109
+ components = []
110
+
111
+ visit = lambda do |node|
112
+ indexes[node] = index
113
+ low_links[node] = index
114
+ index += 1
115
+ stack << node
116
+ on_stack << node
117
+
118
+ adjacency[node].each do |target|
119
+ unless indexes.key?(target)
120
+ visit.call(target)
121
+ low_links[node] = [low_links[node], low_links[target]].min
122
+ next
123
+ end
124
+ low_links[node] = [low_links[node], indexes[target]].min if on_stack.include?(target)
125
+ end
126
+
127
+ return unless low_links[node] == indexes[node]
128
+
129
+ component = []
130
+ loop do
131
+ member = stack.pop
132
+ on_stack.delete(member)
133
+ component << member
134
+ break if member == node
135
+ end
136
+ components << component
137
+ end
138
+
139
+ nodes.each { visit.call(_1) unless indexes.key?(_1) }
140
+ components
141
+ end
142
+
143
+ def module_dependencies
144
+ grouped = @index.references.each_with_object(Hash.new { |hash, key| hash[key] = [] }) do |ref, groups|
145
+ from = ref.source.module_name
146
+ to = ref.target.module_name
147
+ groups[[from, to]] << ref if from && to && from != to
148
+ end
149
+
150
+ grouped.map do |(from, to), references|
151
+ ModuleDependency.new(
152
+ from, to, references.freeze,
153
+ references.map(&:source).uniq(&:id).sort_by(&:qualified_name).freeze
154
+ )
155
+ end.sort_by { [_1.from, _1.to] }
156
+ end
157
+
158
+ def cycle_diagnostics(cycles)
159
+ cycles.map do |cycle|
160
+ names = cycle.artifacts.map(&:qualified_name)
161
+ Diagnostic.new(
162
+ :call_cycle, :error,
163
+ "call cycle: #{names.join(' -> ')} -> #{names.first}",
164
+ cycle.artifacts, { references: cycle.references.size }.freeze
165
+ )
166
+ end
167
+ end
168
+
169
+ def unreferenced_diagnostics(artifacts)
170
+ artifacts.map do |artifact|
171
+ Diagnostic.new(
172
+ :unreferenced, :warning,
173
+ "unreferenced #{artifact.kind}: #{artifact.qualified_name}",
174
+ [artifact].freeze, {}.freeze
175
+ )
176
+ end
177
+ end
178
+
179
+ def unresolved_diagnostics(references)
180
+ references.map do |reference|
181
+ external = external_reference?(reference)
182
+ Diagnostic.new(
183
+ external ? :external_reference : :unresolved_reference,
184
+ external ? :warning : :error,
185
+ "#{reference.source.qualified_name} references #{external ? 'external' : 'missing'} " \
186
+ "#{reference.qualified_name} at #{reference.path.join('.')}",
187
+ [reference.source].freeze,
188
+ {
189
+ target: reference.qualified_name,
190
+ expected_kinds: reference.expected_kinds,
191
+ path: reference.path
192
+ }.freeze
193
+ )
194
+ end
195
+ end
196
+
197
+ def external_reference?(reference)
198
+ target_module = reference.qualified_name.split(".").first
199
+ @index.artifacts.none? { _1.module_name == target_module }
200
+ end
201
+
202
+ def quality_diagnostics
203
+ security = project_security
204
+ definition = if @project.respond_to?(:architecture_definition)
205
+ @project.architecture_definition || {}
206
+ else
207
+ {}
208
+ end
209
+ definition = definition.merge(security: native_security_definition(security)) \
210
+ unless definition[:security]
211
+ persistent_access_diagnostics(security) +
212
+ secured_artifact_diagnostics(security) +
213
+ public_contract_diagnostics(definition) +
214
+ navigation_diagnostics(definition) +
215
+ duplicate_role_diagnostics(definition) +
216
+ design_system_diagnostics(definition)
217
+ end
218
+
219
+ def native_security_definition(security)
220
+ roles = IO::BsonCodec.parse_array(security&.fetch("UserRoles", nil))[:items]
221
+ {
222
+ user_roles: roles.map do |role|
223
+ {
224
+ name: role["Name"],
225
+ module_roles: IO::BsonCodec.parse_array(role["ModuleRoles"])[:items]
226
+ }
227
+ end
228
+ }
229
+ end
230
+
231
+ def project_security
232
+ return nil unless @project.respond_to?(:all_units) && @project.respond_to?(:parse_bson)
233
+
234
+ raw = @project.all_units.find do |unit|
235
+ @project.parse_bson(unit)["$Type"] == "Security$ProjectSecurity"
236
+ end
237
+ raw && @project.parse_bson(raw)
238
+ end
239
+
240
+ def strict_security?(security)
241
+ security && security["SecurityLevel"] == "CheckEverything"
242
+ end
243
+
244
+ def persistent_access_diagnostics(security)
245
+ return [] unless strict_security?(security)
246
+
247
+ @index.artifacts.filter_map do |artifact|
248
+ next unless artifact.kind == :entity
249
+
250
+ entity = artifact.metadata[:model]
251
+ next unless entity
252
+ next unless entity.persistable != false && Array(entity.access_rules).empty?
253
+
254
+ Diagnostic.new(
255
+ :persistent_entity_without_access, :warning,
256
+ "persistent entity has no access rules: #{artifact.qualified_name}",
257
+ [artifact].freeze, {}.freeze
258
+ )
259
+ end
260
+ end
261
+
262
+ def secured_artifact_diagnostics(security)
263
+ return [] unless strict_security?(security)
264
+
265
+ @index.artifacts.filter_map do |artifact|
266
+ next unless %i[page microflow nanoflow].include?(artifact.kind)
267
+ next if artifact.metadata[:excluded] == true
268
+ next unless Array(artifact.metadata[:allowed_roles]).empty?
269
+
270
+ Diagnostic.new(
271
+ :secured_artifact_without_roles, :warning,
272
+ "secured #{artifact.kind} has no allowed module roles: #{artifact.qualified_name}",
273
+ [artifact].freeze, {}.freeze
274
+ )
275
+ end
276
+ end
277
+
278
+ def public_contract_diagnostics(definition)
279
+ Array(definition[:modules]).flat_map do |mod|
280
+ %i[microflows nanoflows repositories].flat_map do |collection|
281
+ Array(mod[collection]).filter_map do |item|
282
+ next unless item[:public] == true
283
+ next unless item[:documentation].to_s.empty?
284
+
285
+ artifact = @index.find("#{mod[:name]}.#{item[:name]}")
286
+ Diagnostic.new(
287
+ :public_contract_without_documentation, :warning,
288
+ "public contract has no documentation: #{mod[:name]}.#{item[:name]}",
289
+ Array(artifact).freeze, { kind: collection.to_s.sub(/s\z/, "").to_sym }.freeze
290
+ )
291
+ end
292
+ end
293
+ end
294
+ end
295
+
296
+ def navigation_diagnostics(definition)
297
+ fallback = @project.navigation.to_h[:profiles] if @project.respond_to?(:navigation)
298
+ profiles = Array(definition.dig(:navigation, :profiles) || fallback)
299
+ duplicate_names = profiles.map { _1[:name].to_s }.tally
300
+ diagnostics = profiles.filter_map do |profile|
301
+ next unless duplicate_names.fetch(profile[:name].to_s) > 1
302
+
303
+ duplicate_names[profile[:name].to_s] = 0
304
+ Diagnostic.new(
305
+ :duplicate_navigation_profile, :error,
306
+ "duplicate navigation profile: #{profile[:name]}",
307
+ [].freeze, { profile: profile[:name] }.freeze
308
+ )
309
+ end
310
+ diagnostics + profiles.flat_map { profile_navigation_diagnostics(_1, definition) }
311
+ end
312
+
313
+ def profile_navigation_diagnostics(profile, definition)
314
+ targets = navigation_targets(profile)
315
+ missing = targets.filter_map do |field, target|
316
+ next if target.to_s.empty? || @index.find(target)
317
+
318
+ Diagnostic.new(
319
+ :missing_navigation_target, :error,
320
+ "navigation profile #{profile[:name]} references missing #{field}: #{target}",
321
+ [].freeze, { profile: profile[:name], field:, target: }.freeze
322
+ )
323
+ end
324
+ missing +
325
+ navigation_home_diagnostics(profile) +
326
+ navigation_role_diagnostics(profile, definition) +
327
+ navigation_role_access_diagnostics(profile, definition)
328
+ end
329
+
330
+ def navigation_targets(profile)
331
+ targets = profile.values_at(:home_page, :home_microflow, :sign_in_page, :menu)
332
+ .then { %i[home_page home_microflow sign_in_page menu].zip(_1).to_h }
333
+ role_homes = profile.fetch(:role_homes, {})
334
+ homes = role_homes.is_a?(Hash) ? role_homes.map { |role, page| { role:, page: } } : role_homes
335
+ homes += Array(profile[:role_home_details])
336
+ homes.each { targets["role_home:#{_1[:role]}"] = _1[:page] || _1[:microflow] }
337
+ targets
338
+ end
339
+
340
+ def navigation_home_diagnostics(profile)
341
+ return [] if profile[:home_page] || profile[:home_microflow]
342
+
343
+ [Diagnostic.new(
344
+ :navigation_profile_without_home, :error,
345
+ "navigation profile has no default home: #{profile[:name]}",
346
+ [].freeze, { profile: profile[:name] }.freeze
347
+ )]
348
+ end
349
+
350
+ def navigation_role_diagnostics(profile, definition)
351
+ known = Array(definition.dig(:security, :user_roles)).map { _1[:name].to_s }
352
+ role_homes = profile.fetch(:role_homes, {})
353
+ roles = if role_homes.is_a?(Hash)
354
+ role_homes.keys
355
+ else
356
+ role_homes.map { _1[:role] }
357
+ end
358
+ roles += Array(profile[:role_home_details]).map { _1[:role] }
359
+ roles.uniq.filter_map do |role|
360
+ next if known.include?(role.to_s)
361
+
362
+ Diagnostic.new(
363
+ :unknown_navigation_role, :error,
364
+ "navigation profile #{profile[:name]} uses unknown user role: #{role}",
365
+ [].freeze, { profile: profile[:name], role: role.to_s }.freeze
366
+ )
367
+ end
368
+ end
369
+
370
+ def navigation_role_access_diagnostics(profile, definition)
371
+ roles = Array(definition.dig(:security, :user_roles)).to_h do |role|
372
+ [role[:name].to_s, Array(role[:module_roles]).map(&:to_s)]
373
+ end
374
+ navigation_role_homes(profile).filter_map do |home|
375
+ target = @index.find(home[:page] || home[:microflow])
376
+ allowed = target && Array(target.metadata[:allowed_roles])
377
+ next if !target || allowed.empty? || !roles.key?(home[:role].to_s)
378
+
379
+ qualified = allowed.map do |role|
380
+ role.include?('.') ? role : "#{target.module_name}.#{role}"
381
+ end
382
+ next unless (qualified & roles.fetch(home[:role].to_s)).empty?
383
+
384
+ Diagnostic.new(
385
+ :inaccessible_navigation_home, :error,
386
+ "user role #{home[:role]} cannot access navigation home #{target.qualified_name}",
387
+ [target].freeze, { role: home[:role].to_s, target: target.qualified_name }.freeze
388
+ )
389
+ end
390
+ end
391
+
392
+ def navigation_role_homes(profile)
393
+ homes = profile.fetch(:role_homes, {})
394
+ homes = homes.map { |role, page| { role:, page: } } if homes.is_a?(Hash)
395
+ homes + Array(profile[:role_home_details])
396
+ end
397
+
398
+ def design_system_diagnostics(definition)
399
+ return [] unless @project.respond_to?(:design_system)
400
+
401
+ diagnostics = @project.design_system.unresolved_references.map do |entry|
402
+ token = entry.fetch(:token)
403
+ Diagnostic.new(
404
+ :unresolved_design_token, :error,
405
+ "#{token.path}:#{token.line} references missing design token #{entry.fetch(:reference)}",
406
+ [].freeze, entry.freeze
407
+ )
408
+ end
409
+ diagnostics.concat(contrast_diagnostics(definition))
410
+ return diagnostics unless definition.dig(:design_system, :forbid_literal_colors)
411
+
412
+ diagnostics + @project.design_system.literal_colors.map do |token|
413
+ Diagnostic.new(
414
+ :literal_design_color, :warning,
415
+ "#{token.path}:#{token.line} uses literal color #{token.value}",
416
+ [].freeze, { token: token.name, value: token.value }.freeze
417
+ )
418
+ end
419
+ end
420
+
421
+ def contrast_diagnostics(definition)
422
+ design = definition[:design_system] || {}
423
+ tokens = Array(design[:tokens]) +
424
+ Array(design[:themes]).flat_map { Array(_1[:tokens]) }
425
+ values = tokens.to_h { [_1[:name].to_s, _1[:value].to_s] }
426
+ Array(design[:contrast_pairs]).filter_map do |pair|
427
+ foreground = values.fetch(pair[:foreground].to_s, pair[:foreground].to_s)
428
+ background = values.fetch(pair[:background].to_s, pair[:background].to_s)
429
+ ratio = @project.design_system.contrast_ratio(foreground, background)
430
+ required = pair[:level].to_s.downcase == "aaa" ? 7.0 : 4.5
431
+ next if ratio >= required
432
+
433
+ Diagnostic.new(
434
+ :insufficient_color_contrast, :error,
435
+ "color contrast #{ratio}:1 is below #{pair[:level].to_s.upcase} (#{required}:1)",
436
+ [].freeze, pair.merge(ratio:, required:).freeze
437
+ )
438
+ rescue ArgumentError
439
+ Diagnostic.new(
440
+ :invalid_contrast_color, :error,
441
+ "contrast pair uses a non-hex color: #{foreground} / #{background}",
442
+ [].freeze, pair.freeze
443
+ )
444
+ end
445
+ end
446
+
447
+ def duplicate_role_diagnostics(definition)
448
+ roles = Array(definition.dig(:security, :user_roles))
449
+ roles.filter_map do |role|
450
+ module_roles = Array(role[:module_roles])
451
+ duplicates = module_roles.tally.select { |_name, count| count > 1 }.keys.sort
452
+ next if duplicates.empty?
453
+
454
+ Diagnostic.new(
455
+ :duplicate_module_role, :warning,
456
+ "user role #{role[:name]} maps duplicate module roles: #{duplicates.join(', ')}",
457
+ [].freeze, { user_role: role[:name], module_roles: duplicates.freeze }.freeze
458
+ )
459
+ end
460
+ end
461
+
462
+ def custom_diagnostics(rules)
463
+ rules.flat_map do |rule|
464
+ diagnostics = Array(rule.call(@project, @index))
465
+ unless diagnostics.all? { _1.is_a?(Diagnostic) }
466
+ raise ArgumentError, "custom semantic rules must return Mxrb::Semantic::Diagnostic"
467
+ end
468
+ diagnostics
469
+ end
470
+ end
471
+ end
472
+ end
473
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module Mxrb
6
+ module Semantic
7
+ # Wraps multiple plans into a single atomic operation with rollback on failure.
8
+ #
9
+ # Before applying, a backup of the MPR is taken using SQLite's VACUUM INTO.
10
+ # If any plan raises, the backup is restored and the project state is reset
11
+ # as if the batch never ran.
12
+ #
13
+ # Usage:
14
+ # plan1 = project.plan_rename("M.OldName", to: "M.NewName")
15
+ # plan2 = project.plan_add_attribute("M.NewName", name: :Slug, type: :string)
16
+ # project.batch_plan([plan1, plan2]).apply!
17
+ class BatchPlan
18
+ attr_reader :plans, :changes
19
+
20
+ def initialize(project:, plans:)
21
+ @project = project
22
+ @plans = plans.freeze
23
+ @applied = false
24
+ @changes = @plans.flat_map { |p| p.respond_to?(:changes) ? Array(p.changes) : [] }.freeze
25
+ end
26
+
27
+ def empty? = @plans.empty?
28
+ def applied? = @applied
29
+
30
+ def apply!
31
+ raise ArgumentError, "batch plan was already applied" if @applied
32
+ return self if @plans.empty?
33
+
34
+ backup_path = "#{@project.mpr.path}.mxrb_batch_backup"
35
+ @project.mpr.backup!(backup_path)
36
+
37
+ begin
38
+ @plans.each(&:apply!)
39
+ @applied = true
40
+ rescue => error
41
+ idx = @plans.index { !_1.applied? }.to_i + 1
42
+ rollback_errors = rollback_applied_plans
43
+ @project.mpr.restore_from!(backup_path)
44
+ @project.refresh!
45
+ detail = rollback_errors.empty? ? "" : "; rollback failed: #{rollback_errors.join(', ')}"
46
+ raise BatchError,
47
+ "batch failed at plan #{idx} of #{@plans.size}: #{error.message}#{detail}"
48
+ ensure
49
+ FileUtils.rm_f(backup_path) rescue nil
50
+ end
51
+ self
52
+ end
53
+
54
+ private
55
+
56
+ def rollback_applied_plans
57
+ @plans.reverse_each.filter_map do |plan|
58
+ next unless plan.respond_to?(:rollback!) && plan.applied?
59
+
60
+ plan.rollback!
61
+ nil
62
+ rescue StandardError => error
63
+ "#{plan.class}: #{error.message}"
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end