mbeditor 0.13.1 → 0.14.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 (58) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +91 -0
  3. data/app/assets/javascripts/mbeditor/application.js +3 -0
  4. data/app/assets/javascripts/mbeditor/audit_log.js +165 -0
  5. data/app/assets/javascripts/mbeditor/collaboration_service.js +248 -26
  6. data/app/assets/javascripts/mbeditor/components/ChangelogView.js +89 -94
  7. data/app/assets/javascripts/mbeditor/components/CodeReviewPanel.js +6 -9
  8. data/app/assets/javascripts/mbeditor/components/CollapsibleSection.js +12 -7
  9. data/app/assets/javascripts/mbeditor/components/CombinedDiffViewer.js +20 -0
  10. data/app/assets/javascripts/mbeditor/components/DiffViewer.js +1 -1
  11. data/app/assets/javascripts/mbeditor/components/EditorPanel.js +429 -247
  12. data/app/assets/javascripts/mbeditor/components/FileHistoryPanel.js +6 -9
  13. data/app/assets/javascripts/mbeditor/components/FileTree.js +26 -12
  14. data/app/assets/javascripts/mbeditor/components/GitPanel.js +3 -0
  15. data/app/assets/javascripts/mbeditor/components/Gutter.js +51 -0
  16. data/app/assets/javascripts/mbeditor/components/LogPanel.js +3 -44
  17. data/app/assets/javascripts/mbeditor/components/MbeditorApp.js +1168 -1243
  18. data/app/assets/javascripts/mbeditor/components/ModelGraph.js +94 -37
  19. data/app/assets/javascripts/mbeditor/components/ProblemsPanel.js +47 -65
  20. data/app/assets/javascripts/mbeditor/components/QuickOpenDialog.js +26 -8
  21. data/app/assets/javascripts/mbeditor/components/SettingsModal.js +342 -0
  22. data/app/assets/javascripts/mbeditor/components/ShortcutHelp.js +2 -1
  23. data/app/assets/javascripts/mbeditor/components/TabBar.js +67 -98
  24. data/app/assets/javascripts/mbeditor/editor_plugins.js +687 -305
  25. data/app/assets/javascripts/mbeditor/file_import.js +13 -15
  26. data/app/assets/javascripts/mbeditor/file_service.js +38 -39
  27. data/app/assets/javascripts/mbeditor/git_service.js +15 -1
  28. data/app/assets/javascripts/mbeditor/history_service.js +5 -13
  29. data/app/assets/javascripts/mbeditor/search_service.js +9 -0
  30. data/app/assets/javascripts/mbeditor/tab_manager.js +127 -49
  31. data/app/assets/javascripts/mbeditor/websocket_service.js +13 -5
  32. data/app/assets/stylesheets/mbeditor/application.css +6 -1
  33. data/app/assets/stylesheets/mbeditor/editor.css +642 -248
  34. data/app/assets/stylesheets/mbeditor/glass.css +163 -0
  35. data/app/assets/stylesheets/mbeditor/themes.css +90 -30
  36. data/app/channels/mbeditor/collaboration_channel.rb +17 -4
  37. data/app/controllers/mbeditor/application_controller.rb +26 -3
  38. data/app/controllers/mbeditor/editors_controller.rb +117 -439
  39. data/app/services/mbeditor/archive_service.rb +137 -0
  40. data/app/services/mbeditor/collaboration_doc_store.rb +143 -14
  41. data/app/services/mbeditor/editor_state_service.rb +14 -51
  42. data/app/services/mbeditor/file_history_service.rb +222 -0
  43. data/app/services/mbeditor/git_info_service.rb +6 -0
  44. data/app/services/mbeditor/js_syntax_check_service.rb +42 -16
  45. data/app/services/mbeditor/lint_service.rb +137 -0
  46. data/app/services/mbeditor/locked_json_file.rb +67 -0
  47. data/app/services/mbeditor/process_runner.rb +32 -0
  48. data/app/services/mbeditor/ruby_lsp_result_translator.rb +226 -0
  49. data/app/services/mbeditor/search_replace_service.rb +8 -0
  50. data/app/views/layouts/mbeditor/application.html.erb +1 -1
  51. data/lib/mbeditor/audit_log.rb +203 -0
  52. data/lib/mbeditor/configuration.rb +6 -1
  53. data/lib/mbeditor/rack/pending_migration_bypass.rb +15 -9
  54. data/lib/mbeditor/route_map.rb +4 -0
  55. data/lib/mbeditor/ruby_lsp_client.rb +82 -14
  56. data/lib/mbeditor/version.rb +1 -1
  57. data/lib/mbeditor.rb +1 -0
  58. metadata +12 -2
@@ -0,0 +1,222 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mbeditor
4
+ # Per-branch, per-file undo history: the ops the client couldn't replay from
5
+ # its own in-memory undo stack (a reload, a second tab) get persisted here as
6
+ # a base snapshot plus an appended op log, keyed by branch+path so switching
7
+ # branches doesn't cross-contaminate history.
8
+ #
9
+ # Built on LockedJsonFile — the same sidecar-lock + atomic-write primitive
10
+ # EditorStateService uses — rather than a hand-rolled flock loop that wrote
11
+ # in place with truncate/rewind. A crash mid-write there truncated history
12
+ # instead of leaving the previous file untouched.
13
+ class FileHistoryService
14
+ LockTimeoutError = LockedJsonFile::LockTimeoutError
15
+ BaseRequiredError = Class.new(StandardError)
16
+ BaseTooLargeError = Class.new(StandardError)
17
+
18
+ # v1 (no "v") began tracking before the file content had arrived, so it
19
+ # recorded the whole-file load as an insert-at-origin op against an empty
20
+ # base — once per open. v2 begins tracking once the load has landed, so the
21
+ # load is the base and only real edits are ops. Legacy files are migrated on
22
+ # read (migrate_legacy!); legacy writes are normalized in #append.
23
+ FORMAT_VERSION = 2
24
+ MAX_OPS = 10_000
25
+ COMPACT_TARGET = 5_000
26
+ MAX_AGE_SECONDS = 7 * 24 * 3600
27
+ BASE_MAX_BYTES = EditorStateService::STATE_MAX_BYTES
28
+ # Op count alone doesn't bound size: a few huge pastes can outweigh MAX_OPS
29
+ # tiny edits, so compaction also triggers on the serialized payload size.
30
+ MAX_BYTES = 4 * 1024 * 1024
31
+
32
+ def initialize(workspace_root, lock_timeout: EditorStateService::DEFAULT_LOCK_TIMEOUT, max_bytes: MAX_BYTES)
33
+ @root = workspace_root
34
+ @lock_timeout = lock_timeout
35
+ @max_bytes = max_bytes
36
+ end
37
+
38
+ # Returns { "base" => ..., "ops" => [...] }, or nil if there is no history
39
+ # or it aged out (an aged-out file is pruned as a side effect).
40
+ def read(branch, rel_path)
41
+ path = history_path(branch, rel_path)
42
+ return nil unless File.exist?(path)
43
+
44
+ data = JSON.parse(File.read(path))
45
+
46
+ if data["t"] && (Time.now.utc - Time.parse(data["t"])) > MAX_AGE_SECONDS
47
+ FileUtils.rm_f(path)
48
+ return nil
49
+ end
50
+
51
+ data = migrate_legacy!(path, data)
52
+ return nil unless data
53
+
54
+ { "base" => data["base"], "ops" => data["ops"] || [] }
55
+ rescue JSON::ParserError
56
+ FileUtils.rm_f(path)
57
+ nil
58
+ end
59
+
60
+ # Appends ops, seeding the history with `base` on the first write for this
61
+ # branch+path. `base_given` distinguishes "no base param at all" (an
62
+ # error, except on a non-first write) from an explicit empty base (a
63
+ # legitimate first snapshot — e.g. a file that is empty on disk, tracked
64
+ # from "" before the first edit).
65
+ #
66
+ # `version` is the client's format version. A legacy client (absent or <
67
+ # FORMAT_VERSION) sends base "" plus the whole-file load as its first op;
68
+ # that is folded into the base here so the bad shape never reaches disk.
69
+ def append(branch, rel_path, ops:, base: nil, base_given: false, version: nil)
70
+ ops = Array(ops)
71
+ base = base.to_s
72
+
73
+ if version.to_i < FORMAT_VERSION && base_given && base.empty?
74
+ base = ops.shift[4].to_s if load_op?(ops.first)
75
+ ops = ops.reject { |op| load_op?(op) }
76
+ end
77
+
78
+ file = LockedJsonFile.new(history_path(branch, rel_path), lock_timeout: @lock_timeout, error_class: LockTimeoutError)
79
+
80
+ file.with_lock do
81
+ existing = file.read
82
+
83
+ if existing.empty?
84
+ raise BaseRequiredError unless base_given
85
+ raise BaseTooLargeError if base.bytesize > BASE_MAX_BYTES
86
+
87
+ existing = {
88
+ "branch" => branch,
89
+ "path" => rel_path,
90
+ "base" => base,
91
+ "ops" => [],
92
+ "v" => FORMAT_VERSION,
93
+ "t" => Time.now.utc.iso8601
94
+ }
95
+ end
96
+
97
+ existing["v"] = FORMAT_VERSION
98
+ existing["ops"] = (existing["ops"] || []) + ops
99
+ existing["t"] = Time.now.utc.iso8601
100
+
101
+ payload = existing.to_json
102
+ if existing["ops"].length > MAX_OPS || payload.bytesize > @max_bytes
103
+ compact_until_bounded!(existing)
104
+ payload = existing.to_json
105
+ end
106
+
107
+ file.write(payload)
108
+ end
109
+ nil
110
+ end
111
+
112
+ # Deletes history files for branches no longer in active_branches.
113
+ def prune(active_branches:)
114
+ hist_dir = @root.join("tmp", "mbeditor_history")
115
+ return unless File.directory?(hist_dir)
116
+
117
+ Dir.glob(File.join(hist_dir, "*.json")) do |hist_file|
118
+ data = begin
119
+ JSON.parse(File.read(hist_file))
120
+ rescue JSON::ParserError => e
121
+ Rails.logger.error("[mbeditor] FileHistoryService#prune: skipping corrupt history file #{hist_file}: #{e.message}")
122
+ nil
123
+ end
124
+ next unless data.is_a?(Hash) && data["branch"]
125
+
126
+ FileUtils.rm_f(hist_file) unless active_branches.include?(data["branch"])
127
+ end
128
+ nil
129
+ end
130
+
131
+ private
132
+
133
+ def history_path(branch, rel_path)
134
+ branch_hash = Digest::SHA256.hexdigest(branch.to_s)[0, 16]
135
+ file_hash = Digest::SHA256.hexdigest(rel_path.to_s)[0, 16]
136
+ @root.join("tmp", "mbeditor_history", "#{branch_hash}_#{file_hash}.json")
137
+ end
138
+
139
+ # Rewrites a v1 history into the v2 shape and returns it, or nil if there is
140
+ # nothing left to replay (the file is then removed). A v2 file is returned
141
+ # untouched. Writes are atomic (rename), so a concurrent reader never sees a
142
+ # half-written file; the rewrite is idempotent, so a racing append that also
143
+ # normalizes is harmless.
144
+ def migrate_legacy!(path, data)
145
+ return data if data["v"].to_i >= FORMAT_VERSION
146
+
147
+ base = data["base"].to_s
148
+ ops = Array(data["ops"])
149
+
150
+ if base.empty? && load_op?(ops.first)
151
+ # The first load is exactly equivalent to the base it was applied to.
152
+ base = ops.shift[4].to_s
153
+ # Every later load starts another session whose preceding edits already
154
+ # reproduce it. Left in place they concatenate the file onto itself;
155
+ # dropping them reconstructs the last session's edits over the first
156
+ # session's content.
157
+ ops = ops.reject { |op| load_op?(op) }
158
+ end
159
+
160
+ migrated = data.merge("base" => base, "ops" => ops, "v" => FORMAT_VERSION)
161
+
162
+ if base.empty? && ops.empty?
163
+ FileUtils.rm_f(path)
164
+ nil
165
+ else
166
+ LockedJsonFile.new(path, lock_timeout: @lock_timeout, error_class: LockTimeoutError).write(migrated)
167
+ migrated
168
+ end
169
+ end
170
+
171
+ # A whole-file load as the v1 client recorded it: an insertion at the very
172
+ # start of the document. Its text is the file content at that open.
173
+ def load_op?(op)
174
+ return false unless op.is_a?(Array) && op.length >= 5
175
+
176
+ op[0].to_i == 1 && op[1].to_i == 1 && op[2].to_i == 1 && op[3].to_i == 1
177
+ end
178
+
179
+ # Folds ops into the base until both the op count and the serialized size
180
+ # are back under budget. Stops early when the op log is empty — a base that
181
+ # alone exceeds the budget (a very large file) can't be shrunk further
182
+ # without the current file contents, which the service does not have.
183
+ def compact_until_bounded!(data)
184
+ data["ops"] ||= []
185
+ loop do
186
+ break if data["ops"].length <= MAX_OPS && data.to_json.bytesize <= @max_bytes
187
+ break if data["ops"].empty?
188
+
189
+ data["base"] = compact_ops(data["base"], data["ops"].shift(COMPACT_TARGET))
190
+ end
191
+ end
192
+
193
+ # Replays a batch of ops against `base` to fold them into a new snapshot
194
+ # once the op log outgrows MAX_OPS, so history keeps a bounded number of
195
+ # ops without losing anything: base + remaining ops still reconstructs the
196
+ # same document. Each op is [startLine, startCol, endLine, endCol, insertedText],
197
+ # 1-based, matching Monaco's model.onDidChangeContent ranges.
198
+ def compact_ops(base, ops)
199
+ text = base.to_s
200
+ ops.each do |op|
201
+ sl, sc, el, ec, ins = op[0].to_i, op[1].to_i, op[2].to_i, op[3].to_i, op[4].to_s
202
+ lines = text.split("\n", -1)
203
+ sl0 = [[sl - 1, 0].max, [lines.length - 1, 0].max].min
204
+ el0 = [[el - 1, 0].max, [lines.length - 1, 0].max].min
205
+ sc0 = sc - 1
206
+ ec0 = ec - 1
207
+ prefix = (lines[sl0] || "")[0, sc0] || ""
208
+ suffix = (lines[el0] || "")[ec0..] || ""
209
+ ins_lines = ins.split("\n", -1)
210
+ new_seg = if ins_lines.length <= 1
211
+ [prefix + (ins_lines[0] || "") + suffix]
212
+ else
213
+ [prefix + ins_lines[0]] + ins_lines[1..-2] + [ins_lines[-1] + suffix]
214
+ end
215
+ text = (lines[0...sl0] + new_seg + lines[(el0 + 1)..]).join("\n")
216
+ end
217
+ text
218
+ rescue StandardError
219
+ base.to_s
220
+ end
221
+ end
222
+ end
@@ -43,8 +43,14 @@ module Mbeditor
43
43
  end
44
44
 
45
45
  begin
46
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
46
47
  compute(repo_path)
47
48
  ensure
49
+ # One record per git-info wave, and only for the thread that owns the
50
+ # computation: `ms` is the wall time of the whole concurrent fan-out.
51
+ # Each git subprocess inside it is recorded separately by ProcessRunner.
52
+ AuditLog.record(:git_wave,
53
+ ms: ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000).round)
48
54
  done = GIT_INFO_MUTEX.synchronize { @git_info_flights.delete(repo_path) }
49
55
  done&.close
50
56
  end
@@ -165,10 +165,16 @@ module Mbeditor
165
165
  MUTEX = Mutex.new
166
166
  private_constant :MUTEX
167
167
 
168
+ # ponytail: one shared V8 context behind one global lock, so concurrent
169
+ # saves queue; a check skipped under contention is advisory-only. Hold a
170
+ # small pool of contexts if skips ever become common.
171
+ LOCK_WAIT_SECONDS = 0.1
172
+
168
173
  class << self
169
174
  def available?
170
175
  return false if Mbeditor.configuration.js_syntax_check == false
171
176
  return false unless defined?(::MiniRacer)
177
+ return false if @context == :broken
172
178
 
173
179
  !babel_source_path.nil?
174
180
  end
@@ -178,7 +184,7 @@ module Mbeditor
178
184
  def check(source)
179
185
  return nil unless available?
180
186
 
181
- MUTEX.synchronize do
187
+ with_lock(nil) do
182
188
  begin
183
189
  ctx = context
184
190
  return nil unless ctx
@@ -224,7 +230,7 @@ module Mbeditor
224
230
  program = JsProgramService.call(workspace_root.to_s)
225
231
  globals = JsGlobalsService.call(workspace_root.to_s)
226
232
 
227
- MUTEX.synchronize do
233
+ with_lock([]) do
228
234
  begin
229
235
  ctx = context
230
236
  return [] unless ctx
@@ -257,27 +263,47 @@ module Mbeditor
257
263
 
258
264
  private
259
265
 
260
- def context
261
- @context ||= begin
262
- path = babel_source_path
263
- return nil unless path
264
-
265
- ctx = ::MiniRacer::Context.new(timeout: EVAL_TIMEOUT_MS)
266
- ctx.eval(File.read(path))
267
- ctx.eval("if (typeof Babel === 'undefined') { throw new Error('Babel global missing'); }")
268
- @checks_run = 0
269
- ctx
270
- rescue StandardError
271
- @context = nil
272
- nil
266
+ # Skipping the lock costs a lint run, never correctness.
267
+ def with_lock(unavailable)
268
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + LOCK_WAIT_SECONDS
269
+ until MUTEX.try_lock
270
+ return unavailable if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
271
+
272
+ sleep 0.005
273
273
  end
274
+
275
+ begin
276
+ yield
277
+ ensure
278
+ MUTEX.unlock
279
+ end
280
+ end
281
+
282
+ def context
283
+ return nil if @context == :broken
284
+ return @context if @context
285
+
286
+ path = babel_source_path
287
+ return nil unless path
288
+
289
+ ctx = ::MiniRacer::Context.new(timeout: EVAL_TIMEOUT_MS)
290
+ ctx.eval(File.read(path))
291
+ ctx.eval("if (typeof Babel === 'undefined') { throw new Error('Babel global missing'); }")
292
+ @checks_run = 0
293
+ @context = ctx
294
+ rescue StandardError => e
295
+ # A bundle that will not evaluate will not evaluate on the next save
296
+ # either; re-reading multi-MB of babel per save is pure waste.
297
+ @context = :broken
298
+ Rails.logger&.warn("[mbeditor] babel asset #{path.inspect} failed to load, JS syntax check disabled: #{e.message}")
299
+ nil
274
300
  end
275
301
 
276
302
  def reset_context!
277
303
  # Dropping the reference leaks the V8 isolate until the GC finalizer
278
304
  # runs; dispose releases it now.
279
305
  begin
280
- @context&.dispose
306
+ @context.dispose if @context.respond_to?(:dispose)
281
307
  rescue StandardError
282
308
  nil
283
309
  end
@@ -0,0 +1,137 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tempfile"
4
+ require "tmpdir"
5
+ require "json"
6
+
7
+ module Mbeditor
8
+ # The module CONTEXT.md's glossary already names: mbeditor's linting
9
+ # toolchain, rubocop (diagnostics via --stdin; autocorrect via a
10
+ # workspace-local tempfile so rubocop's config discovery finds the host
11
+ # app's .rubocop.yml) and haml-lint (diagnostics only). Every subprocess
12
+ # runs through ProcessRunner.
13
+ #
14
+ # Diagnostics come back Monaco-marker-shaped (startLine/copName/…) rather
15
+ # than as a neutral intermediate — matching LspDiagnosticsTranslator, the
16
+ # sibling module for ruby-lsp diagnostics, which does the same. Client-side
17
+ # JS linting (JsSyntaxCheckService) and the Monaco-edit shaping in
18
+ # EditorsController#compute_text_edit are presentation-layer concerns and
19
+ # stay out of this module.
20
+ module LintService
21
+ module_function
22
+
23
+ def rubocop_diagnostics(workspace_root, path, code)
24
+ cmd = AvailabilityProbe.rubocop_command(workspace_root) +
25
+ [AvailabilityProbe.rubocop_server_flag(workspace_root), "--stdin", path.to_s, "--format", "json", "--no-color"]
26
+ output = run(cmd, env: rubocop_env, stdin_data: code)[:stdout]
27
+
28
+ idx = output.index("{")
29
+ result = idx ? JSON.parse(output[idx..]) : {}
30
+ result = {} unless result.is_a?(Hash)
31
+ offenses = result.dig("files", 0, "offenses") || []
32
+
33
+ markers = offenses.map do |offense|
34
+ {
35
+ severity: cop_severity(offense["severity"]),
36
+ copName: offense["cop_name"],
37
+ correctable: offense["correctable"] == true,
38
+ message: "[#{offense['cop_name']}] #{offense['message']}",
39
+ startLine: offense.dig("location", "start_line") || offense.dig("location", "line"),
40
+ startCol: offense.dig("location", "start_column") || offense.dig("location", "column") || 1,
41
+ endLine: offense.dig("location", "last_line") || offense.dig("location", "line"),
42
+ endCol: offense.dig("location", "last_column") || offense.dig("location", "column") || 1,
43
+ # Same predicate the ruby-lsp path uses, so dead code fades whichever
44
+ # linter produced the offense. Plain rubocop JSON carries no
45
+ # code_description, so there's no codeHref to pass on here.
46
+ unnecessary: LspDiagnosticsTranslator.unnecessary?(offense["cop_name"])
47
+ }
48
+ end
49
+
50
+ { markers: markers, summary: result["summary"] }
51
+ end
52
+
53
+ def haml_diagnostics(workspace_root, code)
54
+ markers = []
55
+ Tempfile.create(["mbeditor_haml", ".haml"]) do |f|
56
+ f.write(code)
57
+ f.flush
58
+ cmd = AvailabilityProbe.haml_lint_command(workspace_root) + ["--reporter", "json", "--no-color", f.path]
59
+ output = run(cmd)[:stdout]
60
+ idx = output.index("{")
61
+ result = idx ? JSON.parse(output[idx..]) : {}
62
+ result = {} unless result.is_a?(Hash)
63
+ offenses = result.dig("files", 0, "offenses") || []
64
+ markers = offenses.map do |offense|
65
+ {
66
+ severity: haml_lint_severity(offense["severity"]),
67
+ message: "[#{offense['linter_name']}] #{offense['message']}",
68
+ startLine: offense.dig("location", "line"),
69
+ startCol: (offense.dig("location", "column") || 1) - 1,
70
+ endLine: offense.dig("location", "line"),
71
+ endCol: offense.dig("location", "column") || 1
72
+ }
73
+ end
74
+ end
75
+ markers
76
+ end
77
+
78
+ # Runs a full `rubocop -A` pass on +code+ (not the file on disk) via a
79
+ # workspace-local tempfile, and reports whether the pass completed.
80
+ # +ok: false+ means rubocop itself failed (exit status neither 0 nor 1),
81
+ # not that no offense was found — callers that care about an actual diff
82
+ # compare +content+ against the code they passed in.
83
+ def autocorrect(workspace_root, path, code)
84
+ ext = File.extname(File.basename(path))
85
+ Tempfile.create([".mbeditor_autocorrect_", ext], File.dirname(path)) do |f|
86
+ f.write(code)
87
+ f.flush
88
+ tmpfile = f.path
89
+
90
+ cmd = AvailabilityProbe.rubocop_command(workspace_root) +
91
+ [AvailabilityProbe.rubocop_server_flag(workspace_root), "-A", "--no-color", tmpfile]
92
+ status = run(cmd, env: rubocop_env)[:exit_status]
93
+
94
+ # exit 0 = no offenses, exit 1 = offenses corrected, exit 2 = error
95
+ next { ok: false, content: code } unless status.success? || status.exitstatus == 1
96
+
97
+ corrected = File.read(tmpfile, encoding: "UTF-8", invalid: :replace, undef: :replace)
98
+ { ok: true, content: corrected }
99
+ end
100
+ end
101
+
102
+ # Kept in step with LspDiagnosticsTranslator::SEVERITIES so a file linted
103
+ # through ruby-lsp and the same file linted through `rubocop --stdin` grade
104
+ # their offenses identically. rubocop's own `info` is the weakest level and
105
+ # maps to hint; convention/refactor fall through to info.
106
+ def cop_severity(severity)
107
+ case severity
108
+ when "error", "fatal" then "error"
109
+ when "warning" then "warning"
110
+ when "info" then "hint"
111
+ else "info"
112
+ end
113
+ end
114
+
115
+ def haml_lint_severity(severity)
116
+ case severity
117
+ when "error" then "error"
118
+ when "warning" then "warning"
119
+ else "info"
120
+ end
121
+ end
122
+
123
+ def rubocop_env
124
+ { "RUBOCOP_CACHE_ROOT" => File.join(Dir.tmpdir, "rubocop") }
125
+ end
126
+
127
+ # Same lint_timeout ceiling for every subprocess this module runs.
128
+ # quick_fix, format_file and haml-lint used to spawn with Open3.capture3
129
+ # and no timeout at all, so a wedged rubocop held a request thread until
130
+ # the client gave up.
131
+ def run(cmd, env: {}, stdin_data: nil)
132
+ timeout_seconds = Mbeditor.configuration.lint_timeout&.to_i
133
+ timeout = timeout_seconds && timeout_seconds > 0 ? timeout_seconds : nil
134
+ ProcessRunner.call(cmd, timeout: timeout, env: env, stdin_data: stdin_data)
135
+ end
136
+ end
137
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mbeditor
4
+ # Sidecar-lock + atomic-write primitive for a single JSON file on disk.
5
+ # Extracted from EditorStateService so FileHistoryService could stop
6
+ # hand-rolling its own flock loop and, worse, writing in place with
7
+ # truncate/rewind — a crash mid-write there truncated history instead of
8
+ # leaving the previous file untouched.
9
+ class LockedJsonFile
10
+ LockTimeoutError = Class.new(StandardError)
11
+ LOCK_RETRY_INTERVAL = 0.01
12
+
13
+ def initialize(path, lock_timeout:, error_class: LockTimeoutError)
14
+ @path = path
15
+ @lock_timeout = lock_timeout
16
+ @error_class = error_class
17
+ end
18
+
19
+ # Readers take no lock at all: every write lands by rename, so a read sees
20
+ # either the whole previous file or the whole new one — never the empty
21
+ # window a truncate-then-write leaves, which readers would otherwise have
22
+ # to swallow as {}.
23
+ def read
24
+ return {} unless File.exist?(@path)
25
+
26
+ raw = File.read(@path)
27
+ raw.empty? ? {} : JSON.parse(raw)
28
+ end
29
+
30
+ # The file is replaced by rename, not edited in place, so a crash mid-write
31
+ # leaves the previous contents intact rather than a truncated file.
32
+ def write(payload)
33
+ FileUtils.mkdir_p(File.dirname(@path))
34
+ tmp = "#{@path}.tmp"
35
+ File.write(tmp, payload.is_a?(String) ? payload : payload.to_json)
36
+ File.rename(tmp, @path)
37
+ end
38
+
39
+ # The lock sits on a sidecar file, not on the data file itself: the data
40
+ # file is replaced by rename, so a lock held on the inode it had before
41
+ # the write would exclude nobody afterwards.
42
+ #
43
+ # Acquires the lock without blocking forever: a blocking acquire would let
44
+ # a single stuck holder (e.g. a request paused at a breakpoint mid-write)
45
+ # wedge every later writer indefinitely, so this retries a non-blocking
46
+ # flock with a bounded deadline and raises rather than hanging the worker.
47
+ def with_lock
48
+ FileUtils.mkdir_p(File.dirname(@path))
49
+ File.open("#{@path}.lock", File::RDWR | File::CREAT) do |f|
50
+ lock_exclusive!(f)
51
+ yield
52
+ end
53
+ end
54
+
55
+ private
56
+
57
+ def lock_exclusive!(file)
58
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + @lock_timeout
59
+ until file.flock(File::LOCK_EX | File::LOCK_NB)
60
+ if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
61
+ raise @error_class, "could not acquire lock within #{@lock_timeout}s"
62
+ end
63
+ sleep LOCK_RETRY_INTERVAL
64
+ end
65
+ end
66
+ end
67
+ end
@@ -11,10 +11,34 @@ module Mbeditor
11
11
  CHUNK_BYTES = 64 * 1024
12
12
  private_constant :CHUNK_BYTES
13
13
 
14
+ # Known subprocess executables, by basename, mapped to a fixed audit Symbol.
15
+ # Anything unlisted — including a host-configured command like
16
+ # `rubocop_command` — is :other. Interning the executable string instead
17
+ # would launder host data through the audit log's Symbol-only guard.
18
+ TOOL_SYMBOLS = {
19
+ "git" => :git,
20
+ "rg" => :rg,
21
+ "grep" => :grep,
22
+ "rubocop" => :rubocop,
23
+ "haml-lint" => :haml_lint,
24
+ "haml_lint" => :haml_lint,
25
+ "rspec" => :rspec,
26
+ "rails" => :rails,
27
+ "bundle" => :bundle,
28
+ "ruby" => :ruby
29
+ }.freeze
30
+ private_constant :TOOL_SYMBOLS
31
+
32
+ def tool_symbol(cmd)
33
+ TOOL_SYMBOLS[File.basename(Array(cmd).first.to_s)] || :other
34
+ end
35
+ private_class_method :tool_symbol
36
+
14
37
  # +max_bytes+ bounds how much of each stream is kept in memory (nil =
15
38
  # unbounded). Anything past the cap is still read and discarded — stopping
16
39
  # would block the child on a full pipe and hang the wait below.
17
40
  def call(cmd, timeout: nil, env: {}, stdin_data: nil, chdir: nil, max_bytes: nil)
41
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
18
42
  out = +""
19
43
  err = +""
20
44
  exit_status = nil
@@ -61,6 +85,14 @@ module Mbeditor
61
85
  raise TimeoutError, "process timed out after #{timeout}s" if timed_out
62
86
 
63
87
  { stdout: out, stderr: err, exit_status: exit_status }
88
+ ensure
89
+ # One record per spawn: `tool` is the mapped Symbol for a known
90
+ # executable (never the raw command), `ms` wall time, `status` a
91
+ # code-authored outcome — never the process's own output.
92
+ AuditLog.record(:subprocess,
93
+ tool: tool_symbol(cmd),
94
+ ms: ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000).round,
95
+ status: timed_out ? :timeout : (exit_status&.success? ? :ok : :error))
64
96
  end
65
97
 
66
98
  def read_capped(io, max_bytes)