Files
2026-08-01 19:39:59 +09:00

869 lines
23 KiB
TypeScript

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<string>();
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",
],
},
},
];