mbeditor 0.12.7 → 0.12.9

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.
@@ -14,6 +14,153 @@ module Mbeditor
14
14
  # V8 heap grows across transforms; recreate the context periodically.
15
15
  MAX_CHECKS_PER_CONTEXT = 50
16
16
  BABEL_ASSET_CANDIDATES = %w[babel.min.js babel.js babel-standalone.js babel-standalone.min.js].freeze
17
+ MAX_SCOPE_FINDINGS = 50
18
+
19
+ # Names defined by the browser rather than by any workspace file. ES
20
+ # builtins (Array, Promise, ...) are NOT listed: babel's own
21
+ # Scope#hasBinding already knows them, so this only needs the DOM layer.
22
+ BROWSER_GLOBALS = %w[
23
+ window document navigator location history screen console
24
+ alert confirm prompt getComputedStyle matchMedia scrollTo scrollBy
25
+ innerWidth innerHeight devicePixelRatio
26
+ fetch Headers Request Response XMLHttpRequest WebSocket EventSource
27
+ FormData URL URLSearchParams Blob File FileReader FileList DataTransfer
28
+ AbortController AbortSignal DOMParser XMLSerializer
29
+ setTimeout setInterval clearTimeout clearInterval
30
+ requestAnimationFrame cancelAnimationFrame requestIdleCallback cancelIdleCallback
31
+ queueMicrotask structuredClone atob btoa
32
+ localStorage sessionStorage indexedDB crypto performance
33
+ Event CustomEvent KeyboardEvent MouseEvent TouchEvent ErrorEvent
34
+ MessageEvent PopStateEvent StorageEvent ProgressEvent ClipboardEvent
35
+ MutationObserver ResizeObserver IntersectionObserver
36
+ Node NodeList Element HTMLElement SVGElement Image Audio Option
37
+ CSS customElements
38
+ ].freeze
39
+
40
+ # The React UMD globals plus the bare hook aliases host apps conventionally
41
+ # pull out of React at the top of a Sprockets bundle.
42
+ REACT_GLOBALS = %w[
43
+ React ReactDOM PropTypes
44
+ useState useEffect useLayoutEffect useRef useMemo useCallback useContext
45
+ useReducer useId useTransition useDeferredValue useSyncExternalStore
46
+ useImperativeHandle useDebugValue
47
+ ].freeze
48
+
49
+ # Installed into the V8 context alongside babel-standalone. collect()
50
+ # returns a file's top-level declaration names (Sprockets concatenates
51
+ # every file into one scope, so these are the cross-file globals). lint()
52
+ # reports references babel can bind to no scope and no whitelist entry,
53
+ # plus bindings that are only ever assigned inside a
54
+ # useEffect/useLayoutEffect callback but read during render.
55
+ LINT_HELPERS_JS = <<~'JS'
56
+ (function () {
57
+ if (globalThis.__mbLint) return;
58
+ if (typeof Babel === "undefined" || !Babel.packages || !Babel.packages.parser || !Babel.packages.traverse) return;
59
+ var parser = Babel.packages.parser;
60
+ var traverse = Babel.packages.traverse["default"] || Babel.packages.traverse;
61
+
62
+ function parse(source) {
63
+ return parser.parse(source, { sourceType: "script", plugins: ["jsx"], errorRecovery: true });
64
+ }
65
+
66
+ function bindNames(node, out) {
67
+ if (!node) return;
68
+ switch (node.type) {
69
+ case "Identifier": out.push(node.name); break;
70
+ case "ObjectPattern": node.properties.forEach(function (p) { bindNames(p.value || p.argument, out); }); break;
71
+ case "ArrayPattern": node.elements.forEach(function (el) { bindNames(el, out); }); break;
72
+ case "AssignmentPattern": bindNames(node.left, out); break;
73
+ case "RestElement": bindNames(node.argument, out); break;
74
+ }
75
+ }
76
+
77
+ // Is this path inside the callback argument of useEffect/useLayoutEffect?
78
+ function insideEffectCallback(path) {
79
+ var fn = path.getFunctionParent();
80
+ while (fn) {
81
+ var parent = fn.parentPath;
82
+ if (parent && parent.isCallExpression() && parent.node.arguments[0] === fn.node) {
83
+ var callee = parent.node.callee;
84
+ var name = callee.type === "Identifier" ? callee.name
85
+ : (callee.type === "MemberExpression" && !callee.computed && callee.property.type === "Identifier"
86
+ ? callee.property.name : null);
87
+ if (name === "useEffect" || name === "useLayoutEffect") return true;
88
+ }
89
+ fn = fn.getFunctionParent();
90
+ }
91
+ return false;
92
+ }
93
+
94
+ globalThis.__mbLint = {
95
+ collect: function (source) {
96
+ var names = [];
97
+ var ast;
98
+ try { ast = parse(source); } catch (e) { return names; }
99
+ ast.program.body.forEach(function (node) {
100
+ if (node.type === "VariableDeclaration") {
101
+ node.declarations.forEach(function (d) { bindNames(d.id, names); });
102
+ } else if (node.type === "FunctionDeclaration" || node.type === "ClassDeclaration") {
103
+ if (node.id) names.push(node.id.name);
104
+ }
105
+ });
106
+ return names;
107
+ },
108
+
109
+ lint: function (source, whitelist, max) {
110
+ var findings = [];
111
+ var wl = {};
112
+ whitelist.forEach(function (n) { wl[n] = true; });
113
+ var ast;
114
+ try { ast = parse(source); } catch (e) { return findings; }
115
+ var seen = {};
116
+
117
+ try {
118
+ traverse(ast, {
119
+ ReferencedIdentifier: function (p) {
120
+ if (findings.length >= max) { p.stop(); return; }
121
+ var name = p.node.name;
122
+ if (p.isJSXIdentifier() && !/^[A-Z]/.test(name)) return; // <div>, <span>
123
+ if (wl[name]) return;
124
+ if (p.scope.hasBinding(name)) return; // includes ES builtins
125
+ if (p.parentPath && p.parentPath.isUnaryExpression({ operator: "typeof" })) return;
126
+ var loc = p.node.loc && p.node.loc.start;
127
+ var key = name + ":" + (loc ? loc.line : 0);
128
+ if (seen[key]) return;
129
+ seen[key] = true;
130
+ findings.push({
131
+ kind: "undeclared", name: name,
132
+ line: loc ? loc.line : 1, column: loc ? loc.column : 0,
133
+ message: "'" + name + "' is not defined in any reachable scope"
134
+ });
135
+ },
136
+
137
+ Function: function (p) {
138
+ if (findings.length >= max) { p.stop(); return; }
139
+ var bindings = p.scope.bindings;
140
+ Object.keys(bindings).forEach(function (name) {
141
+ var b = bindings[name];
142
+ if (b.kind !== "let" && b.kind !== "var") return;
143
+ if (!b.path.isVariableDeclarator() || b.path.node.init) return;
144
+ var writes = b.constantViolations || [];
145
+ if (!writes.length) return;
146
+ if (!writes.every(insideEffectCallback)) return;
147
+ var renderReads = (b.referencePaths || []).filter(function (r) { return !insideEffectCallback(r); });
148
+ if (!renderReads.length) return;
149
+ var loc = renderReads[0].node.loc && renderReads[0].node.loc.start;
150
+ findings.push({
151
+ kind: "effect", name: name,
152
+ line: loc ? loc.line : 1, column: loc ? loc.column : 0,
153
+ message: "'" + name + "' is only assigned inside an effect but read during render — undefined on first render"
154
+ });
155
+ });
156
+ }
157
+ });
158
+ } catch (e) { /* traversal blew up on odd input — report what we have */ }
159
+ return findings;
160
+ }
161
+ };
162
+ })();
163
+ JS
17
164
 
18
165
  MUTEX = Mutex.new
19
166
  private_constant :MUTEX
@@ -62,11 +209,43 @@ module Mbeditor
62
209
  end
63
210
  end
64
211
 
212
+ # Babel-based scope lint: warnings for identifier references that bind to
213
+ # no scope, no top-level declaration anywhere in the workspace's own JS
214
+ # (Sprockets: one shared scope), no known window.X global, and no
215
+ # browser/React name — the typos Monaco's TS worker misses once ambient
216
+ # globals are declared. Plus the effect-write/render-read hazard.
217
+ # Report-only; returns [] whenever anything is unavailable or fails.
218
+ def scope_lint(workspace_root, source)
219
+ return [] unless available? && Mbeditor.configuration.js_scope_lint != false
220
+
221
+ MUTEX.synchronize do
222
+ begin
223
+ ctx = context
224
+ return [] unless ctx
225
+
226
+ ctx.eval(LINT_HELPERS_JS) unless @lint_helpers_loaded
227
+ @lint_helpers_loaded = true
228
+ return [] unless ctx.eval("typeof __mbLint !== 'undefined'")
229
+
230
+ names = whitelist(ctx, workspace_root)
231
+ findings = ctx.eval("__mbLint.lint(#{source.to_json}, #{names.to_json}, #{MAX_SCOPE_FINDINGS})")
232
+
233
+ @checks_run = (@checks_run || 0) + 1
234
+ reset_context! if @checks_run >= MAX_CHECKS_PER_CONTEXT
235
+ Array(findings).select { |f| f.is_a?(Hash) }
236
+ rescue StandardError
237
+ reset_context!
238
+ []
239
+ end
240
+ end
241
+ end
242
+
65
243
  # Exposed for tests.
66
244
  def reset!
67
245
  MUTEX.synchronize do
68
246
  reset_context!
69
247
  @babel_path = :unresolved
248
+ @decl_cache = nil
70
249
  end
71
250
  end
72
251
 
@@ -91,6 +270,39 @@ module Mbeditor
91
270
  def reset_context!
92
271
  @context = nil
93
272
  @checks_run = 0
273
+ @lint_helpers_loaded = false
274
+ end
275
+
276
+ # Cross-file whitelist: every top-level declaration in the workspace's
277
+ # own JS program, every window.X-style global JsGlobalsService knows,
278
+ # plus the browser and React layers. Per-file declaration names are
279
+ # cached by content digest, so a steady-state save re-parses only the
280
+ # files that changed since the last lint.
281
+ def whitelist(ctx, workspace_root)
282
+ names = []
283
+ @decl_cache ||= {}
284
+ live = {}
285
+
286
+ program = JsProgramService.call(workspace_root.to_s)
287
+ Array(program[:files]).each do |f|
288
+ digest = f[:content].hash
289
+ entry = @decl_cache[f[:path]]
290
+ entry = { digest: digest, names: collect_names(ctx, f[:content]) } unless entry && entry[:digest] == digest
291
+ live[f[:path]] = entry
292
+ names.concat(entry[:names])
293
+ end
294
+ @decl_cache = live
295
+
296
+ globals = JsGlobalsService.call(workspace_root.to_s)
297
+ names.concat(Array(globals[:symbols]).map { |s| s[:name].to_s })
298
+
299
+ (names + BROWSER_GLOBALS + REACT_GLOBALS).uniq
300
+ end
301
+
302
+ def collect_names(ctx, source)
303
+ Array(ctx.eval("__mbLint.collect(#{source.to_json})")).grep(String)
304
+ rescue StandardError
305
+ []
94
306
  end
95
307
 
96
308
  def babel_source_path
@@ -229,11 +229,20 @@ module Mbeditor
229
229
  end
230
230
  end
231
231
 
232
+ # Used to locate the match within each hit line so a result can carry
233
+ # its columns. Invalid user regexes are already reported elsewhere;
234
+ # here a nil pattern just means the rows come back without columns.
235
+ pattern = begin
236
+ build_pattern(query, use_regex: use_regex, match_case: match_case, whole_word: whole_word)
237
+ rescue RegexpError
238
+ nil
239
+ end
240
+
232
241
  begin
233
242
  io.each_line do |raw|
234
243
  break if results.length >= max
235
244
 
236
- row = parse_line(tier, raw, root)
245
+ row = parse_line(tier, raw, root, pattern)
237
246
  next unless row
238
247
  next if matcher.excluded?(row[:file])
239
248
 
@@ -297,9 +306,11 @@ module Mbeditor
297
306
  # Exclusions have to reach git as pathspecs, not just be dropped from
298
307
  # the results by the matcher below: otherwise git walks node_modules
299
308
  # and every other excluded tree in full before we discard the matches.
300
- # A pathspec list of nothing but :(exclude) entries means
301
- # "everything except these", which is exactly what the unscoped
302
- # search wants.
309
+ # The "." anchor is required: newer git reads a pathspec list of
310
+ # nothing but :(exclude) entries as "everything except these", but
311
+ # older git refuses it outright ("fatal: There is nothing to exclude
312
+ # from"), exits 128, and search silently returns empty.
313
+ args << "." if paths.nil? && exclusions.any?
303
314
  args += exclusions.map { |p| ":(exclude)#{p}" }
304
315
  # No LC_ALL=C: measured neutral for the -F -i default and 2.2x slower
305
316
  # for -E, and the UTF-8 locale case-folds non-ASCII correctly.
@@ -334,7 +345,7 @@ module Mbeditor
334
345
  end
335
346
  end
336
347
 
337
- def parse_line(tier, raw, root)
348
+ def parse_line(tier, raw, root, pattern = nil)
338
349
  if tier == :rg
339
350
  begin
340
351
  data = JSON.parse(raw)
@@ -344,11 +355,23 @@ module Mbeditor
344
355
  return nil unless data["type"] == "match"
345
356
 
346
357
  md = data["data"]
358
+ raw_text = md.dig("lines", "text").to_s
359
+ # rg reports submatch offsets in BYTES; Monaco columns are character
360
+ # based, so slice the prefix and measure it as characters.
361
+ sub = Array(md["submatches"]).first
362
+ cols = if sub && sub["start"] && sub["end"]
363
+ bytes = raw_text.dup.force_encoding(Encoding::BINARY)
364
+ start_chars = bytes[0, sub["start"]].to_s.force_encoding(Encoding::UTF_8).scrub.length
365
+ match_chars = bytes[sub["start"], sub["end"] - sub["start"]].to_s.force_encoding(Encoding::UTF_8).scrub.length
366
+ { col: start_chars + 1, end_col: start_chars + match_chars + 1 }
367
+ else
368
+ match_columns(raw_text, pattern)
369
+ end
347
370
  return {
348
371
  file: relative_path(md.dig("path", "text").to_s, root),
349
372
  line: md.dig("line_number"),
350
- text: md.dig("lines", "text").to_s.strip
351
- }
373
+ text: raw_text.strip
374
+ }.merge(cols)
352
375
  end
353
376
 
354
377
  # git grep / grep emit "path:line:text" — possibly with bytes that are
@@ -365,7 +388,25 @@ module Mbeditor
365
388
  file_path = relative_path(file_path, root)
366
389
  end
367
390
 
368
- { file: file_path, line: Regexp.last_match(2).to_i, text: Regexp.last_match(3).strip }
391
+ raw_text = Regexp.last_match(3)
392
+ { file: file_path, line: Regexp.last_match(2).to_i, text: raw_text.strip }
393
+ .merge(match_columns(raw_text, pattern))
394
+ end
395
+
396
+ # 1-based Monaco columns for the first match on a hit line. Returns an
397
+ # empty hash when there is no usable pattern or it doesn't match — the
398
+ # row is still a valid result, it just opens at the start of the line.
399
+ # Measured against the RAW line, never the stripped `text`: the client
400
+ # cannot recover the leading whitespace the strip removed.
401
+ def match_columns(raw_text, pattern)
402
+ return {} unless pattern
403
+
404
+ m = pattern.match(raw_text)
405
+ return {} unless m
406
+
407
+ { col: m.begin(0) + 1, end_col: m.end(0) + 1 }
408
+ rescue StandardError
409
+ {}
369
410
  end
370
411
 
371
412
  def register_search(root, pid)
@@ -10,7 +10,7 @@ module Mbeditor
10
10
  :ruby_def_include_dirs, :related_files_custom_paths,
11
11
  :mount_path, :resilient_routing, :js_global_identifiers,
12
12
  :js_program, :js_program_exclude,
13
- :js_syntax_check, :babel_standalone_path,
13
+ :js_syntax_check, :babel_standalone_path, :js_scope_lint,
14
14
  :ruby_lsp, :ruby_lsp_command, :ruby_lsp_timeout,
15
15
  :exception_capture, :model_graph_max_models,
16
16
  :search_respect_gitignore, :ripgrep_command
@@ -84,6 +84,7 @@ module Mbeditor
84
84
  # third-party or generated JS here, e.g. "app/assets/javascripts/react".
85
85
  @js_program_exclude = %w[vendor]
86
86
  @js_syntax_check = :auto # save-time babel parse check via host mini_racer + babel-standalone; false disables
87
+ @js_scope_lint = true # save-time undeclared-identifier warnings (needs js_syntax_check active); false disables
87
88
  @babel_standalone_path = nil # explicit path to babel-standalone JS; nil auto-detects via the asset pipeline
88
89
  @ruby_lsp = :auto # use the host's ruby-lsp for Ruby definitions/hover/completion when available; false disables
89
90
  @ruby_lsp_command = nil # override the ruby-lsp launch command (String or Array); nil auto-resolves bin/ruby-lsp > gem > bundle exec
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Mbeditor
4
- VERSION = "0.12.7"
4
+ VERSION = "0.12.9"
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: mbeditor
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.12.7
4
+ version: 0.12.9
5
5
  platform: ruby
6
6
  authors:
7
7
  - Oliver Noonan
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-08-06 00:00:00.000000000 Z
11
+ date: 2026-08-10 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails