Class: Mbeditor::JsSyntaxCheckService

Inherits:
Object
  • Object
show all
Defined in:
app/services/mbeditor/js_syntax_check_service.rb

Overview

Save-time JS/JSX syntax check using the HOST app's own toolchain: runs babel-standalone inside a MiniRacer (V8) context — the same transform that sprockets/react-rails will apply — so code that would break the host's asset pipeline is caught in the editor instead of at page load.

Zero new dependencies (ADR-0001): active only when the host already has mini_racer loaded AND a babel-standalone asset can be found. Degrades to a no-op otherwise.

Constant Summary collapse

EVAL_TIMEOUT_MS =
2_000
MAX_CHECKS_PER_CONTEXT =

V8 heap grows across transforms; recreate the context periodically.

50
BABEL_ASSET_CANDIDATES =
%w[babel.min.js babel.js babel-standalone.js babel-standalone.min.js].freeze
MAX_SCOPE_FINDINGS =
50
BROWSER_GLOBALS =

Names defined by the browser rather than by any workspace file. ES builtins (Array, Promise, ...) are NOT listed: babel's own Scope#hasBinding already knows them, so this only needs the DOM layer.

%w[
  window document navigator location history screen console
  alert confirm prompt getComputedStyle matchMedia scrollTo scrollBy
  innerWidth innerHeight devicePixelRatio
  fetch Headers Request Response XMLHttpRequest WebSocket EventSource
  FormData URL URLSearchParams Blob File FileReader FileList DataTransfer
  AbortController AbortSignal DOMParser XMLSerializer
  setTimeout setInterval clearTimeout clearInterval
  requestAnimationFrame cancelAnimationFrame requestIdleCallback cancelIdleCallback
  queueMicrotask structuredClone atob btoa
  localStorage sessionStorage indexedDB crypto performance
  Event CustomEvent KeyboardEvent MouseEvent TouchEvent ErrorEvent
  MessageEvent PopStateEvent StorageEvent ProgressEvent ClipboardEvent
  MutationObserver ResizeObserver IntersectionObserver
  Node NodeList Element HTMLElement SVGElement Image Audio Option
  CSS customElements
].freeze
REACT_GLOBALS =

The React UMD globals plus the bare hook aliases host apps conventionally pull out of React at the top of a Sprockets bundle.

%w[
  React ReactDOM PropTypes
  useState useEffect useLayoutEffect useRef useMemo useCallback useContext
  useReducer useId useTransition useDeferredValue useSyncExternalStore
  useImperativeHandle useDebugValue
].freeze
LINT_HELPERS_JS =

Installed into the V8 context alongside babel-standalone. collect() returns a file's top-level declaration names (Sprockets concatenates every file into one scope, so these are the cross-file globals). lint() reports references babel can bind to no scope and no whitelist entry, plus bindings that are only ever assigned inside a useEffect/useLayoutEffect callback but read during render.

<<~'JS'
  (function () {
    if (globalThis.__mbLint) return;
    if (typeof Babel === "undefined" || !Babel.packages || !Babel.packages.parser || !Babel.packages.traverse) return;
    var parser = Babel.packages.parser;
    var traverse = Babel.packages.traverse["default"] || Babel.packages.traverse;

    function parse(source) {
      return parser.parse(source, { sourceType: "script", plugins: ["jsx"], errorRecovery: true });
    }

    function bindNames(node, out) {
      if (!node) return;
      switch (node.type) {
        case "Identifier": out.push(node.name); break;
        case "ObjectPattern": node.properties.forEach(function (p) { bindNames(p.value || p.argument, out); }); break;
        case "ArrayPattern": node.elements.forEach(function (el) { bindNames(el, out); }); break;
        case "AssignmentPattern": bindNames(node.left, out); break;
        case "RestElement": bindNames(node.argument, out); break;
      }
    }

    // Is this path inside the callback argument of useEffect/useLayoutEffect?
    function insideEffectCallback(path) {
      var fn = path.getFunctionParent();
      while (fn) {
        var parent = fn.parentPath;
        if (parent && parent.isCallExpression() && parent.node.arguments[0] === fn.node) {
          var callee = parent.node.callee;
          var name = callee.type === "Identifier" ? callee.name
            : (callee.type === "MemberExpression" && !callee.computed && callee.property.type === "Identifier"
                ? callee.property.name : null);
          if (name === "useEffect" || name === "useLayoutEffect") return true;
        }
        fn = fn.getFunctionParent();
      }
      return false;
    }

    globalThis.__mbLint = {
      collect: function (source) {
        var names = [];
        var ast;
        try { ast = parse(source); } catch (e) { return names; }
        ast.program.body.forEach(function (node) {
          if (node.type === "VariableDeclaration") {
            node.declarations.forEach(function (d) { bindNames(d.id, names); });
          } else if (node.type === "FunctionDeclaration" || node.type === "ClassDeclaration") {
            if (node.id) names.push(node.id.name);
          }
        });
        return names;
      },

      lint: function (source, whitelist, max) {
        var findings = [];
        var wl = {};
        whitelist.forEach(function (n) { wl[n] = true; });
        var ast;
        try { ast = parse(source); } catch (e) { return findings; }
        var seen = {};

        try {
          traverse(ast, {
            ReferencedIdentifier: function (p) {
              if (findings.length >= max) { p.stop(); return; }
              var name = p.node.name;
              if (p.isJSXIdentifier() && !/^[A-Z]/.test(name)) return; // <div>, <span>
              if (wl[name]) return;
              if (p.scope.hasBinding(name)) return; // includes ES builtins
              if (p.parentPath && p.parentPath.isUnaryExpression({ operator: "typeof" })) return;
              var loc = p.node.loc && p.node.loc.start;
              var key = name + ":" + (loc ? loc.line : 0);
              if (seen[key]) return;
              seen[key] = true;
              findings.push({
                kind: "undeclared", name: name,
                line: loc ? loc.line : 1, column: loc ? loc.column : 0,
                message: "'" + name + "' is not defined in any reachable scope"
              });
            },

            Function: function (p) {
              if (findings.length >= max) { p.stop(); return; }
              var bindings = p.scope.bindings;
              Object.keys(bindings).forEach(function (name) {
                var b = bindings[name];
                if (b.kind !== "let" && b.kind !== "var") return;
                if (!b.path.isVariableDeclarator() || b.path.node.init) return;
                var writes = b.constantViolations || [];
                if (!writes.length) return;
                if (!writes.every(insideEffectCallback)) return;
                var renderReads = (b.referencePaths || []).filter(function (r) { return !insideEffectCallback(r); });
                if (!renderReads.length) return;
                var loc = renderReads[0].node.loc && renderReads[0].node.loc.start;
                findings.push({
                  kind: "effect", name: name,
                  line: loc ? loc.line : 1, column: loc ? loc.column : 0,
                  message: "'" + name + "' is only assigned inside an effect but read during render — undefined on first render"
                });
              });
            }
          });
        } catch (e) { /* traversal blew up on odd input — report what we have */ }
        return findings;
      }
    };
  })();
JS

Class Method Summary collapse

Class Method Details

.available?Boolean

Returns:

  • (Boolean)


169
170
171
172
173
174
# File 'app/services/mbeditor/js_syntax_check_service.rb', line 169

def available?
  return false if Mbeditor.configuration.js_syntax_check == false
  return false unless defined?(::MiniRacer)

  !babel_source_path.nil?
end

.check(source) ⇒ Object

Returns nil when the source parses cleanly (or the checker is unavailable/broken), else { "message" =>, "line" =>, "column" => }.



178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
# File 'app/services/mbeditor/js_syntax_check_service.rb', line 178

def check(source)
  return nil unless available?

  MUTEX.synchronize do
    begin
      ctx = context
      return nil unless ctx

      result = ctx.eval(<<~JS)
        (function () {
          try {
            Babel.transform(#{source.to_json}, { presets: ["react"], code: false });
            return null;
          } catch (e) {
            return {
              message: String((e && e.message) || e).split("\\n")[0],
              line: (e && e.loc && e.loc.line) || null,
              column: (e && e.loc && e.loc.column) || null
            };
          }
        })()
      JS

      @checks_run = (@checks_run || 0) + 1
      reset_context! if @checks_run >= MAX_CHECKS_PER_CONTEXT
      result
    rescue StandardError
      # Timeout / V8 OOM / eval failure: recreate on next use, report clean.
      reset_context!
      nil
    end
  end
end

.reset!Object

Exposed for tests.



244
245
246
247
248
249
250
# File 'app/services/mbeditor/js_syntax_check_service.rb', line 244

def reset!
  MUTEX.synchronize do
    reset_context!
    @babel_path = :unresolved
    @decl_cache = nil
  end
end

.scope_lint(workspace_root, source) ⇒ Object

Babel-based scope lint: warnings for identifier references that bind to no scope, no top-level declaration anywhere in the workspace's own JS (Sprockets: one shared scope), no known window.X global, and no browser/React name — the typos Monaco's TS worker misses once ambient globals are declared. Plus the effect-write/render-read hazard. Report-only; returns [] whenever anything is unavailable or fails.



218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
# File 'app/services/mbeditor/js_syntax_check_service.rb', line 218

def scope_lint(workspace_root, source)
  return [] unless available? && Mbeditor.configuration.js_scope_lint != false

  MUTEX.synchronize do
    begin
      ctx = context
      return [] unless ctx

      ctx.eval(LINT_HELPERS_JS) unless @lint_helpers_loaded
      @lint_helpers_loaded = true
      return [] unless ctx.eval("typeof __mbLint !== 'undefined'")

      names = whitelist(ctx, workspace_root)
      findings = ctx.eval("__mbLint.lint(#{source.to_json}, #{names.to_json}, #{MAX_SCOPE_FINDINGS})")

      @checks_run = (@checks_run || 0) + 1
      reset_context! if @checks_run >= MAX_CHECKS_PER_CONTEXT
      Array(findings).select { |f| f.is_a?(Hash) }
    rescue StandardError
      reset_context!
      []
    end
  end
end