mirror of
https://github.com/murashit/codex-panel.git
synced 2026-07-22 06:57:10 +00:00
Consolidate ESLint source policy checks
This commit is contained in:
parent
2898e0f74a
commit
6afe931d01
5 changed files with 344 additions and 666 deletions
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<string, string>): 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<void> {
|
||||
const messages = await lintSource(filePath, source);
|
||||
expect(messages, name).toContain(ruleId);
|
||||
}
|
||||
|
||||
async function expectClean(name: string, filePath: string, source: string, ruleId: string): Promise<void> {
|
||||
const messages = await lintSource(filePath, source);
|
||||
expect(messages, name).not.toContain(ruleId);
|
||||
}
|
||||
|
||||
async function lintSource(filePath: string, source: string): Promise<string[]> {
|
||||
const [result] = await eslint.lintText(source, { filePath: path.join(repoRoot, filePath) });
|
||||
return (result?.messages ?? []).map((message) => message.ruleId ?? message.message);
|
||||
}
|
||||
Loading…
Reference in a new issue