@getmikk/core 1.8.2 → 1.9.0

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.
Files changed (43) hide show
  1. package/package.json +3 -1
  2. package/src/constants.ts +285 -0
  3. package/src/contract/contract-generator.ts +7 -0
  4. package/src/contract/index.ts +2 -3
  5. package/src/contract/lock-compiler.ts +74 -42
  6. package/src/contract/lock-reader.ts +24 -4
  7. package/src/contract/schema.ts +27 -1
  8. package/src/error-handler.ts +430 -0
  9. package/src/graph/cluster-detector.ts +45 -20
  10. package/src/graph/confidence-engine.ts +60 -0
  11. package/src/graph/dead-code-detector.ts +27 -5
  12. package/src/graph/graph-builder.ts +298 -238
  13. package/src/graph/impact-analyzer.ts +131 -114
  14. package/src/graph/index.ts +4 -0
  15. package/src/graph/memory-manager.ts +345 -0
  16. package/src/graph/query-engine.ts +79 -0
  17. package/src/graph/risk-engine.ts +86 -0
  18. package/src/graph/types.ts +89 -64
  19. package/src/parser/boundary-checker.ts +3 -1
  20. package/src/parser/change-detector.ts +99 -0
  21. package/src/parser/go/go-extractor.ts +28 -9
  22. package/src/parser/go/go-parser.ts +2 -0
  23. package/src/parser/index.ts +88 -38
  24. package/src/parser/javascript/js-extractor.ts +1 -1
  25. package/src/parser/javascript/js-parser.ts +2 -0
  26. package/src/parser/oxc-parser.ts +675 -0
  27. package/src/parser/oxc-resolver.ts +83 -0
  28. package/src/parser/tree-sitter/parser.ts +27 -15
  29. package/src/parser/types.ts +100 -73
  30. package/src/parser/typescript/ts-extractor.ts +241 -537
  31. package/src/parser/typescript/ts-parser.ts +16 -171
  32. package/src/parser/typescript/ts-resolver.ts +11 -1
  33. package/src/search/bm25.ts +5 -2
  34. package/src/utils/minimatch.ts +1 -1
  35. package/tests/contract.test.ts +2 -2
  36. package/tests/dead-code.test.ts +7 -7
  37. package/tests/esm-resolver.test.ts +75 -0
  38. package/tests/graph.test.ts +20 -20
  39. package/tests/helpers.ts +11 -6
  40. package/tests/impact-classified.test.ts +37 -41
  41. package/tests/parser.test.ts +7 -5
  42. package/tests/ts-parser.test.ts +27 -52
  43. package/test-output.txt +0 -373
@@ -1,13 +1,20 @@
1
1
  import ts from 'typescript'
2
- import type { ParsedFunction, ParsedClass, ParsedImport, ParsedExport, ParsedParam, ParsedGeneric, ParsedRoute } from '../types.js'
2
+ import type {
3
+ ParsedFunction,
4
+ ParsedClass,
5
+ ParsedImport,
6
+ ParsedExport,
7
+ ParsedParam,
8
+ ParsedGeneric,
9
+ ParsedRoute,
10
+ ParsedVariable,
11
+ CallExpression
12
+ } from '../types.js'
3
13
  import { hashContent } from '../../hash/file-hasher.js'
4
14
 
5
- /**
6
- * TypeScript AST extractor walks the TypeScript AST using the TS Compiler API
7
- * and extracts functions, classes, imports, exports and call relationships.
8
- */
9
15
  export class TypeScriptExtractor {
10
16
  protected readonly sourceFile: ts.SourceFile
17
+ private nameCounter = new Map<string, number>()
11
18
 
12
19
  constructor(
13
20
  protected readonly filePath: string,
@@ -17,12 +24,11 @@ export class TypeScriptExtractor {
17
24
  filePath,
18
25
  content,
19
26
  ts.ScriptTarget.Latest,
20
- true, // setParentNodes
27
+ true,
21
28
  this.inferScriptKind(filePath)
22
29
  )
23
30
  }
24
31
 
25
- /** Infer TypeScript ScriptKind from file extension (supports JS/JSX/TS/TSX) */
26
32
  private inferScriptKind(filePath: string): ts.ScriptKind {
27
33
  if (filePath.endsWith('.tsx')) return ts.ScriptKind.TSX
28
34
  if (filePath.endsWith('.jsx')) return ts.ScriptKind.JSX
@@ -30,23 +36,28 @@ export class TypeScriptExtractor {
30
36
  return ts.ScriptKind.TS
31
37
  }
32
38
 
33
- /** Extract all top-level and variable-assigned functions */
39
+ private allocateId(prefix: string, name: string): string {
40
+ const count = (this.nameCounter.get(name) ?? 0) + 1
41
+ this.nameCounter.set(name, count)
42
+ const suffix = count === 1 ? '' : `#${count}`
43
+ const normalizedPath = this.filePath.replace(/\\/g, '/')
44
+ return `${prefix}:${normalizedPath}:${name}${suffix}`.toLowerCase()
45
+ }
46
+
47
+ resetCounters(): void {
48
+ this.nameCounter.clear()
49
+ }
50
+
34
51
  extractFunctions(): ParsedFunction[] {
35
52
  const functions: ParsedFunction[] = []
36
53
  this.walkNode(this.sourceFile, (node) => {
37
- // function declarations: function foo() {}
38
54
  if (ts.isFunctionDeclaration(node) && node.name) {
39
55
  functions.push(this.parseFunctionDeclaration(node))
40
56
  }
41
- // variable declarations with arrow functions or function expressions:
42
- // const foo = () => {} or const foo = function() {}
43
57
  if (ts.isVariableStatement(node)) {
44
58
  for (const decl of node.declarationList.declarations) {
45
59
  if (decl.initializer && ts.isIdentifier(decl.name)) {
46
- if (
47
- ts.isArrowFunction(decl.initializer) ||
48
- ts.isFunctionExpression(decl.initializer)
49
- ) {
60
+ if (ts.isArrowFunction(decl.initializer) || ts.isFunctionExpression(decl.initializer)) {
50
61
  functions.push(this.parseVariableFunction(node, decl, decl.initializer))
51
62
  }
52
63
  }
@@ -56,7 +67,6 @@ export class TypeScriptExtractor {
56
67
  return functions
57
68
  }
58
69
 
59
- /** Extract all class declarations */
60
70
  extractClasses(): ParsedClass[] {
61
71
  const classes: ParsedClass[] = []
62
72
  this.walkNode(this.sourceFile, (node) => {
@@ -67,677 +77,371 @@ export class TypeScriptExtractor {
67
77
  return classes
68
78
  }
69
79
 
70
- /** Extract generic declarations (interfaces, types, constants with metadata) */
80
+ extractVariables(): ParsedVariable[] {
81
+ const variables: ParsedVariable[] = []
82
+ this.walkNode(this.sourceFile, (node) => {
83
+ if (ts.isVariableStatement(node)) {
84
+ for (const decl of node.declarationList.declarations) {
85
+ if (ts.isIdentifier(decl.name) && !this.isFunctionLike(decl.initializer)) {
86
+ variables.push(this.parseVariable(node, decl))
87
+ }
88
+ }
89
+ }
90
+ })
91
+ return variables
92
+ }
93
+
94
+ private isFunctionLike(node?: ts.Node): boolean {
95
+ return !!node && (ts.isArrowFunction(node) || ts.isFunctionExpression(node))
96
+ }
97
+
71
98
  extractGenerics(): ParsedGeneric[] {
72
99
  const generics: ParsedGeneric[] = []
73
100
  this.walkNode(this.sourceFile, (node) => {
74
101
  if (ts.isInterfaceDeclaration(node)) {
75
- const tp = this.extractTypeParameters(node.typeParameters)
76
102
  generics.push({
77
- id: `intf:${this.filePath}:${node.name.text}`,
103
+ id: this.allocateId('intf', node.name.text),
78
104
  name: node.name.text,
79
105
  type: 'interface',
80
106
  file: this.filePath,
81
- startLine: this.getLineNumber(node.getStart()),
107
+ startLine: this.getLineNumber(node.getStart(this.sourceFile)),
82
108
  endLine: this.getLineNumber(node.getEnd()),
83
109
  isExported: this.hasExportModifier(node),
84
- ...(tp.length > 0 ? { typeParameters: tp } : {}),
110
+ typeParameters: this.extractTypeParameters(node.typeParameters),
111
+ hash: hashContent(node.getText(this.sourceFile)),
85
112
  purpose: this.extractPurpose(node),
86
113
  })
87
114
  } else if (ts.isTypeAliasDeclaration(node)) {
88
- const tp = this.extractTypeParameters(node.typeParameters)
89
115
  generics.push({
90
- id: `type:${this.filePath}:${node.name.text}`,
116
+ id: this.allocateId('type', node.name.text),
91
117
  name: node.name.text,
92
118
  type: 'type',
93
119
  file: this.filePath,
94
- startLine: this.getLineNumber(node.getStart()),
120
+ startLine: this.getLineNumber(node.getStart(this.sourceFile)),
95
121
  endLine: this.getLineNumber(node.getEnd()),
96
122
  isExported: this.hasExportModifier(node),
97
- ...(tp.length > 0 ? { typeParameters: tp } : {}),
123
+ typeParameters: this.extractTypeParameters(node.typeParameters),
124
+ hash: hashContent(node.getText(this.sourceFile)),
98
125
  purpose: this.extractPurpose(node),
99
126
  })
100
- } else if (ts.isVariableStatement(node) && !this.isVariableFunction(node)) {
101
- // top-level constants (not functions)
102
- for (const decl of node.declarationList.declarations) {
103
- if (ts.isIdentifier(decl.name)) {
104
- generics.push({
105
- id: `const:${this.filePath}:${decl.name.text}`,
106
- name: decl.name.text,
107
- type: 'const',
108
- file: this.filePath,
109
- startLine: this.getLineNumber(node.getStart()),
110
- endLine: this.getLineNumber(node.getEnd()),
111
- isExported: this.hasExportModifier(node),
112
- purpose: this.extractPurpose(node),
113
- })
114
- }
115
- }
116
127
  }
117
128
  })
118
129
  return generics
119
130
  }
120
131
 
121
- protected isVariableFunction(node: ts.VariableStatement): boolean {
122
- for (const decl of node.declarationList.declarations) {
123
- if (decl.initializer && (ts.isArrowFunction(decl.initializer) || ts.isFunctionExpression(decl.initializer))) {
124
- return true
125
- }
126
- }
127
- return false
128
- }
129
-
130
- /** Extract all import statements (static and dynamic) */
131
132
  extractImports(): ParsedImport[] {
132
133
  const imports: ParsedImport[] = []
133
134
  this.walkNode(this.sourceFile, (node) => {
134
135
  if (ts.isImportDeclaration(node)) {
136
+ if (node.importClause?.isTypeOnly) return;
135
137
  const parsed = this.parseImport(node)
136
- if (parsed) imports.push(parsed)
137
- }
138
- })
139
-
140
- // Also detect dynamic import() calls: await import('./path')
141
- const walkDynamic = (n: ts.Node) => {
142
- if (ts.isCallExpression(n) && n.expression.kind === ts.SyntaxKind.ImportKeyword) {
143
- const arg = n.arguments[0]
144
- if (arg && ts.isStringLiteral(arg)) {
145
- imports.push({
146
- source: arg.text,
147
- resolvedPath: '', // Filled in by resolver
148
- names: [],
149
- isDefault: false,
150
- isDynamic: true,
151
- })
138
+ if (parsed) {
139
+ // Filter out type-only named imports
140
+ parsed.names = parsed.names.filter(n => !n.startsWith('type '))
141
+ imports.push(parsed)
152
142
  }
153
143
  }
154
- ts.forEachChild(n, walkDynamic)
155
- }
156
- ts.forEachChild(this.sourceFile, walkDynamic)
157
-
144
+ })
158
145
  return imports
159
146
  }
160
147
 
161
- /** Extract all exported symbols */
162
148
  extractExports(): ParsedExport[] {
163
149
  const exports: ParsedExport[] = []
164
150
  this.walkNode(this.sourceFile, (node) => {
165
- // export function foo() {}
166
151
  if (ts.isFunctionDeclaration(node) && node.name && this.hasExportModifier(node)) {
167
- exports.push({
168
- name: node.name.text,
169
- type: 'function',
170
- file: this.filePath,
171
- })
152
+ exports.push({ name: node.name.text, type: 'function', file: this.filePath })
172
153
  }
173
- // export class Foo {}
174
154
  if (ts.isClassDeclaration(node) && node.name && this.hasExportModifier(node)) {
175
- exports.push({
176
- name: node.name.text,
177
- type: 'class',
178
- file: this.filePath,
179
- })
155
+ exports.push({ name: node.name.text, type: 'class', file: this.filePath })
180
156
  }
181
- // export const foo = ...
182
157
  if (ts.isVariableStatement(node) && this.hasExportModifier(node)) {
183
- for (const decl of node.declarationList.declarations) {
158
+ node.declarationList.declarations.forEach(decl => {
184
159
  if (ts.isIdentifier(decl.name)) {
185
- const type = decl.initializer &&
186
- (ts.isArrowFunction(decl.initializer) || ts.isFunctionExpression(decl.initializer))
187
- ? 'function' : 'const'
188
- exports.push({
189
- name: decl.name.text,
190
- type: type as ParsedExport['type'],
191
- file: this.filePath,
192
- })
160
+ exports.push({ name: decl.name.text, type: 'const', file: this.filePath })
193
161
  }
194
- }
195
- }
196
- // export interface Foo {}
197
- if (ts.isInterfaceDeclaration(node) && this.hasExportModifier(node)) {
198
- exports.push({
199
- name: node.name.text,
200
- type: 'interface',
201
- file: this.filePath,
202
162
  })
203
163
  }
204
- // export type Foo = ...
205
- if (ts.isTypeAliasDeclaration(node) && this.hasExportModifier(node)) {
206
- exports.push({
207
- name: node.name.text,
208
- type: 'type',
209
- file: this.filePath,
210
- })
211
- }
212
- // export default ...
213
164
  if (ts.isExportAssignment(node)) {
214
- const name = node.expression && ts.isIdentifier(node.expression) ? node.expression.text : 'default'
215
- exports.push({
216
- name,
217
- type: 'default',
218
- file: this.filePath,
219
- })
220
- }
221
- // export { foo, bar } from './module'
222
- if (ts.isExportDeclaration(node) && node.exportClause && ts.isNamedExports(node.exportClause)) {
223
- for (const element of node.exportClause.elements) {
224
- exports.push({
225
- name: element.name.text,
226
- type: 'const',
227
- file: this.filePath,
228
- })
229
- }
165
+ exports.push({ name: 'default', type: 'default', file: this.filePath })
230
166
  }
231
167
  })
232
168
  return exports
233
169
  }
234
170
 
235
- /**
236
- * Extract HTTP route registrations.
237
- * Detects Express/Koa/Hono patterns like:
238
- * router.get("/path", handler)
239
- * app.post("/path", middleware, handler)
240
- * app.use("/prefix", subrouter)
241
- * router.use(middleware)
242
- */
243
171
  extractRoutes(): ParsedRoute[] {
244
172
  const routes: ParsedRoute[] = []
245
- const HTTP_METHODS = new Set(['get', 'post', 'put', 'delete', 'patch', 'head', 'options', 'all', 'use'])
246
- // Only detect routes on receiver objects that look like routers/apps
247
- const ROUTER_NAMES = new Set(['app', 'router', 'server', 'route', 'api', 'express'])
248
-
249
- const walk = (node: ts.Node) => {
250
- if (
251
- ts.isCallExpression(node) &&
252
- ts.isPropertyAccessExpression(node.expression)
253
- ) {
254
- const methodName = node.expression.name.text.toLowerCase()
255
- if (HTTP_METHODS.has(methodName)) {
256
- // Check if the receiver is a known router/app-like identifier
257
- const receiver = node.expression.expression
258
- let receiverName = ''
259
- if (ts.isIdentifier(receiver)) {
260
- receiverName = receiver.text.toLowerCase()
261
- } else if (ts.isPropertyAccessExpression(receiver) && ts.isIdentifier(receiver.expression)) {
262
- receiverName = receiver.expression.text.toLowerCase()
263
- }
264
-
265
- // Skip if receiver doesn't look like a router (e.g. prisma.file.delete)
266
- if (!ROUTER_NAMES.has(receiverName)) {
267
- ts.forEachChild(node, walk)
268
- return
269
- }
270
-
271
- const args = node.arguments
272
- let routePath = ''
273
- const middlewares: string[] = []
274
- let handler = 'anonymous'
275
-
276
- for (let i = 0; i < args.length; i++) {
277
- const arg = args[i]
278
- // First string literal is the route path
279
- if (ts.isStringLiteral(arg) && !routePath) {
280
- routePath = arg.text
281
- } else if (ts.isIdentifier(arg)) {
282
- // Last identifier is the handler; earlier ones are middleware
283
- if (i === args.length - 1) {
284
- handler = arg.text
285
- } else {
286
- middlewares.push(arg.text)
287
- }
288
- } else if (ts.isCallExpression(arg)) {
289
- // e.g. upload.single("file") middleware call
290
- middlewares.push(arg.expression.getText(this.sourceFile))
291
- } else if (ts.isArrowFunction(arg) || ts.isFunctionExpression(arg)) {
292
- handler = 'anonymous'
293
- }
173
+ this.walkNode(this.sourceFile, (node) => {
174
+ if (ts.isCallExpression(node)) {
175
+ const text = node.expression.getText(this.sourceFile)
176
+ if (text.match(/^(router|app|express)\.(get|post|put|delete|patch)$/)) {
177
+ const method = text.split('.')[1].toUpperCase() as any
178
+ const pathArg = node.arguments[0]
179
+ if (pathArg && ts.isStringLiteral(pathArg)) {
180
+ const path = pathArg.text
181
+ const handler = node.arguments[node.arguments.length - 1]
182
+ const middlewares = node.arguments.slice(1, -1).map(a => a.getText(this.sourceFile))
183
+ routes.push({
184
+ method,
185
+ path,
186
+ handler: handler.getText(this.sourceFile),
187
+ middlewares,
188
+ file: this.filePath,
189
+ line: this.getLineNumber(node.getStart(this.sourceFile))
190
+ })
294
191
  }
295
-
296
- routes.push({
297
- method: methodName.toUpperCase(),
298
- path: routePath || '*',
299
- handler,
300
- middlewares,
301
- file: this.filePath,
302
- line: this.getLineNumber(node.getStart()),
303
- })
304
192
  }
305
193
  }
306
- ts.forEachChild(node, walk)
307
- }
308
- ts.forEachChild(this.sourceFile, walk)
194
+ })
309
195
  return routes
310
196
  }
311
197
 
312
- // Protected Helpers ------------------------------------------------------
198
+ extractModuleCalls(): CallExpression[] {
199
+ // Calls occurring at the top level of the file
200
+ const calls: CallExpression[] = []
201
+ this.sourceFile.statements.forEach(stmt => {
202
+ if (!ts.isFunctionDeclaration(stmt) && !ts.isClassDeclaration(stmt)) {
203
+ calls.push(...this.extractCallsFromNode(stmt))
204
+ }
205
+ })
206
+ return calls
207
+ }
208
+
209
+ // --- Private Parsers ---
313
210
 
314
- protected parseFunctionDeclaration(node: ts.FunctionDeclaration): ParsedFunction {
211
+ private parseFunctionDeclaration(node: ts.FunctionDeclaration): ParsedFunction {
315
212
  const name = node.name!.text
316
- const startLine = this.getLineNumber(node.getStart())
317
- const endLine = this.getLineNumber(node.getEnd())
318
- const params = this.extractParams(node.parameters)
319
- const returnType = normalizeTypeAnnotation(node.type ? node.type.getText(this.sourceFile) : 'void')
320
- const isAsync = !!node.modifiers?.some(m => m.kind === ts.SyntaxKind.AsyncKeyword)
321
- const isGenerator = !!node.asteriskToken
322
- const typeParameters = this.extractTypeParameters(node.typeParameters)
323
- const calls = this.extractCalls(node)
324
213
  const bodyText = node.getText(this.sourceFile)
325
-
326
214
  return {
327
- // Include startLine in the ID to prevent collision between overload signatures
328
- // and same-named functions in different scopes (e.g. two `init` declarations).
329
- id: `fn:${this.filePath}:${name}:${startLine}`,
215
+ id: this.allocateId('fn', name),
330
216
  name,
331
217
  file: this.filePath,
332
- startLine,
333
- endLine,
334
- params,
335
- returnType,
218
+ startLine: this.getLineNumber(node.getStart(this.sourceFile)),
219
+ endLine: this.getLineNumber(node.getEnd()),
220
+ params: this.extractParams(node.parameters),
221
+ returnType: node.type ? node.type.getText(this.sourceFile) : 'void',
336
222
  isExported: this.hasExportModifier(node),
337
- isAsync,
338
- ...(isGenerator ? { isGenerator } : {}),
339
- ...(typeParameters.length > 0 ? { typeParameters } : {}),
340
- calls,
223
+ isAsync: !!node.modifiers?.some(m => m.kind === ts.SyntaxKind.AsyncKeyword),
224
+ calls: this.extractCallsFromNode(node),
341
225
  hash: hashContent(bodyText),
342
226
  purpose: this.extractPurpose(node),
343
227
  edgeCasesHandled: this.extractEdgeCases(node),
344
228
  errorHandling: this.extractErrorHandling(node),
345
- detailedLines: this.extractDetailedLines(node),
229
+ detailedLines: [],
346
230
  }
347
231
  }
348
232
 
349
- protected parseVariableFunction(
350
- stmt: ts.VariableStatement,
351
- decl: ts.VariableDeclaration,
352
- fn: ts.ArrowFunction | ts.FunctionExpression
353
- ): ParsedFunction {
233
+ private parseVariableFunction(stmt: ts.VariableStatement, decl: ts.VariableDeclaration, fn: ts.ArrowFunction | ts.FunctionExpression): ParsedFunction {
354
234
  const name = (decl.name as ts.Identifier).text
355
- const startLine = this.getLineNumber(stmt.getStart())
356
- const endLine = this.getLineNumber(stmt.getEnd())
357
- const params = this.extractParams(fn.parameters)
358
- const returnType = normalizeTypeAnnotation(fn.type ? fn.type.getText(this.sourceFile) : 'void')
359
- const isAsync = !!fn.modifiers?.some(m => m.kind === ts.SyntaxKind.AsyncKeyword)
360
- const isGenerator = ts.isFunctionExpression(fn) && !!fn.asteriskToken
361
- const typeParameters = this.extractTypeParameters(fn.typeParameters)
362
- const calls = this.extractCalls(fn)
363
235
  const bodyText = stmt.getText(this.sourceFile)
364
-
365
236
  return {
366
- // Include startLine to prevent collision between same-named const arrow functions
367
- // at different scopes (e.g. two `handler` declarations in different blocks).
368
- id: `fn:${this.filePath}:${name}:${startLine}`,
237
+ id: this.allocateId('fn', name),
369
238
  name,
370
239
  file: this.filePath,
371
- startLine,
372
- endLine,
373
- params,
374
- returnType,
240
+ startLine: this.getLineNumber(stmt.getStart(this.sourceFile)),
241
+ endLine: this.getLineNumber(stmt.getEnd()),
242
+ params: this.extractParams(fn.parameters),
243
+ returnType: fn.type ? fn.type.getText(this.sourceFile) : 'unknown',
375
244
  isExported: this.hasExportModifier(stmt),
376
- isAsync,
377
- ...(isGenerator ? { isGenerator } : {}),
378
- ...(typeParameters.length > 0 ? { typeParameters } : {}),
379
- calls,
245
+ isAsync: !!fn.modifiers?.some(m => m.kind === ts.SyntaxKind.AsyncKeyword),
246
+ calls: this.extractCallsFromNode(fn),
380
247
  hash: hashContent(bodyText),
381
248
  purpose: this.extractPurpose(stmt),
382
249
  edgeCasesHandled: this.extractEdgeCases(fn),
383
250
  errorHandling: this.extractErrorHandling(fn),
384
- detailedLines: this.extractDetailedLines(fn),
251
+ detailedLines: [],
385
252
  }
386
253
  }
387
254
 
388
- protected parseClass(node: ts.ClassDeclaration): ParsedClass {
255
+ private parseClass(node: ts.ClassDeclaration): ParsedClass {
389
256
  const name = node.name!.text
390
- const startLine = this.getLineNumber(node.getStart())
391
- const endLine = this.getLineNumber(node.getEnd())
392
257
  const methods: ParsedFunction[] = []
393
- const decorators = this.extractDecorators(node)
394
- const typeParameters = this.extractTypeParameters(node.typeParameters)
258
+ const properties: ParsedVariable[] = []
395
259
 
396
260
  for (const member of node.members) {
397
- if (ts.isConstructorDeclaration(member)) {
398
- // Track class constructors as methods
399
- const mStartLine = this.getLineNumber(member.getStart())
400
- const mEndLine = this.getLineNumber(member.getEnd())
401
- const params = this.extractParams(member.parameters)
402
- const calls = this.extractCalls(member)
403
- const bodyText = member.getText(this.sourceFile)
404
-
405
- methods.push({
406
- id: `fn:${this.filePath}:${name}.constructor:${mStartLine}`,
407
- name: `${name}.constructor`,
408
- file: this.filePath,
409
- startLine: mStartLine,
410
- endLine: mEndLine,
411
- params,
412
- returnType: name,
413
- isExported: this.hasExportModifier(node),
414
- isAsync: false,
415
- calls,
416
- hash: hashContent(bodyText),
417
- purpose: this.extractPurpose(member),
418
- edgeCasesHandled: this.extractEdgeCases(member),
419
- errorHandling: this.extractErrorHandling(member),
420
- detailedLines: this.extractDetailedLines(member),
421
- })
422
- } else if (ts.isMethodDeclaration(member) && member.name) {
423
- const methodName = member.name.getText(this.sourceFile)
424
- const mStartLine = this.getLineNumber(member.getStart())
425
- const mEndLine = this.getLineNumber(member.getEnd())
426
- const params = this.extractParams(member.parameters)
427
- const returnType = normalizeTypeAnnotation(member.type ? member.type.getText(this.sourceFile) : 'void')
428
- const isAsync = !!member.modifiers?.some(m => m.kind === ts.SyntaxKind.AsyncKeyword)
429
- const isGenerator = !!member.asteriskToken
430
- const methodTypeParams = this.extractTypeParameters(member.typeParameters)
431
- const calls = this.extractCalls(member)
432
- const bodyText = member.getText(this.sourceFile)
433
-
434
- methods.push({
435
- id: `fn:${this.filePath}:${name}.${methodName}:${mStartLine}`,
436
- name: `${name}.${methodName}`,
437
- file: this.filePath,
438
- startLine: mStartLine,
439
- endLine: mEndLine,
440
- params,
441
- returnType,
442
- isExported: this.hasExportModifier(node),
443
- isAsync,
444
- ...(isGenerator ? { isGenerator } : {}),
445
- ...(methodTypeParams.length > 0 ? { typeParameters: methodTypeParams } : {}),
446
- calls,
447
- hash: hashContent(bodyText),
448
- purpose: this.extractPurpose(member),
449
- edgeCasesHandled: this.extractEdgeCases(member),
450
- errorHandling: this.extractErrorHandling(member),
451
- detailedLines: this.extractDetailedLines(member),
452
- })
261
+ if (ts.isMethodDeclaration(member) && member.name) {
262
+ methods.push(this.parseMethod(name, member))
263
+ } else if (ts.isPropertyDeclaration(member) && member.name) {
264
+ properties.push(this.parseProperty(name, member))
453
265
  }
454
266
  }
455
267
 
456
268
  return {
457
- id: `class:${this.filePath}:${name}`,
269
+ id: this.allocateId('class', name),
458
270
  name,
459
271
  file: this.filePath,
460
- startLine,
461
- endLine,
272
+ startLine: this.getLineNumber(node.getStart(this.sourceFile)),
273
+ endLine: this.getLineNumber(node.getEnd()),
462
274
  methods,
275
+ properties,
276
+ extends: node.heritageClauses?.find(c => c.token === ts.SyntaxKind.ExtendsKeyword)?.types[0]?.getText(this.sourceFile),
277
+ implements: node.heritageClauses?.find(c => c.token === ts.SyntaxKind.ImplementsKeyword)?.types.map(t => t.getText(this.sourceFile)),
463
278
  isExported: this.hasExportModifier(node),
464
- ...(decorators.length > 0 ? { decorators } : {}),
465
- ...(typeParameters.length > 0 ? { typeParameters } : {}),
279
+ hash: hashContent(node.getText(this.sourceFile)),
280
+ purpose: this.extractPurpose(node),
281
+ edgeCasesHandled: this.extractEdgeCases(node),
282
+ errorHandling: this.extractErrorHandling(node),
283
+ }
284
+ }
285
+
286
+ private parseMethod(className: string, node: ts.MethodDeclaration): ParsedFunction {
287
+ const methodName = node.name.getText(this.sourceFile)
288
+ const fullName = `${className}.${methodName}`
289
+ return {
290
+ id: this.allocateId('fn', fullName),
291
+ name: fullName,
292
+ file: this.filePath,
293
+ startLine: this.getLineNumber(node.getStart(this.sourceFile)),
294
+ endLine: this.getLineNumber(node.getEnd()),
295
+ params: this.extractParams(node.parameters),
296
+ returnType: node.type ? node.type.getText(this.sourceFile) : 'void',
297
+ isExported: false,
298
+ isAsync: !!node.modifiers?.some(m => m.kind === ts.SyntaxKind.AsyncKeyword),
299
+ calls: this.extractCallsFromNode(node),
300
+ hash: hashContent(node.getText(this.sourceFile)),
466
301
  purpose: this.extractPurpose(node),
467
302
  edgeCasesHandled: this.extractEdgeCases(node),
468
303
  errorHandling: this.extractErrorHandling(node),
304
+ detailedLines: [],
469
305
  }
470
306
  }
471
307
 
472
- protected parseImport(node: ts.ImportDeclaration): ParsedImport | null {
308
+ private parseProperty(className: string, node: ts.PropertyDeclaration): ParsedVariable {
309
+ const propName = node.name.getText(this.sourceFile)
310
+ return {
311
+ id: this.allocateId('var', `${className}.${propName}`),
312
+ name: propName,
313
+ type: node.type ? node.type.getText(this.sourceFile) : 'any',
314
+ file: this.filePath,
315
+ line: this.getLineNumber(node.getStart(this.sourceFile)),
316
+ isExported: false,
317
+ isStatic: !!node.modifiers?.some(m => m.kind === ts.SyntaxKind.StaticKeyword),
318
+ }
319
+ }
320
+
321
+ private parseVariable(stmt: ts.VariableStatement, decl: ts.VariableDeclaration): ParsedVariable {
322
+ const name = (decl.name as ts.Identifier).text
323
+ return {
324
+ id: this.allocateId('var', name),
325
+ name,
326
+ type: decl.type ? decl.type.getText(this.sourceFile) : 'any',
327
+ file: this.filePath,
328
+ line: this.getLineNumber(stmt.getStart(this.sourceFile)),
329
+ isExported: this.hasExportModifier(stmt),
330
+ }
331
+ }
332
+
333
+ private parseImport(node: ts.ImportDeclaration): ParsedImport {
473
334
  const source = (node.moduleSpecifier as ts.StringLiteral).text
474
335
  const names: string[] = []
475
336
  let isDefault = false
476
-
477
337
  if (node.importClause) {
478
- // import Foo from './module' (default import)
479
338
  if (node.importClause.name) {
480
339
  names.push(node.importClause.name.text)
481
340
  isDefault = true
482
341
  }
483
- // import { foo, bar } from './module'
484
- if (node.importClause.namedBindings) {
485
- if (ts.isNamedImports(node.importClause.namedBindings)) {
486
- for (const element of node.importClause.namedBindings.elements) {
487
- names.push(element.name.text)
488
- }
489
- }
490
- // import * as foo from './module'
491
- if (ts.isNamespaceImport(node.importClause.namedBindings)) {
492
- names.push(node.importClause.namedBindings.name.text)
493
- }
342
+ if (node.importClause.namedBindings && ts.isNamedImports(node.importClause.namedBindings)) {
343
+ node.importClause.namedBindings.elements.forEach(el => names.push(el.name.text))
494
344
  }
495
345
  }
496
-
497
- // Skip type-only imports
498
- if (node.importClause?.isTypeOnly) return null
499
-
500
- return {
501
- source,
502
- resolvedPath: '', // Filled in by resolver
503
- names,
504
- isDefault,
505
- isDynamic: false,
506
- }
346
+ return { source, resolvedPath: '', names, isDefault, isDynamic: false }
507
347
  }
508
348
 
509
- /** Extract function/method call expressions from a node (including new Foo()) */
510
- protected extractCalls(node: ts.Node): string[] {
511
- const calls: string[] = []
512
- const walkCalls = (n: ts.Node) => {
349
+ // --- Helpers ---
350
+
351
+ protected extractCallsFromNode(node: ts.Node): CallExpression[] {
352
+ const calls: CallExpression[] = []
353
+ const walk = (n: ts.Node) => {
513
354
  if (ts.isCallExpression(n)) {
514
- const callee = n.expression
515
- if (ts.isIdentifier(callee)) {
516
- calls.push(callee.text)
517
- } else if (ts.isPropertyAccessExpression(callee)) {
518
- // e.g., obj.method() we capture the full dotted name
519
- calls.push(callee.getText(this.sourceFile))
520
- }
521
- }
522
- // Track constructor calls: new Foo(...) -> "Foo"
523
- if (ts.isNewExpression(n)) {
524
- const callee = n.expression
525
- if (ts.isIdentifier(callee)) {
526
- calls.push(callee.text)
527
- } else if (ts.isPropertyAccessExpression(callee)) {
528
- calls.push(callee.getText(this.sourceFile))
529
- }
355
+ calls.push({
356
+ name: n.expression.getText(this.sourceFile),
357
+ line: this.getLineNumber(n.getStart(this.sourceFile)),
358
+ type: ts.isPropertyAccessExpression(n.expression) ? 'method' : 'function'
359
+ })
360
+ } else if (ts.isNewExpression(n)) {
361
+ calls.push({
362
+ name: n.expression.getText(this.sourceFile),
363
+ line: this.getLineNumber(n.getStart(this.sourceFile)),
364
+ type: 'function'
365
+ })
366
+ } else if (ts.isPropertyAccessExpression(n) && !ts.isCallExpression(n.parent)) {
367
+ // Property access that isn't a call
368
+ calls.push({
369
+ name: n.getText(this.sourceFile),
370
+ line: this.getLineNumber(n.getStart(this.sourceFile)),
371
+ type: 'property'
372
+ })
530
373
  }
531
- ts.forEachChild(n, walkCalls)
374
+ ts.forEachChild(n, walk)
532
375
  }
533
- ts.forEachChild(node, walkCalls)
534
- return [...new Set(calls)] // deduplicate
376
+ walk(node)
377
+ return calls
535
378
  }
536
379
 
537
- /** Extract the purpose from JSDoc comments or preceding single-line comments.
538
- * Falls back to deriving a human-readable sentence from the function name. */
539
- protected extractPurpose(node: ts.Node): string {
540
- const fullText = this.sourceFile.getFullText()
541
- const commentRanges = ts.getLeadingCommentRanges(fullText, node.getFullStart())
542
- if (commentRanges && commentRanges.length > 0) {
543
- const meaningfulLines: string[] = []
544
- for (const range of commentRanges) {
545
- const comment = fullText.slice(range.pos, range.end)
546
- let clean = ''
547
- if (comment.startsWith('/**') || comment.startsWith('/*')) {
548
- // Strip only the comment delimiters (/* ** */) NOT arbitrary slashes.
549
- // Using /[\/\*]/g was wrong — it removes slashes inside URL paths and
550
- // regex literals embedded in doc comments (e.g. "/api/users" → "apiusers").
551
- clean = comment
552
- .replace(/^\/\*+/, '') // remove leading /* or /**
553
- .replace(/\*+\/$/, '') // remove trailing */
554
- .replace(/^\s*\*+\s?/gm, '') // remove leading * on each line
555
- .trim()
556
- } else if (comment.startsWith('//')) {
557
- clean = comment.replace(/^\/\/+\s?/, '').trim()
558
- }
559
-
560
- // Skip divider lines (lines with 3+ repeated special characters)
561
- if (/^[\-_=\*]{3,}$/.test(clean)) continue
380
+ protected extractParams(params: ts.NodeArray<ts.ParameterDeclaration>): ParsedParam[] {
381
+ return params.map(p => ({
382
+ name: p.name.getText(this.sourceFile),
383
+ type: p.type ? p.type.getText(this.sourceFile) : 'any',
384
+ optional: !!p.questionToken || !!p.initializer,
385
+ defaultValue: p.initializer ? p.initializer.getText(this.sourceFile) : undefined,
386
+ }))
387
+ }
562
388
 
563
- if (clean) meaningfulLines.push(clean)
564
- }
389
+ protected extractTypeParameters(typeParams?: ts.NodeArray<ts.TypeParameterDeclaration>): string[] {
390
+ return typeParams?.map(t => t.name.text) || []
391
+ }
565
392
 
566
- // Return the first meaningful line in JSDoc, the first line is the summary.
567
- const fromComment = meaningfulLines.length > 0 ? meaningfulLines[0].split('\n')[0].trim() : ''
568
- if (fromComment) return fromComment
569
- }
393
+ protected hasExportModifier(node: ts.Node): boolean {
394
+ return !!ts.getModifiers(node as any)?.some(m => m.kind === ts.SyntaxKind.ExportKeyword)
395
+ }
570
396
 
571
- // Fallback: derive a human-readable sentence from the function/identifier name
572
- const name = this.getNodeName(node)
573
- return name ? derivePurposeFromName(name) : ''
397
+ protected getLineNumber(pos: number): number {
398
+ return this.sourceFile.getLineAndCharacterOfPosition(pos).line + 1
574
399
  }
575
400
 
576
- /** Get the identifier name from common declaration node types */
577
- protected getNodeName(node: ts.Node): string {
578
- if (ts.isFunctionDeclaration(node) && node.name) return node.name.text
579
- if (ts.isArrowFunction(node) || ts.isFunctionExpression(node)) {
580
- const parent = node.parent
581
- if (parent && ts.isVariableDeclaration(parent) && ts.isIdentifier(parent.name)) {
582
- return parent.name.text
583
- }
584
- }
585
- if (ts.isMethodDeclaration(node) && ts.isIdentifier(node.name)) return node.name.text
586
- if (ts.isConstructorDeclaration(node)) return 'constructor'
587
- if ((ts.isInterfaceDeclaration(node) || ts.isTypeAliasDeclaration(node) || ts.isClassDeclaration(node)) && node.name) {
588
- return (node as any).name.text
401
+ protected extractPurpose(node: ts.Node): string {
402
+ const fullText = this.sourceFile.getFullText()
403
+ const ranges = ts.getLeadingCommentRanges(fullText, node.pos)
404
+ if (ranges && ranges.length > 0) {
405
+ const lastComment = fullText.slice(ranges[ranges.length - 1].pos, ranges[ranges.length - 1].end)
406
+ return lastComment.replace(/\/\*+|\*+\/|\/\/+/g, '').trim()
589
407
  }
590
408
  return ''
591
409
  }
592
410
 
593
- /** Extract edge cases handled (if statements returning early) */
594
411
  protected extractEdgeCases(node: ts.Node): string[] {
595
412
  const edgeCases: string[] = []
596
- const walkEdgeCases = (n: ts.Node) => {
597
- if (ts.isIfStatement(n)) {
598
- // simple heuristic for early returns inside if blocks
599
- if (
600
- ts.isReturnStatement(n.thenStatement) ||
601
- (ts.isBlock(n.thenStatement) && n.thenStatement.statements.some(ts.isReturnStatement)) ||
602
- ts.isThrowStatement(n.thenStatement) ||
603
- (ts.isBlock(n.thenStatement) && n.thenStatement.statements.some(ts.isThrowStatement))
604
- ) {
605
- edgeCases.push(n.expression.getText(this.sourceFile))
606
- }
413
+ const walk = (n: ts.Node) => {
414
+ if (ts.isIfStatement(n) || ts.isConditionalExpression(n)) {
415
+ edgeCases.push(n.getText(this.sourceFile).split('{')[0].trim())
607
416
  }
608
- ts.forEachChild(n, walkEdgeCases)
417
+ ts.forEachChild(n, walk)
609
418
  }
610
- ts.forEachChild(node, walkEdgeCases)
419
+ walk(node)
611
420
  return edgeCases
612
421
  }
613
422
 
614
- /** Extract try-catch blocks or explicit throw statements */
615
423
  protected extractErrorHandling(node: ts.Node): { line: number, type: 'try-catch' | 'throw', detail: string }[] {
616
- const errors: { line: number, type: 'try-catch' | 'throw', detail: string }[] = []
617
- const walkErrors = (n: ts.Node) => {
424
+ const result: { line: number, type: 'try-catch' | 'throw', detail: string }[] = []
425
+ const walk = (n: ts.Node) => {
618
426
  if (ts.isTryStatement(n)) {
619
- errors.push({
620
- line: this.getLineNumber(n.getStart()),
621
- type: 'try-catch',
622
- detail: 'try-catch block'
623
- })
427
+ result.push({ line: this.getLineNumber(n.getStart(this.sourceFile)), type: 'try-catch', detail: 'try block' })
428
+ } else if (ts.isThrowStatement(n)) {
429
+ result.push({ line: this.getLineNumber(n.getStart(this.sourceFile)), type: 'throw', detail: n.expression?.getText(this.sourceFile) || 'unknown' })
624
430
  }
625
- if (ts.isThrowStatement(n)) {
626
- errors.push({
627
- line: this.getLineNumber(n.getStart()),
628
- type: 'throw',
629
- detail: n.expression ? n.expression.getText(this.sourceFile) : 'throw error'
630
- })
631
- }
632
- ts.forEachChild(n, walkErrors)
633
- }
634
- ts.forEachChild(node, walkErrors)
635
- return errors
636
- }
637
-
638
- /** Extract detailed line block breakdowns */
639
- protected extractDetailedLines(node: ts.Node): { startLine: number, endLine: number, blockType: string }[] {
640
- const blocks: { startLine: number, endLine: number, blockType: string }[] = []
641
- const walkBlocks = (n: ts.Node) => {
642
- if (ts.isIfStatement(n) || ts.isSwitchStatement(n)) {
643
- blocks.push({
644
- startLine: this.getLineNumber(n.getStart()),
645
- endLine: this.getLineNumber(n.getEnd()),
646
- blockType: 'ControlFlow'
647
- })
648
- } else if (ts.isForStatement(n) || ts.isWhileStatement(n) || ts.isForOfStatement(n) || ts.isForInStatement(n)) {
649
- blocks.push({
650
- startLine: this.getLineNumber(n.getStart()),
651
- endLine: this.getLineNumber(n.getEnd()),
652
- blockType: 'Loop'
653
- })
654
- } else if (ts.isVariableStatement(n) || ts.isExpressionStatement(n)) {
655
- // Ignore single lines for brevity unless part of larger logical units
656
- }
657
- ts.forEachChild(n, walkBlocks)
431
+ ts.forEachChild(n, walk)
658
432
  }
659
- ts.forEachChild(node, walkBlocks)
660
- return blocks
433
+ walk(node)
434
+ return result
661
435
  }
662
436
 
663
- /** Extract type parameter names from a generic declaration */
664
- protected extractTypeParameters(typeParams: ts.NodeArray<ts.TypeParameterDeclaration> | undefined): string[] {
665
- if (!typeParams || typeParams.length === 0) return []
666
- return typeParams.map(tp => tp.name.text)
437
+ protected extractDetailedLines(node: ts.Node): { startLine: number; endLine: number; blockType: string }[] {
438
+ return [] // Implementation for behavioral tracking
667
439
  }
668
440
 
669
- /** Extract decorator names from a class declaration */
670
- protected extractDecorators(node: ts.ClassDeclaration): string[] {
671
- const decorators: string[] = []
672
- const modifiers = ts.canHaveDecorators(node) ? ts.getDecorators(node) : undefined
673
- if (modifiers) {
674
- for (const decorator of modifiers) {
675
- if (ts.isCallExpression(decorator.expression)) {
676
- // @Injectable() decorator with arguments
677
- decorators.push(decorator.expression.expression.getText(this.sourceFile))
678
- } else if (ts.isIdentifier(decorator.expression)) {
679
- // @Sealed decorator without arguments
680
- decorators.push(decorator.expression.text)
681
- }
682
- }
683
- }
684
- return decorators
685
- }
686
-
687
- /** Extract parameters from a function's parameter list */
688
- protected extractParams(params: ts.NodeArray<ts.ParameterDeclaration>): ParsedParam[] {
689
- return params.map((p) => ({
690
- name: p.name.getText(this.sourceFile),
691
- type: normalizeTypeAnnotation(p.type ? p.type.getText(this.sourceFile) : 'any'),
692
- optional: !!p.questionToken || !!p.initializer,
693
- defaultValue: p.initializer ? p.initializer.getText(this.sourceFile) : undefined,
694
- }))
695
- }
696
-
697
- /** Check if a node has the 'export' modifier */
698
- protected hasExportModifier(node: ts.Node): boolean {
699
- const modifiers = ts.canHaveModifiers(node) ? ts.getModifiers(node) : undefined
700
- return !!modifiers?.some(m => m.kind === ts.SyntaxKind.ExportKeyword)
701
- }
702
-
703
- /** Get 1-indexed line number from a character position */
704
- protected getLineNumber(pos: number): number {
705
- return this.sourceFile.getLineAndCharacterOfPosition(pos).line + 1
706
- }
707
-
708
- /** Walk the top-level children of a node (non-recursive callbacks decide depth) */
709
- protected walkNode(node: ts.Node, callback: (node: ts.Node) => void): void {
441
+ private walkNode(node: ts.Node, callback: (node: ts.Node) => void): void {
710
442
  ts.forEachChild(node, (child) => {
711
443
  callback(child)
444
+ this.walkNode(child, callback)
712
445
  })
713
446
  }
714
447
  }
715
-
716
- //
717
-
718
- /**
719
- * Derive a human-readable purpose sentence from a camelCase/PascalCase identifier.
720
- * Examples:
721
- * validateJwtToken -> "Validate jwt token"
722
- * buildGraphFromLock -> "Build graph from lock"
723
- * UserRepository -> "User repository"
724
- * parseFiles -> "Parse files"
725
- */
726
- function normalizeTypeAnnotation(type: string): string {
727
- return type.replace(/\s*\n\s*/g, ' ').replace(/\s{2,}/g, ' ').trim()
728
- }
729
-
730
- function derivePurposeFromName(name: string): string {
731
- if (!name || name === 'constructor') return ''
732
- // Split on camelCase/PascalCase boundaries and underscores
733
- const words = name
734
- .replace(/_+/g, ' ')
735
- .replace(/([a-z])([A-Z])/g, '$1 $2')
736
- .replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2')
737
- .toLowerCase()
738
- .split(/\s+/)
739
- .filter(Boolean)
740
- if (words.length === 0) return ''
741
- words[0] = words[0].charAt(0).toUpperCase() + words[0].slice(1)
742
- return words.join(' ')
743
- }