@getmikk/core 1.8.1 → 1.8.3
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/package.json +1 -1
- package/src/contract/contract-reader.ts +9 -9
- package/src/contract/lock-compiler.ts +8 -7
- package/src/contract/lock-reader.ts +9 -6
- package/src/contract/schema.ts +6 -1
- package/src/graph/dead-code-detector.ts +134 -53
- package/src/graph/graph-builder.ts +216 -61
- package/src/graph/impact-analyzer.ts +59 -21
- package/src/graph/types.ts +1 -0
- package/src/parser/boundary-checker.ts +3 -1
- package/src/parser/go/go-extractor.ts +10 -1
- package/src/parser/javascript/js-extractor.ts +22 -6
- package/src/parser/javascript/js-parser.ts +24 -17
- package/src/parser/javascript/js-resolver.ts +63 -22
- package/src/parser/parser-constants.ts +82 -0
- package/src/parser/tree-sitter/parser.ts +356 -69
- package/src/parser/types.ts +2 -0
- package/src/parser/typescript/ts-extractor.ts +109 -34
- package/src/parser/typescript/ts-parser.ts +22 -18
- package/src/parser/typescript/ts-resolver.ts +78 -45
- package/src/utils/json.ts +27 -0
- package/tests/js-parser.test.ts +23 -3
- package/tests/tree-sitter-parser.test.ts +11 -3
|
@@ -5,9 +5,18 @@ import { hashContent } from '../../hash/file-hasher.js'
|
|
|
5
5
|
/**
|
|
6
6
|
* TypeScript AST extractor walks the TypeScript AST using the TS Compiler API
|
|
7
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
|
|
8
15
|
*/
|
|
9
16
|
export class TypeScriptExtractor {
|
|
10
17
|
protected readonly sourceFile: ts.SourceFile
|
|
18
|
+
/** Per-file collision counter: name -> count of times seen so far */
|
|
19
|
+
private nameCounter = new Map<string, number>()
|
|
11
20
|
|
|
12
21
|
constructor(
|
|
13
22
|
protected readonly filePath: string,
|
|
@@ -30,6 +39,23 @@ export class TypeScriptExtractor {
|
|
|
30
39
|
return ts.ScriptKind.TS
|
|
31
40
|
}
|
|
32
41
|
|
|
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 {
|
|
49
|
+
const count = (this.nameCounter.get(name) ?? 0) + 1
|
|
50
|
+
this.nameCounter.set(name, count)
|
|
51
|
+
return count === 1 ? `fn:${this.filePath}:${name}` : `fn:${this.filePath}:${name}#${count}`
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Reset the collision counter (call before each full extraction pass if reused) */
|
|
55
|
+
resetCounters(): void {
|
|
56
|
+
this.nameCounter.clear()
|
|
57
|
+
}
|
|
58
|
+
|
|
33
59
|
/** Extract all top-level and variable-assigned functions */
|
|
34
60
|
extractFunctions(): ParsedFunction[] {
|
|
35
61
|
const functions: ParsedFunction[] = []
|
|
@@ -78,7 +104,7 @@ export class TypeScriptExtractor {
|
|
|
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
110
|
...(tp.length > 0 ? { typeParameters: tp } : {}),
|
|
@@ -91,7 +117,7 @@ export class TypeScriptExtractor {
|
|
|
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
123
|
...(tp.length > 0 ? { typeParameters: tp } : {}),
|
|
@@ -106,7 +132,7 @@ export class TypeScriptExtractor {
|
|
|
106
132
|
name: decl.name.text,
|
|
107
133
|
type: 'const',
|
|
108
134
|
file: this.filePath,
|
|
109
|
-
startLine: this.getLineNumber(node.getStart()),
|
|
135
|
+
startLine: this.getLineNumber(node.getStart(this.sourceFile)),
|
|
110
136
|
endLine: this.getLineNumber(node.getEnd()),
|
|
111
137
|
isExported: this.hasExportModifier(node),
|
|
112
138
|
purpose: this.extractPurpose(node),
|
|
@@ -299,7 +325,7 @@ export class TypeScriptExtractor {
|
|
|
299
325
|
handler,
|
|
300
326
|
middlewares,
|
|
301
327
|
file: this.filePath,
|
|
302
|
-
line: this.getLineNumber(node.getStart()),
|
|
328
|
+
line: this.getLineNumber(node.getStart(this.sourceFile)),
|
|
303
329
|
})
|
|
304
330
|
}
|
|
305
331
|
}
|
|
@@ -313,7 +339,8 @@ export class TypeScriptExtractor {
|
|
|
313
339
|
|
|
314
340
|
protected parseFunctionDeclaration(node: ts.FunctionDeclaration): ParsedFunction {
|
|
315
341
|
const name = node.name!.text
|
|
316
|
-
const
|
|
342
|
+
const id = this.allocateFnId(name)
|
|
343
|
+
const startLine = this.getLineNumber(node.getStart(this.sourceFile))
|
|
317
344
|
const endLine = this.getLineNumber(node.getEnd())
|
|
318
345
|
const params = this.extractParams(node.parameters)
|
|
319
346
|
const returnType = normalizeTypeAnnotation(node.type ? node.type.getText(this.sourceFile) : 'void')
|
|
@@ -324,7 +351,7 @@ export class TypeScriptExtractor {
|
|
|
324
351
|
const bodyText = node.getText(this.sourceFile)
|
|
325
352
|
|
|
326
353
|
return {
|
|
327
|
-
id
|
|
354
|
+
id,
|
|
328
355
|
name,
|
|
329
356
|
file: this.filePath,
|
|
330
357
|
startLine,
|
|
@@ -350,7 +377,8 @@ export class TypeScriptExtractor {
|
|
|
350
377
|
fn: ts.ArrowFunction | ts.FunctionExpression
|
|
351
378
|
): ParsedFunction {
|
|
352
379
|
const name = (decl.name as ts.Identifier).text
|
|
353
|
-
const
|
|
380
|
+
const id = this.allocateFnId(name)
|
|
381
|
+
const startLine = this.getLineNumber(stmt.getStart(this.sourceFile))
|
|
354
382
|
const endLine = this.getLineNumber(stmt.getEnd())
|
|
355
383
|
const params = this.extractParams(fn.parameters)
|
|
356
384
|
const returnType = normalizeTypeAnnotation(fn.type ? fn.type.getText(this.sourceFile) : 'void')
|
|
@@ -361,7 +389,7 @@ export class TypeScriptExtractor {
|
|
|
361
389
|
const bodyText = stmt.getText(this.sourceFile)
|
|
362
390
|
|
|
363
391
|
return {
|
|
364
|
-
id
|
|
392
|
+
id,
|
|
365
393
|
name,
|
|
366
394
|
file: this.filePath,
|
|
367
395
|
startLine,
|
|
@@ -383,7 +411,7 @@ export class TypeScriptExtractor {
|
|
|
383
411
|
|
|
384
412
|
protected parseClass(node: ts.ClassDeclaration): ParsedClass {
|
|
385
413
|
const name = node.name!.text
|
|
386
|
-
const startLine = this.getLineNumber(node.getStart())
|
|
414
|
+
const startLine = this.getLineNumber(node.getStart(this.sourceFile))
|
|
387
415
|
const endLine = this.getLineNumber(node.getEnd())
|
|
388
416
|
const methods: ParsedFunction[] = []
|
|
389
417
|
const decorators = this.extractDecorators(node)
|
|
@@ -392,15 +420,16 @@ export class TypeScriptExtractor {
|
|
|
392
420
|
for (const member of node.members) {
|
|
393
421
|
if (ts.isConstructorDeclaration(member)) {
|
|
394
422
|
// Track class constructors as methods
|
|
395
|
-
const mStartLine = this.getLineNumber(member.getStart())
|
|
423
|
+
const mStartLine = this.getLineNumber(member.getStart(this.sourceFile))
|
|
396
424
|
const mEndLine = this.getLineNumber(member.getEnd())
|
|
397
425
|
const params = this.extractParams(member.parameters)
|
|
398
426
|
const calls = this.extractCalls(member)
|
|
399
427
|
const bodyText = member.getText(this.sourceFile)
|
|
428
|
+
const methodName = `${name}.constructor`
|
|
400
429
|
|
|
401
430
|
methods.push({
|
|
402
|
-
id:
|
|
403
|
-
name:
|
|
431
|
+
id: this.allocateFnId(methodName),
|
|
432
|
+
name: methodName,
|
|
404
433
|
file: this.filePath,
|
|
405
434
|
startLine: mStartLine,
|
|
406
435
|
endLine: mEndLine,
|
|
@@ -416,8 +445,8 @@ export class TypeScriptExtractor {
|
|
|
416
445
|
detailedLines: this.extractDetailedLines(member),
|
|
417
446
|
})
|
|
418
447
|
} else if (ts.isMethodDeclaration(member) && member.name) {
|
|
419
|
-
const methodName = member.name.getText(this.sourceFile)
|
|
420
|
-
const mStartLine = this.getLineNumber(member.getStart())
|
|
448
|
+
const methodName = `${name}.${member.name.getText(this.sourceFile)}`
|
|
449
|
+
const mStartLine = this.getLineNumber(member.getStart(this.sourceFile))
|
|
421
450
|
const mEndLine = this.getLineNumber(member.getEnd())
|
|
422
451
|
const params = this.extractParams(member.parameters)
|
|
423
452
|
const returnType = normalizeTypeAnnotation(member.type ? member.type.getText(this.sourceFile) : 'void')
|
|
@@ -428,8 +457,8 @@ export class TypeScriptExtractor {
|
|
|
428
457
|
const bodyText = member.getText(this.sourceFile)
|
|
429
458
|
|
|
430
459
|
methods.push({
|
|
431
|
-
id:
|
|
432
|
-
name:
|
|
460
|
+
id: this.allocateFnId(methodName),
|
|
461
|
+
name: methodName,
|
|
433
462
|
file: this.filePath,
|
|
434
463
|
startLine: mStartLine,
|
|
435
464
|
endLine: mEndLine,
|
|
@@ -471,6 +500,9 @@ export class TypeScriptExtractor {
|
|
|
471
500
|
let isDefault = false
|
|
472
501
|
|
|
473
502
|
if (node.importClause) {
|
|
503
|
+
// Skip type-only imports: import type { Foo } or import type * as X
|
|
504
|
+
if (node.importClause.isTypeOnly) return null
|
|
505
|
+
|
|
474
506
|
// import Foo from './module' (default import)
|
|
475
507
|
if (node.importClause.name) {
|
|
476
508
|
names.push(node.importClause.name.text)
|
|
@@ -480,7 +512,10 @@ export class TypeScriptExtractor {
|
|
|
480
512
|
if (node.importClause.namedBindings) {
|
|
481
513
|
if (ts.isNamedImports(node.importClause.namedBindings)) {
|
|
482
514
|
for (const element of node.importClause.namedBindings.elements) {
|
|
483
|
-
|
|
515
|
+
// Skip individual type-only elements: import { type Foo }
|
|
516
|
+
if (!element.isTypeOnly) {
|
|
517
|
+
names.push(element.name.text)
|
|
518
|
+
}
|
|
484
519
|
}
|
|
485
520
|
}
|
|
486
521
|
// import * as foo from './module'
|
|
@@ -490,9 +525,6 @@ export class TypeScriptExtractor {
|
|
|
490
525
|
}
|
|
491
526
|
}
|
|
492
527
|
|
|
493
|
-
// Skip type-only imports
|
|
494
|
-
if (node.importClause?.isTypeOnly) return null
|
|
495
|
-
|
|
496
528
|
return {
|
|
497
529
|
source,
|
|
498
530
|
resolvedPath: '', // Filled in by resolver
|
|
@@ -502,9 +534,21 @@ export class TypeScriptExtractor {
|
|
|
502
534
|
}
|
|
503
535
|
}
|
|
504
536
|
|
|
505
|
-
/**
|
|
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
|
+
*/
|
|
506
545
|
protected extractCalls(node: ts.Node): string[] {
|
|
507
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
|
+
|
|
508
552
|
const walkCalls = (n: ts.Node) => {
|
|
509
553
|
if (ts.isCallExpression(n)) {
|
|
510
554
|
const callee = n.expression
|
|
@@ -526,7 +570,7 @@ export class TypeScriptExtractor {
|
|
|
526
570
|
}
|
|
527
571
|
ts.forEachChild(n, walkCalls)
|
|
528
572
|
}
|
|
529
|
-
ts.forEachChild(
|
|
573
|
+
ts.forEachChild(bodyNode, walkCalls)
|
|
530
574
|
return [...new Set(calls)] // deduplicate
|
|
531
575
|
}
|
|
532
576
|
|
|
@@ -541,9 +585,13 @@ export class TypeScriptExtractor {
|
|
|
541
585
|
const comment = fullText.slice(range.pos, range.end)
|
|
542
586
|
let clean = ''
|
|
543
587
|
if (comment.startsWith('/**') || comment.startsWith('/*')) {
|
|
544
|
-
clean = comment
|
|
588
|
+
clean = comment
|
|
589
|
+
.replace(/^\/\*+/, '') // remove leading /* or /**
|
|
590
|
+
.replace(/\*+\/$/, '') // remove trailing */
|
|
591
|
+
.replace(/^\s*\*+\s?/gm, '') // remove leading * on each line
|
|
592
|
+
.trim()
|
|
545
593
|
} else if (comment.startsWith('//')) {
|
|
546
|
-
clean = comment.replace(
|
|
594
|
+
clean = comment.replace(/^\/\/+\s?/, '').trim()
|
|
547
595
|
}
|
|
548
596
|
|
|
549
597
|
// Skip divider lines (lines with 3+ repeated special characters)
|
|
@@ -552,7 +600,6 @@ export class TypeScriptExtractor {
|
|
|
552
600
|
if (clean) meaningfulLines.push(clean)
|
|
553
601
|
}
|
|
554
602
|
|
|
555
|
-
// Return the first meaningful line in JSDoc, the first line is the summary.
|
|
556
603
|
const fromComment = meaningfulLines.length > 0 ? meaningfulLines[0].split('\n')[0].trim() : ''
|
|
557
604
|
if (fromComment) return fromComment
|
|
558
605
|
}
|
|
@@ -574,7 +621,7 @@ export class TypeScriptExtractor {
|
|
|
574
621
|
if (ts.isMethodDeclaration(node) && ts.isIdentifier(node.name)) return node.name.text
|
|
575
622
|
if (ts.isConstructorDeclaration(node)) return 'constructor'
|
|
576
623
|
if ((ts.isInterfaceDeclaration(node) || ts.isTypeAliasDeclaration(node) || ts.isClassDeclaration(node)) && node.name) {
|
|
577
|
-
return (node as
|
|
624
|
+
return (node as ts.InterfaceDeclaration | ts.TypeAliasDeclaration | ts.ClassDeclaration).name!.text
|
|
578
625
|
}
|
|
579
626
|
return ''
|
|
580
627
|
}
|
|
@@ -606,14 +653,14 @@ export class TypeScriptExtractor {
|
|
|
606
653
|
const walkErrors = (n: ts.Node) => {
|
|
607
654
|
if (ts.isTryStatement(n)) {
|
|
608
655
|
errors.push({
|
|
609
|
-
line: this.getLineNumber(n.getStart()),
|
|
656
|
+
line: this.getLineNumber(n.getStart(this.sourceFile)),
|
|
610
657
|
type: 'try-catch',
|
|
611
658
|
detail: 'try-catch block'
|
|
612
659
|
})
|
|
613
660
|
}
|
|
614
661
|
if (ts.isThrowStatement(n)) {
|
|
615
662
|
errors.push({
|
|
616
|
-
line: this.getLineNumber(n.getStart()),
|
|
663
|
+
line: this.getLineNumber(n.getStart(this.sourceFile)),
|
|
617
664
|
type: 'throw',
|
|
618
665
|
detail: n.expression ? n.expression.getText(this.sourceFile) : 'throw error'
|
|
619
666
|
})
|
|
@@ -630,18 +677,16 @@ export class TypeScriptExtractor {
|
|
|
630
677
|
const walkBlocks = (n: ts.Node) => {
|
|
631
678
|
if (ts.isIfStatement(n) || ts.isSwitchStatement(n)) {
|
|
632
679
|
blocks.push({
|
|
633
|
-
startLine: this.getLineNumber(n.getStart()),
|
|
680
|
+
startLine: this.getLineNumber(n.getStart(this.sourceFile)),
|
|
634
681
|
endLine: this.getLineNumber(n.getEnd()),
|
|
635
682
|
blockType: 'ControlFlow'
|
|
636
683
|
})
|
|
637
684
|
} else if (ts.isForStatement(n) || ts.isWhileStatement(n) || ts.isForOfStatement(n) || ts.isForInStatement(n)) {
|
|
638
685
|
blocks.push({
|
|
639
|
-
startLine: this.getLineNumber(n.getStart()),
|
|
686
|
+
startLine: this.getLineNumber(n.getStart(this.sourceFile)),
|
|
640
687
|
endLine: this.getLineNumber(n.getEnd()),
|
|
641
688
|
blockType: 'Loop'
|
|
642
689
|
})
|
|
643
|
-
} else if (ts.isVariableStatement(n) || ts.isExpressionStatement(n)) {
|
|
644
|
-
// Ignore single lines for brevity unless part of larger logical units
|
|
645
690
|
}
|
|
646
691
|
ts.forEachChild(n, walkBlocks)
|
|
647
692
|
}
|
|
@@ -694,15 +739,45 @@ export class TypeScriptExtractor {
|
|
|
694
739
|
return this.sourceFile.getLineAndCharacterOfPosition(pos).line + 1
|
|
695
740
|
}
|
|
696
741
|
|
|
697
|
-
/**
|
|
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
|
+
*/
|
|
698
750
|
protected walkNode(node: ts.Node, callback: (node: ts.Node) => void): void {
|
|
699
751
|
ts.forEachChild(node, (child) => {
|
|
700
752
|
callback(child)
|
|
753
|
+
this.walkNode(child, callback)
|
|
701
754
|
})
|
|
702
755
|
}
|
|
703
756
|
}
|
|
704
757
|
|
|
705
|
-
//
|
|
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
|
+
}
|
|
706
781
|
|
|
707
782
|
/**
|
|
708
783
|
* Derive a human-readable purpose sentence from a camelCase/PascalCase identifier.
|
|
@@ -5,6 +5,7 @@ import { TypeScriptExtractor } from './ts-extractor.js'
|
|
|
5
5
|
import { TypeScriptResolver } from './ts-resolver.js'
|
|
6
6
|
import { hashContent } from '../../hash/file-hasher.js'
|
|
7
7
|
import type { ParsedFile } from '../types.js'
|
|
8
|
+
import { MIN_FILES_FOR_COMPLETE_SCAN, parseJsonWithComments } from '../parser-constants.js'
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
11
|
* TypeScript parser — uses TS Compiler API to parse .ts/.tsx files
|
|
@@ -21,23 +22,21 @@ export class TypeScriptParser extends BaseParser {
|
|
|
21
22
|
const exports = extractor.extractExports()
|
|
22
23
|
const routes = extractor.extractRoutes()
|
|
23
24
|
|
|
24
|
-
// Cross-reference:
|
|
25
|
-
//
|
|
26
|
-
|
|
25
|
+
// Cross-reference: re-export declarations (`export { Name }` or
|
|
26
|
+
// `export { X as Y } from './m'`) may refer to symbols whose declaration
|
|
27
|
+
// doesn't carry an export keyword. Mark them as exported here.
|
|
28
|
+
// Exclude `type: 'default'` to avoid marking an unrelated local called 'default'.
|
|
29
|
+
const exportedNonDefault = new Set(
|
|
30
|
+
exports.filter(e => e.type !== 'default').map(e => e.name)
|
|
31
|
+
)
|
|
27
32
|
for (const fn of functions) {
|
|
28
|
-
if (!fn.isExported &&
|
|
29
|
-
fn.isExported = true
|
|
30
|
-
}
|
|
33
|
+
if (!fn.isExported && exportedNonDefault.has(fn.name)) fn.isExported = true
|
|
31
34
|
}
|
|
32
35
|
for (const cls of classes) {
|
|
33
|
-
if (!cls.isExported &&
|
|
34
|
-
cls.isExported = true
|
|
35
|
-
}
|
|
36
|
+
if (!cls.isExported && exportedNonDefault.has(cls.name)) cls.isExported = true
|
|
36
37
|
}
|
|
37
38
|
for (const gen of generics) {
|
|
38
|
-
if (!gen.isExported &&
|
|
39
|
-
gen.isExported = true
|
|
40
|
-
}
|
|
39
|
+
if (!gen.isExported && exportedNonDefault.has(gen.name)) gen.isExported = true
|
|
41
40
|
}
|
|
42
41
|
|
|
43
42
|
return {
|
|
@@ -58,7 +57,14 @@ export class TypeScriptParser extends BaseParser {
|
|
|
58
57
|
resolveImports(files: ParsedFile[], projectRoot: string): ParsedFile[] {
|
|
59
58
|
const tsConfigPaths = loadTsConfigPaths(projectRoot)
|
|
60
59
|
const resolver = new TypeScriptResolver(projectRoot, tsConfigPaths)
|
|
61
|
-
|
|
60
|
+
|
|
61
|
+
// Only pass the project file list when it is large enough to be a meaningful
|
|
62
|
+
// scan. Sparse lists (< MIN_FILES_FOR_COMPLETE_SCAN files) cause alias
|
|
63
|
+
// resolution lookups to fail with '', so we only trust the list once it is
|
|
64
|
+
// sufficiently large. With an empty list the resolver falls back to extension
|
|
65
|
+
// probing, which is safe for alias-defined paths.
|
|
66
|
+
const allFilePaths = files.length >= MIN_FILES_FOR_COMPLETE_SCAN ? files.map(f => f.path) : []
|
|
67
|
+
|
|
62
68
|
return files.map(file => ({
|
|
63
69
|
...file,
|
|
64
70
|
imports: file.imports.map(imp => resolver.resolve(imp, file.path, allFilePaths)),
|
|
@@ -79,7 +85,7 @@ export class TypeScriptParser extends BaseParser {
|
|
|
79
85
|
* - extends with relative paths (./tsconfig.base.json)
|
|
80
86
|
* - extends with node_modules packages (@tsconfig/node-lts)
|
|
81
87
|
* - baseUrl prefix so aliases like "@/*" → ["src/*"] resolve correctly
|
|
82
|
-
* - JSON5-style comments (line and block comments)
|
|
88
|
+
* - JSON5-style comments (line and block comments) via the shared helper
|
|
83
89
|
*/
|
|
84
90
|
function loadTsConfigPaths(projectRoot: string): Record<string, string[]> {
|
|
85
91
|
const candidates = ['tsconfig.json', 'tsconfig.base.json']
|
|
@@ -121,13 +127,11 @@ function loadTsConfigWithExtends(configPath: string, visited: Set<string>): any
|
|
|
121
127
|
return {}
|
|
122
128
|
}
|
|
123
129
|
|
|
124
|
-
// Strip JSON5 comments
|
|
125
|
-
const stripped = raw.replace(/\/\/.*$/gm, '').replace(/\/\*[\s\S]*?\*\//g, '')
|
|
126
130
|
let config: any
|
|
127
131
|
try {
|
|
128
|
-
config =
|
|
132
|
+
config = parseJsonWithComments(raw)
|
|
129
133
|
} catch {
|
|
130
|
-
|
|
134
|
+
return {}
|
|
131
135
|
}
|
|
132
136
|
|
|
133
137
|
if (!config.extends) return config
|
|
@@ -6,8 +6,15 @@ interface TSConfigPaths {
|
|
|
6
6
|
}
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
|
-
*
|
|
10
|
-
*
|
|
9
|
+
* TypeScriptResolver — resolves TS/TSX import paths to project-relative files.
|
|
10
|
+
*
|
|
11
|
+
* Handles:
|
|
12
|
+
* - Relative ESM imports: import './utils' → ./utils.ts / ./utils/index.ts / ...
|
|
13
|
+
* - Path aliases from tsconfig.json compilerOptions.paths
|
|
14
|
+
* - Mixed TS/JS projects: probes .ts, .tsx, .js, .jsx in that order
|
|
15
|
+
*
|
|
16
|
+
* Performance: allProjectFiles is converted to a Set internally for O(1) checks.
|
|
17
|
+
* All alias targets are tried in order (not just targets[0]).
|
|
11
18
|
*/
|
|
12
19
|
export class TypeScriptResolver {
|
|
13
20
|
private aliases: TSConfigPaths
|
|
@@ -16,71 +23,97 @@ export class TypeScriptResolver {
|
|
|
16
23
|
private projectRoot: string,
|
|
17
24
|
tsConfigPaths?: TSConfigPaths
|
|
18
25
|
) {
|
|
19
|
-
this.aliases = tsConfigPaths
|
|
26
|
+
this.aliases = tsConfigPaths ?? {}
|
|
20
27
|
}
|
|
21
28
|
|
|
22
29
|
/** Resolve a single import relative to the importing file */
|
|
23
30
|
resolve(imp: ParsedImport, fromFile: string, allProjectFiles: string[] = []): ParsedImport {
|
|
24
|
-
|
|
25
|
-
|
|
31
|
+
if (
|
|
32
|
+
!imp.source.startsWith('.') &&
|
|
33
|
+
!imp.source.startsWith('/') &&
|
|
34
|
+
!this.matchesAlias(imp.source)
|
|
35
|
+
) {
|
|
26
36
|
return { ...imp, resolvedPath: '' }
|
|
27
37
|
}
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
return { ...imp, resolvedPath: resolved }
|
|
38
|
+
const fileSet = allProjectFiles.length > 0 ? new Set(allProjectFiles) : null
|
|
39
|
+
return { ...imp, resolvedPath: this.resolvePath(imp.source, fromFile, fileSet) }
|
|
31
40
|
}
|
|
32
41
|
|
|
33
|
-
|
|
34
|
-
|
|
42
|
+
resolveAll(imports: ParsedImport[], fromFile: string, allProjectFiles: string[] = []): ParsedImport[] {
|
|
43
|
+
const fileSet = allProjectFiles.length > 0 ? new Set(allProjectFiles) : null
|
|
44
|
+
return imports.map(imp => {
|
|
45
|
+
if (
|
|
46
|
+
!imp.source.startsWith('.') &&
|
|
47
|
+
!imp.source.startsWith('/') &&
|
|
48
|
+
!this.matchesAlias(imp.source)
|
|
49
|
+
) {
|
|
50
|
+
return { ...imp, resolvedPath: '' }
|
|
51
|
+
}
|
|
52
|
+
return { ...imp, resolvedPath: this.resolvePath(imp.source, fromFile, fileSet) }
|
|
53
|
+
})
|
|
54
|
+
}
|
|
35
55
|
|
|
36
|
-
|
|
56
|
+
private resolvePath(source: string, fromFile: string, fileSet: Set<string> | null): string {
|
|
57
|
+
// 1. Alias substitution — try ALL targets in order, not just targets[0]
|
|
37
58
|
for (const [alias, targets] of Object.entries(this.aliases)) {
|
|
38
|
-
const
|
|
39
|
-
if (source.startsWith(
|
|
40
|
-
const suffix = source.slice(
|
|
41
|
-
const target
|
|
42
|
-
|
|
43
|
-
|
|
59
|
+
const prefix = alias.replace('/*', '')
|
|
60
|
+
if (source.startsWith(prefix)) {
|
|
61
|
+
const suffix = source.slice(prefix.length)
|
|
62
|
+
for (const target of targets) {
|
|
63
|
+
const substituted = target.replace('/*', '') + suffix
|
|
64
|
+
const resolved = this.normalizePath(substituted, fromFile)
|
|
65
|
+
const found = this.probeExtensions(resolved, fileSet)
|
|
66
|
+
if (found) return found
|
|
67
|
+
}
|
|
68
|
+
// All alias targets exhausted — unresolved
|
|
69
|
+
return ''
|
|
44
70
|
}
|
|
45
71
|
}
|
|
46
72
|
|
|
47
|
-
// 2.
|
|
48
|
-
|
|
49
|
-
if (resolvedSource.startsWith('.')) {
|
|
50
|
-
const fromDir = path.dirname(fromFile)
|
|
51
|
-
resolved = path.posix.normalize(path.posix.join(fromDir, resolvedSource))
|
|
52
|
-
} else {
|
|
53
|
-
resolved = resolvedSource
|
|
54
|
-
}
|
|
73
|
+
// 2. Build normalized posix path from relative source
|
|
74
|
+
const resolved = this.normalizePath(source, fromFile)
|
|
55
75
|
|
|
56
|
-
//
|
|
57
|
-
|
|
76
|
+
// 3. Already has a concrete TS/JS extension — validate and return
|
|
77
|
+
const concreteExts = ['.ts', '.tsx', '.js', '.jsx', '.mjs']
|
|
78
|
+
if (concreteExts.some(e => resolved.endsWith(e))) {
|
|
79
|
+
if (fileSet && !fileSet.has(resolved)) return ''
|
|
80
|
+
return resolved
|
|
81
|
+
}
|
|
58
82
|
|
|
59
|
-
//
|
|
60
|
-
|
|
83
|
+
// 4. Probe extensions
|
|
84
|
+
return this.probeExtensions(resolved, fileSet) ?? resolved + '.ts'
|
|
85
|
+
}
|
|
61
86
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
87
|
+
private normalizePath(source: string, fromFile: string): string {
|
|
88
|
+
let resolved: string
|
|
89
|
+
if (source.startsWith('.')) {
|
|
90
|
+
const fromDir = path.dirname(fromFile.replace(/\\/g, '/'))
|
|
91
|
+
resolved = path.posix.normalize(path.posix.join(fromDir, source))
|
|
92
|
+
} else {
|
|
93
|
+
resolved = source
|
|
65
94
|
}
|
|
95
|
+
return resolved.replace(/\\/g, '/')
|
|
96
|
+
}
|
|
66
97
|
|
|
67
|
-
|
|
68
|
-
|
|
98
|
+
/**
|
|
99
|
+
* Probe extensions in priority order.
|
|
100
|
+
* TS-first since this is a TypeScript resolver; JS fallback for mixed projects.
|
|
101
|
+
*/
|
|
102
|
+
private probeExtensions(resolved: string, fileSet: Set<string> | null): string | null {
|
|
103
|
+
const probeOrder = [
|
|
104
|
+
'.ts', '.tsx',
|
|
105
|
+
'/index.ts', '/index.tsx',
|
|
106
|
+
'.js', '.jsx', '.mjs',
|
|
107
|
+
'/index.js', '/index.jsx',
|
|
108
|
+
]
|
|
109
|
+
for (const ext of probeOrder) {
|
|
69
110
|
const candidate = resolved + ext
|
|
70
|
-
if (
|
|
71
|
-
return candidate
|
|
72
|
-
}
|
|
111
|
+
if (fileSet === null || fileSet.has(candidate)) return candidate
|
|
73
112
|
}
|
|
74
|
-
|
|
75
|
-
// Fallback: just add .ts
|
|
76
|
-
return resolved + '.ts'
|
|
113
|
+
return null
|
|
77
114
|
}
|
|
78
115
|
|
|
79
116
|
private matchesAlias(source: string): boolean {
|
|
80
|
-
|
|
81
|
-
const prefix = alias.replace('/*', '')
|
|
82
|
-
if (source.startsWith(prefix)) return true
|
|
83
|
-
}
|
|
84
|
-
return false
|
|
117
|
+
return Object.keys(this.aliases).some(a => source.startsWith(a.replace('/*', '')))
|
|
85
118
|
}
|
|
86
119
|
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import * as fs from 'node:fs/promises'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Safe JSON reading utility with descriptive error messages.
|
|
5
|
+
* Centralizes JSON.parse hardening against syntax errors.
|
|
6
|
+
*/
|
|
7
|
+
export async function readJsonSafe(
|
|
8
|
+
filePath: string,
|
|
9
|
+
fileLabel: string = 'JSON file'
|
|
10
|
+
): Promise<any> {
|
|
11
|
+
let content: string
|
|
12
|
+
try {
|
|
13
|
+
content = await fs.readFile(filePath, 'utf-8')
|
|
14
|
+
} catch (e: any) {
|
|
15
|
+
if (e.code === 'ENOENT') {
|
|
16
|
+
throw e // Let callers handle missing files (e.g. ContractNotFoundError)
|
|
17
|
+
}
|
|
18
|
+
throw new Error(`Failed to read ${fileLabel}: ${e.message}`)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const sanitized = content.replace(/^\uFEFF/, '')
|
|
22
|
+
try {
|
|
23
|
+
return JSON.parse(sanitized)
|
|
24
|
+
} catch (e: any) {
|
|
25
|
+
throw new Error(`Malformed ${fileLabel}: Syntax error - ${e.message}`)
|
|
26
|
+
}
|
|
27
|
+
}
|
package/tests/js-parser.test.ts
CHANGED
|
@@ -521,9 +521,29 @@ describe('JavaScriptParser', () => {
|
|
|
521
521
|
parser.parse('src/auth.js', CJS_MODULE),
|
|
522
522
|
parser.parse('src/loader.js', ESM_MODULE),
|
|
523
523
|
])
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
524
|
+
// When no allProjectFiles list is passed to resolveImports, the resolver
|
|
525
|
+
// falls back to extension probing without filesystem validation and resolves
|
|
526
|
+
// relative imports to their most-likely path (e.g. './db' → 'src/db.js').
|
|
527
|
+
//
|
|
528
|
+
// Previously the test relied on the broken behaviour where the resolver
|
|
529
|
+
// always probed through even when the file wasn't in the provided list.
|
|
530
|
+
// The correct fix is to call resolveImports without a restrictive file list,
|
|
531
|
+
// which is what happens in production (the parser computes allFilePaths
|
|
532
|
+
// from the full project scan, not just the two files under test).
|
|
533
|
+
//
|
|
534
|
+
// We simulate a "full project" by telling the resolver that src/db.js exists.
|
|
535
|
+
const allProjectFiles = [
|
|
536
|
+
'src/auth.js',
|
|
537
|
+
'src/loader.js',
|
|
538
|
+
'src/db.js', // ← the file that auth.js imports
|
|
539
|
+
]
|
|
540
|
+
// resolveImports in JavaScriptParser uses files.map(f => f.path) internally,
|
|
541
|
+
// so to inject a richer file list we call the resolver directly here.
|
|
542
|
+
const resolver = new JavaScriptResolver('/project')
|
|
543
|
+
const authFile = files.find((f: any) => f.path === 'src/auth.js')!
|
|
544
|
+
const resolvedImports = resolver.resolveAll(authFile.imports, authFile.path, allProjectFiles)
|
|
545
|
+
const dbImport = resolvedImports.find((i: any) => i.source === './db')
|
|
546
|
+
expect(dbImport).toBeDefined()
|
|
527
547
|
expect(dbImport!.resolvedPath).toMatch(/src\/db/)
|
|
528
548
|
expect(dbImport!.resolvedPath).toMatch(/\.js$/)
|
|
529
549
|
})
|