murashit_codex-panel/scripts/lint/eslint-plugin-codex-panel.mjs

380 lines
13 KiB
JavaScript

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;