From 2fb02b737dca86569f5c032269cda02e1c809d06 Mon Sep 17 00:00:00 2001 From: saberzero1 Date: Mon, 8 Jun 2026 10:38:08 +0200 Subject: [PATCH 1/7] feat: discourage monkey patching --- lib/index.ts | 3 + lib/rules/noMonkeyPatching.ts | 175 ++++++++++++++++++++++++++++++++++ 2 files changed, 178 insertions(+) create mode 100644 lib/rules/noMonkeyPatching.ts diff --git a/lib/index.ts b/lib/index.ts index dd0cc3d..9640a0a 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -25,6 +25,7 @@ import sampleNames from "./rules/sampleNames.js"; import validateManifest from "./rules/validateManifest.js"; import validateLicense from "./rules/validateLicense.js"; import ruleCustomMessage from "./rules/ruleCustomMessage.js"; +import noMonkeyPatching from "./rules/noMonkeyPatching.js"; import noNodejsModules from "./rules/noNodejsModules.js"; import noUnsupportedApi from "./rules/noUnsupportedApi.js"; import { getManifest } from "./manifest.js"; @@ -85,6 +86,7 @@ const plugin = { "no-sample-code": noSampleCode, "no-tfile-tfolder-cast": noTFileTFolderCast, "no-view-references-in-plugin": noViewReferencesInPlugin, + "no-monkey-patching": noMonkeyPatching, "no-static-styles-assignment": noStaticStylesAssignment, "object-assign": objectAssign, platform: platform, @@ -142,6 +144,7 @@ const recommendedPluginRulesConfig: RulesConfig = { "obsidianmd/no-plugin-as-component": "error", "obsidianmd/no-sample-code": "error", "obsidianmd/no-tfile-tfolder-cast": "error", + "obsidianmd/no-monkey-patching": "error", "obsidianmd/no-static-styles-assignment": "error", "obsidianmd/object-assign": "error", "obsidianmd/platform": "error", diff --git a/lib/rules/noMonkeyPatching.ts b/lib/rules/noMonkeyPatching.ts new file mode 100644 index 0000000..dd700ba --- /dev/null +++ b/lib/rules/noMonkeyPatching.ts @@ -0,0 +1,175 @@ +import { TSESTree, ESLintUtils } from "@typescript-eslint/utils"; + +const ruleCreator = ESLintUtils.RuleCreator( + (name) => + `https://github.com/obsidianmd/eslint-plugin/blob/master/docs/rules/${name}.md`, +); + +// Matches: Foo.prototype.bar, Foo.prototype +function isPrototypeAccess(node: TSESTree.MemberExpression): boolean { + if ( + node.object.type === TSESTree.AST_NODE_TYPES.MemberExpression && + node.object.property.type === TSESTree.AST_NODE_TYPES.Identifier && + node.object.property.name === "prototype" + ) { + return true; + } + + if ( + node.property.type === TSESTree.AST_NODE_TYPES.Identifier && + node.property.name === "prototype" + ) { + return true; + } + + return false; +} + +function getMemberName(node: TSESTree.MemberExpression): string { + const parts: string[] = []; + let current: TSESTree.Expression = node; + while (current.type === TSESTree.AST_NODE_TYPES.MemberExpression) { + const prop = current.property; + if (prop.type === TSESTree.AST_NODE_TYPES.Identifier) { + parts.unshift(prop.name); + } else if (prop.type === TSESTree.AST_NODE_TYPES.Literal) { + parts.unshift(String(prop.value)); + } else { + parts.unshift("[computed]"); + } + current = current.object; + } + if (current.type === TSESTree.AST_NODE_TYPES.Identifier) { + parts.unshift(current.name); + } + return parts.join("."); +} + +function isPrototypeMemberExpression(node: TSESTree.Node): boolean { + if (node.type !== TSESTree.AST_NODE_TYPES.MemberExpression) { + return false; + } + return ( + node.property.type === TSESTree.AST_NODE_TYPES.Identifier && + node.property.name === "prototype" + ); +} + +export default ruleCreator({ + name: "no-monkey-patching", + meta: { + type: "problem" as const, + docs: { + description: + "Discourage monkey patching prototypes and using the `monkey-around` package.", + }, + schema: [], + messages: { + noMonkeyAroundImport: + "Do not use the `monkey-around` package. Monkey patching Obsidian internals is discouraged.", + directPrototypeAssignment: + "Do not assign to `{{name}}`. Directly modifying prototypes is unsafe and discouraged.", + definePropertyOnPrototype: + "Do not use `Object.defineProperty` on `{{name}}`. Directly modifying prototypes is unsafe and discouraged.", + assignToPrototype: + "Do not use `Object.assign` on `{{name}}`. Directly modifying prototypes is unsafe and discouraged.", + }, + }, + defaultOptions: [], + create(context) { + return { + ImportDeclaration(node: TSESTree.ImportDeclaration) { + if (node.source.value === "monkey-around") { + context.report({ + node, + messageId: "noMonkeyAroundImport", + }); + } + }, + + CallExpression(node: TSESTree.CallExpression) { + if ( + node.callee.type === TSESTree.AST_NODE_TYPES.Identifier && + node.callee.name === "require" && + node.arguments.length >= 1 && + node.arguments[0].type === TSESTree.AST_NODE_TYPES.Literal && + node.arguments[0].value === "monkey-around" + ) { + context.report({ + node, + messageId: "noMonkeyAroundImport", + }); + return; + } + + if ( + node.callee.type === TSESTree.AST_NODE_TYPES.MemberExpression && + node.callee.object.type === TSESTree.AST_NODE_TYPES.Identifier && + node.callee.object.name === "Object" && + node.callee.property.type === TSESTree.AST_NODE_TYPES.Identifier && + node.callee.property.name === "defineProperty" && + node.arguments.length >= 2 && + isPrototypeMemberExpression(node.arguments[0]) + ) { + const target = node.arguments[0] as TSESTree.MemberExpression; + context.report({ + node, + messageId: "definePropertyOnPrototype", + data: { name: getMemberName(target) }, + }); + return; + } + + if ( + node.callee.type === TSESTree.AST_NODE_TYPES.MemberExpression && + node.callee.object.type === TSESTree.AST_NODE_TYPES.Identifier && + node.callee.object.name === "Object" && + node.callee.property.type === TSESTree.AST_NODE_TYPES.Identifier && + node.callee.property.name === "defineProperties" && + node.arguments.length >= 1 && + isPrototypeMemberExpression(node.arguments[0]) + ) { + const target = node.arguments[0] as TSESTree.MemberExpression; + context.report({ + node, + messageId: "definePropertyOnPrototype", + data: { name: getMemberName(target) }, + }); + return; + } + + if ( + node.callee.type === TSESTree.AST_NODE_TYPES.MemberExpression && + node.callee.object.type === TSESTree.AST_NODE_TYPES.Identifier && + node.callee.object.name === "Object" && + node.callee.property.type === TSESTree.AST_NODE_TYPES.Identifier && + node.callee.property.name === "assign" && + node.arguments.length >= 1 && + isPrototypeMemberExpression(node.arguments[0]) + ) { + const target = node.arguments[0] as TSESTree.MemberExpression; + context.report({ + node, + messageId: "assignToPrototype", + data: { name: getMemberName(target) }, + }); + return; + } + }, + + AssignmentExpression(node: TSESTree.AssignmentExpression) { + if (node.left.type !== TSESTree.AST_NODE_TYPES.MemberExpression) { + return; + } + + if (isPrototypeAccess(node.left)) { + context.report({ + node, + messageId: "directPrototypeAssignment", + data: { name: getMemberName(node.left) }, + }); + } + }, + }; + }, +}); From f9ccc0fe43c35de11a66b87820ee9369559c4158 Mon Sep 17 00:00:00 2001 From: saberzero1 Date: Mon, 8 Jun 2026 10:38:22 +0200 Subject: [PATCH 2/7] test: monkey patching cases --- tests/noMonkeyPatching.test.ts | 93 ++++++++++++++++++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 tests/noMonkeyPatching.test.ts diff --git a/tests/noMonkeyPatching.test.ts b/tests/noMonkeyPatching.test.ts new file mode 100644 index 0000000..5ba41fa --- /dev/null +++ b/tests/noMonkeyPatching.test.ts @@ -0,0 +1,93 @@ +import { RuleTester } from "@typescript-eslint/rule-tester"; +import noMonkeyPatchingRule from "../lib/rules/noMonkeyPatching.js"; + +const ruleTester = new RuleTester(); + +ruleTester.run("no-monkey-patching", noMonkeyPatchingRule, { + valid: [ + { + name: "normal import is allowed", + code: "import { Plugin } from 'obsidian';", + }, + { + name: "normal require is allowed", + code: "const obsidian = require('obsidian');", + }, + { + name: "assigning to own class prototype is allowed via normal property", + code: "const obj = {}; obj.foo = 'bar';", + }, + { + name: "Object.assign with plain objects is allowed", + code: "Object.assign(target, source);", + }, + { + name: "Object.defineProperty on plain object is allowed", + code: "Object.defineProperty(obj, 'key', { value: 42 });", + }, + { + name: "accessing prototype without assignment is allowed", + code: "const proto = Array.prototype;", + }, + { + name: "instanceof check via prototype is allowed", + code: "const x = Object.getPrototypeOf(obj);", + }, + { + name: "Object.create with prototype is allowed", + code: "const child = Object.create(Parent.prototype);", + }, + ], + invalid: [ + { + name: "import from monkey-around is forbidden", + code: "import { around } from 'monkey-around';", + errors: [{ messageId: "noMonkeyAroundImport" }], + }, + { + name: "import default from monkey-around is forbidden", + code: "import monkeyAround from 'monkey-around';", + errors: [{ messageId: "noMonkeyAroundImport" }], + }, + { + name: "require monkey-around is forbidden", + code: "const { around } = require('monkey-around');", + errors: [{ messageId: "noMonkeyAroundImport" }], + }, + { + name: "direct prototype method assignment is forbidden", + code: "Workspace.prototype.getActiveViewOfType = function() { return null; };", + errors: [{ messageId: "directPrototypeAssignment", data: { name: "Workspace.prototype.getActiveViewOfType" } }], + }, + { + name: "Array.prototype assignment is forbidden", + code: "Array.prototype.customMethod = function() {};", + errors: [{ messageId: "directPrototypeAssignment", data: { name: "Array.prototype.customMethod" } }], + }, + { + name: "Object.prototype assignment is forbidden", + code: "Object.prototype.foo = 'bar';", + errors: [{ messageId: "directPrototypeAssignment", data: { name: "Object.prototype.foo" } }], + }, + { + name: "assigning to .prototype itself is forbidden", + code: "MyClass.prototype = {};", + errors: [{ messageId: "directPrototypeAssignment", data: { name: "MyClass.prototype" } }], + }, + { + name: "Object.defineProperty on prototype is forbidden", + code: "Object.defineProperty(Workspace.prototype, 'method', { value: function() {} });", + errors: [{ messageId: "definePropertyOnPrototype", data: { name: "Workspace.prototype" } }], + }, + { + name: "Object.defineProperties on prototype is forbidden", + code: "Object.defineProperties(Array.prototype, { custom: { value: 1 } });", + errors: [{ messageId: "definePropertyOnPrototype", data: { name: "Array.prototype" } }], + }, + { + name: "Object.assign on prototype is forbidden", + code: "Object.assign(Element.prototype, { customMethod() {} });", + errors: [{ messageId: "assignToPrototype", data: { name: "Element.prototype" } }], + }, + ], +}); From d24380bfd3dc250bd9fe6ae63478858685fe2743 Mon Sep 17 00:00:00 2001 From: saberzero1 Date: Mon, 8 Jun 2026 10:38:59 +0200 Subject: [PATCH 3/7] docs: regenerated documentation --- README.md | 3 ++- docs/rules/no-monkey-patching.md | 7 +++++++ 2 files changed, 9 insertions(+), 1 deletion(-) create mode 100644 docs/rules/no-monkey-patching.md diff --git a/README.md b/README.md index 51f6d36..92d6a00 100644 --- a/README.md +++ b/README.md @@ -99,7 +99,7 @@ You can also override or add rules: 🇬🇧 Set in the `recommendedWithLocalesEn` configuration.\ 🔧 Automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/user-guide/command-line-interface#--fix). -| Name                                          | Description | 💼 | ⚠️ | 🔧 | +| Name | Description | 💼 | ⚠️ | 🔧 | | :----------------------------------------------------------------------------------------------------------- | :---------------------------------------------------------------------------------------------------------------------------------------------------- | :----- | :----- | :- | | [commands/no-command-in-command-id](docs/rules/commands/no-command-in-command-id.md) | Disallow using the word 'command' in a command ID. | ✅ 🇬🇧 | | | | [commands/no-command-in-command-name](docs/rules/commands/no-command-in-command-name.md) | Disallow using the word 'command' in a command name. | ✅ 🇬🇧 | | | @@ -111,6 +111,7 @@ You can also override or add rules: | [hardcoded-config-path](docs/rules/hardcoded-config-path.md) | Disallow hardcoded `.obsidian` config paths. Use `Vault#configDir` instead. | ✅ 🇬🇧 | | | | [no-forbidden-elements](docs/rules/no-forbidden-elements.md) | Disallow attachment of forbidden elements to the DOM in Obsidian plugins. | ✅ 🇬🇧 | | | | [no-global-this](docs/rules/no-global-this.md) | Disallow `global` and `globalThis`. Use `window` or `activeWindow` for popout window compatibility. | ✅ 🇬🇧 | | 🔧 | +| [no-monkey-patching](docs/rules/no-monkey-patching.md) | Discourage directly modifying prototypes. | ✅ 🇬🇧 | | | | [no-nodejs-modules](docs/rules/no-nodejs-modules.md) | Disallow importing Node.js built-in modules unless guarded by Platform.isDesktop | ✅ 🇬🇧 | | | | [no-plugin-as-component](docs/rules/no-plugin-as-component.md) | Disallow anti-patterns when passing a component to MarkdownRenderer.render to prevent memory leaks. | ✅ 🇬🇧 | | | | [no-sample-code](docs/rules/no-sample-code.md) | Disallow sample code snippets from the Obsidian plugin template. | ✅ 🇬🇧 | | 🔧 | diff --git a/docs/rules/no-monkey-patching.md b/docs/rules/no-monkey-patching.md new file mode 100644 index 0000000..8d03234 --- /dev/null +++ b/docs/rules/no-monkey-patching.md @@ -0,0 +1,7 @@ +# obsidianmd/no-monkey-patching + +📝 Discourage monkey patching prototypes and using the `monkey-around` package. + +💼 This rule is enabled in the following configs: ✅ `recommended`, 🇬🇧 `recommendedWithLocalesEn`. + + From 1d947fe3764c123d6c720bbe7d8fc3c2f7b761ec Mon Sep 17 00:00:00 2001 From: saberzero1 Date: Mon, 8 Jun 2026 10:52:02 +0200 Subject: [PATCH 4/7] refactor: move monkey-patching package checks to banned dependencies --- lib/ruleOptions.ts | 5 +++++ lib/rules/noMonkeyPatching.ts | 27 +-------------------------- 2 files changed, 6 insertions(+), 26 deletions(-) diff --git a/lib/ruleOptions.ts b/lib/ruleOptions.ts index 76a2309..57b73c9 100644 --- a/lib/ruleOptions.ts +++ b/lib/ruleOptions.ts @@ -52,6 +52,11 @@ export const restrictedImportsOptions = [ message: "The 'moment' package is bundled with Obsidian. Please import it from 'obsidian' instead.", }, + { + name: "monkey-around", + message: + "Monkey patching Obsidian internals is discouraged.", + }, ] as const; export const noUnusedExpressionsOptions = [{ allowShortCircuit: true, allowTernary: true }] as const; \ No newline at end of file diff --git a/lib/rules/noMonkeyPatching.ts b/lib/rules/noMonkeyPatching.ts index dd700ba..f58dd74 100644 --- a/lib/rules/noMonkeyPatching.ts +++ b/lib/rules/noMonkeyPatching.ts @@ -61,12 +61,10 @@ export default ruleCreator({ type: "problem" as const, docs: { description: - "Discourage monkey patching prototypes and using the `monkey-around` package.", + "Discourage directly modifying prototypes.", }, schema: [], messages: { - noMonkeyAroundImport: - "Do not use the `monkey-around` package. Monkey patching Obsidian internals is discouraged.", directPrototypeAssignment: "Do not assign to `{{name}}`. Directly modifying prototypes is unsafe and discouraged.", definePropertyOnPrototype: @@ -78,30 +76,7 @@ export default ruleCreator({ defaultOptions: [], create(context) { return { - ImportDeclaration(node: TSESTree.ImportDeclaration) { - if (node.source.value === "monkey-around") { - context.report({ - node, - messageId: "noMonkeyAroundImport", - }); - } - }, - CallExpression(node: TSESTree.CallExpression) { - if ( - node.callee.type === TSESTree.AST_NODE_TYPES.Identifier && - node.callee.name === "require" && - node.arguments.length >= 1 && - node.arguments[0].type === TSESTree.AST_NODE_TYPES.Literal && - node.arguments[0].value === "monkey-around" - ) { - context.report({ - node, - messageId: "noMonkeyAroundImport", - }); - return; - } - if ( node.callee.type === TSESTree.AST_NODE_TYPES.MemberExpression && node.callee.object.type === TSESTree.AST_NODE_TYPES.Identifier && From b78dbce3e5c5ad96807d6d919f93a7811887ca5f Mon Sep 17 00:00:00 2001 From: saberzero1 Date: Mon, 8 Jun 2026 10:52:18 +0200 Subject: [PATCH 5/7] test: removed monkey-patching tests --- tests/noMonkeyPatching.test.ts | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/tests/noMonkeyPatching.test.ts b/tests/noMonkeyPatching.test.ts index 5ba41fa..ace54d0 100644 --- a/tests/noMonkeyPatching.test.ts +++ b/tests/noMonkeyPatching.test.ts @@ -39,21 +39,6 @@ ruleTester.run("no-monkey-patching", noMonkeyPatchingRule, { }, ], invalid: [ - { - name: "import from monkey-around is forbidden", - code: "import { around } from 'monkey-around';", - errors: [{ messageId: "noMonkeyAroundImport" }], - }, - { - name: "import default from monkey-around is forbidden", - code: "import monkeyAround from 'monkey-around';", - errors: [{ messageId: "noMonkeyAroundImport" }], - }, - { - name: "require monkey-around is forbidden", - code: "const { around } = require('monkey-around');", - errors: [{ messageId: "noMonkeyAroundImport" }], - }, { name: "direct prototype method assignment is forbidden", code: "Workspace.prototype.getActiveViewOfType = function() { return null; };", From 2666ea42470e9c0ec22fc2c3dc9f6b651fd8e20d Mon Sep 17 00:00:00 2001 From: saberzero1 Date: Mon, 8 Jun 2026 10:52:24 +0200 Subject: [PATCH 6/7] docs: regenerated documentation --- docs/rules/no-monkey-patching.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/rules/no-monkey-patching.md b/docs/rules/no-monkey-patching.md index 8d03234..e15364b 100644 --- a/docs/rules/no-monkey-patching.md +++ b/docs/rules/no-monkey-patching.md @@ -1,6 +1,6 @@ # obsidianmd/no-monkey-patching -📝 Discourage monkey patching prototypes and using the `monkey-around` package. +📝 Discourage directly modifying prototypes. 💼 This rule is enabled in the following configs: ✅ `recommended`, 🇬🇧 `recommendedWithLocalesEn`. From 254991fbc3be573a6ee4bd0a3bb7a8fa0895e42e Mon Sep 17 00:00:00 2001 From: saberzero1 Date: Mon, 15 Jun 2026 16:21:05 +0200 Subject: [PATCH 7/7] chore: addressed review feedback --- lib/ruleOptions.ts | 2 +- lib/rules/noMonkeyPatching.ts | 115 ++++++++++++++++++++------------- tests/noMonkeyPatching.test.ts | 63 +++++++++++++++++- 3 files changed, 133 insertions(+), 47 deletions(-) diff --git a/lib/ruleOptions.ts b/lib/ruleOptions.ts index 57b73c9..7af90a0 100644 --- a/lib/ruleOptions.ts +++ b/lib/ruleOptions.ts @@ -55,7 +55,7 @@ export const restrictedImportsOptions = [ { name: "monkey-around", message: - "Monkey patching Obsidian internals is discouraged.", + "This plugin may be modifying Obsidian internals. Plugins that do this have a higher likelihood to introduce unexpected behavior in the app. They are also more likely to break when Obsidian updates.", }, ] as const; diff --git a/lib/rules/noMonkeyPatching.ts b/lib/rules/noMonkeyPatching.ts index f58dd74..56c4bde 100644 --- a/lib/rules/noMonkeyPatching.ts +++ b/lib/rules/noMonkeyPatching.ts @@ -5,26 +5,30 @@ const ruleCreator = ESLintUtils.RuleCreator( `https://github.com/obsidianmd/eslint-plugin/blob/master/docs/rules/${name}.md`, ); -// Matches: Foo.prototype.bar, Foo.prototype -function isPrototypeAccess(node: TSESTree.MemberExpression): boolean { +function isPrototypeAccess(node: TSESTree.Node): node is TSESTree.MemberExpression { + if (node.type !== TSESTree.AST_NODE_TYPES.MemberExpression) { + return false; + } if ( - node.object.type === TSESTree.AST_NODE_TYPES.MemberExpression && - node.object.property.type === TSESTree.AST_NODE_TYPES.Identifier && - node.object.property.name === "prototype" + node.property.type === TSESTree.AST_NODE_TYPES.Identifier && + node.property.name === "prototype" ) { return true; } - if ( - node.property.type === TSESTree.AST_NODE_TYPES.Identifier && - node.property.name === "prototype" + node.computed && + node.property.type === TSESTree.AST_NODE_TYPES.Literal && + node.property.value === "prototype" ) { return true; } - return false; } +function isPrototypeMemberAccess(node: TSESTree.MemberExpression): boolean { + return isPrototypeAccess(node.object); +} + function getMemberName(node: TSESTree.MemberExpression): string { const parts: string[] = []; let current: TSESTree.Expression = node; @@ -45,13 +49,21 @@ function getMemberName(node: TSESTree.MemberExpression): string { return parts.join("."); } -function isPrototypeMemberExpression(node: TSESTree.Node): boolean { - if (node.type !== TSESTree.AST_NODE_TYPES.MemberExpression) { - return false; - } +const PROTO_METHODS = { + defineProperty: "definePropertyOnPrototype", + defineProperties: "definePropertyOnPrototype", + assign: "assignToPrototype", + set: "assignToPrototype", +} as const; + +function isGetPrototypeOfCall(node: TSESTree.Expression): boolean { return ( - node.property.type === TSESTree.AST_NODE_TYPES.Identifier && - node.property.name === "prototype" + node.type === TSESTree.AST_NODE_TYPES.CallExpression && + node.callee.type === TSESTree.AST_NODE_TYPES.MemberExpression && + node.callee.object.type === TSESTree.AST_NODE_TYPES.Identifier && + (node.callee.object.name === "Object" || node.callee.object.name === "Reflect") && + node.callee.property.type === TSESTree.AST_NODE_TYPES.Identifier && + node.callee.property.name === "getPrototypeOf" ); } @@ -71,6 +83,12 @@ export default ruleCreator({ "Do not use `Object.defineProperty` on `{{name}}`. Directly modifying prototypes is unsafe and discouraged.", assignToPrototype: "Do not use `Object.assign` on `{{name}}`. Directly modifying prototypes is unsafe and discouraged.", + setPrototypeOf: + "Do not use `Object.setPrototypeOf` on a prototype. Directly modifying prototypes is unsafe and discouraged.", + deletePrototypeMember: + "Do not delete `{{name}}`. Directly modifying prototypes is unsafe and discouraged.", + getPrototypeOfAssignment: + "Do not assign to a member of `Object.getPrototypeOf(...)`. Directly modifying prototypes is unsafe and discouraged.", }, }, defaultOptions: [], @@ -78,54 +96,39 @@ export default ruleCreator({ return { CallExpression(node: TSESTree.CallExpression) { if ( - node.callee.type === TSESTree.AST_NODE_TYPES.MemberExpression && - node.callee.object.type === TSESTree.AST_NODE_TYPES.Identifier && - node.callee.object.name === "Object" && - node.callee.property.type === TSESTree.AST_NODE_TYPES.Identifier && - node.callee.property.name === "defineProperty" && - node.arguments.length >= 2 && - isPrototypeMemberExpression(node.arguments[0]) + node.callee.type !== TSESTree.AST_NODE_TYPES.MemberExpression || + node.callee.object.type !== TSESTree.AST_NODE_TYPES.Identifier || + node.callee.property.type !== TSESTree.AST_NODE_TYPES.Identifier ) { - const target = node.arguments[0] as TSESTree.MemberExpression; - context.report({ - node, - messageId: "definePropertyOnPrototype", - data: { name: getMemberName(target) }, - }); return; } + const objectName = node.callee.object.name; + const methodName = node.callee.property.name; + if ( - node.callee.type === TSESTree.AST_NODE_TYPES.MemberExpression && - node.callee.object.type === TSESTree.AST_NODE_TYPES.Identifier && - node.callee.object.name === "Object" && - node.callee.property.type === TSESTree.AST_NODE_TYPES.Identifier && - node.callee.property.name === "defineProperties" && + (objectName === "Object" || objectName === "Reflect") && + methodName === "setPrototypeOf" && node.arguments.length >= 1 && - isPrototypeMemberExpression(node.arguments[0]) + isPrototypeAccess(node.arguments[0]) ) { - const target = node.arguments[0] as TSESTree.MemberExpression; context.report({ node, - messageId: "definePropertyOnPrototype", - data: { name: getMemberName(target) }, + messageId: "setPrototypeOf", }); return; } if ( - node.callee.type === TSESTree.AST_NODE_TYPES.MemberExpression && - node.callee.object.type === TSESTree.AST_NODE_TYPES.Identifier && - node.callee.object.name === "Object" && - node.callee.property.type === TSESTree.AST_NODE_TYPES.Identifier && - node.callee.property.name === "assign" && + (objectName === "Object" || objectName === "Reflect") && + methodName in PROTO_METHODS && node.arguments.length >= 1 && - isPrototypeMemberExpression(node.arguments[0]) + isPrototypeAccess(node.arguments[0]) ) { const target = node.arguments[0] as TSESTree.MemberExpression; context.report({ node, - messageId: "assignToPrototype", + messageId: PROTO_METHODS[methodName as keyof typeof PROTO_METHODS], data: { name: getMemberName(target) }, }); return; @@ -137,7 +140,15 @@ export default ruleCreator({ return; } - if (isPrototypeAccess(node.left)) { + if (isGetPrototypeOfCall(node.left.object)) { + context.report({ + node, + messageId: "getPrototypeOfAssignment", + }); + return; + } + + if (isPrototypeMemberAccess(node.left) || isPrototypeAccess(node.left)) { context.report({ node, messageId: "directPrototypeAssignment", @@ -145,6 +156,20 @@ export default ruleCreator({ }); } }, + + UnaryExpression(node: TSESTree.UnaryExpression) { + if ( + node.operator === "delete" && + node.argument.type === TSESTree.AST_NODE_TYPES.MemberExpression && + isPrototypeMemberAccess(node.argument) + ) { + context.report({ + node, + messageId: "deletePrototypeMember", + data: { name: getMemberName(node.argument) }, + }); + } + }, }; }, }); diff --git a/tests/noMonkeyPatching.test.ts b/tests/noMonkeyPatching.test.ts index ace54d0..038357f 100644 --- a/tests/noMonkeyPatching.test.ts +++ b/tests/noMonkeyPatching.test.ts @@ -30,13 +30,29 @@ ruleTester.run("no-monkey-patching", noMonkeyPatchingRule, { code: "const proto = Array.prototype;", }, { - name: "instanceof check via prototype is allowed", + name: "Object.getPrototypeOf without assignment is allowed", code: "const x = Object.getPrototypeOf(obj);", }, { name: "Object.create with prototype is allowed", code: "const child = Object.create(Parent.prototype);", }, + { + name: "Reflect.defineProperty on plain object is allowed", + code: "Reflect.defineProperty(obj, 'key', { value: 42 });", + }, + { + name: "Reflect.set on plain object is allowed", + code: "Reflect.set(target, 'key', value);", + }, + { + name: "delete on non-prototype member is allowed", + code: "delete obj.foo;", + }, + { + name: "Object.setPrototypeOf on non-prototype target is allowed", + code: "Object.setPrototypeOf(obj, proto);", + }, ], invalid: [ { @@ -74,5 +90,50 @@ ruleTester.run("no-monkey-patching", noMonkeyPatchingRule, { code: "Object.assign(Element.prototype, { customMethod() {} });", errors: [{ messageId: "assignToPrototype", data: { name: "Element.prototype" } }], }, + { + name: "Reflect.defineProperty on prototype is forbidden", + code: "Reflect.defineProperty(Workspace.prototype, 'method', { value: function() {} });", + errors: [{ messageId: "definePropertyOnPrototype", data: { name: "Workspace.prototype" } }], + }, + { + name: "Reflect.set on prototype is forbidden", + code: "Reflect.set(Array.prototype, 'customMethod', function() {});", + errors: [{ messageId: "assignToPrototype", data: { name: "Array.prototype" } }], + }, + { + name: "computed prototype access via bracket notation is forbidden", + code: "Workspace['prototype'].getLeaf = function() {};", + errors: [{ messageId: "directPrototypeAssignment", data: { name: "Workspace.prototype.getLeaf" } }], + }, + { + name: "Object.defineProperty with computed prototype access is forbidden", + code: "Object.defineProperty(Workspace['prototype'], 'method', { value: function() {} });", + errors: [{ messageId: "definePropertyOnPrototype", data: { name: "Workspace.prototype" } }], + }, + { + name: "Object.getPrototypeOf assignment is forbidden", + code: "Object.getPrototypeOf(workspace).getLeaf = function() {};", + errors: [{ messageId: "getPrototypeOfAssignment" }], + }, + { + name: "Reflect.getPrototypeOf assignment is forbidden", + code: "Reflect.getPrototypeOf(workspace).getLeaf = function() {};", + errors: [{ messageId: "getPrototypeOfAssignment" }], + }, + { + name: "Object.setPrototypeOf on prototype is forbidden", + code: "Object.setPrototypeOf(MyClass.prototype, OtherClass.prototype);", + errors: [{ messageId: "setPrototypeOf" }], + }, + { + name: "Reflect.setPrototypeOf on prototype is forbidden", + code: "Reflect.setPrototypeOf(MyClass.prototype, OtherClass.prototype);", + errors: [{ messageId: "setPrototypeOf" }], + }, + { + name: "delete on prototype member is forbidden", + code: "delete Workspace.prototype.getLeaf;", + errors: [{ messageId: "deletePrototypeMember", data: { name: "Workspace.prototype.getLeaf" } }], + }, ], });