프로젝트 편집 화면에서 활동 유형이나 제목을 한 글자 치면 화면이 통째로 죽었다 — "Cannot read properties of null (reading 'value')". setState 업데이터는 핸들러가 끝난 뒤 다음 렌더에 실행된다. 그때 React 는 이미 `event.currentTarget` 을 null 로 되돌려 놓았으므로, 업데이터 안에서 그것을 읽으면 반드시 터진다. 타입 검사도 lint 도 이것을 잡지 못했고, 첫 입력에서야 드러났다. 값은 핸들러가 도는 동안 지역 변수로 꺼내 두고 업데이터에는 그 값을 넘긴다. 같은 실수를 다시 못 하도록 lint 규칙을 세운다: `setXxx(...)` 에 곧바로 넘기는 화살표 함수 안에서는 `currentTarget` 을 읽을 수 없다. 처음 쓴 선택자는 동기 핸들러까지 잡아 (map 콜백, 즉시 호출되는 update 등 아홉 자리) 너무 넓었으므로, 실제로 위험한 setter 업데이터만 겨냥하도록 좁혔다. 결함을 되돌려 규칙이 잡는 것을 확인했다. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XEHXspz4rv5pB5wiiSsVDu
1077 lines
30 KiB
TypeScript
1077 lines
30 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,
|
|
},
|
|
};
|
|
|
|
/**
|
|
* 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<any>()): 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",
|
|
],
|
|
},
|
|
},
|
|
];
|