Skip to content

Feat: Add no-member-access rule #5

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 25 commits into from
Jul 3, 2025
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
2a05148
initial work on no-repeated-member-access rule
meowbmw May 23, 2025
00f0549
second attempt; still trying to figure this out
meowbmw May 26, 2025
434d4e8
Add no repeated member access rule
meowbmw Jun 3, 2025
3fac4ff
Add more documentation on how this rule works
meowbmw Jun 5, 2025
9b6f232
Merge branch 'main' into main
xpirad Jun 6, 2025
7667f9d
change default minOcurrences to 3; small code clean up
meowbmw Jun 6, 2025
21732a1
clean up test case
meowbmw Jun 6, 2025
6221add
enable debug support
meowbmw Jun 13, 2025
4f44e12
add barebone rewrite
meowbmw Jun 13, 2025
defaea0
still wip;
xpirad Jun 15, 2025
500b0ca
wip 2: refactor old version
meowbmw Jun 16, 2025
edecf9f
wip 3; near completion??
xpirad Jun 16, 2025
00709fb
ready for review
meowbmw Jun 17, 2025
a84bc60
clearer naming
meowbmw Jun 17, 2025
af3a92e
clean up comment
meowbmw Jun 17, 2025
b6e7556
clean up draft file
xpirad Jun 17, 2025
dd0221a
remove files to support ts debug; add force curly after if; minor adj…
meowbmw Jun 30, 2025
2c569fa
refactor memberaccess using tree structures
meowbmw Jun 30, 2025
7cfe59d
change test case
meowbmw Jun 30, 2025
8608146
fix logic in mark modified
meowbmw Jun 30, 2025
802b6d4
set class members to private & add chains cache
meowbmw Jun 30, 2025
337bb8d
set modified in constructor to ensure the flag is inhereited; constru…
meowbmw Jun 30, 2025
f9382c6
clean up class member & rewrite path generate logic & add visitor fun…
meowbmw Jul 1, 2025
c105abb
Update readme
meowbmw Jul 1, 2025
857cca2
Merge branch 'main' into main
xpirad Jul 3, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 0 additions & 12 deletions docs/rules/no-repeated-member-access.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,18 +28,6 @@ y = a.b.c;
z = a.b.c;
```

## Rule Options

This rule accepts an options object with the following properties:

```json
{
"minOccurrences": 3
}
```

- `minOccurrences` (default: 3): Minimum number of times a member chain must be accessed before triggering the rule

## Examples

### Incorrect
Expand Down
140 changes: 71 additions & 69 deletions plugins/rules/memberAccess.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,61 +10,75 @@ const noRepeatedMemberAccess = createRule({
description:
"Optimize repeated member access patterns by extracting variables",
},
schema: [],
fixable: "code",
schema: [
{
type: "object",
properties: {
minOccurrences: { type: "number", minimum: 2, default: 3 },
},
},
],
messages: {
repeatedAccess:
"Member chain '{{ chain }}' accessed {{ count }} times. Extract to variable.",
"Member chain '{{ chain }}' is accessed multiple times. Extract to variable.",
},
},
defaultOptions: [{ minOccurrences: 3 }],
defaultOptions: [],

create(context, [options]) {
create(context) {
const sourceCode = context.sourceCode;
const minOccurrences = options.minOccurrences;

// Track which chains have already been reported to avoid duplicate reports
const reportedChains = new Set<string>();

// Tree-based approach for storing member access chains
// Each node represents a property in the chain (e.g., a -> b -> c for a.b.c)
class ChainNode {
name: string;
count: number = 0;
modified: boolean = false;
parent?: ChainNode;
children: Map<string, ChainNode> = new Map();

constructor(name: string) {
this.name = name;
private count: number = 0;
private modified: boolean = false;
private parent?: ChainNode;
private children: Map<string, ChainNode> = new Map();
private path: string;

constructor(name: string, parent?: ChainNode) {
this.parent = parent;
this.modified = this.parent?.modified || false;

if (name === "__root__") {
this.path = "";
} else if (!this.parent || this.parent.path === "") {
this.path = name;
} else {
this.path = this.parent.path + "." + name;
}
}

get getCount(): number {
return this.count;
}

get isModified(): boolean {
return this.modified;
}

get getParent(): ChainNode | undefined {
return this.parent;
}

get getChildren(): Map<string, ChainNode> {
return this.children;
}

get getPath(): string {
return this.path;
}

incrementCount(): void {
this.count++;
}

// Get or create child node
getOrCreateChild(childName: string): ChainNode {
if (!this.children.has(childName)) {
this.children.set(childName, new ChainNode(childName));
this.children.set(childName, new ChainNode(childName, this));
}
return this.children.get(childName)!;
}

// Get the full chain path from root to this node
getChainPath(): string {
const path: string[] = [];
let current = this as ChainNode | undefined;
while (current && current.name !== "__root__") {
path.unshift(current.name);
current = current.parent;
}
return path.join(".");
}

// Mark this node and all its descendants as modified
markAsModified(): void {
this.modified = true;
Expand All @@ -76,7 +90,7 @@ const noRepeatedMemberAccess = createRule({

// Root node for the tree (per scope)
class ChainTree {
root: ChainNode = new ChainNode("__root__");
private root: ChainNode = new ChainNode("__root__");

// Insert a chain path into the tree and increment counts
insertChain(properties: string[]): void {
Expand All @@ -85,12 +99,11 @@ const noRepeatedMemberAccess = createRule({
// Navigate/create path in tree
for (const prop of properties) {
const child = current.getOrCreateChild(prop);
child.parent = current;
current = child;

// Only increment count for non-single properties (chains with dots)
if (properties.length > 1) {
current.count++;
current.incrementCount();
}
}
}
Expand All @@ -101,52 +114,39 @@ const noRepeatedMemberAccess = createRule({

// Navigate to the target node, creating nodes if they don't exist
for (const prop of properties) {
const child = current.children.get(prop);
if (child) {
current = child;
} else {
// Create the chain if it doesn't exist
const newChild = current.getOrCreateChild(prop);
newChild.parent = current;
current = newChild;
}
const newChild = current.getOrCreateChild(prop);
current = newChild;
}

// Mark this node and all descendants as modified
current.markAsModified();
}

// Find the longest valid chain that meets the minimum occurrence threshold
findLongestValidChain(
minOccurrences: number
): { chain: string; count: number } | null {
let bestChain: string | null = null;
let bestCount = 0;
// Find any valid chain that meets the minimum occurrence threshold
findValidChains() {
const validChains: Array<{ chain: string }> = [];

const traverse = (node: ChainNode, depth: number) => {
// Only consider chains with more than one segment (has dots)
if (depth > 1 && !node.modified && node.count >= minOccurrences) {
const chainPath = node.getChainPath();
if (chainPath.length > (bestChain?.length || 0)) {
bestChain = chainPath;
bestCount = node.count;
}
if (depth > 1 && !node.isModified && node.getCount >= 2) {
validChains.push({
chain: node.getPath,
});
}

// Stop traversing if this node is modified
if (node.modified) {
if (node.isModified) {
return;
}

// Recursively traverse children
for (const child of node.children.values()) {
for (const child of node.getChildren.values()) {
traverse(child, depth + 1);
}
};

traverse(this.root, 0);

return bestChain ? { chain: bestChain, count: bestCount } : null;
return validChains;
}
}

Expand Down Expand Up @@ -224,15 +224,17 @@ const noRepeatedMemberAccess = createRule({
// Insert the chain into the tree (this will increment counts automatically)
chainTree.insertChain(properties);

// Find the longest valid chain to report
const result = chainTree.findLongestValidChain(minOccurrences);
if (result && !reportedChains.has(result.chain)) {
context.report({
node: node,
messageId: "repeatedAccess",
data: { chain: result.chain, count: result.count },
});
reportedChains.add(result.chain);
// Find all valid chains to report
const validChains = chainTree.findValidChains();
for (const result of validChains) {
if (!reportedChains.has(result.chain)) {
context.report({
node: node,
messageId: "repeatedAccess",
data: { chain: result.chain },
});
reportedChains.add(result.chain);
}
}
}

Expand Down
25 changes: 23 additions & 2 deletions tests/rules/noRepeatedMemberAccess.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,25 @@ describe("Rule: no-spread", () => {
const v2 = a.b.c;
const v3 = a.b.c;
`,
errors: [{ messageId: "repeatedAccess" }],
errors: [
{ messageId: "repeatedAccess" },
{ messageId: "repeatedAccess" },
],
},
{
code: `
const data = a.b.c.d;
const data = a.b.c.d;
const data = a.b.c.d;
const data = a.b.c;
const data = a.b.c;

`,
errors: [
{ messageId: "repeatedAccess" },
{ messageId: "repeatedAccess" },
{ messageId: "repeatedAccess" },
],
},
{
code: `
Expand All @@ -137,7 +155,10 @@ describe("Rule: no-spread", () => {
return obj.a.b.d;
}
`,
errors: [{ messageId: "repeatedAccess" }],
errors: [
{ messageId: "repeatedAccess" },
{ messageId: "repeatedAccess" },
],
},
{
code: `
Expand Down