@opensip-tools/lang-typescript 1.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +4 -0
- package/.turbo/turbo-typecheck.log +4 -0
- package/LICENSE +21 -0
- package/dist/__tests__/adapter.test.d.ts +2 -0
- package/dist/__tests__/adapter.test.d.ts.map +1 -0
- package/dist/__tests__/adapter.test.js +56 -0
- package/dist/__tests__/adapter.test.js.map +1 -0
- package/dist/__tests__/ast-utilities.test.d.ts +2 -0
- package/dist/__tests__/ast-utilities.test.d.ts.map +1 -0
- package/dist/__tests__/ast-utilities.test.js +237 -0
- package/dist/__tests__/ast-utilities.test.js.map +1 -0
- package/dist/adapter.d.ts +6 -0
- package/dist/adapter.d.ts.map +1 -0
- package/dist/adapter.js +15 -0
- package/dist/adapter.js.map +1 -0
- package/dist/ast-utilities.d.ts +74 -0
- package/dist/ast-utilities.d.ts.map +1 -0
- package/dist/ast-utilities.js +217 -0
- package/dist/ast-utilities.js.map +1 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +9 -0
- package/dist/index.js.map +1 -0
- package/dist/parse.d.ts +10 -0
- package/dist/parse.d.ts.map +1 -0
- package/dist/parse.js +18 -0
- package/dist/parse.js.map +1 -0
- package/dist/query.d.ts +4 -0
- package/dist/query.d.ts.map +1 -0
- package/dist/query.js +74 -0
- package/dist/query.js.map +1 -0
- package/dist/strip.d.ts +19 -0
- package/dist/strip.d.ts.map +1 -0
- package/dist/strip.js +26 -0
- package/dist/strip.js.map +1 -0
- package/package.json +41 -0
- package/src/__tests__/adapter.test.ts +67 -0
- package/src/__tests__/ast-utilities.test.ts +252 -0
- package/src/adapter.ts +21 -0
- package/src/ast-utilities.ts +238 -0
- package/src/index.ts +26 -0
- package/src/parse.ts +22 -0
- package/src/query.ts +76 -0
- package/src/strip.ts +30 -0
- package/tsconfig.json +8 -0
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
countUnescapedBackticks,
|
|
5
|
+
findBinaryExpressions,
|
|
6
|
+
findCallExpressions,
|
|
7
|
+
findTemplateLiterals,
|
|
8
|
+
getColumn,
|
|
9
|
+
getIdentifierName,
|
|
10
|
+
getLineNumber,
|
|
11
|
+
getPropertyChain,
|
|
12
|
+
getSharedSourceFile,
|
|
13
|
+
isInComment,
|
|
14
|
+
isInStringLiteral,
|
|
15
|
+
isLiteral,
|
|
16
|
+
isPropertyAccess,
|
|
17
|
+
parseSource,
|
|
18
|
+
ts,
|
|
19
|
+
walkNodes,
|
|
20
|
+
} from '../ast-utilities.js';
|
|
21
|
+
|
|
22
|
+
const parse = (content: string) => parseSource(content, 'x.ts');
|
|
23
|
+
|
|
24
|
+
describe('parseSource', () => {
|
|
25
|
+
it('parses valid TypeScript', () => {
|
|
26
|
+
expect(parse('const x = 1;')).not.toBeNull();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('returns null on parse failure', () => {
|
|
30
|
+
// Note: TS parser is permissive — try with a sentinel call that throws.
|
|
31
|
+
// Most invalid syntax still produces a tree; instead exercise the catch
|
|
32
|
+
// path by passing a non-string.
|
|
33
|
+
// Cast to any to bypass the type guard for this test.
|
|
34
|
+
const result = parseSource(undefined as unknown as string, 'x.ts');
|
|
35
|
+
expect(result).toBeNull();
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe('getSharedSourceFile', () => {
|
|
40
|
+
it('returns a parsed source file', () => {
|
|
41
|
+
expect(getSharedSourceFile('shared.ts', 'export const x = 1;')).not.toBeNull();
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe('walkNodes', () => {
|
|
46
|
+
it('visits every descendant node', () => {
|
|
47
|
+
const sf = parse('const x = 1; const y = 2;');
|
|
48
|
+
if (!sf) throw new Error('parse failed');
|
|
49
|
+
let count = 0;
|
|
50
|
+
walkNodes(sf, () => count++);
|
|
51
|
+
expect(count).toBeGreaterThan(2);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe('getIdentifierName / getPropertyChain', () => {
|
|
56
|
+
it('returns the leaf identifier from an Identifier', () => {
|
|
57
|
+
const sf = parse('foo;');
|
|
58
|
+
if (!sf) throw new Error('parse failed');
|
|
59
|
+
let leaf = '';
|
|
60
|
+
walkNodes(sf, (n) => {
|
|
61
|
+
if (ts.isIdentifier(n) && leaf === '') leaf = getIdentifierName(n);
|
|
62
|
+
});
|
|
63
|
+
expect(leaf).toBe('foo');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('returns the property name from a PropertyAccessExpression', () => {
|
|
67
|
+
const sf = parse('a.b.c;');
|
|
68
|
+
if (!sf) throw new Error('parse failed');
|
|
69
|
+
let result = '';
|
|
70
|
+
walkNodes(sf, (n) => {
|
|
71
|
+
if (ts.isPropertyAccessExpression(n) && result === '') result = getPropertyChain(n);
|
|
72
|
+
});
|
|
73
|
+
expect(result).toBe('a.b.c');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('returns empty string for non-identifier non-property nodes', () => {
|
|
77
|
+
const sf = parse('1 + 2;');
|
|
78
|
+
if (!sf) throw new Error('parse failed');
|
|
79
|
+
let found = '';
|
|
80
|
+
walkNodes(sf, (n) => {
|
|
81
|
+
if (ts.isBinaryExpression(n)) found = getIdentifierName(n);
|
|
82
|
+
});
|
|
83
|
+
expect(found).toBe('');
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('getPropertyChain returns empty for non-identifier non-property', () => {
|
|
87
|
+
const sf = parse('1 + 2;');
|
|
88
|
+
if (!sf) throw new Error('parse failed');
|
|
89
|
+
let found = '';
|
|
90
|
+
walkNodes(sf, (n) => {
|
|
91
|
+
if (ts.isBinaryExpression(n) && found === '') found = getPropertyChain(n);
|
|
92
|
+
});
|
|
93
|
+
expect(found).toBe('');
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
describe('getLineNumber / getColumn', () => {
|
|
98
|
+
it('returns 1-based line and 0-based column', () => {
|
|
99
|
+
const sf = parse('\n\nconst x = 1;');
|
|
100
|
+
if (!sf) throw new Error('parse failed');
|
|
101
|
+
let line = 0;
|
|
102
|
+
let col = 0;
|
|
103
|
+
walkNodes(sf, (n) => {
|
|
104
|
+
if (ts.isVariableDeclaration(n)) {
|
|
105
|
+
line = getLineNumber(n, sf);
|
|
106
|
+
col = getColumn(n, sf);
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
expect(line).toBe(3);
|
|
110
|
+
expect(col).toBe(6); // "const " (6 chars)
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
describe('isPropertyAccess', () => {
|
|
115
|
+
it('matches when the property name is the right one', () => {
|
|
116
|
+
const sf = parse('foo.bar();');
|
|
117
|
+
if (!sf) throw new Error('parse failed');
|
|
118
|
+
let matched = false;
|
|
119
|
+
walkNodes(sf, (n) => {
|
|
120
|
+
if (ts.isPropertyAccessExpression(n) && isPropertyAccess(n, 'bar')) matched = true;
|
|
121
|
+
});
|
|
122
|
+
expect(matched).toBe(true);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('does not match when the property name differs', () => {
|
|
126
|
+
const sf = parse('foo.bar();');
|
|
127
|
+
if (!sf) throw new Error('parse failed');
|
|
128
|
+
let matched = false;
|
|
129
|
+
walkNodes(sf, (n) => {
|
|
130
|
+
if (ts.isPropertyAccessExpression(n) && isPropertyAccess(n, 'baz')) matched = true;
|
|
131
|
+
});
|
|
132
|
+
expect(matched).toBe(false);
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
describe('isLiteral', () => {
|
|
137
|
+
it.each([
|
|
138
|
+
['"hi"', true],
|
|
139
|
+
['42', true],
|
|
140
|
+
['true', true],
|
|
141
|
+
['false', true],
|
|
142
|
+
['null', true],
|
|
143
|
+
['undefined', true],
|
|
144
|
+
['x', false],
|
|
145
|
+
])('isLiteral(%s) === %s', (src, expected) => {
|
|
146
|
+
const sf = parse(`(${src});`);
|
|
147
|
+
if (!sf) throw new Error('parse failed');
|
|
148
|
+
let result: boolean | null = null;
|
|
149
|
+
walkNodes(sf, (n) => {
|
|
150
|
+
if (ts.isParenthesizedExpression(n) && result === null) result = isLiteral(n.expression);
|
|
151
|
+
});
|
|
152
|
+
expect(result).toBe(expected);
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
describe('isInStringLiteral', () => {
|
|
157
|
+
it('returns true for nodes inside a string template', () => {
|
|
158
|
+
const sf = parse('const x = `${foo}`;');
|
|
159
|
+
if (!sf) throw new Error('parse failed');
|
|
160
|
+
let found = false;
|
|
161
|
+
walkNodes(sf, (n) => {
|
|
162
|
+
if (ts.isIdentifier(n) && n.text === 'foo' && isInStringLiteral(n)) found = true;
|
|
163
|
+
});
|
|
164
|
+
expect(found).toBe(true);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('returns false for nodes outside string literals', () => {
|
|
168
|
+
const sf = parse('const x = 1; const y = x;');
|
|
169
|
+
if (!sf) throw new Error('parse failed');
|
|
170
|
+
let foundOutside = false;
|
|
171
|
+
walkNodes(sf, (n) => {
|
|
172
|
+
if (ts.isIdentifier(n) && n.text === 'y' && !isInStringLiteral(n)) foundOutside = true;
|
|
173
|
+
});
|
|
174
|
+
expect(foundOutside).toBe(true);
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
describe('findCallExpressions', () => {
|
|
179
|
+
it('finds matching call sites by object + method name', () => {
|
|
180
|
+
const sf = parse('console.log(1); foo(); console.log(2);');
|
|
181
|
+
if (!sf) throw new Error('parse failed');
|
|
182
|
+
const calls = findCallExpressions(sf, 'console', 'log');
|
|
183
|
+
expect(calls.length).toBe(2);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('matches when objectName is a suffix of the property chain', () => {
|
|
187
|
+
const sf = parse('a.b.console.log(1);');
|
|
188
|
+
if (!sf) throw new Error('parse failed');
|
|
189
|
+
const calls = findCallExpressions(sf, 'console', 'log');
|
|
190
|
+
expect(calls.length).toBe(1);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it('returns empty when no matches', () => {
|
|
194
|
+
const sf = parse('foo();');
|
|
195
|
+
if (!sf) throw new Error('parse failed');
|
|
196
|
+
expect(findCallExpressions(sf, 'console', 'log')).toEqual([]);
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
describe('findBinaryExpressions', () => {
|
|
201
|
+
it('finds binary expressions of the given operator kind', () => {
|
|
202
|
+
const sf = parse('a + b; c - d; a + e;');
|
|
203
|
+
if (!sf) throw new Error('parse failed');
|
|
204
|
+
expect(findBinaryExpressions(sf, ts.SyntaxKind.PlusToken).length).toBe(2);
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
describe('findTemplateLiterals', () => {
|
|
209
|
+
it('finds template expressions with interpolations', () => {
|
|
210
|
+
const sf = parse('const x = `${a}${b}`;');
|
|
211
|
+
if (!sf) throw new Error('parse failed');
|
|
212
|
+
expect(findTemplateLiterals(sf).length).toBe(1);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it('skips no-substitution templates', () => {
|
|
216
|
+
const sf = parse('const x = `static`;');
|
|
217
|
+
if (!sf) throw new Error('parse failed');
|
|
218
|
+
expect(findTemplateLiterals(sf)).toEqual([]);
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
describe('isInComment', () => {
|
|
223
|
+
it('returns false for a position outside a comment', () => {
|
|
224
|
+
const src = 'const x = 1;\nconst y = 2;';
|
|
225
|
+
const sf = parse(src);
|
|
226
|
+
if (!sf) throw new Error('parse failed');
|
|
227
|
+
const xIdx = src.indexOf('const x');
|
|
228
|
+
expect(isInComment(xIdx, sf)).toBe(false);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it('returns true for a position inside a leading block comment', () => {
|
|
232
|
+
const src = '/* block\n comment\n*/\nconst x = 1;';
|
|
233
|
+
const sf = parse(src);
|
|
234
|
+
if (!sf) throw new Error('parse failed');
|
|
235
|
+
const insideIdx = src.indexOf('block');
|
|
236
|
+
expect(isInComment(insideIdx, sf)).toBe(true);
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
describe('countUnescapedBackticks', () => {
|
|
241
|
+
it('counts unescaped backticks', () => {
|
|
242
|
+
expect(countUnescapedBackticks('a `b` c')).toBe(2);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it('does not count escaped backticks', () => {
|
|
246
|
+
expect(countUnescapedBackticks('a \\` b')).toBe(0);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it('returns 0 when there are none', () => {
|
|
250
|
+
expect(countUnescapedBackticks('plain text')).toBe(0);
|
|
251
|
+
});
|
|
252
|
+
});
|
package/src/adapter.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
|
|
2
|
+
|
|
3
|
+
import { parseSource } from './parse.js'
|
|
4
|
+
import { typescriptQuery } from './query.js'
|
|
5
|
+
import { stripComments, stripStrings } from './strip.js'
|
|
6
|
+
|
|
7
|
+
import type { LanguageAdapter } from '@opensip-tools/core/languages/adapter.js'
|
|
8
|
+
import type ts from 'typescript'
|
|
9
|
+
|
|
10
|
+
export const typescriptAdapter: LanguageAdapter<ts.SourceFile, ts.Node> = {
|
|
11
|
+
id: 'typescript',
|
|
12
|
+
fileExtensions: ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs'],
|
|
13
|
+
aliases: ['javascript', 'tsx', 'jsx', 'js'],
|
|
14
|
+
parse: parseSource,
|
|
15
|
+
stripStrings,
|
|
16
|
+
stripComments,
|
|
17
|
+
query: typescriptQuery,
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Plugin contract — exported as the lang plugin's `adapters` array. */
|
|
21
|
+
export const adapters = [typescriptAdapter] as const
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
// @fitness-ignore-file batch-operation-limits -- iterates bounded collections (config entries, registry items, or small analysis results)
|
|
2
|
+
/**
|
|
3
|
+
* @fileoverview Shared AST utilities for fitness checks.
|
|
4
|
+
*
|
|
5
|
+
* Common TypeScript AST operations for source parsing, tree walking,
|
|
6
|
+
* and node inspection. Used by AST-based fitness checks. Lives in
|
|
7
|
+
* @opensip-tools/lang-typescript so the dependency on `typescript` is
|
|
8
|
+
* isolated to the language pack.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { getParseTree } from '@opensip-tools/core/languages/parse-cache.js'
|
|
12
|
+
import * as ts from 'typescript'
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
import { typescriptAdapter } from './adapter.js'
|
|
16
|
+
|
|
17
|
+
// =============================================================================
|
|
18
|
+
// SOURCE PARSING
|
|
19
|
+
// =============================================================================
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Parse TypeScript/JavaScript source into an AST SourceFile.
|
|
23
|
+
* Returns null on parse failure.
|
|
24
|
+
*/
|
|
25
|
+
export function parseSource(content: string, filePath: string): ts.SourceFile | null {
|
|
26
|
+
try {
|
|
27
|
+
return ts.createSourceFile(filePath, content, ts.ScriptTarget.Latest, true)
|
|
28
|
+
} catch {
|
|
29
|
+
return null
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Cached parse — uses the language-aware parse cache via the registered
|
|
35
|
+
* TS adapter. Falls back to a direct parse when no cache is active.
|
|
36
|
+
*/
|
|
37
|
+
export function getSharedSourceFile(filePath: string, content: string): ts.SourceFile | null {
|
|
38
|
+
return getParseTree(typescriptAdapter, filePath, content)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// =============================================================================
|
|
42
|
+
// TREE WALKING
|
|
43
|
+
// =============================================================================
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Depth-first walk of all nodes in a SourceFile or subtree.
|
|
47
|
+
*/
|
|
48
|
+
export function walkNodes(root: ts.Node, visitor: (node: ts.Node) => void): void {
|
|
49
|
+
function visit(node: ts.Node): void {
|
|
50
|
+
visitor(node)
|
|
51
|
+
ts.forEachChild(node, visit)
|
|
52
|
+
}
|
|
53
|
+
ts.forEachChild(root, visit)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// =============================================================================
|
|
57
|
+
// NODE INSPECTION
|
|
58
|
+
// =============================================================================
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Get the leaf identifier text from an expression node.
|
|
62
|
+
*/
|
|
63
|
+
export function getIdentifierName(node: ts.Node): string {
|
|
64
|
+
if (ts.isIdentifier(node)) return node.text
|
|
65
|
+
if (ts.isPropertyAccessExpression(node)) return node.name.text
|
|
66
|
+
return ''
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Get the full dotted path of a property access chain.
|
|
71
|
+
*/
|
|
72
|
+
export function getPropertyChain(node: ts.Node): string {
|
|
73
|
+
if (ts.isIdentifier(node)) return node.text
|
|
74
|
+
if (ts.isPropertyAccessExpression(node)) {
|
|
75
|
+
return `${getPropertyChain(node.expression)}.${node.name.text}`
|
|
76
|
+
}
|
|
77
|
+
return ''
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Get the 1-indexed line number for a node.
|
|
82
|
+
*/
|
|
83
|
+
export function getLineNumber(node: ts.Node, sourceFile: ts.SourceFile): number {
|
|
84
|
+
const { line } = sourceFile.getLineAndCharacterOfPosition(node.getStart())
|
|
85
|
+
return line + 1
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Get the column number (0-indexed) for a node.
|
|
90
|
+
*/
|
|
91
|
+
export function getColumn(node: ts.Node, sourceFile: ts.SourceFile): number {
|
|
92
|
+
const { character } = sourceFile.getLineAndCharacterOfPosition(node.getStart())
|
|
93
|
+
return character
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Check if a node is a property access matching a specific property name.
|
|
98
|
+
*/
|
|
99
|
+
export function isPropertyAccess(node: ts.Node, propertyName: string): boolean {
|
|
100
|
+
return ts.isPropertyAccessExpression(node) && node.name.text === propertyName
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Check if a node is a literal value.
|
|
105
|
+
*/
|
|
106
|
+
export function isLiteral(node: ts.Node): boolean {
|
|
107
|
+
if (ts.isStringLiteral(node) || ts.isNumericLiteral(node)) return true
|
|
108
|
+
if (ts.isNoSubstitutionTemplateLiteral(node)) return true
|
|
109
|
+
if (node.kind === ts.SyntaxKind.TrueKeyword || node.kind === ts.SyntaxKind.FalseKeyword)
|
|
110
|
+
return true
|
|
111
|
+
if (node.kind === ts.SyntaxKind.NullKeyword) return true
|
|
112
|
+
if (ts.isIdentifier(node) && node.text === 'undefined') return true
|
|
113
|
+
return false
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Check if a node is inside a string literal or template literal.
|
|
118
|
+
*/
|
|
119
|
+
export function isInStringLiteral(node: ts.Node): boolean {
|
|
120
|
+
let current = node.parent
|
|
121
|
+
while (!ts.isSourceFile(current)) {
|
|
122
|
+
if (
|
|
123
|
+
ts.isStringLiteral(current) ||
|
|
124
|
+
ts.isNoSubstitutionTemplateLiteral(current) ||
|
|
125
|
+
ts.isTemplateExpression(current)
|
|
126
|
+
) {
|
|
127
|
+
return true
|
|
128
|
+
}
|
|
129
|
+
current = current.parent
|
|
130
|
+
}
|
|
131
|
+
return false
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// =============================================================================
|
|
135
|
+
// NODE FINDERS
|
|
136
|
+
// =============================================================================
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Find all call expressions matching `object.method()` pattern.
|
|
140
|
+
*/
|
|
141
|
+
export function findCallExpressions(
|
|
142
|
+
root: ts.Node,
|
|
143
|
+
objectName: string,
|
|
144
|
+
methodName: string,
|
|
145
|
+
): ts.CallExpression[] {
|
|
146
|
+
const results: ts.CallExpression[] = []
|
|
147
|
+
walkNodes(root, (node) => {
|
|
148
|
+
if (!ts.isCallExpression(node)) return
|
|
149
|
+
const expr = node.expression
|
|
150
|
+
if (!ts.isPropertyAccessExpression(expr)) return
|
|
151
|
+
if (expr.name.text !== methodName) return
|
|
152
|
+
const chain = getPropertyChain(expr.expression)
|
|
153
|
+
if (chain === objectName || chain.endsWith(`.${objectName}`)) {
|
|
154
|
+
results.push(node)
|
|
155
|
+
}
|
|
156
|
+
})
|
|
157
|
+
return results
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Find all binary expressions with a specific operator.
|
|
162
|
+
*/
|
|
163
|
+
export function findBinaryExpressions(
|
|
164
|
+
root: ts.Node,
|
|
165
|
+
operator: ts.SyntaxKind,
|
|
166
|
+
): ts.BinaryExpression[] {
|
|
167
|
+
const results: ts.BinaryExpression[] = []
|
|
168
|
+
walkNodes(root, (node) => {
|
|
169
|
+
if (ts.isBinaryExpression(node) && node.operatorToken.kind === operator) {
|
|
170
|
+
results.push(node)
|
|
171
|
+
}
|
|
172
|
+
})
|
|
173
|
+
return results
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Find all template literal expressions (with substitutions).
|
|
178
|
+
*/
|
|
179
|
+
export function findTemplateLiterals(root: ts.Node): ts.TemplateExpression[] {
|
|
180
|
+
const results: ts.TemplateExpression[] = []
|
|
181
|
+
walkNodes(root, (node) => {
|
|
182
|
+
if (ts.isTemplateExpression(node)) {
|
|
183
|
+
results.push(node)
|
|
184
|
+
}
|
|
185
|
+
})
|
|
186
|
+
return results
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// =============================================================================
|
|
190
|
+
// COMMENT DETECTION
|
|
191
|
+
// =============================================================================
|
|
192
|
+
|
|
193
|
+
function isPositionInRanges(position: number, ranges: ts.CommentRange[] | undefined): boolean {
|
|
194
|
+
if (!ranges) return false
|
|
195
|
+
for (const range of ranges) {
|
|
196
|
+
if (position >= range.pos && position < range.end) return true
|
|
197
|
+
}
|
|
198
|
+
return false
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Check if a position in the source falls inside a comment.
|
|
203
|
+
*/
|
|
204
|
+
export function isInComment(position: number, sourceFile: ts.SourceFile): boolean {
|
|
205
|
+
const text = sourceFile.getFullText()
|
|
206
|
+
const lineStarts = sourceFile.getLineStarts()
|
|
207
|
+
|
|
208
|
+
for (let i = 0; i < lineStarts.length; i++) {
|
|
209
|
+
const lineStart = lineStarts[i] ?? 0
|
|
210
|
+
const lineEnd = i + 1 < lineStarts.length ? (lineStarts[i + 1] ?? text.length) : text.length
|
|
211
|
+
|
|
212
|
+
if (position < lineStart || position >= lineEnd) continue
|
|
213
|
+
|
|
214
|
+
if (isPositionInRanges(position, ts.getLeadingCommentRanges(text, lineStart))) return true
|
|
215
|
+
if (isPositionInRanges(position, ts.getTrailingCommentRanges(text, lineStart))) return true
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return false
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// =============================================================================
|
|
222
|
+
// STRING UTILITIES
|
|
223
|
+
// =============================================================================
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Count unescaped backtick characters in a line.
|
|
227
|
+
*/
|
|
228
|
+
export function countUnescapedBackticks(line: string): number {
|
|
229
|
+
let count = 0
|
|
230
|
+
for (let ci = 0; ci < line.length; ci++) {
|
|
231
|
+
if (line[ci] === '`' && (ci === 0 || line[ci - 1] !== '\\')) count++
|
|
232
|
+
}
|
|
233
|
+
return count
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** Re-export TypeScript namespace for check authors */
|
|
237
|
+
// eslint-disable-next-line unicorn/prefer-export-from -- `export * as from 'typescript'` is invalid (typescript uses `export =`); the namespace import + named export form is the only working shape
|
|
238
|
+
export { ts }
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// TypeScript LanguageAdapter for opensip-tools
|
|
2
|
+
export { typescriptAdapter, adapters } from './adapter.js'
|
|
3
|
+
export { parseSource } from './parse.js'
|
|
4
|
+
export { typescriptQuery } from './query.js'
|
|
5
|
+
export { stripStrings, stripComments, filterContent, clearFilterCache } from './strip.js'
|
|
6
|
+
export type { FilteredContent } from './strip.js'
|
|
7
|
+
|
|
8
|
+
// Legacy AST helpers — re-exported so existing TS checks can keep their imports
|
|
9
|
+
// pointing at @opensip-tools/lang-typescript instead of @opensip-tools/core/framework/*
|
|
10
|
+
export {
|
|
11
|
+
getSharedSourceFile,
|
|
12
|
+
walkNodes,
|
|
13
|
+
getIdentifierName,
|
|
14
|
+
getPropertyChain,
|
|
15
|
+
getLineNumber,
|
|
16
|
+
getColumn,
|
|
17
|
+
isPropertyAccess,
|
|
18
|
+
isLiteral,
|
|
19
|
+
isInStringLiteral,
|
|
20
|
+
findCallExpressions,
|
|
21
|
+
findBinaryExpressions,
|
|
22
|
+
findTemplateLiterals,
|
|
23
|
+
isInComment,
|
|
24
|
+
countUnescapedBackticks,
|
|
25
|
+
ts,
|
|
26
|
+
} from './ast-utilities.js'
|
package/src/parse.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import ts from 'typescript'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Parse TypeScript/JavaScript source into a SourceFile.
|
|
5
|
+
* Returns null on parse failure.
|
|
6
|
+
*
|
|
7
|
+
* Uses ts.ScriptKind.TSX so the same parse path handles .ts and .tsx
|
|
8
|
+
* (and is permissive enough for .js / .jsx).
|
|
9
|
+
*/
|
|
10
|
+
export function parseSource(content: string, filePath: string): ts.SourceFile | null {
|
|
11
|
+
try {
|
|
12
|
+
return ts.createSourceFile(
|
|
13
|
+
filePath,
|
|
14
|
+
content,
|
|
15
|
+
ts.ScriptTarget.Latest,
|
|
16
|
+
/* setParentNodes */ true,
|
|
17
|
+
ts.ScriptKind.TSX,
|
|
18
|
+
)
|
|
19
|
+
} catch {
|
|
20
|
+
return null
|
|
21
|
+
}
|
|
22
|
+
}
|
package/src/query.ts
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import ts from 'typescript'
|
|
2
|
+
|
|
3
|
+
import type { LanguageQueryAPI } from '@opensip-tools/core/languages/adapter.js'
|
|
4
|
+
import type { GenericFunction, Import, Location } from '@opensip-tools/core/languages/generic-types.js'
|
|
5
|
+
|
|
6
|
+
function locationOf(sourceFile: ts.SourceFile, node: ts.Node): Location {
|
|
7
|
+
const { line, character } = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile))
|
|
8
|
+
return { file: sourceFile.fileName, line: line + 1, column: character }
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function walk(node: ts.Node, visit: (n: ts.Node) => void): void {
|
|
12
|
+
visit(node)
|
|
13
|
+
ts.forEachChild(node, (child) => walk(child, visit))
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const typescriptQuery: LanguageQueryAPI<ts.SourceFile, ts.Node> = {
|
|
17
|
+
findFunctions(tree) {
|
|
18
|
+
const out: GenericFunction<ts.Node>[] = []
|
|
19
|
+
walk(tree, (n) => {
|
|
20
|
+
if (
|
|
21
|
+
ts.isFunctionDeclaration(n) ||
|
|
22
|
+
ts.isFunctionExpression(n) ||
|
|
23
|
+
ts.isArrowFunction(n) ||
|
|
24
|
+
ts.isMethodDeclaration(n)
|
|
25
|
+
) {
|
|
26
|
+
const name = (n as ts.FunctionDeclaration).name?.text ?? null
|
|
27
|
+
out.push({ name, location: locationOf(tree, n), node: n })
|
|
28
|
+
}
|
|
29
|
+
})
|
|
30
|
+
return out
|
|
31
|
+
},
|
|
32
|
+
findImports(tree) {
|
|
33
|
+
const out: Import[] = []
|
|
34
|
+
walk(tree, (n) => {
|
|
35
|
+
if (ts.isImportDeclaration(n) && ts.isStringLiteral(n.moduleSpecifier)) {
|
|
36
|
+
const specifier = n.moduleSpecifier.text
|
|
37
|
+
const names: string[] = []
|
|
38
|
+
const clause = n.importClause
|
|
39
|
+
if (clause?.name) names.push(clause.name.text)
|
|
40
|
+
if (clause?.namedBindings && ts.isNamedImports(clause.namedBindings)) {
|
|
41
|
+
for (const elem of clause.namedBindings.elements) names.push(elem.name.text)
|
|
42
|
+
}
|
|
43
|
+
out.push({ specifier, names, location: locationOf(tree, n) })
|
|
44
|
+
}
|
|
45
|
+
})
|
|
46
|
+
return out
|
|
47
|
+
},
|
|
48
|
+
findCallsTo(tree, name) {
|
|
49
|
+
const out: ts.Node[] = []
|
|
50
|
+
walk(tree, (n) => {
|
|
51
|
+
if (ts.isCallExpression(n)) {
|
|
52
|
+
const expr = n.expression
|
|
53
|
+
let target = ''
|
|
54
|
+
if (ts.isIdentifier(expr)) target = expr.text
|
|
55
|
+
else if (ts.isPropertyAccessExpression(expr)) target = expr.name.text
|
|
56
|
+
if (target === name) out.push(n)
|
|
57
|
+
}
|
|
58
|
+
})
|
|
59
|
+
return out
|
|
60
|
+
},
|
|
61
|
+
findStringLiterals(tree) {
|
|
62
|
+
const out: { value: string; location: Location }[] = []
|
|
63
|
+
walk(tree, (n) => {
|
|
64
|
+
if (ts.isStringLiteralLike(n)) {
|
|
65
|
+
out.push({ value: n.text, location: locationOf(tree, n) })
|
|
66
|
+
}
|
|
67
|
+
})
|
|
68
|
+
return out
|
|
69
|
+
},
|
|
70
|
+
getLocation(tree, node) {
|
|
71
|
+
return locationOf(tree, node)
|
|
72
|
+
},
|
|
73
|
+
getText(tree, node) {
|
|
74
|
+
return node.getText(tree)
|
|
75
|
+
},
|
|
76
|
+
}
|
package/src/strip.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview TypeScript string and comment stripping.
|
|
3
|
+
*
|
|
4
|
+
* Implements the LanguageAdapter contract methods stripStrings/stripComments
|
|
5
|
+
* by re-using the rich filterContent implementation in core. Both functions
|
|
6
|
+
* preserve byte length so line/column positions remain stable.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { filterContent } from '@opensip-tools/fitness'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Replace string literal content with whitespace of equal length.
|
|
13
|
+
* Quote/backtick delimiters are preserved; only the inside is blanked.
|
|
14
|
+
*/
|
|
15
|
+
export function stripStrings(content: string): string {
|
|
16
|
+
return filterContent(content).code
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Replace string literals AND comments with whitespace of equal length.
|
|
21
|
+
*/
|
|
22
|
+
export function stripComments(content: string): string {
|
|
23
|
+
return filterContent(content).codeNoComments
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Re-export filterContent and the FilteredContent type for richer
|
|
27
|
+
// position-aware needs. The clearFilterCache helper is also re-exported
|
|
28
|
+
// for compatibility with any callers that managed it directly.
|
|
29
|
+
export { filterContent, clearFilterCache } from '@opensip-tools/fitness'
|
|
30
|
+
export type { FilteredContent } from '@opensip-tools/fitness'
|