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,12 @@
1
+ module Parapet
2
+ class User < InternalRecord
3
+ self.table_name = "users"
4
+
5
+ has_secure_password
6
+ has_many :sessions, dependent: :destroy
7
+
8
+ normalizes :email_address, with: ->(email_address) { email_address.strip.downcase }
9
+
10
+ validates :email_address, presence: true, uniqueness: true
11
+ end
12
+ end
@@ -0,0 +1,35 @@
1
+ module Parapet
2
+ class ActivityPhraseRenderer
3
+ def initialize(view_context:)
4
+ @view_context = view_context
5
+ end
6
+
7
+ def render(activity:, viewer:)
8
+ activity_type = activity.activity_type.to_s
9
+ action = activity.action.to_s
10
+ partial = [
11
+ "parapet/activities/#{activity_type}/#{action}",
12
+ "parapet/activities/#{activity_type}",
13
+ "parapet/activities/generic"
14
+ ].find { |candidate| @view_context.lookup_context.exists?(candidate, [], true) }
15
+
16
+ @view_context.render(
17
+ partial: partial,
18
+ locals: {
19
+ activity: activity,
20
+ actor_name: actor_name(activity, viewer),
21
+ viewer: viewer
22
+ }
23
+ )
24
+ end
25
+
26
+ private
27
+
28
+ def actor_name(activity, viewer)
29
+ return "Parapet" unless activity.user
30
+ return "You" if viewer && activity.user_id == viewer.id
31
+
32
+ activity.user.email_address
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,26 @@
1
+ module Parapet
2
+ class ActivityWriter
3
+ def self.run!(run_history:, user:, action:, performed_via:)
4
+ return unless user
5
+
6
+ Activity.create!(
7
+ user: user,
8
+ activity_type: "run_history",
9
+ action: action,
10
+ performed_via: performed_via,
11
+ trackable: run_history,
12
+ details: {}
13
+ )
14
+ end
15
+
16
+ def self.file_saved!(user:, path:, performed_via: "human")
17
+ Activity.create!(
18
+ user: user,
19
+ activity_type: "file_editor",
20
+ action: "file_saved",
21
+ performed_via: performed_via,
22
+ details: { "file_path" => path }
23
+ )
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,198 @@
1
+ require "json"
2
+
3
+ module Parapet
4
+ module Authorization
5
+ class Error < StandardError
6
+ end
7
+
8
+ class NotPmCapableError < Error
9
+ end
10
+
11
+ class DelegateLimitExceededError < Error
12
+ end
13
+
14
+ class LastDelegateCannotBeRevokedError < Error
15
+ end
16
+
17
+ class TierCeilingExceededError < Error
18
+ end
19
+
20
+ class BootstrapAlreadyCompletedError < Error
21
+ end
22
+
23
+ class InvalidConfigurationError < Error
24
+ end
25
+
26
+ DELEGATE_SETTING_KEY = "delegate_ids"
27
+ MAX_DELEGATES = 2
28
+
29
+ TIER_RANKS = {
30
+ "novice" => 0,
31
+ "intermediate" => 1,
32
+ "advanced" => 2,
33
+ "power" => 3
34
+ }.freeze
35
+
36
+ class << self
37
+ def authorized?(user, suite, min_tier: :novice)
38
+ return false unless valid_identity?(user)
39
+
40
+ suite_name = suite.to_s
41
+ minimum_rank = TIER_RANKS[min_tier.to_s]
42
+ return false unless SuiteAssignment::SUITES.include?(suite_name) && minimum_rank
43
+
44
+ effective_tier = tier_for(user, suite_name)
45
+ effective_tier && TIER_RANKS.fetch(effective_tier) >= minimum_rank
46
+ end
47
+
48
+ def tier_for(user, suite)
49
+ return unless valid_identity?(user)
50
+
51
+ suite_name = suite.to_s
52
+ return unless SuiteAssignment::SUITES.include?(suite_name)
53
+
54
+ assignment = SuiteAssignment.find_by(user_id: user.id, suite: suite_name)
55
+ tier = assignment&.tier || ("novice" if suite_name == "intentions_suite")
56
+ assigned_rank = TIER_RANKS[tier]
57
+ ceiling_rank = TIER_RANKS[license_ceiling.to_s]
58
+ return unless assigned_rank && ceiling_rank
59
+
60
+ TIER_RANKS.key([ assigned_rank, ceiling_rank ].min)
61
+ end
62
+
63
+ def role_for(user, suite)
64
+ return unless valid_identity?(user)
65
+
66
+ suite_name = suite.to_s
67
+ return unless SuiteAssignment::SUITES.include?(suite_name)
68
+
69
+ SuiteAssignment.find_by(user_id: user.id, suite: suite_name)&.role
70
+ end
71
+
72
+ def pm_capable?(user)
73
+ valid_identity?(user) && delegate_ids.include?(user.id)
74
+ end
75
+
76
+ def bootstrap_owner!(owner:)
77
+ ensure_identity!(owner)
78
+
79
+ RuntimeSetting.transaction do
80
+ setting = RuntimeSetting.lock.find_by(setting_key: DELEGATE_SETTING_KEY)
81
+
82
+ if setting
83
+ ids = parse_delegate_ids(setting.value)
84
+ raise BootstrapAlreadyCompletedError unless ids.include?(owner.id)
85
+ else
86
+ RuntimeSetting.create!(
87
+ setting_key: DELEGATE_SETTING_KEY,
88
+ value: JSON.generate([ owner.id ])
89
+ )
90
+ end
91
+ end
92
+
93
+ owner
94
+ end
95
+
96
+ def grant_pm!(actor:, user:)
97
+ require_pm!(actor)
98
+ ensure_identity!(user)
99
+
100
+ update_delegate_ids do |ids|
101
+ next ids if ids.include?(user.id)
102
+ raise DelegateLimitExceededError if ids.length >= MAX_DELEGATES
103
+
104
+ ids + [ user.id ]
105
+ end
106
+
107
+ user
108
+ end
109
+
110
+ def revoke_pm!(actor:, user:)
111
+ require_pm!(actor)
112
+ ensure_identity!(user)
113
+
114
+ update_delegate_ids do |ids|
115
+ raise LastDelegateCannotBeRevokedError if ids == [ user.id ]
116
+
117
+ ids.reject { |id| id == user.id }
118
+ end
119
+ user
120
+ end
121
+
122
+ def assign!(actor:, user:, suite:, role: nil, tier: nil)
123
+ require_pm!(actor)
124
+ ensure_identity!(user)
125
+
126
+ suite_name = suite.to_s
127
+ raise ArgumentError, "Unknown suite" unless SuiteAssignment::SUITES.include?(suite_name)
128
+ raise ArgumentError, "Provide a role, a tier, or both" if role.nil? && tier.nil?
129
+
130
+ tier_name = tier&.to_s
131
+ if tier_name
132
+ tier_rank = TIER_RANKS[tier_name]
133
+ ceiling_rank = TIER_RANKS[license_ceiling.to_s]
134
+ raise ArgumentError, "Unknown tier" unless tier_rank
135
+ raise TierCeilingExceededError if tier_rank > ceiling_rank
136
+ end
137
+
138
+ assignment = SuiteAssignment.find_or_initialize_by(user: user, suite: suite_name)
139
+ assignment.role = role.to_s unless role.nil?
140
+ assignment.tier = tier_name if tier_name
141
+ assignment.save!
142
+ assignment
143
+ end
144
+
145
+ def license_ceiling
146
+ :power
147
+ end
148
+
149
+ private
150
+
151
+ def delegate_ids
152
+ setting = RuntimeSetting.find_by(setting_key: DELEGATE_SETTING_KEY)
153
+ setting ? parse_delegate_ids(setting.value) : []
154
+ end
155
+
156
+ def parse_delegate_ids(value)
157
+ ids = JSON.parse(value)
158
+ unless ids.is_a?(Array) && ids.length <= MAX_DELEGATES && ids.uniq == ids && ids.all? { |id| id.is_a?(Integer) && id.positive? }
159
+ raise InvalidConfigurationError
160
+ end
161
+
162
+ ids
163
+ rescue JSON::ParserError, TypeError
164
+ raise InvalidConfigurationError
165
+ end
166
+
167
+ def update_delegate_ids
168
+ RuntimeSetting.transaction do
169
+ setting = RuntimeSetting.lock.find_by(setting_key: DELEGATE_SETTING_KEY)
170
+ ids = setting ? parse_delegate_ids(setting.value) : []
171
+ updated_ids = yield(ids)
172
+ raise DelegateLimitExceededError if updated_ids.length > MAX_DELEGATES
173
+
174
+ value = JSON.generate(updated_ids)
175
+ if setting
176
+ setting.update!(value: value)
177
+ else
178
+ RuntimeSetting.create!(setting_key: DELEGATE_SETTING_KEY, value: value)
179
+ end
180
+ end
181
+ end
182
+
183
+ def require_pm!(user)
184
+ raise NotPmCapableError unless pm_capable?(user)
185
+ end
186
+
187
+ def ensure_identity!(user)
188
+ unless valid_identity?(user)
189
+ raise ArgumentError, "User must be a persisted account with credentials"
190
+ end
191
+ end
192
+
193
+ def valid_identity?(user)
194
+ user.is_a?(User) && user.persisted? && user.password_digest.present?
195
+ end
196
+ end
197
+ end
198
+ end
@@ -0,0 +1,58 @@
1
+ require 'nokogiri'
2
+ require 'json'
3
+ require 'digest'
4
+
5
+ module Parapet
6
+ class ConsistencyAuditor
7
+ CONSISTENT_IDS = [
8
+ 'pp-header',
9
+ 'pp-footer',
10
+ 'pp-global-nav',
11
+ 'pp-explorer-bar',
12
+ 'pp-inspector-bar'
13
+ ].freeze
14
+
15
+ def self.audit_project(root_path)
16
+ fingerprints = {} # id => { hash => [files] }
17
+
18
+ # Normalize path for Windows/Unix consistency
19
+ base_dir = File.expand_path(root_path)
20
+ pattern = File.join(base_dir, 'app', 'views', '**', '*.html.erb').gsub('\\', '/')
21
+
22
+ Dir.glob(pattern).each do |file|
23
+ next if file.include?('/shared/') # Skip partials as they are the source of truth
24
+
25
+ content = File.read(file) rescue next
26
+ doc = Nokogiri::HTML.fragment(content.gsub(/<%.*?%>/m, '<!--ERB-->'))
27
+
28
+ CONSISTENT_IDS.each do |id|
29
+ node = doc.at_css("##{id}")
30
+ next unless node
31
+
32
+ # Normalize node for comparison (remove whitespace/comments)
33
+ normalized = node.to_s.gsub(/\s+/, ' ').strip
34
+ hash = Digest::SHA256.hexdigest(normalized)
35
+
36
+ fingerprints[id] ||= {}
37
+ fingerprints[id][hash] ||= []
38
+ fingerprints[id][hash] << file.sub(base_dir, '')
39
+ end
40
+ end
41
+
42
+ issues = []
43
+ fingerprints.each do |id, variants|
44
+ if variants.size > 1
45
+ issues << {
46
+ type: 'consistency',
47
+ id: id,
48
+ message: "Structural Drift Detected: Element ##{id} has #{variants.size} different implementations across your views.",
49
+ variants: variants,
50
+ severity: 'red'
51
+ }
52
+ end
53
+ end
54
+
55
+ { status: issues.empty? ? 'green' : 'red', issues: issues }
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,159 @@
1
+ module Parapet
2
+ class DirectoryScanner
3
+ def initialize(root_path)
4
+ @root_path = root_path
5
+ end
6
+
7
+ def scan
8
+ return nil unless File.directory?(@root_path)
9
+
10
+ {
11
+ name: File.basename(@root_path),
12
+ type: 'directory',
13
+ path: '/',
14
+ icon: 'icon-folder qss-icon-folder',
15
+ children: scan_directory(@root_path, '')
16
+ }
17
+ end
18
+
19
+ private
20
+
21
+ def scan_directory(current_path, relative_path)
22
+ items = []
23
+
24
+ # Directories to ignore (case-insensitive)
25
+ ignore_list = [ '.', '..', '.git', '.bundle', 'node_modules', 'tmp', 'log' ]
26
+
27
+ Dir.foreach(current_path) do |item|
28
+ next if ignore_list.any? { |ignored| item.downcase == ignored.downcase }
29
+ next if item == '.keep'
30
+ next if item.start_with?('.') && item != '.zencoder' # Keep our own folder for dogfooding
31
+
32
+ full_path = File.join(current_path, item)
33
+ item_relative_path = if relative_path.empty?
34
+ item
35
+ else
36
+ File.join(relative_path, item)
37
+ end.gsub('\\', '/')
38
+
39
+ if File.directory?(full_path)
40
+ children = scan_directory(full_path, item_relative_path)
41
+ health = calculate_health(children)
42
+
43
+ items << {
44
+ name: item,
45
+ type: 'directory',
46
+ path: item_relative_path,
47
+ icon: 'icon-folder',
48
+ children: children,
49
+ health: health
50
+ }
51
+ else
52
+ ext = File.extname(item).delete('.')
53
+ status = determine_status(full_path, item, ext)
54
+
55
+ # Detect Sidecar (.qss) for View files
56
+ sidecar = nil
57
+ if [ 'html', 'erb' ].include?(ext)
58
+ qss_item = item.sub(/\.(html\.erb|erb)$/, '.qss')
59
+ if File.exist?(File.join(current_path, qss_item))
60
+ sidecar = File.join(relative_path, qss_item).gsub('\\', '/')
61
+ end
62
+ end
63
+
64
+ items << {
65
+ name: item,
66
+ type: 'file',
67
+ path: item_relative_path,
68
+ ext: ext,
69
+ icon: icon_for_file(item, ext),
70
+ iconColor: color_for_file(item, ext),
71
+ status: status,
72
+ children: [],
73
+ sidecar: sidecar
74
+ }
75
+ end
76
+ end
77
+
78
+ items.sort_by { |i| [ i[:type] == 'directory' ? 0 : 1, i[:name] ] }
79
+ end
80
+
81
+ def determine_status(full_path, name, ext)
82
+ # 1. Use QSS Auditor for view/style files
83
+ if [ 'html', 'erb', 'qss', 'css', 'scss' ].include?(ext)
84
+ return QSS::QssAuditor.audit_file(full_path)[:status]
85
+ end
86
+
87
+ # 2. Use stable "random" status for other files to simulate active project state
88
+ return nil unless is_test_file?(name) || ext == 'rb'
89
+
90
+ hash = name.hash % 100
91
+ if hash < 60
92
+ 'green'
93
+ elsif hash < 85
94
+ 'red'
95
+ elsif hash < 95
96
+ 'orange'
97
+ else
98
+ 'grey'
99
+ end
100
+ end
101
+
102
+ def calculate_health(children)
103
+ return nil if children.empty?
104
+
105
+ stats = { 'red' => 0, 'green' => 0, 'orange' => 0, 'grey' => 0 }
106
+ count = 0
107
+
108
+ children.each do |child|
109
+ if child[:type] == 'file'
110
+ if child[:status]
111
+ stats[child[:status]] += 1
112
+ count += 1
113
+ end
114
+ elsif child[:type] == 'directory' && child[:health]
115
+ # Factor in directory health
116
+ child[:health].each do |status, percent|
117
+ stats[status] += (percent / 100.0)
118
+ end
119
+ count += 1
120
+ end
121
+ end
122
+
123
+ return nil if count == 0
124
+
125
+ # Convert to percentages
126
+ stats.transform_values { |v| (v / count.to_f * 100).round }
127
+ end
128
+
129
+ def is_test_file?(name)
130
+ if Parapet.test_framework == :rspec
131
+ name.end_with?('_spec.rb')
132
+ else
133
+ name.end_with?('_test.rb')
134
+ end
135
+ end
136
+
137
+ def icon_for_file(name, ext)
138
+ return 'icon-gem' if is_test_file?(name)
139
+
140
+ case ext
141
+ when 'rb' then 'icon-gem'
142
+ when 'feature' then 'icon-list-checks'
143
+ when 'json' then 'icon-database'
144
+ else 'icon-file'
145
+ end
146
+ end
147
+
148
+ def color_for_file(name, ext)
149
+ return 'qss-text-amber-500' if is_test_file?(name)
150
+
151
+ case ext
152
+ when 'rb' then 'qss-text-red-400'
153
+ when 'feature' then 'qss-text-emerald-400'
154
+ when 'json' then 'qss-text-indigo-300'
155
+ else 'qss-text-slate-400'
156
+ end
157
+ end
158
+ end
159
+ end
@@ -0,0 +1,18 @@
1
+ module Parapet
2
+ class DraftRecoveryStore
3
+ def self.find(user:, path:)
4
+ DraftRecovery.find_by(user_id: user.id, file_path: path)
5
+ end
6
+
7
+ def self.save!(user:, path:, content:)
8
+ draft = DraftRecovery.find_or_initialize_by(user_id: user.id, file_path: path)
9
+ draft.content = content
10
+ draft.save!
11
+ draft
12
+ end
13
+
14
+ def self.clear!(user:, path:)
15
+ DraftRecovery.where(user_id: user.id, file_path: path).delete_all
16
+ end
17
+ end
18
+ end