Class: Mbeditor::JsSyntaxCheckService
- Inherits:
-
Object
- Object
- Mbeditor::JsSyntaxCheckService
- 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
- .available? ⇒ Boolean
-
.check(source) ⇒ Object
Returns nil when the source parses cleanly (or the checker is unavailable/broken), else { "message" =>, "line" =>, "column" => }.
-
.reset! ⇒ Object
Exposed for tests.
-
.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.
Class Method Details
.available? ⇒ 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 |