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, }, }; 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.", }, ], }; 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, }, }, }, { 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", ], }, }, ];