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