feat: discourage monkey patching - #159
saberzero1 wants to merge 7 commits into
Conversation
| { | ||
| name: "monkey-around", | ||
| message: | ||
| "Monkey patching Obsidian internals is discouraged.", |
There was a problem hiding this comment.
| "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.", |
tgrosinger
left a comment
There was a problem hiding this comment.
What do you think about these other prototyping patterns that Claude suggested? Glancing over them quickly they seem reasonable, especially number 2.
High value — common patterns currently missed
- Patching via
Object.getPrototypeOf(...)
This is arguably the most common hand-rolled technique in Obsidian, because plugins usually hold an instance (this.app.workspace) rather than a class reference, so they reach the prototype dynamically:
Object.getPrototypeOf(this.app.workspace).getLeaf = function () { … };
Detect: AssignmentExpression where left.object is a CallExpression to Object.getPrototypeOf (or Reflect.getPrototypeOf). The current rule catches none of this.
- Instance method patching (the big one — needs type info)
The dominant monkey-patch style isn't on prototypes at all; it's overwriting a method on a live Obsidian object:
this.app.workspace.getLeaf = function () { … };
Banning the monkey-around import covers the library path, but hand-rolled instance patching slips through entirely. You can't catch this safely without type information — flagging every obj.method = fn would be far too noisy. The principled version is to move the rule into recommendedTypedRulesConfig and use parser services to flag assignment to a method only when the receiver's type resolves to an obsidian export (Workspace, App, MetadataCache, etc.). That's the highest-impact change but also the largest; worth treating as a follow-up rather than folding into this PR.
Quick wins — close trivial bypasses cheaply
Reflect.*equivalents of theObject.*checks
Reflect.defineProperty(X.prototype, …) and Reflect.set(X.prototype, …) bypass the Object.defineProperty/assign branches. Adding Reflect as an accepted callee object in the same check is nearly free.
- Computed prototype access on the object side
X['prototype'].foo = … is missed because isPrototypeAccess only matches an Identifier named prototype, not a string Literal. (Note: X.prototype['foo'] = … is already caught — only the ['prototype'] side leaks.) One-line fix: also accept Literal with value "prototype".
Object.setPrototypeOf(X.prototype, …)anddelete X.prototype.method
Replacing or deleting prototype members is the same anti-pattern as adding to it. delete is a UnaryExpression (operator === "delete") over a prototype member; setPrototypeOf fits the existing Object.* call check. Lower frequency, but completes the "no prototype mutation" story coherently.
| ); | ||
|
|
||
| // Matches: Foo.prototype.bar, Foo.prototype | ||
| function isPrototypeAccess(node: TSESTree.MemberExpression): boolean { |
There was a problem hiding this comment.
isPrototypeAccess and isPrototypeMemberExpression are very similar (one is a subset of the other. Do we need both? Could we consolidate them?
| defaultOptions: [], | ||
| create(context) { | ||
| return { | ||
| CallExpression(node: TSESTree.CallExpression) { |
There was a problem hiding this comment.
These three expressions are nearly identical. Can we combine them? Claude has this suggestion:
They collapse cleanly into one:
const PROTO_METHODS = { defineProperty: "definePropertyOnPrototype",
defineProperties: "definePropertyOnPrototype",
assign: "assignToPrototype" } as const;
then a single guard checking node.callee.property.name in PROTO_METHODS. Cuts ~50 lines and removes the inconsistent arguments.length >= 2 vs >= 1 checks (those bounds are loose anyway and don't affect correctness).
I see the value, I think that should be a separate PR and a separate rule entirely. If we have good signal on it, we should make it an error, not a warning. |
100b582 to
254991f
Compare
|
|
|
I agree that unmanaged monkey patching should be discouraged. One distinction I think is worth preserving is lifecycle-managed, reversible patching. Excalidraw uses this.plugin.register(
around(Workspace.prototype, {
...
}),
);This means the original behavior is restored automatically when the plugin unloads, and behavior is altered only for narrowly defined plugin features. The proposed blanket Could the rule remain strict for direct or untracked patching, while allowing a narrowly documented exception, or possibly recognizing the As I also commented in #156, if there are legitimate, lifecycle-safe use cases, and I am confident Excalidraw's uses fall into that category, I think the rule should provide guardrails that allow those cases while still strongly discouraging unsafe monkey patching, rather than treating all uses as equivalent. |
Summary
closes #156
Adds a
no-monkey-patchingrule that discourages directly modifying prototypes, and bans themonkey-aroundpackage viano-restricted-imports.Problem
Monkey patching Obsidian internals is a common source of breakage between plugins and across Obsidian updates. Plugins use the
monkey-aroundpackage or directly assign to class prototypes to intercept core behavior. There's currently no lint rule to flag this.Changes
New rule: no-monkey-patching (
lib/rules/noMonkeyPatching.ts)Workspace.prototype.method = ...Object.defineProperty(X.prototype, ...)andObject.defineProperties(X.prototype, ...)Object.assign(X.prototype, ...)Object.assign(target, source),Object.defineProperty(obj, ...),Object.create(Parent.prototype)Banned dependency (
lib/ruleOptions.ts)monkey-aroundtorestrictedImportsOptions, handled by the existingno-restricted-importsrule alongsideaxios,got, etc.Registration (
lib/index.ts)no-monkey-patchingrecommendedPluginRulesConfigaterrorDocumentation
docs/rules/no-monkey-patching.mdTests
tests/noMonkeyPatching.test.ts; 8 valid and 7 invalid cases covering all prototype mutation patternsWhat changes for consumers
The
no-monkey-patchingrule is now included inrecommendedandrecommendedWithLocalesEnat error severity. Plugins directly modifying prototypes will see errors. Plugins importingmonkey-aroundwill see an error fromno-restricted-imports.