From 6afe931d01579a54c8ed8288da89b6756395e864 Mon Sep 17 00:00:00 2001 From: murashit Date: Wed, 24 Jun 2026 21:02:31 +0900 Subject: [PATCH] Consolidate ESLint source policy checks --- eslint.config.mjs | 383 ++++++++++++++++++--- scripts/check-plan.mjs | 2 +- scripts/lint/eslint-plugin-codex-panel.mjs | 380 -------------------- tests/scripts/check-plan.test.mjs | 4 +- tests/scripts/eslint-config.test.ts | 241 ------------- 5 files changed, 344 insertions(+), 666 deletions(-) delete mode 100644 scripts/lint/eslint-plugin-codex-panel.mjs delete mode 100644 tests/scripts/eslint-config.test.ts diff --git a/eslint.config.mjs b/eslint.config.mjs index 46628dd2..5cb6e7e1 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -1,18 +1,22 @@ import { defineConfig } from "eslint/config"; import obsidianmd from "eslint-plugin-obsidianmd"; +import ts from "typescript"; import tseslint from "typescript-eslint"; -import codexPanelEslintPlugin from "./scripts/lint/eslint-plugin-codex-panel.mjs"; -const typeScriptFiles = ["src/**/*.{ts,tsx}", "tests/**/*.{ts,tsx}"]; -const nodeJavaScriptFiles = ["*.mjs", "scripts/**/*.mjs", "tests/**/*.mjs"]; -const typeScriptConfigFiles = ["*.config.ts"]; -const lintedTypeScriptFiles = [...typeScriptFiles, ...typeScriptConfigFiles]; +const sourceTypeScriptFiles = ["src/**/*.{ts,tsx}"]; const unsafeAnyTypeScriptRules = { "@typescript-eslint/no-unsafe-argument": "error", "@typescript-eslint/no-unsafe-assignment": "error", "@typescript-eslint/no-unsafe-call": "error", "@typescript-eslint/no-unsafe-member-access": "error", }; +const codexPanelRuleIds = { + chatStateDirectMutation: "codex-panel/no-chat-state-direct-mutation", + imperativeDom: "codex-panel/no-imperative-dom", +}; +// These local rules need TypeScript type information. Keep them validated by +// eslint over real source files instead of synthetic Vitest fixtures, which +// would start the TypeScript project service during the test suite. const chatExternalDomBridgeFiles = [ "src/features/chat/ui/message-stream/markdown-renderer.ts", "src/features/chat/ui/message-stream/stream-markdown-renderer.ts", @@ -38,17 +42,59 @@ const nonChatImperativeDomBridgeFiles = [ "src/shared/ui/textarea-caret.ts", "src/shared/ui/ui-root.tsx", ]; -function obsidianRecommendedConfig(config) { - const rules = Object.fromEntries(Object.entries(config.rules ?? {}).filter(([ruleName]) => ruleName.startsWith("obsidianmd/"))); - if (Object.keys(rules).length === 0) return null; - const obsidianConfig = { - basePath: "src", - rules, - }; - if (config.files) obsidianConfig.files = config.files; - if (config.ignores) obsidianConfig.ignores = config.ignores; - return obsidianConfig; -} +const imperativeDomWriteMethods = new Set([ + "addClass", + "addClasses", + "append", + "appendChild", + "after", + "before", + "createDiv", + "createEl", + "createSpan", + "empty", + "hide", + "insertAdjacentElement", + "insertAdjacentHTML", + "insertAdjacentText", + "insertBefore", + "prepend", + "removeClass", + "removeClasses", + "remove", + "removeChild", + "replaceChildren", + "replaceWith", + "setCssProps", + "setCssStyles", + "setText", + "show", + "setAttr", + "toggleClass", +]); +const imperativeDomEventMethods = new Set(["addEventListener", "removeEventListener"]); +const imperativeDomAssignmentProperties = new Set([ + "checked", + "innerHTML", + "onblur", + "onchange", + "onclick", + "ondblclick", + "onfocus", + "oninput", + "onkeydown", + "onkeyup", + "onmousedown", + "onmousemove", + "onmouseup", + "onpointerdown", + "onpointerup", + "onscroll", + "onselect", + "outerHTML", + "textContent", + "value", +]); export default defineConfig([ { @@ -62,7 +108,7 @@ export default defineConfig([ }, ...obsidianmd.configs.recommended.map(obsidianRecommendedConfig).filter(Boolean), { - files: lintedTypeScriptFiles, + files: sourceTypeScriptFiles, plugins: { "@typescript-eslint": tseslint.plugin, }, @@ -71,8 +117,8 @@ export default defineConfig([ ecmaVersion: 2022, sourceType: "module", parserOptions: { - projectService: true, tsconfigRootDir: import.meta.dirname, + projectService: true, }, globals: { AbortSignal: "readonly", @@ -87,26 +133,12 @@ export default defineConfig([ setTimeout: "readonly", }, }, - rules: { - ...unsafeAnyTypeScriptRules, - }, + rules: unsafeAnyTypeScriptRules, }, { - files: nodeJavaScriptFiles, - languageOptions: { - ecmaVersion: 2022, - sourceType: "module", - globals: { - URL: "readonly", - console: "readonly", - process: "readonly", - }, - }, - }, - { - files: ["src/**/*.ts", "src/**/*.tsx"], + files: sourceTypeScriptFiles, plugins: { - "codex-panel": codexPanelEslintPlugin, + "codex-panel": codexPanelEslintPlugin(), obsidianmd, }, rules: { @@ -120,31 +152,298 @@ export default defineConfig([ }, }, { - files: ["src/**/*.{ts,tsx}"], + files: sourceTypeScriptFiles, ignores: ["src/features/chat/**/*.{ts,tsx}", ...nonChatImperativeDomBridgeFiles], rules: { - "codex-panel/no-imperative-dom": "error", + [codexPanelRuleIds.imperativeDom]: "error", }, }, { files: ["src/features/chat/**/*.{ts,tsx}"], ignores: ["src/features/chat/panel/shell-state.tsx", ...chatImperativeDomBridgeFiles], rules: { - "codex-panel/no-imperative-dom": "error", - "codex-panel/no-chat-state-direct-mutation": "error", + [codexPanelRuleIds.imperativeDom]: "error", + [codexPanelRuleIds.chatStateDirectMutation]: "error", }, }, { files: ["src/features/chat/panel/shell-state.tsx"], rules: { - "codex-panel/no-imperative-dom": "error", - "codex-panel/no-chat-state-direct-mutation": "error", + [codexPanelRuleIds.imperativeDom]: "error", + [codexPanelRuleIds.chatStateDirectMutation]: "error", }, }, { files: chatImperativeDomBridgeFiles, rules: { - "codex-panel/no-chat-state-direct-mutation": "error", + [codexPanelRuleIds.chatStateDirectMutation]: "error", }, }, ]); + +function obsidianRecommendedConfig(config) { + const rules = Object.fromEntries(Object.entries(config.rules ?? {}).filter(([ruleName]) => ruleName.startsWith("obsidianmd/"))); + if (Object.keys(rules).length === 0) return null; + const obsidianConfig = { + basePath: "src", + rules, + }; + if (config.files) obsidianConfig.files = config.files; + if (config.ignores) obsidianConfig.ignores = config.ignores; + return obsidianConfig; +} + +function codexPanelEslintPlugin() { + return { + rules: { + [localRuleName(codexPanelRuleIds.chatStateDirectMutation)]: chatStateDirectMutationRule(), + [localRuleName(codexPanelRuleIds.imperativeDom)]: imperativeDomRule(), + }, + }; +} + +function localRuleName(ruleId) { + return ruleId.replace("codex-panel/", ""); +} + +function chatStateDirectMutationRule() { + return { + meta: { + type: "problem", + docs: { + description: "Disallow direct ChatState mutation in chat modules.", + }, + messages: { + assign: "Route ChatState updates through ChatStateStore.dispatch().", + mutateCollection: "Clone ChatState collections and update them through ChatStateStore.dispatch().", + }, + schema: [], + }, + create(context) { + const typed = typedContext(context); + const mutatingCollectionMethods = new Set(["add", "clear", "delete", "push", "set"]); + const chatStateAliasVariables = new WeakSet(); + const variableForIdentifier = (node) => { + let scope = context.sourceCode.getScope(node); + while (scope) { + const variable = scope.variables.find((item) => item.name === node.name); + if (variable) return variable; + scope = scope.upper; + } + return null; + }; + const markChatStateAlias = (node) => { + const variable = variableForIdentifier(node); + if (variable) chatStateAliasVariables.add(variable); + }; + const isChatStateAlias = (node) => { + if (node?.type !== "Identifier") return false; + const variable = variableForIdentifier(node); + return Boolean(variable && chatStateAliasVariables.has(variable)); + }; + const isChatStateValue = (node) => typeIncludesChatState(typed.typeAt(node), typed.typeChecker()); + const isChatStateAliasSource = (node) => + (isChatStateTarget(node) || isChatStateValue(node)) && typeCanCarryChatStateMutation(typed.typeAt(node)); + const isChatStateTarget = (node) => { + if (isChatStateAlias(node)) return true; + if (!isMemberExpression(node)) return false; + if (isChatStateMember(node)) return true; + const root = rootMemberObject(node); + if (!root) return false; + if (isChatStateAlias(root)) return true; + return typeIncludesChatState(typed.typeAt(root), typed.typeChecker()); + }; + return { + VariableDeclarator(node) { + if (node.id.type !== "Identifier" || !node.init) return; + if (isChatStateAliasSource(node.init)) markChatStateAlias(node.id); + }, + AssignmentExpression(node) { + if (isMemberExpression(node.left) && isChatStateTarget(node.left)) context.report({ node: node.left, messageId: "assign" }); + }, + CallExpression(node) { + if (!isMemberExpression(node.callee)) return; + const method = staticPropertyName(node.callee.property); + if (!method || !mutatingCollectionMethods.has(method)) return; + if (isChatStateTarget(node.callee.object)) context.report({ node: node.callee, messageId: "mutateCollection" }); + }, + }; + }, + }; +} + +function imperativeDomRule() { + return { + meta: { + type: "problem", + docs: { + description: "Disallow imperative DOM writes and event wiring outside explicit bridge files.", + }, + messages: { + event: "Keep imperative DOM event wiring in an explicit bridge module or Obsidian-owned UI boundary.", + write: "Keep imperative DOM writes in an explicit bridge module or Obsidian-owned UI boundary.", + }, + schema: [], + }, + create(context) { + const typed = typedContext(context); + const isDomTarget = (node) => typeIncludesDom(typed.typeAt(node), typed.typeChecker()); + + return { + AssignmentExpression(node) { + if (!isMemberExpression(node.left)) return; + const property = staticPropertyName(node.left.property); + if (!property || !imperativeDomAssignmentProperties.has(property)) return; + if (isDomTarget(node.left.object)) context.report({ node: node.left, messageId: "write" }); + }, + CallExpression(node) { + if (!isMemberExpression(node.callee)) return; + const method = staticPropertyName(node.callee.property); + if (!method) return; + if (imperativeDomWriteMethods.has(method) && isDomTarget(node.callee.object)) { + context.report({ node: node.callee, messageId: "write" }); + return; + } + if (imperativeDomEventMethods.has(method) && isDomTarget(node.callee.object)) { + context.report({ node: node.callee, messageId: "event" }); + } + }, + }; + }, + }; +} + +function typedContext(context) { + let parserServices = null; + let checker = null; + + const services = () => { + if (!parserServices) { + parserServices = parserServicesFromContext(context); + checker = parserServices.program.getTypeChecker(); + } + return parserServices; + }; + const typeChecker = () => { + services(); + return checker; + }; + + return { + typeChecker, + typeAt(node) { + const tsNode = services().esTreeNodeToTSNodeMap.get(node); + return typeChecker().getTypeAtLocation(tsNode); + }, + }; +} + +function isChatStateMember(node) { + if (!isMemberExpression(node)) return false; + + let current = node; + while (isMemberExpression(current)) { + if (isIdentifier(current.object, "state")) return true; + if (isThisStateMember(current.object)) return true; + current = current.object; + } + return false; +} + +function isThisStateMember(node) { + return isMemberExpression(node) && node.object?.type === "ThisExpression" && staticPropertyName(node.property) === "state"; +} + +function rootMemberObject(node) { + if (!isMemberExpression(node)) return null; + let current = node; + while (isMemberExpression(current)) current = current.object; + return current; +} + +function isMemberExpression(node) { + return node?.type === "MemberExpression"; +} + +function isIdentifier(node, name) { + return node?.type === "Identifier" && node.name === name; +} + +function staticPropertyName(node) { + return node?.type === "Identifier" ? node.name : node?.type === "Literal" && typeof node.value === "string" ? node.value : null; +} + +function parserServicesFromContext(context) { + const services = context.sourceCode.parserServices; + if (!services?.program || !services.esTreeNodeToTSNodeMap) { + throw new Error("codex-panel typed ESLint rules require TypeScript parser services."); + } + return services; +} + +function typeIncludesDom(type, checker, seen = new Set()) { + if (!type || seen.has(type.id)) return false; + seen.add(type.id); + + if (type.flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown | ts.TypeFlags.Never)) return false; + if (type.isUnionOrIntersection()) return type.types.some((item) => typeIncludesDom(item, checker, seen)); + + const typeName = checker.typeToString(type); + if (domTypeName(typeName)) return true; + + const symbolName = type.getSymbol()?.getName() ?? type.aliasSymbol?.getName() ?? ""; + if (domTypeName(symbolName)) return true; + + const apparent = checker.getApparentType(type); + if (apparent !== type && typeIncludesDom(apparent, checker, seen)) return true; + + const bases = typeof type.getBaseTypes === "function" ? (type.getBaseTypes() ?? []) : []; + return bases.some((base) => typeIncludesDom(base, checker, seen)); +} + +function typeIncludesChatState(type, checker, seen = new Set()) { + if (!type || seen.has(type.id)) return false; + seen.add(type.id); + + if (type.flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown | ts.TypeFlags.Never)) return false; + if (type.isUnionOrIntersection()) return type.types.some((item) => typeIncludesChatState(item, checker, seen)); + + const typeName = checker.typeToString(type); + if (chatStateTypeName(typeName)) return true; + + const symbolName = type.getSymbol()?.getName() ?? type.aliasSymbol?.getName() ?? ""; + if (chatStateTypeName(symbolName)) return true; + + const apparent = checker.getApparentType(type); + if (apparent !== type && typeIncludesChatState(apparent, checker, seen)) return true; + + const bases = typeof type.getBaseTypes === "function" ? (type.getBaseTypes() ?? []) : []; + return bases.some((base) => typeIncludesChatState(base, checker, seen)); +} + +function typeCanCarryChatStateMutation(type, seen = new Set()) { + if (!type || seen.has(type.id)) return false; + seen.add(type.id); + + if (type.flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown | ts.TypeFlags.Never)) return false; + if (type.isUnionOrIntersection()) return type.types.some((item) => typeCanCarryChatStateMutation(item, seen)); + + const primitiveLikeFlags = + ts.TypeFlags.StringLike | + ts.TypeFlags.NumberLike | + ts.TypeFlags.BooleanLike | + ts.TypeFlags.BigIntLike | + ts.TypeFlags.ESSymbolLike | + ts.TypeFlags.Null | + ts.TypeFlags.Undefined | + ts.TypeFlags.Void; + return (type.flags & primitiveLikeFlags) === 0; +} + +function domTypeName(name) { + return /\b(?:Document|Element|HTML[A-Za-z]*Element|HTMLElement|Node|SVG[A-Za-z]*Element|SVGElement|Window)\b/.test(name); +} + +function chatStateTypeName(name) { + return /\bChatState\b/.test(name); +} diff --git a/scripts/check-plan.mjs b/scripts/check-plan.mjs index 3abcff00..8162aae4 100644 --- a/scripts/check-plan.mjs +++ b/scripts/check-plan.mjs @@ -37,7 +37,7 @@ function nonBiomeLintCommands({ ciMode, namePrefix = "" }) { return [ { name: `${namePrefix}eslint`, - command: `eslint src tests scripts "*.config.ts" "*.config.mjs" --max-warnings=0${eslintCacheArgs}`, + command: `eslint src --max-warnings=0${eslintCacheArgs}`, }, { name: `${namePrefix}css`, command: 'stylelint "src/**/*.css" --max-warnings=0' }, { name: `${namePrefix}css-usage`, command: "node scripts/lint/check-css-usage.mjs" }, diff --git a/scripts/lint/eslint-plugin-codex-panel.mjs b/scripts/lint/eslint-plugin-codex-panel.mjs deleted file mode 100644 index 919a54c5..00000000 --- a/scripts/lint/eslint-plugin-codex-panel.mjs +++ /dev/null @@ -1,380 +0,0 @@ -import ts from "typescript"; - -const imperativeDomWriteMethods = new Set([ - "addClass", - "addClasses", - "append", - "appendChild", - "after", - "before", - "createDiv", - "createEl", - "createSpan", - "empty", - "hide", - "insertAdjacentElement", - "insertAdjacentHTML", - "insertAdjacentText", - "insertBefore", - "prepend", - "removeClass", - "removeClasses", - "remove", - "removeChild", - "replaceChildren", - "replaceWith", - "setCssProps", - "setCssStyles", - "setText", - "show", - "setAttr", - "toggleClass", -]); -const imperativeDomEventMethods = new Set(["addEventListener", "removeEventListener"]); -const imperativeDomAssignmentProperties = new Set([ - "checked", - "innerHTML", - "onblur", - "onchange", - "onclick", - "ondblclick", - "onfocus", - "oninput", - "onkeydown", - "onkeyup", - "onmousedown", - "onmousemove", - "onmouseup", - "onpointerdown", - "onpointerup", - "onscroll", - "onselect", - "outerHTML", - "textContent", - "value", -]); - -const codexPanelEslintPlugin = { - rules: { - "no-self-referential-initializer-callback": { - meta: { - type: "problem", - docs: { - description: "Disallow callbacks in variable initializers from referencing the variable being initialized.", - }, - messages: { - selfReference: "Avoid referencing '{{name}}' from a callback inside its own initializer; declare it first with an explicit type.", - }, - schema: [], - }, - create(context) { - return { - VariableDeclarator(node) { - if (node.id.type !== "Identifier" || !node.init) return; - if (node.init.type !== "NewExpression") return; - const reference = findInitializerCallbackReference(node.init, node.id.name); - if (reference) context.report({ node: reference, messageId: "selfReference", data: { name: node.id.name } }); - }, - }; - }, - }, - "no-chat-state-direct-mutation": { - meta: { - type: "problem", - docs: { - description: "Disallow direct ChatState mutation in chat modules.", - }, - messages: { - assign: "Route ChatState updates through ChatStateStore.dispatch().", - mutateCollection: "Clone ChatState collections and update them through ChatStateStore.dispatch().", - }, - schema: [], - }, - create(context) { - const mutatingCollectionMethods = new Set(["add", "clear", "delete", "push", "set"]); - const chatStateAliasVariables = new WeakSet(); - let parserServices = null; - let checker = null; - const typeChecker = () => { - if (!parserServices) { - parserServices = parserServicesFromContext(context); - checker = parserServices.program.getTypeChecker(); - } - return checker; - }; - const variableForIdentifier = (node) => { - let scope = context.sourceCode.getScope(node); - while (scope) { - const variable = scope.variables.find((item) => item.name === node.name); - if (variable) return variable; - scope = scope.upper; - } - return null; - }; - const markChatStateAlias = (node) => { - const variable = variableForIdentifier(node); - if (variable) chatStateAliasVariables.add(variable); - }; - const isChatStateAlias = (node) => { - if (node?.type !== "Identifier") return false; - const variable = variableForIdentifier(node); - return Boolean(variable && chatStateAliasVariables.has(variable)); - }; - const typeAt = (node) => { - const services = parserServices ?? parserServicesFromContext(context); - if (!parserServices) { - parserServices = services; - checker = services.program.getTypeChecker(); - } - const tsNode = services.esTreeNodeToTSNodeMap.get(node); - return typeChecker().getTypeAtLocation(tsNode); - }; - const isChatStateValue = (node) => typeIncludesChatState(typeAt(node), typeChecker()); - const isChatStateAliasSource = (node) => - (isChatStateTarget(node) || isChatStateValue(node)) && typeCanCarryChatStateMutation(typeAt(node)); - const isChatStateTarget = (node) => { - if (isChatStateAlias(node)) return true; - if (!isMemberExpression(node)) return false; - if (isChatStateMember(node)) return true; - const root = rootMemberObject(node); - if (!root) return false; - if (isChatStateAlias(root)) return true; - return typeIncludesChatState(typeAt(root), typeChecker()); - }; - return { - VariableDeclarator(node) { - if (node.id.type !== "Identifier" || !node.init) return; - if (isChatStateAliasSource(node.init)) markChatStateAlias(node.id); - }, - AssignmentExpression(node) { - if (isMemberExpression(node.left) && isChatStateTarget(node.left)) context.report({ node: node.left, messageId: "assign" }); - }, - CallExpression(node) { - if (!isMemberExpression(node.callee)) return; - const method = staticPropertyName(node.callee.property); - if (!method || !mutatingCollectionMethods.has(method)) return; - if (isChatStateTarget(node.callee.object)) context.report({ node: node.callee, messageId: "mutateCollection" }); - }, - }; - }, - }, - "no-imperative-dom": { - meta: { - type: "problem", - docs: { - description: "Disallow imperative DOM writes and event wiring outside explicit bridge files.", - }, - messages: { - event: "Keep imperative DOM event wiring in an explicit bridge module or Obsidian-owned UI boundary.", - write: "Keep imperative DOM writes in an explicit bridge module or Obsidian-owned UI boundary.", - }, - schema: [], - }, - create(context) { - let parserServices = null; - let checker = null; - - const typeChecker = () => { - if (!parserServices) { - parserServices = parserServicesFromContext(context); - checker = parserServices.program.getTypeChecker(); - } - return checker; - }; - - const isDomTarget = (node) => { - const services = parserServices ?? parserServicesFromContext(context); - if (!parserServices) { - parserServices = services; - checker = services.program.getTypeChecker(); - } - const tsNode = services.esTreeNodeToTSNodeMap.get(node); - return typeIncludesDom(typeChecker().getTypeAtLocation(tsNode), typeChecker()); - }; - - return { - AssignmentExpression(node) { - if (!isMemberExpression(node.left)) return; - const property = staticPropertyName(node.left.property); - if (!property || !imperativeDomAssignmentProperties.has(property)) return; - if (isDomTarget(node.left.object)) context.report({ node: node.left, messageId: "write" }); - }, - CallExpression(node) { - if (!isMemberExpression(node.callee)) return; - const method = staticPropertyName(node.callee.property); - if (!method) return; - if (imperativeDomWriteMethods.has(method) && isDomTarget(node.callee.object)) { - context.report({ node: node.callee, messageId: "write" }); - return; - } - if (imperativeDomEventMethods.has(method) && isDomTarget(node.callee.object)) { - context.report({ node: node.callee, messageId: "event" }); - } - }, - }; - }, - }, - }, -}; - -function isChatStateMember(node) { - if (!isMemberExpression(node)) return false; - - let current = node; - while (isMemberExpression(current)) { - if (isIdentifier(current.object, "state")) return true; - if (isThisStateMember(current.object)) return true; - current = current.object; - } - return false; -} - -function isThisStateMember(node) { - return isMemberExpression(node) && node.object?.type === "ThisExpression" && staticPropertyName(node.property) === "state"; -} - -function rootMemberObject(node) { - if (!isMemberExpression(node)) return null; - let current = node; - while (isMemberExpression(current)) current = current.object; - return current; -} - -function isMemberExpression(node) { - return node?.type === "MemberExpression"; -} - -function isIdentifier(node, name) { - return node?.type === "Identifier" && node.name === name; -} - -function staticPropertyName(node) { - return node?.type === "Identifier" ? node.name : node?.type === "Literal" && typeof node.value === "string" ? node.value : null; -} - -function parserServicesFromContext(context) { - const services = context.sourceCode.parserServices; - if (!services?.program || !services.esTreeNodeToTSNodeMap) { - throw new Error("codex-panel/no-imperative-dom requires TypeScript parser services."); - } - return services; -} - -function typeIncludesDom(type, checker, seen = new Set()) { - if (!type || seen.has(type.id)) return false; - seen.add(type.id); - - if (type.flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown | ts.TypeFlags.Never)) return false; - if (type.isUnionOrIntersection()) return type.types.some((item) => typeIncludesDom(item, checker, seen)); - - const typeName = checker.typeToString(type); - if (domTypeName(typeName)) return true; - - const symbolName = type.getSymbol()?.getName() ?? type.aliasSymbol?.getName() ?? ""; - if (domTypeName(symbolName)) return true; - - const apparent = checker.getApparentType(type); - if (apparent !== type && typeIncludesDom(apparent, checker, seen)) return true; - - const bases = typeof type.getBaseTypes === "function" ? (type.getBaseTypes() ?? []) : []; - return bases.some((base) => typeIncludesDom(base, checker, seen)); -} - -function typeIncludesChatState(type, checker, seen = new Set()) { - if (!type || seen.has(type.id)) return false; - seen.add(type.id); - - if (type.flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown | ts.TypeFlags.Never)) return false; - if (type.isUnionOrIntersection()) return type.types.some((item) => typeIncludesChatState(item, checker, seen)); - - const typeName = checker.typeToString(type); - if (chatStateTypeName(typeName)) return true; - - const symbolName = type.getSymbol()?.getName() ?? type.aliasSymbol?.getName() ?? ""; - if (chatStateTypeName(symbolName)) return true; - - const apparent = checker.getApparentType(type); - if (apparent !== type && typeIncludesChatState(apparent, checker, seen)) return true; - - const bases = typeof type.getBaseTypes === "function" ? (type.getBaseTypes() ?? []) : []; - return bases.some((base) => typeIncludesChatState(base, checker, seen)); -} - -function typeCanCarryChatStateMutation(type, seen = new Set()) { - if (!type || seen.has(type.id)) return false; - seen.add(type.id); - - if (type.flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown | ts.TypeFlags.Never)) return false; - if (type.isUnionOrIntersection()) return type.types.some((item) => typeCanCarryChatStateMutation(item, seen)); - - const primitiveLikeFlags = - ts.TypeFlags.StringLike | - ts.TypeFlags.NumberLike | - ts.TypeFlags.BooleanLike | - ts.TypeFlags.BigIntLike | - ts.TypeFlags.ESSymbolLike | - ts.TypeFlags.Null | - ts.TypeFlags.Undefined | - ts.TypeFlags.Void; - return (type.flags & primitiveLikeFlags) === 0; -} - -function domTypeName(name) { - return /\b(?:Document|Element|HTML[A-Za-z]*Element|HTMLElement|Node|SVG[A-Za-z]*Element|SVGElement|Window)\b/.test(name); -} - -function chatStateTypeName(name) { - return /\bChatState\b/.test(name); -} - -function findInitializerCallbackReference(root, name) { - let reference = null; - - const visit = (node, inCallback) => { - if (!node || reference) return; - if (Array.isArray(node)) { - for (const item of node) visit(item, inCallback); - return; - } - if (typeof node !== "object" || typeof node.type !== "string") return; - - if (isFunctionNode(node)) { - if (functionShadowsName(node, name)) return; - visit(node.body, true); - return; - } - - if (inCallback && node.type === "Identifier" && node.name === name) { - reference = node; - return; - } - - for (const [key, value] of Object.entries(node)) { - if (key === "parent") continue; - if (node.type === "MemberExpression" && key === "property" && !node.computed) continue; - if (node.type === "Property" && key === "key" && !node.computed) continue; - visit(value, inCallback); - } - }; - - visit(root, false); - return reference; -} - -function isFunctionNode(node) { - return node.type === "ArrowFunctionExpression" || node.type === "FunctionExpression"; -} - -function functionShadowsName(node, name) { - return node.params.some((param) => patternContainsName(param, name)) || patternContainsName(node.id, name); -} - -function patternContainsName(node, name) { - if (!node) return false; - if (Array.isArray(node)) return node.some((item) => patternContainsName(item, name)); - if (typeof node !== "object" || typeof node.type !== "string") return false; - if (node.type === "Identifier") return node.name === name; - return Object.entries(node).some(([key, value]) => key !== "parent" && patternContainsName(value, name)); -} - -export default codexPanelEslintPlugin; diff --git a/tests/scripts/check-plan.test.mjs b/tests/scripts/check-plan.test.mjs index 6ebc8d3b..9b51bd30 100644 --- a/tests/scripts/check-plan.test.mjs +++ b/tests/scripts/check-plan.test.mjs @@ -13,7 +13,7 @@ describe("check command plan", () => { { name: "lint:eslint", command: - 'eslint src tests scripts "*.config.ts" "*.config.mjs" --max-warnings=0 --cache --cache-strategy content --cache-location node_modules/.cache/eslint/.eslintcache', + "eslint src --max-warnings=0 --cache --cache-strategy content --cache-location node_modules/.cache/eslint/.eslintcache", }, { name: "lint:css", command: 'stylelint "src/**/*.css" --max-warnings=0' }, { name: "lint:css-usage", command: "node scripts/lint/check-css-usage.mjs" }, @@ -42,7 +42,7 @@ describe("check command plan", () => { it("omits cache-only local flags in CI mode", () => { expect(commandPlan({ ciMode: true, lintOnly: false }).phases[0]?.commands).toContainEqual({ name: "lint:eslint", - command: 'eslint src tests scripts "*.config.ts" "*.config.mjs" --max-warnings=0', + command: "eslint src --max-warnings=0", }); }); }); diff --git a/tests/scripts/eslint-config.test.ts b/tests/scripts/eslint-config.test.ts deleted file mode 100644 index bb4ddb5d..00000000 --- a/tests/scripts/eslint-config.test.ts +++ /dev/null @@ -1,241 +0,0 @@ -import path from "node:path"; -import { ESLint } from "eslint"; -import { describe, expect, it } from "vitest"; - -const repoRoot = process.cwd(); -const ESLINT_STARTUP_TEST_TIMEOUT_MS = 10_000; -const eslint = new ESLint({ - cwd: repoRoot, - overrideConfigFile: path.join(repoRoot, "eslint.config.mjs"), -}); - -describe("eslint config", () => { - describe("custom Codex Panel rules", () => { - it( - "rejects imperative DOM writes while leaving non-DOM writes alone", - async () => { - await expectReports( - "reports DOM property writes", - "src/features/chat/domain/runtime/effective.ts", - ` -export function setStatus(element: HTMLElement): void { - element.textContent = "Loading"; -} -`, - "codex-panel/no-imperative-dom", - ); - await expectReports( - "reports Obsidian HTMLElement mutation helpers", - "src/features/chat/domain/runtime/effective.ts", - ` -export function setStatus(element: HTMLElement): void { - element.addClass("is-ready"); -} -`, - "codex-panel/no-imperative-dom", - ); - await expectClean( - "allows plain value properties", - "src/features/chat/domain/runtime/effective.ts", - ` -interface Box { - value: string; -} - -export function setBoxValue(box: Box): void { - box.value = "ready"; -} -`, - "codex-panel/no-imperative-dom", - ); - await expectClean( - "allows Preact signal value writes", - "src/features/chat/panel/shell-state.tsx", - ` -import { signal } from "@preact/signals"; - -export function setSignalStatus(): string { - const status = signal("idle"); - status.value = "ready"; - return status.value; -} -`, - "codex-panel/no-imperative-dom", - ); - }, - ESLINT_STARTUP_TEST_TIMEOUT_MS, - ); - - it("treats event wiring as imperative only for DOM targets", async () => { - await expectClean( - "allows AbortSignal event wiring", - "src/app-server/services/abortable-operation.ts", - ` -export function attach(signal: AbortSignal): void { - signal.addEventListener("abort", () => undefined); -} -`, - "codex-panel/no-imperative-dom", - ); - await expectClean( - "allows generic EventTarget helpers", - "src/shared/ui/dom-events.ts", - ` -export function attach(target: EventTarget): void { - target.addEventListener("click", () => undefined); -} -`, - "codex-panel/no-imperative-dom", - ); - await expectReports( - "reports DOM event wiring", - "src/features/chat/ui/goal.tsx", - ` -export function attach(element: HTMLElement): void { - element.addEventListener("click", () => undefined); -} -`, - "codex-panel/no-imperative-dom", - ); - }); - - it("rejects direct ChatState mutation through aliases and store snapshots", async () => { - await expectReports( - "reports direct ChatState property mutation", - "src/features/chat/domain/runtime/effective.ts", - ` -import type { ChatState } from "../../application/state/root-reducer"; - -export function mutateState(current: ChatState): void { - current.activeThread.id = "thread"; -} -`, - "codex-panel/no-chat-state-direct-mutation", - ); - await expectReports( - "reports collection mutation from getState", - "src/features/chat/domain/runtime/effective.ts", - ` -import type { ChatStateStore } from "../../application/state/store"; - -export function mutateState(store: ChatStateStore): void { - store.getState().requests.userInputDrafts.set("key", "value"); -} -`, - "codex-panel/no-chat-state-direct-mutation", - ); - await expectReports( - "reports collection mutation through a slice alias", - "src/features/chat/domain/runtime/effective.ts", - ` -import type { ChatStateStore } from "../../application/state/store"; - -export function mutateState(store: ChatStateStore): void { - const requests = store.getState().requests; - requests.userInputDrafts.set("key", "value"); -} -`, - "codex-panel/no-chat-state-direct-mutation", - ); - await expectReports( - "reports collection mutation through a state alias", - "src/features/chat/domain/runtime/effective.ts", - ` -import type { ChatStateStore } from "../../application/state/store"; - -export function mutateState(store: ChatStateStore): void { - const current = store.getState(); - current.requests.userInputDrafts.set("key", "value"); -} -`, - "codex-panel/no-chat-state-direct-mutation", - ); - }); - - it("allows local values that cannot mutate ChatState", async () => { - await expectClean( - "allows scalar values derived from ChatState", - "src/features/chat/domain/runtime/effective.ts", - ` -import type { ChatState } from "../../application/state/root-reducer"; - -export function updateThreadId(state: ChatState, response: { threadId?: string }): string | null { - let threadId = state.activeThread.id; - threadId = response.threadId ?? null; - return threadId; -} -`, - "codex-panel/no-chat-state-direct-mutation", - ); - await expectClean( - "allows replacing a ChatState reference", - "src/features/chat/domain/runtime/effective.ts", - ` -import type { ChatState } from "../../application/state/root-reducer"; - -export function replaceSnapshot(current: ChatState, next: ChatState): ChatState { - current = next; - return current; -} -`, - "codex-panel/no-chat-state-direct-mutation", - ); - await expectClean( - "does not confuse shadowed local names with ChatState aliases", - "src/features/chat/domain/runtime/effective.ts", - ` -import type { ChatStateStore } from "../../application/state/store"; - -export function mutateState(store: ChatStateStore): void { - const requests = store.getState().requests; - function update(requests: Map): void { - requests.set("key", "value"); - } - update(new Map()); -} -`, - "codex-panel/no-chat-state-direct-mutation", - ); - }); - }); - - describe("typed source policy", () => { - it("keeps imperative DOM writes in explicit bridge files", async () => { - await expectReports( - "reports chat UI components outside the bridge allowlist", - "src/features/chat/ui/composer.tsx", - ` -export function renderIcon(element: HTMLElement): void { - element.replaceChildren(); -} -`, - "codex-panel/no-imperative-dom", - ); - await expectClean( - "allows DOM root attachment in explicit Preact renderer bridges", - "src/features/threads-view/renderer.tsx", - ` -export function renderThreadsView(parent: HTMLElement): void { - parent.addClass("codex-panel-threads"); -} -`, - "codex-panel/no-imperative-dom", - ); - }); - }); -}); - -async function expectReports(name: string, filePath: string, source: string, ruleId: string): Promise { - const messages = await lintSource(filePath, source); - expect(messages, name).toContain(ruleId); -} - -async function expectClean(name: string, filePath: string, source: string, ruleId: string): Promise { - const messages = await lintSource(filePath, source); - expect(messages, name).not.toContain(ruleId); -} - -async function lintSource(filePath: string, source: string): Promise { - const [result] = await eslint.lintText(source, { filePath: path.join(repoRoot, filePath) }); - return (result?.messages ?? []).map((message) => message.ruleId ?? message.message); -}