import babelParser from "@babel/eslint-parser"; import eslint from "@eslint/js"; import reactHooks from "eslint-plugin-react-hooks"; import globals from "globals"; import type { Rule } from "eslint"; const sourceExtensions = "{ts,tsx,mts,cts}"; const layerPatterns = { domain: [ "**/application/**", "**/presentation/**", "**/adapters/**", "**/bootstrap/**", "react", "react-dom", "@tanstack/**", ], application: [ "**/presentation/**", "**/adapters/**", "**/bootstrap/**", "react", "react-dom", "@tanstack/**", ], presentation: [ "**/adapters/**", "**/bootstrap/**", "**/application/ports/out/**", "@tanstack/**", ], adapters: ["**/presentation/**", "**/bootstrap/**"], }; function restrictedImports(patterns: readonly string[]) { return ["error", { patterns }]; } const restrictedBrowserDataProperties = [ "error", ...(["globalThis", "window", "self"] as const).flatMap((object) => [ "BroadcastChannel", "localStorage", "sessionStorage", "indexedDB", "caches", "navigator", "File", "Blob", "FileSystemFileHandle", "FileSystemDirectoryHandle", "showOpenFilePicker", "showSaveFilePicker", "URL", ].map((property) => ({ object, property, message: "Browser file/storage APIs are adapter-owned; use the policy port.", })), ), { object: "navigator", property: "storage", message: "StorageManager and OPFS access are adapter-owned.", }, { object: "URL", property: "createObjectURL", message: "Object URL allocation is owned by the preview/download adapter.", }, { object: "URL", property: "revokeObjectURL", message: "Object URL revocation is owned by the preview/download adapter.", }, ] as const; const browserCapabilityRoots = new Set([ "globalThis", "window", "self", "navigator", "URL", ]); const browserCapabilityProperties = new Set([ "BroadcastChannel", "localStorage", "sessionStorage", "indexedDB", "caches", "navigator", "storage", "File", "Blob", "FileSystemFileHandle", "FileSystemDirectoryHandle", "showOpenFilePicker", "showSaveFilePicker", "URL", "createObjectURL", "revokeObjectURL", ]); /** * no-restricted-properties only recognizes the literal object identifier. * This rule follows local aliases of browser roots so `const host = * globalThis; host.indexedDB` cannot bypass the owned-adapter boundary. */ const noBrowserCapabilityAliasRule: Rule.RuleModule = { meta: { type: "problem", schema: [], messages: { owned: "Browser file/storage capabilities are owned by the approved common adapters.", }, }, create(context) { const aliases = new Set(); const unwrap = (input: any): any => { let node = input; while ( node && [ "ChainExpression", "TSAsExpression", "TSNonNullExpression", "TSTypeAssertion", ].includes(node.type) ) { node = node.expression; } return node; }; const propertyName = (input: any): string | null => { const node = unwrap(input); if (!node) return null; if (!node.computed && node.property?.type === "Identifier") { return node.property.name; } if ( node.computed && node.property?.type === "Literal" && typeof node.property.value === "string" ) { return node.property.value; } if ( node.computed && node.property?.type === "StringLiteral" ) { return node.property.value; } return null; }; const isRootAlias = (input: any): boolean => { const node = unwrap(input); if (!node) return false; if (node.type === "Identifier") { return browserCapabilityRoots.has(node.name) || aliases.has(node.name); } if (node.type === "MemberExpression") { return isRootAlias(node.object); } if (node.type === "LogicalExpression") { return isRootAlias(node.left) || isRootAlias(node.right); } if (node.type === "ConditionalExpression") { return ( isRootAlias(node.consequent) || isRootAlias(node.alternate) ); } if (node.type === "SequenceExpression") { return node.expressions.some(isRootAlias); } return false; }; const isBareRootAlias = (input: any): boolean => { const node = unwrap(input); if (!node) return false; if (node.type === "Identifier") { return browserCapabilityRoots.has(node.name) || aliases.has(node.name); } if (node.type === "LogicalExpression") { return ( isBareRootAlias(node.left) || isBareRootAlias(node.right) ); } if (node.type === "ConditionalExpression") { return ( isBareRootAlias(node.consequent) || isBareRootAlias(node.alternate) ); } if (node.type === "SequenceExpression") { return node.expressions.some(isBareRootAlias); } return false; }; const isDirectBrowserRoot = (input: any): boolean => { const node = unwrap(input); return ( node?.type === "Identifier" && browserCapabilityRoots.has(node.name) ); }; const safeMethodThisBinding = ( call: any, argumentIndex: number, ): boolean => { if (argumentIndex !== 0) return false; const callee = unwrap(call.callee); if ( callee?.type !== "MemberExpression" || propertyName(callee) !== "bind" ) { return false; } const target = unwrap(callee.object); const targetName = target?.type === "MemberExpression" ? propertyName(target) : null; return ( targetName !== null && !browserCapabilityProperties.has(targetName) && isRootAlias(target.object) ); }; const rememberPattern = (patternInput: any, source: any): void => { const pattern = unwrap(patternInput); if (!pattern || !isRootAlias(source)) return; if (pattern.type === "Identifier") { aliases.add(pattern.name); return; } if (pattern.type !== "ObjectPattern") return; for (const property of pattern.properties ?? []) { if (property.type !== "Property") continue; const name = property.computed ? property.key?.value : property.key?.name ?? property.key?.value; if ( typeof name === "string" && browserCapabilityProperties.has(name) ) { context.report({ node: property, messageId: "owned" }); } } }; return { VariableDeclarator(node: any) { rememberPattern(node.id, node.init); }, AssignmentExpression(node: any) { rememberPattern(node.left, node.right); const target = unwrap(node.left); if ( target?.type !== "Identifier" && target?.type !== "ObjectPattern" && isBareRootAlias(node.right) ) { context.report({ node: node.right, messageId: "owned" }); } }, AssignmentPattern(node: any) { rememberPattern(node.left, node.right); }, PropertyDefinition(node: any) { if (isBareRootAlias(node.value)) { context.report({ node: node.value, messageId: "owned" }); } }, MemberExpression(node: any) { const name = propertyName(node); if ( isRootAlias(node.object) && ((name !== null && browserCapabilityProperties.has(name)) || (name === null && node.computed)) ) { context.report({ node, messageId: "owned" }); } }, CallExpression(node: any) { for (const [index, argument] of ( node.arguments ?? [] ).entries()) { const candidate = argument.type === "SpreadElement" ? argument.argument : argument; if ( isDirectBrowserRoot(candidate) && !safeMethodThisBinding(node, index) ) { context.report({ node: argument, messageId: "owned" }); } } const callee = unwrap(node.callee); if ( callee?.type !== "MemberExpression" || propertyName(callee) !== "get" || unwrap(callee.object)?.type !== "Identifier" || unwrap(callee.object).name !== "Reflect" || !isRootAlias(node.arguments?.[0]) ) { return; } const key = unwrap(node.arguments?.[1]); if ( (key?.type === "Literal" || key?.type === "StringLiteral") && typeof key.value === "string" && browserCapabilityProperties.has(key.value) ) { context.report({ node, messageId: "owned" }); } }, NewExpression(node: any) { for (const argument of node.arguments ?? []) { const candidate = argument.type === "SpreadElement" ? argument.argument : argument; if (isBareRootAlias(candidate)) { context.report({ node: argument, messageId: "owned" }); } } }, Property(node: any) { if ( node.parent?.type === "ObjectExpression" && isBareRootAlias(node.value) ) { context.report({ node: node.value, messageId: "owned" }); } }, ArrayExpression(node: any) { for (const element of node.elements ?? []) { if (isBareRootAlias(element)) { context.report({ node: element, messageId: "owned" }); } } }, ReturnStatement(node: any) { if (isBareRootAlias(node.argument)) { context.report({ node: node.argument, messageId: "owned" }); } }, ArrowFunctionExpression(node: any) { if ( node.expression === true && isBareRootAlias(node.body) ) { context.report({ node: node.body, messageId: "owned" }); } }, }; }, }; const browserDataBoundaryPlugin = { rules: { "no-capability-alias": noBrowserCapabilityAliasRule, }, }; /** * A DOM element rendered by React carries `__reactFiber$*` / `__reactProps$*` * as *own enumerable* properties. `node:assert` builds its `AssertionError` * eagerly, running `util.inspect` over both operands with `depth: 1000`, * `getters: true` and `maxArrayLength: Infinity`; the fiber graph re-expands * once per traversal path, so inspecting one rendered element allocates * gigabytes and the worker dies before any `AssertionError` is ever thrown. * A genuine regression then reports as an OOM or an opaque timeout instead of * a failed assertion. Measured on this repo's Asset Library heading: * depth 6 = 1.3MB, depth 8 = 7.8MB, depth 10 = 36MB, depth 12 = 135MB. * * Equality assertions only inspect their operands on failure, so an unsafe * comparison stays invisible while green and detonates the day the behaviour * it guards regresses -- which is exactly when the diagnosis is needed. * * `expect` is not affected: vitest prints and diffs through pretty-format's * DOM plugin, which reads tag/attributes/children and never touches the fiber. * So the safe form is always an `expect` matcher -- `toHaveFocus()`, * `not.toBeInTheDocument()`, `toBe(element)` -- and this rule only forbids * handing a DOM element to `node:assert`. */ const TESTING_LIBRARY_QUERY = /^(get|query|find)(All)?By(Role|Text|LabelText|PlaceholderText|AltText|Title|DisplayValue|TestId)$/u; const domQueryMethods = new Set([ "querySelector", "querySelectorAll", "getElementById", "closest", ]); const domElementProperties = new Set([ "activeElement", "parentElement", "firstElementChild", "lastElementChild", "nextElementSibling", "previousElementSibling", "offsetParent", ]); // Fail when the operands *differ*, so on failure at least one element is // still there to be inspected. const positiveAssertEqualities = new Set([ "equal", "strictEqual", "deepEqual", "deepStrictEqual", ]); // Fail when the operands *match*. `assert.notEqual(element, null)` can only // fail with `null` on both sides, so a nullish literal operand makes these // safe; anything else leaves an element to inspect. const negativeAssertEqualities = new Set([ "notEqual", "notStrictEqual", "notDeepEqual", "notDeepStrictEqual", ]); const noElementOperandEqualityRule: Rule.RuleModule = { meta: { type: "problem", schema: [], messages: { unbounded: "node:assert inspects both operands at depth 1000 to build its failure message, and a React-rendered element's __reactFiber$* graph exhausts the worker heap there, so the regression reports as an OOM instead of an assertion. Use an expect matcher instead -- expect(el).toHaveFocus(), expect(el).not.toBeInTheDocument(), expect(actual).toBe(expected) -- which prints DOM nodes through pretty-format's DOM plugin.", }, }, create(context) { const sourceCode = context.sourceCode; const unwrap = (input: any): any => { let node = input; while ( node && [ "AwaitExpression", "ChainExpression", "TSAsExpression", "TSNonNullExpression", "TSSatisfiesExpression", "TSTypeAssertion", ].includes(node.type) ) { node = node.type === "AwaitExpression" ? node.argument : node.expression; } return node; }; const memberName = (node: any): string | null => { if (!node.computed && node.property?.type === "Identifier") { return node.property.name; } if ( node.computed && (node.property?.type === "Literal" || node.property?.type === "StringLiteral") && typeof node.property.value === "string" ) { return node.property.value; } return null; }; const resolveInit = (node: any): any => { const scope = sourceCode.getScope(node); let current: any = scope; while (current) { const variable = current.variables.find( (entry: any) => entry.name === node.name, ); if (variable) { const definition = variable.defs.at(-1); return definition?.node?.type === "VariableDeclarator" ? definition.node.init : null; } current = current.upper; } return null; }; const isElementValued = (input: any, seen = new Set()): boolean => { const node = unwrap(input); if (!node || seen.has(node)) return false; seen.add(node); if (node.type === "MemberExpression") { const name = memberName(node); return name !== null && domElementProperties.has(name); } if (node.type === "CallExpression") { const callee = unwrap(node.callee); if (callee?.type !== "MemberExpression") return false; const name = memberName(callee); return ( name !== null && (TESTING_LIBRARY_QUERY.test(name) || domQueryMethods.has(name)) ); } if (node.type === "Identifier") { return isElementValued(resolveInit(node), seen); } if (node.type === "ConditionalExpression") { return ( isElementValued(node.consequent, seen) || isElementValued(node.alternate, seen) ); } return false; }; const isNullish = (input: any): boolean => { const node = unwrap(input); if (!node) return false; return ( (node.type === "Literal" && node.value === null) || (node.type === "Identifier" && node.name === "undefined") ); }; return { CallExpression(node: any) { const callee = unwrap(node.callee); if (callee?.type !== "MemberExpression") return; const object = unwrap(callee.object); if (object?.type !== "Identifier" || object.name !== "assert") return; const name = memberName(callee); if (name === null) return; const positive = positiveAssertEqualities.has(name); if (!positive && !negativeAssertEqualities.has(name)) return; const operands = (node.arguments ?? []).slice(0, 2); if (!positive && operands.some((argument: any) => isNullish(argument))) { return; } const operand = operands.find((argument: any) => isElementValued(argument), ); if (operand) context.report({ node: operand, messageId: "unbounded" }); }, }; }, }; const testAssertionBoundaryPlugin = { rules: { "no-element-operand-equality": noElementOperandEqualityRule, }, }; const commonLanguageOptions = { ecmaVersion: "latest", sourceType: "module", globals: { ...globals.browser, ...globals.node, }, }; const commonSecurityRules = { "no-eval": "error", "no-new-func": "error", "no-script-url": "error", "no-restricted-syntax": [ "error", { selector: "JSXAttribute[name.name='dangerouslySetInnerHTML']", message: "Raw HTML injection is prohibited by FE-OC-019.", }, { selector: "CallExpression[callee.object.name='document'][callee.property.name='createElement'][arguments.0.value='script']", message: "Runtime script construction is prohibited by FE-OC-019.", }, { /* setState 업데이터는 핸들러가 끝난 뒤, 다음 렌더에 실행된다. 그때 React 는 이미 `event.currentTarget` 을 null 로 되돌려 놓았으므로 업데이터 안에서 그것을 읽으면 "Cannot read properties of null" 로 화면이 통째로 죽는다. 타입 검사도 lint 도 잡지 못했고, 첫 입력에서야 드러났다 — 값은 핸들러가 도는 동안 지역 변수로 꺼내 두고 업데이터에는 그 값을 넘긴다. */ selector: "CallExpression[callee.name=/^set[A-Z]/] > ArrowFunctionExpression MemberExpression[property.name='currentTarget']", message: "Read event.currentTarget before the setState updater runs — it is null by the time the updater is called.", }, ], }; const hookRules = { "react-hooks/rules-of-hooks": "error", "react-hooks/exhaustive-deps": "error", }; export default [ { ignores: [ "dist/**", "node_modules/**", "artifacts/**", "tests/fixtures/typecheck/**", "tests/fixtures/architecture/forbidden/**", "tests/fixtures/diagnostics/forbidden/**", "tests/fixtures/i18n/forbidden/**", "tests/fixtures/security/forbidden/**", "tests/fixtures/optional-recipes/**", "tests/fixtures/browser-file-storage-boundaries/**", ], }, eslint.configs.recommended, { files: [`**/*.${sourceExtensions}`], languageOptions: { ...commonLanguageOptions, parserOptions: { ecmaFeatures: { jsx: true }, }, }, plugins: { "react-hooks": reactHooks, }, rules: { ...commonSecurityRules, ...hookRules, }, }, { files: ["**/*.{ts,mts,cts}"], languageOptions: { ...commonLanguageOptions, parser: babelParser, parserOptions: { requireConfigFile: false, babelOptions: { plugins: [ ["@babel/plugin-syntax-typescript", { isTSX: false }], ], }, }, }, rules: { "no-undef": "off", "no-unused-vars": "off", }, }, { files: ["**/*.d.ts"], languageOptions: { ...commonLanguageOptions, parser: babelParser, parserOptions: { requireConfigFile: false, babelOptions: { plugins: [ ["@babel/plugin-syntax-typescript", { dts: true }], ], }, }, }, rules: { "no-undef": "off", "no-unused-vars": "off", }, }, { files: ["**/*.tsx"], languageOptions: { ...commonLanguageOptions, parser: babelParser, parserOptions: { requireConfigFile: false, babelOptions: { plugins: [ [ "@babel/plugin-syntax-typescript", { allExtensions: true, isTSX: true }, ], "@babel/plugin-syntax-jsx", ], }, }, }, rules: { "no-undef": "off", "no-unused-vars": "off", }, }, { files: [ `src/domain/**/*.${sourceExtensions}`, `src/application/**/*.${sourceExtensions}`, `src/presentation/**/*.${sourceExtensions}`, `src/bootstrap/**/*.${sourceExtensions}`, `src/features/**/*.${sourceExtensions}`, `src/adapters/**/*.${sourceExtensions}`, ], ignores: [ `src/adapters/browser-file-storage/**/*.${sourceExtensions}`, `src/adapters/browser-files/**/*.${sourceExtensions}`, `src/adapters/browser-transfer/**/*.${sourceExtensions}`, `src/adapters/cache-storage/**/*.${sourceExtensions}`, `src/adapters/cross-context-invalidation/**/*.${sourceExtensions}`, `src/adapters/service-worker/**/*.${sourceExtensions}`, `src/bootstrap/register-service-worker.ts`, `src/adapters/platform/browser-lifecycle.ts`, `src/adapters/storage/**/*.${sourceExtensions}`, `src/adapters/storage/indexeddb/**/*.${sourceExtensions}`, `src/adapters/storage/opfs/**/*.${sourceExtensions}`, ], plugins: { "browser-data-boundary": browserDataBoundaryPlugin, }, rules: { "browser-data-boundary/no-capability-alias": "error", }, }, { files: [`src/domain/**/*.${sourceExtensions}`], rules: { "no-restricted-imports": restrictedImports(layerPatterns.domain), "no-restricted-properties": restrictedBrowserDataProperties, "no-restricted-globals": [ "error", "window", "document", "BroadcastChannel", "localStorage", "sessionStorage", "fetch", "indexedDB", "caches", "navigator", "File", "Blob", "FileSystemFileHandle", "FileSystemDirectoryHandle", "showOpenFilePicker", "showSaveFilePicker", ], }, }, { files: [`src/application/**/*.${sourceExtensions}`], rules: { "no-restricted-imports": restrictedImports(layerPatterns.application), "no-restricted-properties": restrictedBrowserDataProperties, "no-restricted-globals": [ "error", "window", "document", "BroadcastChannel", "localStorage", "sessionStorage", "fetch", "indexedDB", "caches", "navigator", "File", "Blob", "FileSystemFileHandle", "FileSystemDirectoryHandle", "showOpenFilePicker", "showSaveFilePicker", ], }, }, { files: [`src/presentation/**/*.${sourceExtensions}`], rules: { "no-restricted-imports": restrictedImports(layerPatterns.presentation), "no-restricted-properties": restrictedBrowserDataProperties, "no-restricted-globals": [ "error", "fetch", "BroadcastChannel", "localStorage", "sessionStorage", "indexedDB", "caches", "File", "Blob", "FileSystemFileHandle", "FileSystemDirectoryHandle", "showOpenFilePicker", "showSaveFilePicker", ], }, }, { files: [ `src/presentation/adapters/query/**/*.${sourceExtensions}`, ], rules: { "no-restricted-imports": restrictedImports([ "**/adapters/http/**", "**/adapters/storage/**", "**/adapters/auth/**", "**/bootstrap/**", "**/application/ports/out/**", ]), }, }, { files: [`src/presentation/templates/**/*.${sourceExtensions}`], rules: { "no-restricted-imports": restrictedImports([ "**/application/**", "**/adapters/**", "**/bootstrap/**", "@tanstack/**", ]), }, }, { files: [ `src/bootstrap/**/*.${sourceExtensions}`, `src/features/installed-feature-*.${sourceExtensions}`, ], rules: { "no-restricted-properties": restrictedBrowserDataProperties, "no-restricted-globals": [ "error", "BroadcastChannel", "localStorage", "sessionStorage", "indexedDB", "caches", "File", "Blob", "FileSystemFileHandle", "FileSystemDirectoryHandle", "showOpenFilePicker", "showSaveFilePicker", ], }, }, { files: [`src/adapters/**/*.${sourceExtensions}`], rules: { "no-restricted-imports": restrictedImports(layerPatterns.adapters), "no-restricted-properties": restrictedBrowserDataProperties, "no-restricted-globals": [ "error", "BroadcastChannel", "localStorage", "sessionStorage", "indexedDB", "caches", "navigator", "File", "Blob", "FileSystemFileHandle", "FileSystemDirectoryHandle", "showOpenFilePicker", "showSaveFilePicker", ], }, }, { files: [ `src/adapters/browser-file-storage/**/*.${sourceExtensions}`, `src/adapters/browser-files/**/*.${sourceExtensions}`, `src/adapters/browser-transfer/**/*.${sourceExtensions}`, `src/adapters/cache-storage/**/*.${sourceExtensions}`, `src/adapters/cross-context-invalidation/**/*.${sourceExtensions}`, `src/adapters/service-worker/**/*.${sourceExtensions}`, `src/bootstrap/register-service-worker.ts`, `src/adapters/platform/browser-lifecycle.ts`, `src/adapters/storage/**/*.${sourceExtensions}`, `src/adapters/storage/indexeddb/**/*.${sourceExtensions}`, `src/adapters/storage/opfs/**/*.${sourceExtensions}`, ], rules: { "no-restricted-properties": "off", "no-restricted-globals": "off", }, }, { files: [`src/features/*/domain/**/*.${sourceExtensions}`], rules: { "no-restricted-imports": restrictedImports(layerPatterns.domain), "no-restricted-properties": restrictedBrowserDataProperties, "no-restricted-globals": [ "error", "window", "document", "BroadcastChannel", "localStorage", "sessionStorage", "fetch", "indexedDB", "caches", "navigator", "File", "Blob", "FileSystemFileHandle", "FileSystemDirectoryHandle", "showOpenFilePicker", "showSaveFilePicker", ], }, }, { files: [`src/features/*/application/**/*.${sourceExtensions}`], rules: { "no-restricted-imports": restrictedImports(layerPatterns.application), "no-restricted-properties": restrictedBrowserDataProperties, "no-restricted-globals": [ "error", "window", "document", "BroadcastChannel", "localStorage", "sessionStorage", "fetch", "indexedDB", "caches", "navigator", "File", "Blob", "FileSystemFileHandle", "FileSystemDirectoryHandle", "showOpenFilePicker", "showSaveFilePicker", ], }, }, { files: [`src/features/*/presentation/**/*.${sourceExtensions}`], rules: { "no-restricted-imports": restrictedImports([ "**/features/*/adapters/**", "**/adapters/http/**", "**/adapters/storage/**", "**/adapters/auth/**", "**/bootstrap/**", "**/application/ports/out/**", "@tanstack/**", ]), "no-restricted-properties": restrictedBrowserDataProperties, "no-restricted-globals": [ "error", "fetch", "BroadcastChannel", "localStorage", "sessionStorage", "indexedDB", "caches", "File", "Blob", "FileSystemFileHandle", "FileSystemDirectoryHandle", "showOpenFilePicker", "showSaveFilePicker", ], }, }, { files: [`src/features/*/adapters/**/*.${sourceExtensions}`], rules: { "no-restricted-imports": restrictedImports([ "**/presentation/**", "**/bootstrap/**", "@tanstack/**", ]), "no-restricted-properties": restrictedBrowserDataProperties, "no-restricted-globals": [ "error", "BroadcastChannel", "localStorage", "sessionStorage", "indexedDB", "caches", "navigator", "File", "Blob", "FileSystemFileHandle", "FileSystemDirectoryHandle", "showOpenFilePicker", "showSaveFilePicker", ], }, }, { files: [`tests/**/*.${sourceExtensions}`], languageOptions: { globals: { ...globals.browser, ...globals.node, }, }, plugins: { "test-assertion-boundary": testAssertionBoundaryPlugin, }, rules: { "test-assertion-boundary/no-element-operand-equality": "error", }, }, { files: [`tests/support/browser/**/*.${sourceExtensions}`], rules: { "react-hooks/rules-of-hooks": "off", }, }, { files: [ `tests/fixtures/architecture/forbidden/**/*.${sourceExtensions}`, ], rules: { "no-restricted-imports": restrictedImports([ "**/adapters/**", "**/application/ports/out/**", "@tanstack/**", "react", "react-dom", "**/application/**", ]), "no-restricted-globals": [ "error", "fetch", "localStorage", "sessionStorage", ], }, }, { files: [ `tests/fixtures/browser-file-storage-boundaries/forbidden/**/*.${sourceExtensions}`, ], plugins: { "browser-data-boundary": browserDataBoundaryPlugin, }, rules: { "browser-data-boundary/no-capability-alias": "error", "no-restricted-properties": restrictedBrowserDataProperties, "no-restricted-globals": [ "error", "BroadcastChannel", "localStorage", "sessionStorage", "indexedDB", "caches", "File", "Blob", "FileSystemFileHandle", "FileSystemDirectoryHandle", "showOpenFilePicker", "showSaveFilePicker", ], }, }, ];