parapet-rails 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 (231) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +48 -0
  4. data/Rakefile +7 -0
  5. data/app/assets/config/parapet/grammar-registry.json +52 -0
  6. data/app/assets/images/parapet/icons/activities/component-inspector.svg +4 -0
  7. data/app/assets/images/parapet/icons/activities/coverage-heatmap.svg +3 -0
  8. data/app/assets/images/parapet/icons/activities/discovery-sidebar.svg +3 -0
  9. data/app/assets/images/parapet/icons/activities/environment-toggles.svg +4 -0
  10. data/app/assets/images/parapet/icons/activities/failure-inspector.svg +5 -0
  11. data/app/assets/images/parapet/icons/activities/flaky-history.svg +3 -0
  12. data/app/assets/images/parapet/icons/activities/flight-recorder.svg +5 -0
  13. data/app/assets/images/parapet/icons/activities/health-graph.svg +3 -0
  14. data/app/assets/images/parapet/icons/activities/injection-engine.svg +3 -0
  15. data/app/assets/images/parapet/icons/activities/live-sql-stream.svg +5 -0
  16. data/app/assets/images/parapet/icons/activities/live-web-preview.svg +4 -0
  17. data/app/assets/images/parapet/icons/activities/monaco-editor.svg +5 -0
  18. data/app/assets/images/parapet/icons/activities/progress-area.svg +3 -0
  19. data/app/assets/images/parapet/icons/activities/registry-metadata.svg +5 -0
  20. data/app/assets/images/parapet/icons/activities/seed-inspector.svg +4 -0
  21. data/app/assets/images/parapet/icons/activities/slow-test-bottlenecks.svg +4 -0
  22. data/app/assets/images/parapet/icons/activities/smart-search.svg +4 -0
  23. data/app/assets/images/parapet/icons/activities/snippet-library.svg +4 -0
  24. data/app/assets/images/parapet/icons/activities/terminal.svg +4 -0
  25. data/app/assets/images/parapet/icons/activities/unified-schema-view.svg +4 -0
  26. data/app/assets/images/parapet/icons/activities/visual-comparison.svg +3 -0
  27. data/app/assets/images/parapet/icons/activities/visual-summary-bar.svg +5 -0
  28. data/app/assets/images/parapet/icons/nav/ideas.svg +4 -0
  29. data/app/assets/images/parapet/icons/nav/languages.svg +8 -0
  30. data/app/assets/images/parapet/icons/nav/profile.svg +4 -0
  31. data/app/assets/images/parapet/icons/nav/pulse.svg +7 -0
  32. data/app/assets/images/parapet/icons/nav/settings.svg +4 -0
  33. data/app/assets/images/parapet/icons/nav/state.svg +5 -0
  34. data/app/assets/images/parapet/icons/nav/styles.svg +7 -0
  35. data/app/assets/images/parapet/icons/nav/tests.svg +4 -0
  36. data/app/assets/images/parapet/icons/nav/traffic.svg +12 -0
  37. data/app/assets/javascripts/parapet/activity-card.js +439 -0
  38. data/app/assets/javascripts/parapet/activity-cards.js +291 -0
  39. data/app/assets/javascripts/parapet/activity-registry.js +181 -0
  40. data/app/assets/javascripts/parapet/activity-toggles.js +76 -0
  41. data/app/assets/javascripts/parapet/application.js +37 -0
  42. data/app/assets/javascripts/parapet/card-failure-inspector.js +134 -0
  43. data/app/assets/javascripts/parapet/card-file-editor.js +1722 -0
  44. data/app/assets/javascripts/parapet/card-progress-area.js +297 -0
  45. data/app/assets/javascripts/parapet/card-smart-search.js +114 -0
  46. data/app/assets/javascripts/parapet/card-snippet-library.js +251 -0
  47. data/app/assets/javascripts/parapet/explorer-resizer.js +92 -0
  48. data/app/assets/javascripts/parapet/parapet_qss.js +342 -0
  49. data/app/assets/javascripts/parapet/proficiency-filter.js +53 -0
  50. data/app/assets/javascripts/parapet/sidebar-resizer.js +197 -0
  51. data/app/assets/javascripts/parapet/styles-workbench.js +819 -0
  52. data/app/assets/javascripts/parapet/tree-logic.js +320 -0
  53. data/app/assets/stylesheets/parapet/_qss_variables.scss +122 -0
  54. data/app/assets/stylesheets/parapet/parapet_qss.scss +69 -0
  55. data/app/assets/stylesheets/parapet/qss_application.css +260940 -0
  56. data/app/assets/stylesheets/parapet/qss_application.css.map +1 -0
  57. data/app/assets/stylesheets/parapet/qss_application.scss +5 -0
  58. data/app/assets/stylesheets/parapet/qss_workspace.scss +209 -0
  59. data/app/assets/stylesheets/parapet/sample.qss +22 -0
  60. data/app/controllers/parapet/application_controller.rb +54 -0
  61. data/app/controllers/parapet/base_controller.rb +18 -0
  62. data/app/controllers/parapet/build_suite/dashboard_controller.rb +10 -0
  63. data/app/controllers/parapet/css_suite/compiler_controller.rb +95 -0
  64. data/app/controllers/parapet/css_suite/dashboard_controller.rb +10 -0
  65. data/app/controllers/parapet/css_suite/preview_controller.rb +46 -0
  66. data/app/controllers/parapet/dashboard_controller.rb +8 -0
  67. data/app/controllers/parapet/drafts_controller.rb +30 -0
  68. data/app/controllers/parapet/explorers_controller.rb +53 -0
  69. data/app/controllers/parapet/files_controller.rb +208 -0
  70. data/app/controllers/parapet/footers_controller.rb +24 -0
  71. data/app/controllers/parapet/intentions_suite/dashboard_controller.rb +10 -0
  72. data/app/controllers/parapet/landing_controller.rb +14 -0
  73. data/app/controllers/parapet/monitor_suite/dashboard_controller.rb +10 -0
  74. data/app/controllers/parapet/pm_suite/publish_controller.rb +16 -0
  75. data/app/controllers/parapet/polyglot_suite/dashboard_controller.rb +10 -0
  76. data/app/controllers/parapet/runner_controller.rb +34 -0
  77. data/app/controllers/parapet/sessions_controller.rb +35 -0
  78. data/app/controllers/parapet/signal_suite/dashboard_controller.rb +10 -0
  79. data/app/controllers/parapet/test_suite/dashboard_controller.rb +10 -0
  80. data/app/controllers/parapet/widgets_controller.rb +33 -0
  81. data/app/helpers/parapet/application_helper.rb +23 -0
  82. data/app/helpers/parapet/icon_helper.rb +57 -0
  83. data/app/jobs/parapet/application_job.rb +4 -0
  84. data/app/mailers/parapet/application_mailer.rb +6 -0
  85. data/app/models/parapet/activity.rb +14 -0
  86. data/app/models/parapet/application_record.rb +5 -0
  87. data/app/models/parapet/current.rb +6 -0
  88. data/app/models/parapet/draft_recovery.rb +9 -0
  89. data/app/models/parapet/internal_record.rb +34 -0
  90. data/app/models/parapet/run_history.rb +22 -0
  91. data/app/models/parapet/runtime_setting.rb +8 -0
  92. data/app/models/parapet/session.rb +7 -0
  93. data/app/models/parapet/suite_assignment.rb +30 -0
  94. data/app/models/parapet/user.rb +12 -0
  95. data/app/services/parapet/activity_phrase_renderer.rb +35 -0
  96. data/app/services/parapet/activity_writer.rb +26 -0
  97. data/app/services/parapet/authorization.rb +198 -0
  98. data/app/services/parapet/consistency_auditor.rb +58 -0
  99. data/app/services/parapet/directory_scanner.rb +159 -0
  100. data/app/services/parapet/draft_recovery_store.rb +18 -0
  101. data/app/services/parapet/explorer_manager.rb +402 -0
  102. data/app/services/parapet/file_save_service.rb +21 -0
  103. data/app/services/parapet/git_side_ref_committer.rb +50 -0
  104. data/app/services/parapet/noun_discovery.rb +26 -0
  105. data/app/services/parapet/publish_gate.rb +83 -0
  106. data/app/services/parapet/test_runner.rb +178 -0
  107. data/app/views/layouts/parapet/application.html.erb +83 -0
  108. data/app/views/layouts/parapet/landing.html.erb +19 -0
  109. data/app/views/parapet/activities/_ai_error_explainer.html.erb +33 -0
  110. data/app/views/parapet/activities/_ai_explainer_widget.html.erb +20 -0
  111. data/app/views/parapet/activities/_calendar_widget.html.erb +31 -0
  112. data/app/views/parapet/activities/_code_snippets_widget.html.erb +22 -0
  113. data/app/views/parapet/activities/_coverage_explorer.html.erb +1 -0
  114. data/app/views/parapet/activities/_dropdown_test.html.erb +20 -0
  115. data/app/views/parapet/activities/_explorer_node.html.erb +102 -0
  116. data/app/views/parapet/activities/_explorer_tree.html.erb +16 -0
  117. data/app/views/parapet/activities/_file_editor.html.erb +183 -0
  118. data/app/views/parapet/activities/_file_explorer.html.erb +23 -0
  119. data/app/views/parapet/activities/_fix_suggestions_widget.html.erb +13 -0
  120. data/app/views/parapet/activities/_generic.html.erb +6 -0
  121. data/app/views/parapet/activities/_live_preview.html.erb +13 -0
  122. data/app/views/parapet/activities/_perspective_filters.html.erb +36 -0
  123. data/app/views/parapet/activities/_progress_area.html.erb +37 -0
  124. data/app/views/parapet/activities/_project_explorer.html.erb +1 -0
  125. data/app/views/parapet/activities/_registry_explorer.html.erb +1 -0
  126. data/app/views/parapet/activities/_run_history.html.erb +2 -0
  127. data/app/views/parapet/activities/_smart_search.html.erb +11 -0
  128. data/app/views/parapet/activities/file_explorer/_perspective_scripts.html.erb +58 -0
  129. data/app/views/parapet/activities/file_explorer/_perspectives.html.erb +0 -0
  130. data/app/views/parapet/activities/file_explorer/_viewport_scripts.html.erb +33 -0
  131. data/app/views/parapet/css_suite/_workbench.html.erb +51 -0
  132. data/app/views/parapet/dashboards/index.html.erb +113 -0
  133. data/app/views/parapet/debug_panel.html.erb +79 -0
  134. data/app/views/parapet/debug_panel_tailwind.html.erb +98 -0
  135. data/app/views/parapet/erb_patch_test.html.erb +5 -0
  136. data/app/views/parapet/explorers/show.html.erb +1 -0
  137. data/app/views/parapet/git_viewer.html.erb +62 -0
  138. data/app/views/parapet/git_viewer_after.html.erb +14 -0
  139. data/app/views/parapet/git_viewer_before.html.erb +14 -0
  140. data/app/views/parapet/git_viewer_governed.html.erb +88 -0
  141. data/app/views/parapet/git_viewer_governed_after.html.erb +36 -0
  142. data/app/views/parapet/git_viewer_governed_before.html.erb +36 -0
  143. data/app/views/parapet/git_viewer_tailwind.html.erb +88 -0
  144. data/app/views/parapet/git_viewer_thin_wrapper.html.erb +85 -0
  145. data/app/views/parapet/hierarchical_layout_test.html.erb +85 -0
  146. data/app/views/parapet/hybrid_stress_test.html.erb +25 -0
  147. data/app/views/parapet/index.html.erb +35 -0
  148. data/app/views/parapet/mutation_stress_test.html.erb +82 -0
  149. data/app/views/parapet/recursive_anchoring_test.html.erb +66 -0
  150. data/app/views/parapet/sessions/new.html.erb +28 -0
  151. data/app/views/parapet/shared/_cad_inspector.html.erb +92 -0
  152. data/app/views/parapet/shared/_discovery_sidebar.html.erb +48 -0
  153. data/app/views/parapet/shared/_explorer_bar.html.erb +71 -0
  154. data/app/views/parapet/shared/_footer.html.erb +10 -0
  155. data/app/views/parapet/shared/_footer_ideas.html.erb +36 -0
  156. data/app/views/parapet/shared/_footer_languages.html.erb +33 -0
  157. data/app/views/parapet/shared/_footer_pulse.html.erb +33 -0
  158. data/app/views/parapet/shared/_footer_state.html.erb +33 -0
  159. data/app/views/parapet/shared/_footer_styles.html.erb +37 -0
  160. data/app/views/parapet/shared/_footer_tests.html.erb +39 -0
  161. data/app/views/parapet/shared/_footer_traffic.html.erb +45 -0
  162. data/app/views/parapet/shared/_global_nav.html.erb +55 -0
  163. data/app/views/parapet/shared/_head.html.erb +4 -0
  164. data/app/views/parapet/shared/_header.html.erb +20 -0
  165. data/app/views/parapet/shared/_object_inspector.html.erb +76 -0
  166. data/app/views/parapet/shared/_ribbon_header.html.erb +3 -0
  167. data/app/views/parapet/shared/_root_selector_modal.html.erb +15 -0
  168. data/app/views/parapet/shared/_tab_bar.html.erb +62 -0
  169. data/app/views/parapet/shared/activities_ribbon/_agent_group.html.erb +15 -0
  170. data/app/views/parapet/shared/activities_ribbon/_analysis_group.html.erb +15 -0
  171. data/app/views/parapet/shared/activities_ribbon/_architect_group.html.erb +21 -0
  172. data/app/views/parapet/shared/activities_ribbon/_explorer_group.html.erb +12 -0
  173. data/app/views/parapet/shared/activities_ribbon/_inspector_group.html.erb +15 -0
  174. data/app/views/parapet/shared/activities_ribbon/_proficiency_filter.html.erb +24 -0
  175. data/app/views/parapet/shared/activities_ribbon/_runner_group.html.erb +21 -0
  176. data/app/views/parapet/shared/activities_ribbon/_spatial_inspector_group.html.erb +18 -0
  177. data/app/views/parapet/shared/activities_ribbon/_studio_group.html.erb +18 -0
  178. data/app/views/parapet/shared/activities_ribbon/_stylist_group.html.erb +18 -0
  179. data/app/views/parapet/shared/explorers/_base.html.erb +107 -0
  180. data/app/views/parapet/shared/explorers/_css.html.erb +1 -0
  181. data/app/views/parapet/shared/explorers/_intentions.html.erb +1 -0
  182. data/app/views/parapet/shared/explorers/_project.html.erb +1 -0
  183. data/app/views/parapet/shared/explorers/_tests.html.erb +1 -0
  184. data/app/views/parapet/shared/head/_meta.html.erb +3 -0
  185. data/app/views/parapet/shared/head/_styles_activities.html.erb +152 -0
  186. data/app/views/parapet/shared/head/_styles_shared.html.erb +52 -0
  187. data/app/views/parapet/shared/head/_styles_workspace.html.erb +67 -0
  188. data/app/views/parapet/shared/ribbon_header/_diagnostic_controls.html.erb +17 -0
  189. data/app/views/parapet/shared/ribbon_header/_execution_controls.html.erb +28 -0
  190. data/app/views/parapet/shared/ribbon_header/_focus_search.html.erb +5 -0
  191. data/app/views/parapet/shared/root_selector/_header.html.erb +10 -0
  192. data/app/views/parapet/shared/root_selector/_path_input.html.erb +13 -0
  193. data/app/views/parapet/shared/root_selector/_quick_select.html.erb +20 -0
  194. data/app/views/parapet/shared/root_selector/_scripts.html.erb +61 -0
  195. data/app/views/parapet/shared/root_selector/_warning.html.erb +11 -0
  196. data/app/views/parapet/shared/widgets/_base.html.erb +6 -0
  197. data/app/views/parapet/shared/widgets/_css.html.erb +79 -0
  198. data/app/views/parapet/widgets/_explainer.html.erb +17 -0
  199. data/app/views/parapet/widgets/_hotkeys.html.erb +20 -0
  200. data/app/views/parapet/widgets/_search.html.erb +68 -0
  201. data/app/views/parapet/widgets/_spatial_inspector.html.erb +54 -0
  202. data/app/views/parapet/widgets/_widget-explainer.html.erb +17 -0
  203. data/app/views/parapet/widgets/_widget-hotkeys.html.erb +20 -0
  204. data/app/views/parapet/widgets/_widget-search.html.erb +68 -0
  205. data/app/views/parapet/widgets/_widget-snippets.html.erb +18 -0
  206. data/app/views/parapet/widgets/_widget-suggestions.html.erb +15 -0
  207. data/app/views/parapet/widgets/show.html.erb +3 -0
  208. data/app/views/shared/_footer.html.erb +0 -0
  209. data/config/parapet/qss_config.yml +117 -0
  210. data/config/parapet/qss_registry.yml +14 -0
  211. data/config/routes.rb +40 -0
  212. data/db/migrate/20260916035300_create_run_histories.rb +16 -0
  213. data/db/migrate/20261002000000_expand_run_histories_for_activity_tracking.rb +7 -0
  214. data/db/parapet_migrate/20260929184200_create_parapet_users.rb +12 -0
  215. data/db/parapet_migrate/20260929184300_create_parapet_sessions.rb +9 -0
  216. data/db/parapet_migrate/20260930000000_create_parapet_runtime_settings.rb +12 -0
  217. data/db/parapet_migrate/20260930000100_create_parapet_suite_assignments.rb +14 -0
  218. data/db/parapet_migrate/20261002000000_create_parapet_activities.rb +16 -0
  219. data/db/parapet_migrate/20261002000100_create_parapet_draft_recoveries.rb +13 -0
  220. data/db/parapet_schema.rb +64 -0
  221. data/db/schema.rb +21 -0
  222. data/db/schema.released.rb +0 -0
  223. data/lib/parapet/engine.rb +11 -0
  224. data/lib/parapet/environment_guard.rb +14 -0
  225. data/lib/parapet/version.rb +3 -0
  226. data/lib/parapet-rails.rb +1 -0
  227. data/lib/parapet.rb +59 -0
  228. data/lib/tasks/parapet_qss.rake +26 -0
  229. data/lib/tasks/parapet_tasks.rake +25 -0
  230. data/lib/tasks/randomize_explorer_health.rb +101 -0
  231. metadata +423 -0
@@ -0,0 +1,402 @@
1
+ module Parapet
2
+ class ExplorerManager
3
+ def self.storage_path(filename = nil)
4
+ base = Parapet::Engine.root.join("storage").to_s
5
+ filename ? File.join(base, filename) : base
6
+ end
7
+
8
+ def self.summary(type = 'project')
9
+ root_path = Parapet::Engine.root.to_s
10
+ scanner = Parapet::DirectoryScanner.new(root_path)
11
+ full_tree = scanner.scan
12
+
13
+ # Use virtualization to get the correct subset for counting
14
+ focused_tree = virtualize(full_tree, type)
15
+ files = flatten_files(focused_tree[:children] || [])
16
+
17
+ counts = { 'green' => 0, 'red' => 0, 'orange' => 0, 'grey' => 0 }
18
+ files.each do |f|
19
+ status = f[:status] || 'grey'
20
+ counts[status] += 1
21
+ end
22
+
23
+ { 'counts' => counts, 'total_files' => files.count }
24
+ end
25
+
26
+ def self.audit_data(type = 'css')
27
+ root_path = Parapet::Engine.root.to_s
28
+ scanner = Parapet::DirectoryScanner.new(root_path)
29
+ full_tree = scanner.scan
30
+
31
+ focused_tree = virtualize(full_tree, type)
32
+ files = flatten_files(focused_tree[:children] || [])
33
+
34
+ audit_stats = {
35
+ 'legibility' => 0,
36
+ 'ergonomic' => 0,
37
+ 'protocol' => 0,
38
+ 'grammar' => 0,
39
+ 'parameter' => 0,
40
+ 'z_collision' => 0 # Future implementation
41
+ }
42
+
43
+ files.each do |f|
44
+ full_path = File.join(Parapet::Engine.root, f[:path])
45
+ audit = QSS::QssAuditor.audit_file(full_path)
46
+ audit[:issues].each do |issue|
47
+ audit_stats[issue[:type]] += 1 if audit_stats.key?(issue[:type])
48
+ end
49
+ end
50
+
51
+ audit_stats
52
+ end
53
+
54
+ def self.virtualize(tree, type, perspective: 'project')
55
+ return nil if tree.nil?
56
+
57
+ # The 'perspective' parameter from the switcher overrides the base suite 'type'
58
+ # unless the perspective is 'project', in which case we use the suite's default view.
59
+ active_type = (perspective == 'project') ? type : perspective
60
+
61
+ case active_type
62
+ when 'tests', 'test', 'project'
63
+ # Focus on all test-related directories
64
+ test_dir = Parapet.test_root
65
+ test_dirs = [ test_dir, 'features', 'test', 'spec' ].uniq
66
+ nodes = tree[:children] || []
67
+ focused_nodes = nodes.select { |c| test_dirs.include?(c[:name]) }
68
+
69
+ {
70
+ name: 'Project Tests',
71
+ type: 'directory',
72
+ path: '/project-tests',
73
+ icon: 'icon-microscope',
74
+ children: focused_nodes,
75
+ health: calculate_virtual_health(flatten_files(focused_nodes))
76
+ }
77
+ when 'css', 'styles'
78
+ group_by_styles(tree)
79
+ when 'views'
80
+ group_by_views(tree)
81
+ when 'intentions', 'ideas'
82
+ group_by_intentions(tree)
83
+ when 'registry'
84
+ files = flatten_files(tree[:children] || [])
85
+ group_by_registry(files)
86
+ when 'coverage'
87
+ files = flatten_files(tree[:children] || [])
88
+ group_by_coverage(files)
89
+ when 'grammar'
90
+ group_by_grammar
91
+ else
92
+ tree
93
+ end
94
+ end
95
+
96
+ private
97
+
98
+ def self.group_by_views(tree)
99
+ return nil if tree.nil?
100
+ nodes = tree[:children] || []
101
+ app_node = nodes.find { |n| n[:name] == 'app' }
102
+ return tree unless app_node
103
+
104
+ focused_children = filter_view_files(app_node[:children] || [])
105
+
106
+ {
107
+ name: 'Views Suite',
108
+ type: 'directory',
109
+ path: '/views',
110
+ icon: 'icon-layout',
111
+ iconColor: 'text-emerald-400',
112
+ children: focused_children,
113
+ health: calculate_virtual_health(flatten_files(focused_children))
114
+ }
115
+ end
116
+
117
+ def self.filter_view_files(nodes)
118
+ nodes.map do |node|
119
+ if node[:type] == 'directory'
120
+ children = filter_view_files(node[:children] || [])
121
+ node.merge(children: children) if children.any?
122
+ elsif [ 'html', 'erb', 'qss' ].include?(node[:ext])
123
+ node
124
+ end
125
+ end.compact
126
+ end
127
+
128
+ def self.group_by_intentions(tree)
129
+ return nil if tree.nil?
130
+
131
+ # For "Intentions/Code Suite", we focus on the app directory as the source of truth
132
+ nodes = tree[:children] || []
133
+ app_node = nodes.find { |n| n[:name] == 'app' }
134
+
135
+ # If app doesn't exist (unlikely in Rails), fallback to tree
136
+ return tree unless app_node
137
+
138
+ {
139
+ name: 'Application Intentions',
140
+ type: 'directory',
141
+ path: '/intentions',
142
+ icon: 'icon-lightbulb',
143
+ children: app_node[:children],
144
+ health: app_node[:health]
145
+ }
146
+ end
147
+
148
+ def self.group_by_styles(tree)
149
+ return nil if tree.nil?
150
+
151
+ # For CSS Suite, we show both Views and Styles in their Rails hierarchy
152
+ nodes = tree[:children] || []
153
+
154
+ # Helper to find a path in the tree
155
+ find_node = ->(current_nodes, name) { (current_nodes || []).find { |n| n[:name] == name } }
156
+
157
+ app_node = find_node.call(nodes, 'app')
158
+ return tree unless app_node
159
+
160
+ # Filter for views and styles within the app directory
161
+ focused_children = filter_style_and_view_files(app_node[:children] || [])
162
+
163
+ {
164
+ name: 'Styles Suite',
165
+ type: 'directory',
166
+ path: '/styles',
167
+ icon: 'icon-palette',
168
+ iconColor: 'text-indigo-400',
169
+ children: focused_children,
170
+ health: calculate_virtual_health(flatten_files(focused_children))
171
+ }
172
+ end
173
+
174
+ def self.filter_style_and_view_files(nodes)
175
+ nodes.map do |node|
176
+ if node[:type] == 'directory'
177
+ children = filter_style_and_view_files(node[:children] || [])
178
+ node.merge(children: children) if children.any?
179
+ elsif [ 'html', 'erb' ].include?(node[:ext])
180
+ node
181
+ end
182
+ end.compact
183
+ end
184
+
185
+ def self.group_by_grammar
186
+ # Define the Agentic CSS Grammar categories
187
+ categories = [
188
+ {
189
+ name: 'Core Syntax',
190
+ icon: 'icon-code',
191
+ color: 'text-indigo-400',
192
+ rules: [
193
+ { name: 'Noun (ID)', syntax: '#[id]', description: 'Structural Identity' },
194
+ { name: 'Adjective (Class)', syntax: '.[class]', description: 'Characteristic' },
195
+ { name: 'Parameter (Bracket)', syntax: '[value]', description: 'Dimension/Attribute' }
196
+ ]
197
+ },
198
+ {
199
+ name: 'Spatial Coordinates',
200
+ icon: 'icon-move',
201
+ color: 'text-emerald-400',
202
+ rules: [
203
+ { name: 'X-Position', syntax: '.pos-x[0...2000]', description: 'Left offset in qss-units' },
204
+ { name: 'Y-Position', syntax: '.pos-y[0...2000]', description: 'Top offset in qss-units' },
205
+ { name: 'Right-Position', syntax: '.pos-r[0...2000]', description: 'Right offset in qss-units' },
206
+ { name: 'Bottom-Position', syntax: '.pos-b[0...2000]', description: 'Bottom offset in qss-units' }
207
+ ]
208
+ },
209
+ {
210
+ name: 'Dimensions',
211
+ icon: 'icon-maximize',
212
+ color: 'text-amber-400',
213
+ rules: [
214
+ { name: 'Width', syntax: '.w[0...2000]', description: 'Width in qss-units' },
215
+ { name: 'Height', syntax: '.h[0...2000]', description: 'Height in qss-units' },
216
+ { name: 'Width %', syntax: '.w-pct[0...1000]', description: 'Width in 0.1% increments' },
217
+ { name: 'Height %', syntax: '.h-pct[0...1000]', description: 'Height in 0.1% increments' }
218
+ ]
219
+ },
220
+ {
221
+ name: 'Visual Depth',
222
+ icon: 'icon-layers',
223
+ color: 'text-purple-400',
224
+ rules: [
225
+ { name: 'Canvas', syntax: '.depth[canvas]', description: 'z-index: 0' },
226
+ { name: 'Content', syntax: '.depth[content]', description: 'z-index: 100' },
227
+ { name: 'UI', syntax: '.depth[ui]', description: 'z-index: 500' },
228
+ { name: 'Overlay', syntax: '.depth[overlay]', description: 'z-index: 900' },
229
+ { name: 'Modal', syntax: '.depth[modal]', description: 'z-index: 1000' }
230
+ ]
231
+ }
232
+ ]
233
+
234
+ virtual_children = categories.map do |cat|
235
+ {
236
+ name: cat[:name],
237
+ type: 'directory',
238
+ path: "/grammar/#{cat[:name].parameterize}",
239
+ icon: cat[:icon],
240
+ iconColor: cat[:color],
241
+ children: cat[:rules].map { |r|
242
+ {
243
+ name: r[:name],
244
+ type: 'file',
245
+ path: "/grammar/#{cat[:name].parameterize}/#{r[:name].parameterize}",
246
+ icon: 'icon-file-text',
247
+ status: 'grey',
248
+ metadata: { syntax: r[:syntax], description: r[:description] }
249
+ }
250
+ },
251
+ health: nil
252
+ }
253
+ end
254
+
255
+ { name: 'Grammar', type: 'directory', path: '/grammar', icon: 'icon-book', children: virtual_children }
256
+ end
257
+
258
+ def self.group_by_registry(files)
259
+ # Filter for files that might contain QSS Nouns (views and partials)
260
+ view_files = files.select do |f|
261
+ [ 'html', 'erb', 'haml', 'slim' ].include?(f[:ext]) && !f[:path].include?('vendor')
262
+ end
263
+
264
+ registry_nodes = []
265
+
266
+ view_files.each do |f|
267
+ full_path = File.join(Parapet::Engine.root, f[:path])
268
+ nouns = Parapet::NounDiscovery.discover(full_path)
269
+ next if nouns.empty?
270
+
271
+ nouns.each do |noun|
272
+ # Find all files related to this noun (e.g. user.rb, user_spec.rb)
273
+ noun_id = noun[:id].downcase
274
+ related_files = files.select { |f| f[:name].downcase.start_with?(noun_id) }
275
+
276
+ registry_nodes << {
277
+ name: noun[:id],
278
+ type: 'directory',
279
+ path: "/registry/noun/#{noun[:id]}",
280
+ icon: 'icon-box',
281
+ iconColor: 'text-indigo-400',
282
+ children: related_files,
283
+ health: calculate_virtual_health(related_files),
284
+ metadata: {
285
+ source: f[:path],
286
+ adjectives: noun[:adjectives],
287
+ is_phantom: noun[:is_phantom],
288
+ type: 'Noun'
289
+ }
290
+ }
291
+ end
292
+ end
293
+
294
+ # Group nouns into logical categories or just alphabetical for now
295
+ # In a real app, we'd use metadata to categorize (UI, Layout, Form, etc.)
296
+ categories = [
297
+ { name: 'Core Layout', icon: 'icon-layout', color: 'text-blue-400' },
298
+ { name: 'UI Components', icon: 'icon-component', color: 'text-emerald-500' },
299
+ { name: 'Form Elements', icon: 'icon-input', color: 'text-amber-500' },
300
+ { name: 'Media Actors', icon: 'icon-image', color: 'text-purple-500' }
301
+ ]
302
+
303
+ virtual_children = categories.map do |cat|
304
+ {
305
+ name: cat[:name],
306
+ type: 'directory',
307
+ path: "/registry/#{cat[:name].parameterize}",
308
+ icon: cat[:icon],
309
+ iconColor: cat[:color],
310
+ children: [],
311
+ health: nil
312
+ }
313
+ end
314
+
315
+ # Distribute nouns into categories (simple heuristic for demo)
316
+ registry_nodes.sort_by { |n| n[:name] }.each_with_index do |node, idx|
317
+ cat_idx = idx % virtual_children.count
318
+ virtual_children[cat_idx][:children] << node
319
+ end
320
+
321
+ { name: 'Registry', type: 'directory', path: '/registry', icon: 'icon-database', children: virtual_children }
322
+ end
323
+
324
+ def self.group_by_coverage(files)
325
+ categories = get_categories('coverage')
326
+
327
+ virtual_children = categories.map do |cat|
328
+ # Filter files based on their determined status
329
+ status_map = {
330
+ 'Regressions' => 'red',
331
+ 'Critical Failures' => 'red',
332
+ 'Healthy (Green Zone)' => 'green',
333
+ 'The Danger Zone' => 'orange',
334
+ 'General / Unsorted' => 'grey'
335
+ }
336
+
337
+ target_status = status_map[cat[:name]]
338
+ assigned_files = files.select { |f| f[:status] == target_status }
339
+
340
+ {
341
+ name: cat[:name],
342
+ type: 'directory',
343
+ path: "/coverage/#{cat[:name].parameterize}",
344
+ icon: cat[:icon],
345
+ iconColor: cat[:color],
346
+ children: assigned_files,
347
+ health: calculate_virtual_health(assigned_files)
348
+ }
349
+ end
350
+
351
+ { name: 'Coverage', type: 'directory', path: '/coverage', icon: 'icon-activity qss-icon-activity', children: virtual_children.reject { |c| c[:children].empty? } }
352
+ end
353
+
354
+ private
355
+
356
+ def self.flatten_files(nodes)
357
+ return [] if nodes.nil?
358
+ list = []
359
+ nodes.each do |node|
360
+ if node[:type] == 'file'
361
+ list << node
362
+ else
363
+ # It's a directory, recurse into children
364
+ list += flatten_files(node[:children])
365
+ end
366
+ end
367
+ list
368
+ end
369
+
370
+ def self.calculate_virtual_health(files)
371
+ return nil if files.empty?
372
+ counts = files.group_by { |f| f[:status] }.transform_values { |v| (v.count.to_f / files.count * 100).round }
373
+ counts.select { |k, v| v > 0 }
374
+ end
375
+
376
+ def self.get_categories(type)
377
+ base = case type
378
+ when 'registry'
379
+ [
380
+ { name: 'Identity & Access', icon: 'icon-shield-check', color: 'text-blue-400' },
381
+ { name: 'Commerce & Billing', icon: 'icon-credit-card', color: 'text-emerald-500' },
382
+ { name: 'Inventory & Catalog', icon: 'icon-package', color: 'text-amber-500' },
383
+ { name: 'Engagement & Comms', icon: 'icon-mail', color: 'text-blue-500' },
384
+ { name: 'Infrastructure', icon: 'icon-cpu', color: 'text-slate-400' }
385
+ ]
386
+ when 'coverage'
387
+ [
388
+ { name: 'Regressions', icon: 'icon-git-branch', color: 'text-red-600' },
389
+ { name: 'Critical Failures', icon: 'icon-alert-octagon', color: 'text-red-500' },
390
+ { name: 'Healthy (Green Zone)', icon: 'icon-check-circle', color: 'text-emerald-500' },
391
+ { name: 'The Danger Zone', icon: 'icon-zap', color: 'text-yellow-500' }
392
+ ]
393
+ else
394
+ []
395
+ end
396
+
397
+ # Always add a catch-all for teaching/realism
398
+ base << { name: 'General / Unsorted', icon: 'icon-archive', color: 'text-slate-500' }
399
+ base
400
+ end
401
+ end
402
+ end
@@ -0,0 +1,21 @@
1
+ require "fileutils"
2
+
3
+ module Parapet
4
+ class FileSaveService
5
+ def initialize(user:, full_path:, relative_path:, content:, performed_via: "human")
6
+ @user = user
7
+ @full_path = full_path
8
+ @relative_path = relative_path
9
+ @content = content
10
+ @performed_via = performed_via
11
+ end
12
+
13
+ def save!
14
+ FileUtils.mkdir_p(File.dirname(@full_path))
15
+ File.write(@full_path, @content)
16
+ GitSideRefCommitter.new.record!(user: @user, path: @full_path)
17
+ DraftRecoveryStore.clear!(user: @user, path: @relative_path)
18
+ ActivityWriter.file_saved!(user: @user, path: @relative_path, performed_via: @performed_via)
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,50 @@
1
+ require "digest"
2
+ require "open3"
3
+ require "pathname"
4
+ require "tmpdir"
5
+
6
+ module Parapet
7
+ class GitSideRefCommitter
8
+ def initialize(root_path: Parapet::Engine.root.to_s)
9
+ @root_path = root_path
10
+ end
11
+
12
+ def record!(user:, path:)
13
+ repository_root = capture!("rev-parse", "--show-toplevel").strip
14
+ absolute_path = File.expand_path(path, @root_path)
15
+ relative_path = Pathname.new(absolute_path).relative_path_from(Pathname.new(repository_root)).to_s.tr("\\", "/")
16
+ raise ArgumentError, "File is outside the repository" if relative_path == ".." || relative_path.start_with?("../")
17
+
18
+ ref = "refs/parapet/file-editor-saves/#{user.id}/#{Digest::SHA256.hexdigest(relative_path)}"
19
+ parent = capture("rev-parse", "--verify", ref, chdir: repository_root)
20
+ parent_id = parent[:status].success? ? parent[:stdout].strip : nil
21
+
22
+ Dir.mktmpdir("parapet-index-") do |directory|
23
+ index_path = File.join(directory, "index")
24
+ env = { "GIT_INDEX_FILE" => index_path }
25
+ capture!("read-tree", "--empty", env: env, chdir: repository_root)
26
+ capture!("add", "-f", "--", relative_path, env: env, chdir: repository_root)
27
+ tree = capture!("write-tree", env: env, chdir: repository_root).strip
28
+ commit_args = [ "-c", "user.name=Parapet", "-c", "user.email=parapet@localhost", "commit-tree", tree ]
29
+ commit_args.concat([ "-p", parent_id ]) if parent_id
30
+ commit = capture!(*commit_args, input: "Parapet file save\n\nUser-ID: #{user.id}\n", chdir: repository_root).strip
31
+ capture!("update-ref", ref, commit, chdir: repository_root)
32
+ commit
33
+ end
34
+ end
35
+
36
+ private
37
+
38
+ def capture(*args, env: {}, input: nil, chdir: @root_path)
39
+ stdout, stderr, status = Open3.capture3(env, "git", *args, chdir: chdir, stdin_data: input)
40
+ { stdout: stdout, stderr: stderr, status: status }
41
+ end
42
+
43
+ def capture!(*args, env: {}, input: nil, chdir: @root_path)
44
+ result = capture(*args, env: env, input: input, chdir: chdir)
45
+ raise "Git operation failed: #{result[:stderr]}" unless result[:status].success?
46
+
47
+ result[:stdout]
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,26 @@
1
+ module Parapet
2
+ # Discovers the QSS nouns (element ids) a view file declares, so the CSS Suite can list the
3
+ # elements on the page in view.
4
+ #
5
+ # PLACEHOLDER. Noun Discovery is shelved until the CSS Suite is built (decision 2026-09-25).
6
+ # It discovers nothing yet. Callers expect a list, so it returns an empty one, and it says so
7
+ # in the log (once per process) so the gap is never silent. The pending example in
8
+ # spec/services/parapet/noun_discovery_spec.rb describes the real behavior and starts failing
9
+ # loudly, as RSpec requires, the moment that behavior exists.
10
+ class NounDiscovery
11
+ REMINDER = "[Parapet] Noun Discovery is a placeholder (shelved until the CSS Suite): " \
12
+ "Parapet::NounDiscovery.discover returns no nouns, so registry views list none."
13
+
14
+ def self.discover(_path)
15
+ remind_once
16
+ []
17
+ end
18
+
19
+ def self.remind_once
20
+ return if @reminded
21
+ @reminded = true
22
+ Rails.logger&.warn(REMINDER)
23
+ end
24
+ private_class_method :remind_once
25
+ end
26
+ end
@@ -0,0 +1,83 @@
1
+ require 'open3'
2
+
3
+ module Parapet
4
+ class PublishGate
5
+ class Result
6
+ attr_reader :run_history
7
+
8
+ def initialize(run_history)
9
+ @run_history = run_history
10
+ end
11
+
12
+ def passed?
13
+ @run_history.passed?
14
+ end
15
+ end
16
+
17
+ def initialize(triggered_by_actor_type: "human", user: Current.user, performed_via: nil)
18
+ @actor_type = triggered_by_actor_type
19
+ @user = user
20
+ @performed_via = performed_via || (triggered_by_actor_type == "ai_agent" ? "ai_agent" : "human")
21
+ # Use Rails.root to point to the host application, but fallback to Engine root
22
+ # if we are in the engine's own development environment (where Rails.root is dummy)
23
+ @root_path = if defined?(Rails) && Rails.root.join('spec').exist?
24
+ Rails.root.to_s
25
+ elsif defined?(Rails) && Rails.root.join('features').exist?
26
+ Rails.root.to_s
27
+ else
28
+ Parapet::Engine.root.to_s
29
+ end
30
+ end
31
+
32
+ def run
33
+ run_history = Parapet::RunHistory.create!(
34
+ run_type: "publish",
35
+ status: "running",
36
+ triggered_by_actor_type: @actor_type,
37
+ user_id: @user&.id,
38
+ performed_via: @performed_via,
39
+ started_at: Time.current,
40
+ command: "bundle exec rspec && bundle exec cucumber"
41
+ )
42
+
43
+ full_output = ""
44
+ final_status = "passed"
45
+ final_exit_code = 0
46
+
47
+ # Determine which commands to run based on presence of directories
48
+ commands = []
49
+ commands << "bundle exec rspec" if Dir.exist?(File.join(@root_path, 'spec'))
50
+ commands << "bundle exec cucumber" if Dir.exist?(File.join(@root_path, 'features'))
51
+
52
+ # If no commands to run, it passes by default (or should it fail? Spec implies both run)
53
+ # Spec says: "Run RSpec, then Cucumber... fail fast"
54
+
55
+ commands.each do |cmd|
56
+ stdout, stderr, status = Open3.capture3(cmd, chdir: @root_path)
57
+ full_output += stdout + stderr
58
+
59
+ if status.exitstatus != 0
60
+ final_status = "failed"
61
+ final_exit_code = status.exitstatus
62
+ break
63
+ end
64
+ end
65
+
66
+ run_history.update!(
67
+ status: final_status,
68
+ exit_code: final_exit_code,
69
+ raw_output: full_output,
70
+ finished_at: Time.current
71
+ )
72
+
73
+ ActivityWriter.run!(
74
+ run_history: run_history,
75
+ user: @user,
76
+ action: "publish",
77
+ performed_via: @performed_via
78
+ )
79
+
80
+ Result.new(run_history)
81
+ end
82
+ end
83
+ end