Skip to content

Commit cf906c3

Browse files
pjurczynskiclaude
andcommitted
Add MethodNodeExpander for comment/decorator preservation in method sorting
Implements node expansion to include method metadata (JSDoc comments, decorators) as composite AST nodes that move as single units during refactoring operations. Key features: - Expands method nodes to include leading comments and decorators - Creates synthetic composite nodes using createWrappedNode - Supports both MethodDeclaration and ConstructorDeclaration - Handles adjacent comment detection with whitespace-only separation - Clean architecture with focused single-responsibility methods 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 864b9ee commit cf906c3

3 files changed

Lines changed: 268 additions & 6 deletions

File tree

.quality-baseline.json

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -715,12 +715,6 @@
715715
"singleUseVariable"
716716
]
717717
},
718-
"tests/utils/test-utilities.ts": {
719-
"lastCommitId": "833b616cbfbafc559be677926289fc4eddf0bc4e",
720-
"violations": [
721-
"singleUseVariable"
722-
]
723-
},
724718
"tests/integration/fixture.test.ts": {
725719
"lastCommitId": "f7d3433d1b8e57e08257e50af05bf009f175e2d0",
726720
"violations": [
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import {
2+
createWrappedNode,
3+
MethodDeclaration,
4+
ConstructorDeclaration,
5+
Node,
6+
SourceFile,
7+
ts,
8+
Decorator,
9+
JSDoc
10+
} from 'ts-morph';
11+
12+
export class MethodNodeExpander {
13+
expandWithMetadata(
14+
method: MethodDeclaration | ConstructorDeclaration,
15+
sourceFile: SourceFile
16+
): Node {
17+
return this.createWrapperNode(method, this.locateMethodMetadata(method), sourceFile);
18+
}
19+
20+
private locateMethodMetadata(method: MethodDeclaration | ConstructorDeclaration): ts.Node[] {
21+
return [
22+
...this.getDecoratorNodes(method),
23+
...this.getJsDocNodes(method)
24+
];
25+
}
26+
27+
private getDecoratorNodes(method: MethodDeclaration | ConstructorDeclaration): ts.Node[] {
28+
if (!Node.isMethodDeclaration(method)) return [];
29+
return method.getDecorators().map((decorator: Decorator) => decorator.compilerNode);
30+
}
31+
32+
private getJsDocNodes(method: MethodDeclaration | ConstructorDeclaration): ts.Node[] {
33+
return method.getJsDocs().map((jsDoc: JSDoc) => jsDoc.compilerNode);
34+
}
35+
36+
private createWrapperNode(
37+
method: MethodDeclaration | ConstructorDeclaration,
38+
metadata: ts.Node[],
39+
sourceFile: SourceFile
40+
): Node {
41+
const allNodes = [...metadata, method.compilerNode];
42+
const wrapperNode = this.createBlockNode(allNodes);
43+
this.setWrapperRange(wrapperNode, allNodes, sourceFile);
44+
return createWrappedNode(wrapperNode, { sourceFile: sourceFile.compilerNode });
45+
}
46+
47+
private createBlockNode(allNodes: ts.Node[]): ts.Block {
48+
return ts.factory.createBlock(
49+
allNodes.map(node => this.nodeToStatement(node)),
50+
false
51+
);
52+
}
53+
54+
55+
private nodeToStatement(node: ts.Node): ts.Statement {
56+
if (ts.isStatement(node)) {
57+
return node;
58+
}
59+
return ts.factory.createExpressionStatement(node as ts.Expression);
60+
}
61+
62+
private setWrapperRange(
63+
wrapperNode: ts.Block,
64+
allNodes: ts.Node[],
65+
sourceFile: SourceFile
66+
): void {
67+
if (allNodes.length === 0) return;
68+
69+
this.setNodePosition(wrapperNode, this.calculateStartPosition(allNodes, sourceFile));
70+
this.setNodeEnd(wrapperNode, this.calculateEndPosition(allNodes));
71+
}
72+
73+
private calculateStartPosition(allNodes: ts.Node[], sourceFile: SourceFile): number {
74+
return Math.min(...allNodes.map(node => {
75+
const leadingComments = ts.getLeadingCommentRanges(sourceFile.getFullText(), node.pos) || [];
76+
return leadingComments.length > 0
77+
? Math.min(...leadingComments.map(c => c.pos))
78+
: node.pos;
79+
}));
80+
}
81+
82+
private calculateEndPosition(allNodes: ts.Node[]): number {
83+
return Math.max(...allNodes.map(node => node.end));
84+
}
85+
86+
private setNodePosition(node: ts.Node, position: number): void {
87+
Object.defineProperty(node, 'pos', { value: position, writable: false });
88+
}
89+
90+
private setNodeEnd(node: ts.Node, endPosition: number): void {
91+
Object.defineProperty(node, 'end', { value: endPosition, writable: false });
92+
}
93+
94+
95+
}
Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
import { describe, expect, it } from '@jest/globals';
2+
import { Project, SourceFile } from 'ts-morph';
3+
import { MethodNodeExpander } from '../../../src/core/locators/method-node-expander';
4+
5+
describe('MethodNodeExpander', () => {
6+
let project: Project;
7+
let expander: MethodNodeExpander;
8+
9+
beforeEach(() => {
10+
project = new Project({ useInMemoryFileSystem: true });
11+
expander = new MethodNodeExpander();
12+
});
13+
14+
function testNodeExpansion(code: string, nodeSelector: (sourceFile: SourceFile) => any, expectedText: string): void {
15+
const sourceFile = project.createSourceFile('test.ts', code);
16+
expect(expander.expandWithMetadata(nodeSelector(sourceFile), sourceFile).getFullText().trim()).toBe(expectedText);
17+
}
18+
19+
describe('Node Expansion', () => {
20+
it('should expand method with JSDoc', () => {
21+
testNodeExpansion(
22+
`class TestClass {
23+
/**
24+
* This is a JSDoc comment
25+
*/
26+
testMethod() {
27+
return 'test';
28+
}
29+
}`,
30+
(sf) => sf.getClassOrThrow('TestClass').getMethodOrThrow('testMethod'),
31+
`/**
32+
* This is a JSDoc comment
33+
*/
34+
testMethod() {
35+
return 'test';
36+
}`
37+
);
38+
});
39+
40+
it('should expand method with decorator', () => {
41+
testNodeExpansion(
42+
`class TestClass {
43+
@deprecated
44+
testMethod() {
45+
return 'test';
46+
}
47+
}`,
48+
(sf) => sf.getClassOrThrow('TestClass').getMethodOrThrow('testMethod'),
49+
`@deprecated
50+
testMethod() {
51+
return 'test';
52+
}`
53+
);
54+
});
55+
56+
it('should expand method with single-line comment', () => {
57+
testNodeExpansion(
58+
`class TestClass {
59+
// This is a single-line comment
60+
testMethod() {
61+
return 'test';
62+
}
63+
}`,
64+
(sf) => sf.getClassOrThrow('TestClass').getMethodOrThrow('testMethod'),
65+
`// This is a single-line comment
66+
testMethod() {
67+
return 'test';
68+
}`
69+
);
70+
});
71+
72+
it('should expand constructor with metadata', () => {
73+
testNodeExpansion(
74+
`class TestClass {
75+
/**
76+
* Constructor documentation
77+
*/
78+
constructor() {
79+
// Constructor body
80+
}
81+
}`,
82+
(sf) => sf.getClassOrThrow('TestClass').getConstructors()[0],
83+
`/**
84+
* Constructor documentation
85+
*/
86+
constructor() {
87+
// Constructor body
88+
}`
89+
);
90+
});
91+
92+
it('should expand method with combined metadata', () => {
93+
testNodeExpansion(
94+
`class TestClass {
95+
/**
96+
* JSDoc comment
97+
*/
98+
@deprecated
99+
@injectable
100+
complexMethod(): void {
101+
console.log('test');
102+
}
103+
}`,
104+
(sf) => sf.getClassOrThrow('TestClass').getMethodOrThrow('complexMethod'),
105+
`/**
106+
* JSDoc comment
107+
*/
108+
@deprecated
109+
@injectable
110+
complexMethod(): void {
111+
console.log('test');
112+
}`
113+
);
114+
});
115+
116+
117+
it('should expand class property with JSDoc', () => {
118+
testNodeExpansion(
119+
`class TestClass {
120+
/**
121+
* A class property with JSDoc
122+
*/
123+
private value: string = 'default';
124+
}`,
125+
(sf) => sf.getClassOrThrow('TestClass').getProperties()[0] as any,
126+
`/**
127+
* A class property with JSDoc
128+
*/
129+
private value: string = 'default';`
130+
);
131+
});
132+
133+
it('should expand arrow function property with JSDoc', () => {
134+
testNodeExpansion(
135+
`class TestClass {
136+
/**
137+
* Arrow function method with JSDoc
138+
* @param input - The input parameter
139+
*/
140+
arrowMethod = (input: string): string => {
141+
return input.toUpperCase();
142+
};
143+
}`,
144+
(sf) => sf.getClassOrThrow('TestClass').getProperties()[0] as any,
145+
`/**
146+
* Arrow function method with JSDoc
147+
* @param input - The input parameter
148+
*/
149+
arrowMethod = (input: string): string => {
150+
return input.toUpperCase();
151+
};`
152+
);
153+
});
154+
155+
it('should expand standalone variable with JSDoc', () => {
156+
testNodeExpansion(
157+
`/**
158+
* A standalone variable with JSDoc
159+
* @type {string}
160+
*/
161+
const globalVariable = 'hello world';
162+
163+
class TestClass {}`,
164+
(sf) => sf.getVariableStatements()[0] as any,
165+
`/**
166+
* A standalone variable with JSDoc
167+
* @type {string}
168+
*/
169+
const globalVariable = 'hello world';`
170+
);
171+
});
172+
});
173+
});

0 commit comments

Comments
 (0)