@getmikk/core 1.5.1 → 1.7.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.
- package/README.md +44 -6
- package/out.log +0 -0
- package/package.json +4 -3
- package/src/contract/adr-manager.ts +75 -0
- package/src/contract/index.ts +2 -0
- package/src/contract/lock-compiler.ts +6 -5
- package/src/contract/lock-reader.ts +40 -26
- package/src/contract/schema.ts +2 -7
- package/src/graph/dead-code-detector.ts +194 -0
- package/src/graph/graph-builder.ts +6 -2
- package/src/graph/impact-analyzer.ts +53 -2
- package/src/graph/index.ts +4 -1
- package/src/graph/types.ts +21 -0
- package/src/index.ts +1 -1
- package/src/parser/go/go-extractor.ts +712 -0
- package/src/parser/go/go-parser.ts +41 -0
- package/src/parser/go/go-resolver.ts +70 -0
- package/src/parser/index.ts +46 -6
- package/src/parser/javascript/js-extractor.ts +262 -0
- package/src/parser/javascript/js-parser.ts +92 -0
- package/src/parser/javascript/js-resolver.ts +83 -0
- package/src/parser/types.ts +1 -1
- package/src/parser/typescript/ts-extractor.ts +93 -42
- package/src/parser/typescript/ts-parser.ts +120 -1
- package/test-output.txt +0 -0
- package/tests/adr-manager.test.ts +97 -0
- package/tests/contract.test.ts +1 -1
- package/tests/dead-code.test.ts +134 -0
- package/tests/go-parser.test.ts +366 -0
- package/tests/helpers.ts +0 -1
- package/tests/impact-classified.test.ts +78 -0
- package/tests/js-parser.test.ts +616 -0
- package/tests/ts-parser.test.ts +93 -0
|
@@ -7,11 +7,11 @@ import { hashContent } from '../../hash/file-hasher.js'
|
|
|
7
7
|
* and extracts functions, classes, imports, exports and call relationships.
|
|
8
8
|
*/
|
|
9
9
|
export class TypeScriptExtractor {
|
|
10
|
-
|
|
10
|
+
protected readonly sourceFile: ts.SourceFile
|
|
11
11
|
|
|
12
12
|
constructor(
|
|
13
|
-
|
|
14
|
-
|
|
13
|
+
protected readonly filePath: string,
|
|
14
|
+
protected readonly content: string
|
|
15
15
|
) {
|
|
16
16
|
this.sourceFile = ts.createSourceFile(
|
|
17
17
|
filePath,
|
|
@@ -118,7 +118,7 @@ export class TypeScriptExtractor {
|
|
|
118
118
|
return generics
|
|
119
119
|
}
|
|
120
120
|
|
|
121
|
-
|
|
121
|
+
protected isVariableFunction(node: ts.VariableStatement): boolean {
|
|
122
122
|
for (const decl of node.declarationList.declarations) {
|
|
123
123
|
if (decl.initializer && (ts.isArrowFunction(decl.initializer) || ts.isFunctionExpression(decl.initializer))) {
|
|
124
124
|
return true
|
|
@@ -309,14 +309,14 @@ export class TypeScriptExtractor {
|
|
|
309
309
|
return routes
|
|
310
310
|
}
|
|
311
311
|
|
|
312
|
-
// ───
|
|
312
|
+
// ─── Protected Helpers ─────────────────────────────────────
|
|
313
313
|
|
|
314
|
-
|
|
314
|
+
protected parseFunctionDeclaration(node: ts.FunctionDeclaration): ParsedFunction {
|
|
315
315
|
const name = node.name!.text
|
|
316
316
|
const startLine = this.getLineNumber(node.getStart())
|
|
317
317
|
const endLine = this.getLineNumber(node.getEnd())
|
|
318
318
|
const params = this.extractParams(node.parameters)
|
|
319
|
-
const returnType = node.type ? node.type.getText(this.sourceFile) : 'void'
|
|
319
|
+
const returnType = normalizeTypeAnnotation(node.type ? node.type.getText(this.sourceFile) : 'void')
|
|
320
320
|
const isAsync = !!node.modifiers?.some(m => m.kind === ts.SyntaxKind.AsyncKeyword)
|
|
321
321
|
const isGenerator = !!node.asteriskToken
|
|
322
322
|
const typeParameters = this.extractTypeParameters(node.typeParameters)
|
|
@@ -344,7 +344,7 @@ export class TypeScriptExtractor {
|
|
|
344
344
|
}
|
|
345
345
|
}
|
|
346
346
|
|
|
347
|
-
|
|
347
|
+
protected parseVariableFunction(
|
|
348
348
|
stmt: ts.VariableStatement,
|
|
349
349
|
decl: ts.VariableDeclaration,
|
|
350
350
|
fn: ts.ArrowFunction | ts.FunctionExpression
|
|
@@ -353,7 +353,7 @@ export class TypeScriptExtractor {
|
|
|
353
353
|
const startLine = this.getLineNumber(stmt.getStart())
|
|
354
354
|
const endLine = this.getLineNumber(stmt.getEnd())
|
|
355
355
|
const params = this.extractParams(fn.parameters)
|
|
356
|
-
const returnType = fn.type ? fn.type.getText(this.sourceFile) : 'void'
|
|
356
|
+
const returnType = normalizeTypeAnnotation(fn.type ? fn.type.getText(this.sourceFile) : 'void')
|
|
357
357
|
const isAsync = !!fn.modifiers?.some(m => m.kind === ts.SyntaxKind.AsyncKeyword)
|
|
358
358
|
const isGenerator = ts.isFunctionExpression(fn) && !!fn.asteriskToken
|
|
359
359
|
const typeParameters = this.extractTypeParameters(fn.typeParameters)
|
|
@@ -381,7 +381,7 @@ export class TypeScriptExtractor {
|
|
|
381
381
|
}
|
|
382
382
|
}
|
|
383
383
|
|
|
384
|
-
|
|
384
|
+
protected parseClass(node: ts.ClassDeclaration): ParsedClass {
|
|
385
385
|
const name = node.name!.text
|
|
386
386
|
const startLine = this.getLineNumber(node.getStart())
|
|
387
387
|
const endLine = this.getLineNumber(node.getEnd())
|
|
@@ -420,7 +420,7 @@ export class TypeScriptExtractor {
|
|
|
420
420
|
const mStartLine = this.getLineNumber(member.getStart())
|
|
421
421
|
const mEndLine = this.getLineNumber(member.getEnd())
|
|
422
422
|
const params = this.extractParams(member.parameters)
|
|
423
|
-
const returnType = member.type ? member.type.getText(this.sourceFile) : 'void'
|
|
423
|
+
const returnType = normalizeTypeAnnotation(member.type ? member.type.getText(this.sourceFile) : 'void')
|
|
424
424
|
const isAsync = !!member.modifiers?.some(m => m.kind === ts.SyntaxKind.AsyncKeyword)
|
|
425
425
|
const isGenerator = !!member.asteriskToken
|
|
426
426
|
const methodTypeParams = this.extractTypeParameters(member.typeParameters)
|
|
@@ -465,7 +465,7 @@ export class TypeScriptExtractor {
|
|
|
465
465
|
}
|
|
466
466
|
}
|
|
467
467
|
|
|
468
|
-
|
|
468
|
+
protected parseImport(node: ts.ImportDeclaration): ParsedImport | null {
|
|
469
469
|
const source = (node.moduleSpecifier as ts.StringLiteral).text
|
|
470
470
|
const names: string[] = []
|
|
471
471
|
let isDefault = false
|
|
@@ -503,7 +503,7 @@ export class TypeScriptExtractor {
|
|
|
503
503
|
}
|
|
504
504
|
|
|
505
505
|
/** Extract function/method call expressions from a node (including new Foo()) */
|
|
506
|
-
|
|
506
|
+
protected extractCalls(node: ts.Node): string[] {
|
|
507
507
|
const calls: string[] = []
|
|
508
508
|
const walkCalls = (n: ts.Node) => {
|
|
509
509
|
if (ts.isCallExpression(n)) {
|
|
@@ -530,35 +530,57 @@ export class TypeScriptExtractor {
|
|
|
530
530
|
return [...new Set(calls)] // deduplicate
|
|
531
531
|
}
|
|
532
532
|
|
|
533
|
-
/** Extract the purpose from JSDoc comments or preceding single-line comments
|
|
534
|
-
|
|
533
|
+
/** Extract the purpose from JSDoc comments or preceding single-line comments.
|
|
534
|
+
* Falls back to deriving a human-readable sentence from the function name. */
|
|
535
|
+
protected extractPurpose(node: ts.Node): string {
|
|
535
536
|
const fullText = this.sourceFile.getFullText()
|
|
536
537
|
const commentRanges = ts.getLeadingCommentRanges(fullText, node.getFullStart())
|
|
537
|
-
if (
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
}
|
|
538
|
+
if (commentRanges && commentRanges.length > 0) {
|
|
539
|
+
const meaningfulLines: string[] = []
|
|
540
|
+
for (const range of commentRanges) {
|
|
541
|
+
const comment = fullText.slice(range.pos, range.end)
|
|
542
|
+
let clean = ''
|
|
543
|
+
if (comment.startsWith('/**') || comment.startsWith('/*')) {
|
|
544
|
+
clean = comment.replace(/[\/\*]/g, '').trim()
|
|
545
|
+
} else if (comment.startsWith('//')) {
|
|
546
|
+
clean = comment.replace(/\/\//g, '').trim()
|
|
547
|
+
}
|
|
548
548
|
|
|
549
|
-
|
|
550
|
-
|
|
549
|
+
// Skip divider lines (lines with 3+ repeated special characters)
|
|
550
|
+
if (/^[─\-_=\*]{3,}$/.test(clean)) continue
|
|
551
|
+
|
|
552
|
+
if (clean) meaningfulLines.push(clean)
|
|
553
|
+
}
|
|
551
554
|
|
|
552
|
-
|
|
555
|
+
// Return the first meaningful line — in JSDoc, the first line is the summary.
|
|
556
|
+
const fromComment = meaningfulLines.length > 0 ? meaningfulLines[0].split('\n')[0].trim() : ''
|
|
557
|
+
if (fromComment) return fromComment
|
|
553
558
|
}
|
|
554
559
|
|
|
555
|
-
//
|
|
556
|
-
|
|
557
|
-
return
|
|
560
|
+
// Fallback: derive a human-readable sentence from the function/identifier name
|
|
561
|
+
const name = this.getNodeName(node)
|
|
562
|
+
return name ? derivePurposeFromName(name) : ''
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/** Get the identifier name from common declaration node types */
|
|
566
|
+
protected getNodeName(node: ts.Node): string {
|
|
567
|
+
if (ts.isFunctionDeclaration(node) && node.name) return node.name.text
|
|
568
|
+
if (ts.isArrowFunction(node) || ts.isFunctionExpression(node)) {
|
|
569
|
+
const parent = node.parent
|
|
570
|
+
if (parent && ts.isVariableDeclaration(parent) && ts.isIdentifier(parent.name)) {
|
|
571
|
+
return parent.name.text
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
if (ts.isMethodDeclaration(node) && ts.isIdentifier(node.name)) return node.name.text
|
|
575
|
+
if (ts.isConstructorDeclaration(node)) return 'constructor'
|
|
576
|
+
if ((ts.isInterfaceDeclaration(node) || ts.isTypeAliasDeclaration(node) || ts.isClassDeclaration(node)) && node.name) {
|
|
577
|
+
return (node as any).name.text
|
|
578
|
+
}
|
|
579
|
+
return ''
|
|
558
580
|
}
|
|
559
581
|
|
|
560
582
|
/** Extract edge cases handled (if statements returning early) */
|
|
561
|
-
|
|
583
|
+
protected extractEdgeCases(node: ts.Node): string[] {
|
|
562
584
|
const edgeCases: string[] = []
|
|
563
585
|
const walkEdgeCases = (n: ts.Node) => {
|
|
564
586
|
if (ts.isIfStatement(n)) {
|
|
@@ -579,7 +601,7 @@ export class TypeScriptExtractor {
|
|
|
579
601
|
}
|
|
580
602
|
|
|
581
603
|
/** Extract try-catch blocks or explicit throw statements */
|
|
582
|
-
|
|
604
|
+
protected extractErrorHandling(node: ts.Node): { line: number, type: 'try-catch' | 'throw', detail: string }[] {
|
|
583
605
|
const errors: { line: number, type: 'try-catch' | 'throw', detail: string }[] = []
|
|
584
606
|
const walkErrors = (n: ts.Node) => {
|
|
585
607
|
if (ts.isTryStatement(n)) {
|
|
@@ -603,7 +625,7 @@ export class TypeScriptExtractor {
|
|
|
603
625
|
}
|
|
604
626
|
|
|
605
627
|
/** Extract detailed line block breakdowns */
|
|
606
|
-
|
|
628
|
+
protected extractDetailedLines(node: ts.Node): { startLine: number, endLine: number, blockType: string }[] {
|
|
607
629
|
const blocks: { startLine: number, endLine: number, blockType: string }[] = []
|
|
608
630
|
const walkBlocks = (n: ts.Node) => {
|
|
609
631
|
if (ts.isIfStatement(n) || ts.isSwitchStatement(n)) {
|
|
@@ -628,13 +650,13 @@ export class TypeScriptExtractor {
|
|
|
628
650
|
}
|
|
629
651
|
|
|
630
652
|
/** Extract type parameter names from a generic declaration */
|
|
631
|
-
|
|
653
|
+
protected extractTypeParameters(typeParams: ts.NodeArray<ts.TypeParameterDeclaration> | undefined): string[] {
|
|
632
654
|
if (!typeParams || typeParams.length === 0) return []
|
|
633
655
|
return typeParams.map(tp => tp.name.text)
|
|
634
656
|
}
|
|
635
657
|
|
|
636
658
|
/** Extract decorator names from a class declaration */
|
|
637
|
-
|
|
659
|
+
protected extractDecorators(node: ts.ClassDeclaration): string[] {
|
|
638
660
|
const decorators: string[] = []
|
|
639
661
|
const modifiers = ts.canHaveDecorators(node) ? ts.getDecorators(node) : undefined
|
|
640
662
|
if (modifiers) {
|
|
@@ -652,30 +674,59 @@ export class TypeScriptExtractor {
|
|
|
652
674
|
}
|
|
653
675
|
|
|
654
676
|
/** Extract parameters from a function's parameter list */
|
|
655
|
-
|
|
677
|
+
protected extractParams(params: ts.NodeArray<ts.ParameterDeclaration>): ParsedParam[] {
|
|
656
678
|
return params.map((p) => ({
|
|
657
679
|
name: p.name.getText(this.sourceFile),
|
|
658
|
-
type: p.type ? p.type.getText(this.sourceFile) : 'any',
|
|
680
|
+
type: normalizeTypeAnnotation(p.type ? p.type.getText(this.sourceFile) : 'any'),
|
|
659
681
|
optional: !!p.questionToken || !!p.initializer,
|
|
660
682
|
defaultValue: p.initializer ? p.initializer.getText(this.sourceFile) : undefined,
|
|
661
683
|
}))
|
|
662
684
|
}
|
|
663
685
|
|
|
664
686
|
/** Check if a node has the 'export' modifier */
|
|
665
|
-
|
|
687
|
+
protected hasExportModifier(node: ts.Node): boolean {
|
|
666
688
|
const modifiers = ts.canHaveModifiers(node) ? ts.getModifiers(node) : undefined
|
|
667
689
|
return !!modifiers?.some(m => m.kind === ts.SyntaxKind.ExportKeyword)
|
|
668
690
|
}
|
|
669
691
|
|
|
670
692
|
/** Get 1-indexed line number from a character position */
|
|
671
|
-
|
|
693
|
+
protected getLineNumber(pos: number): number {
|
|
672
694
|
return this.sourceFile.getLineAndCharacterOfPosition(pos).line + 1
|
|
673
695
|
}
|
|
674
696
|
|
|
675
697
|
/** Walk the top-level children of a node (non-recursive — callbacks decide depth) */
|
|
676
|
-
|
|
698
|
+
protected walkNode(node: ts.Node, callback: (node: ts.Node) => void): void {
|
|
677
699
|
ts.forEachChild(node, (child) => {
|
|
678
700
|
callback(child)
|
|
679
701
|
})
|
|
680
702
|
}
|
|
681
703
|
}
|
|
704
|
+
|
|
705
|
+
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
706
|
+
|
|
707
|
+
/**
|
|
708
|
+
* Derive a human-readable purpose sentence from a camelCase/PascalCase identifier.
|
|
709
|
+
* Examples:
|
|
710
|
+
* validateJwtToken → "Validate jwt token"
|
|
711
|
+
* buildGraphFromLock → "Build graph from lock"
|
|
712
|
+
* UserRepository → "User repository"
|
|
713
|
+
* parseFiles → "Parse files"
|
|
714
|
+
*/
|
|
715
|
+
function normalizeTypeAnnotation(type: string): string {
|
|
716
|
+
return type.replace(/\s*\n\s*/g, ' ').replace(/\s{2,}/g, ' ').trim()
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
function derivePurposeFromName(name: string): string {
|
|
720
|
+
if (!name || name === 'constructor') return ''
|
|
721
|
+
// Split on camelCase/PascalCase boundaries and underscores
|
|
722
|
+
const words = name
|
|
723
|
+
.replace(/_+/g, ' ')
|
|
724
|
+
.replace(/([a-z])([A-Z])/g, '$1 $2')
|
|
725
|
+
.replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2')
|
|
726
|
+
.toLowerCase()
|
|
727
|
+
.split(/\s+/)
|
|
728
|
+
.filter(Boolean)
|
|
729
|
+
if (words.length === 0) return ''
|
|
730
|
+
words[0] = words[0].charAt(0).toUpperCase() + words[0].slice(1)
|
|
731
|
+
return words.join(' ')
|
|
732
|
+
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import * as path from 'node:path'
|
|
2
|
+
import * as fs from 'node:fs'
|
|
1
3
|
import { BaseParser } from '../base-parser.js'
|
|
2
4
|
import { TypeScriptExtractor } from './ts-extractor.js'
|
|
3
5
|
import { TypeScriptResolver } from './ts-resolver.js'
|
|
@@ -54,7 +56,8 @@ export class TypeScriptParser extends BaseParser {
|
|
|
54
56
|
|
|
55
57
|
/** Resolve all import paths in parsed files to absolute project paths */
|
|
56
58
|
resolveImports(files: ParsedFile[], projectRoot: string): ParsedFile[] {
|
|
57
|
-
const
|
|
59
|
+
const tsConfigPaths = loadTsConfigPaths(projectRoot)
|
|
60
|
+
const resolver = new TypeScriptResolver(projectRoot, tsConfigPaths)
|
|
58
61
|
const allFilePaths = files.map(f => f.path)
|
|
59
62
|
return files.map(file => ({
|
|
60
63
|
...file,
|
|
@@ -66,3 +69,119 @@ export class TypeScriptParser extends BaseParser {
|
|
|
66
69
|
return ['.ts', '.tsx']
|
|
67
70
|
}
|
|
68
71
|
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Read compilerOptions.paths from tsconfig.json in projectRoot.
|
|
75
|
+
* Recursively follows "extends" chains (e.g. extends ./tsconfig.base.json,
|
|
76
|
+
* extends @tsconfig/node-lts/tsconfig.json) and merges paths.
|
|
77
|
+
*
|
|
78
|
+
* Handles:
|
|
79
|
+
* - extends with relative paths (./tsconfig.base.json)
|
|
80
|
+
* - extends with node_modules packages (@tsconfig/node-lts)
|
|
81
|
+
* - baseUrl prefix so aliases like "@/*" → ["src/*"] resolve correctly
|
|
82
|
+
* - JSON5-style comments (line and block comments)
|
|
83
|
+
*/
|
|
84
|
+
function loadTsConfigPaths(projectRoot: string): Record<string, string[]> {
|
|
85
|
+
const candidates = ['tsconfig.json', 'tsconfig.base.json']
|
|
86
|
+
for (const name of candidates) {
|
|
87
|
+
const tsConfigPath = path.join(projectRoot, name)
|
|
88
|
+
try {
|
|
89
|
+
const merged = loadTsConfigWithExtends(tsConfigPath, new Set())
|
|
90
|
+
const options = merged.compilerOptions ?? {}
|
|
91
|
+
const rawPaths: Record<string, string[]> = options.paths ?? {}
|
|
92
|
+
if (Object.keys(rawPaths).length === 0) continue
|
|
93
|
+
|
|
94
|
+
const baseUrl: string = options.baseUrl ?? '.'
|
|
95
|
+
const resolved: Record<string, string[]> = {}
|
|
96
|
+
for (const [alias, targets] of Object.entries(rawPaths)) {
|
|
97
|
+
resolved[alias] = (targets as string[]).map(t =>
|
|
98
|
+
t.startsWith('.') ? path.posix.join(baseUrl, t) : t
|
|
99
|
+
)
|
|
100
|
+
}
|
|
101
|
+
return resolved
|
|
102
|
+
} catch { /* tsconfig not found or invalid — continue */ }
|
|
103
|
+
}
|
|
104
|
+
return {}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Recursively load a tsconfig, following the "extends" chain.
|
|
109
|
+
* Merges compilerOptions from parent → child (child wins on conflict).
|
|
110
|
+
* Prevents infinite loops via a visited set.
|
|
111
|
+
*/
|
|
112
|
+
function loadTsConfigWithExtends(configPath: string, visited: Set<string>): any {
|
|
113
|
+
const resolved = path.resolve(configPath)
|
|
114
|
+
if (visited.has(resolved)) return {}
|
|
115
|
+
visited.add(resolved)
|
|
116
|
+
|
|
117
|
+
let raw: string
|
|
118
|
+
try {
|
|
119
|
+
raw = fs.readFileSync(resolved, 'utf-8')
|
|
120
|
+
} catch {
|
|
121
|
+
return {}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Strip JSON5 comments
|
|
125
|
+
const stripped = raw.replace(/\/\/.*$/gm, '').replace(/\/\*[\s\S]*?\*\//g, '')
|
|
126
|
+
let config: any
|
|
127
|
+
try {
|
|
128
|
+
config = JSON.parse(stripped)
|
|
129
|
+
} catch {
|
|
130
|
+
try { config = JSON.parse(raw) } catch { return {} }
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (!config.extends) return config
|
|
134
|
+
|
|
135
|
+
// Resolve the parent config path
|
|
136
|
+
const extendsValue = config.extends
|
|
137
|
+
let parentPath: string
|
|
138
|
+
|
|
139
|
+
if (extendsValue.startsWith('.')) {
|
|
140
|
+
// Relative path: ./tsconfig.base.json or ../tsconfig.json
|
|
141
|
+
parentPath = path.resolve(path.dirname(resolved), extendsValue)
|
|
142
|
+
// Add .json if missing
|
|
143
|
+
if (!parentPath.endsWith('.json')) parentPath += '.json'
|
|
144
|
+
} else {
|
|
145
|
+
// Node module: @tsconfig/node-lts or @tsconfig/node-lts/tsconfig.json
|
|
146
|
+
try {
|
|
147
|
+
// Try resolving as a node module from projectRoot
|
|
148
|
+
const projectRoot = path.dirname(resolved)
|
|
149
|
+
const modulePath = path.join(projectRoot, 'node_modules', extendsValue)
|
|
150
|
+
if (fs.existsSync(modulePath + '.json')) {
|
|
151
|
+
parentPath = modulePath + '.json'
|
|
152
|
+
} else if (fs.existsSync(path.join(modulePath, 'tsconfig.json'))) {
|
|
153
|
+
parentPath = path.join(modulePath, 'tsconfig.json')
|
|
154
|
+
} else if (fs.existsSync(modulePath)) {
|
|
155
|
+
parentPath = modulePath
|
|
156
|
+
} else {
|
|
157
|
+
// Can't resolve — skip extends
|
|
158
|
+
delete config.extends
|
|
159
|
+
return config
|
|
160
|
+
}
|
|
161
|
+
} catch {
|
|
162
|
+
delete config.extends
|
|
163
|
+
return config
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Load parent recursively
|
|
168
|
+
const parent = loadTsConfigWithExtends(parentPath, visited)
|
|
169
|
+
|
|
170
|
+
// Merge: parent compilerOptions → child compilerOptions (child wins)
|
|
171
|
+
const merged = { ...config }
|
|
172
|
+
delete merged.extends
|
|
173
|
+
merged.compilerOptions = {
|
|
174
|
+
...(parent.compilerOptions ?? {}),
|
|
175
|
+
...(config.compilerOptions ?? {}),
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Merge paths specifically (child paths override parent paths for same alias)
|
|
179
|
+
if (parent.compilerOptions?.paths || config.compilerOptions?.paths) {
|
|
180
|
+
merged.compilerOptions.paths = {
|
|
181
|
+
...(parent.compilerOptions?.paths ?? {}),
|
|
182
|
+
...(config.compilerOptions?.paths ?? {}),
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return merged
|
|
187
|
+
}
|
package/test-output.txt
ADDED
|
Binary file
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'bun:test'
|
|
2
|
+
import { AdrManager } from '../src/contract/adr-manager'
|
|
3
|
+
import * as fs from 'node:fs/promises'
|
|
4
|
+
import * as path from 'node:path'
|
|
5
|
+
|
|
6
|
+
describe('AdrManager', () => {
|
|
7
|
+
const TEMP_DIR = path.join(process.cwd(), '.test-temp')
|
|
8
|
+
const CONTRACT_PATH = path.join(TEMP_DIR, 'mikk.json')
|
|
9
|
+
|
|
10
|
+
beforeAll(async () => {
|
|
11
|
+
await fs.mkdir(TEMP_DIR, { recursive: true })
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
afterAll(async () => {
|
|
15
|
+
await fs.rm(TEMP_DIR, { recursive: true, force: true })
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
beforeEach(async () => {
|
|
19
|
+
// Create a fresh mikk.json for each test
|
|
20
|
+
const initialContract = {
|
|
21
|
+
version: '1.0.0',
|
|
22
|
+
project: { name: 'test', description: 'test', language: 'ts', entryPoints: [] },
|
|
23
|
+
declared: {
|
|
24
|
+
modules: [],
|
|
25
|
+
decisions: [
|
|
26
|
+
{ id: 'ADR-1', title: 'First ADR', reason: 'Because', date: '2024-01-01' }
|
|
27
|
+
]
|
|
28
|
+
},
|
|
29
|
+
overwrite: { mode: 'never' as const }
|
|
30
|
+
}
|
|
31
|
+
await fs.writeFile(CONTRACT_PATH, JSON.stringify(initialContract))
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('lists decisions', async () => {
|
|
35
|
+
const manager = new AdrManager(CONTRACT_PATH)
|
|
36
|
+
const decisions = await manager.list()
|
|
37
|
+
expect(decisions).toHaveLength(1)
|
|
38
|
+
expect(decisions[0].id).toBe('ADR-1')
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('gets a decision by id', async () => {
|
|
42
|
+
const manager = new AdrManager(CONTRACT_PATH)
|
|
43
|
+
const decision = await manager.get('ADR-1')
|
|
44
|
+
expect(decision?.title).toBe('First ADR')
|
|
45
|
+
|
|
46
|
+
const missing = await manager.get('NOT-FOUND')
|
|
47
|
+
expect(missing).toBeNull()
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('adds a new decision', async () => {
|
|
51
|
+
const manager = new AdrManager(CONTRACT_PATH)
|
|
52
|
+
await manager.add({
|
|
53
|
+
id: 'ADR-2',
|
|
54
|
+
title: 'Second ADR',
|
|
55
|
+
reason: 'Why not',
|
|
56
|
+
date: '2024-01-02'
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
const decisions = await manager.list()
|
|
60
|
+
expect(decisions).toHaveLength(2)
|
|
61
|
+
expect(decisions[1].id).toBe('ADR-2')
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('fails to add if id already exists', async () => {
|
|
65
|
+
const manager = new AdrManager(CONTRACT_PATH)
|
|
66
|
+
await expect(manager.add({
|
|
67
|
+
id: 'ADR-1',
|
|
68
|
+
title: 'Duplicate',
|
|
69
|
+
reason: 'Will fail',
|
|
70
|
+
date: '2024-01-03'
|
|
71
|
+
})).rejects.toThrow(/already exists/)
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('updates an existing decision', async () => {
|
|
75
|
+
const manager = new AdrManager(CONTRACT_PATH)
|
|
76
|
+
await manager.update('ADR-1', { title: 'Updated Title' })
|
|
77
|
+
|
|
78
|
+
const decision = await manager.get('ADR-1')
|
|
79
|
+
expect(decision?.title).toBe('Updated Title')
|
|
80
|
+
expect(decision?.reason).toBe('Because') // Preserved
|
|
81
|
+
expect(decision?.id).toBe('ADR-1')
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('fails to update missing decision', async () => {
|
|
85
|
+
const manager = new AdrManager(CONTRACT_PATH)
|
|
86
|
+
await expect(manager.update('ADR-99', { title: 'Nope' })).rejects.toThrow(/not found/)
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('removes a decision', async () => {
|
|
90
|
+
const manager = new AdrManager(CONTRACT_PATH)
|
|
91
|
+
const success = await manager.remove('ADR-1')
|
|
92
|
+
expect(success).toBe(true)
|
|
93
|
+
|
|
94
|
+
const decisions = await manager.list()
|
|
95
|
+
expect(decisions).toHaveLength(0)
|
|
96
|
+
})
|
|
97
|
+
})
|
package/tests/contract.test.ts
CHANGED
|
@@ -76,7 +76,7 @@ describe('LockCompiler', () => {
|
|
|
76
76
|
const graph = new GraphBuilder().build(files)
|
|
77
77
|
const lock = compiler.compile(graph, contract, files)
|
|
78
78
|
|
|
79
|
-
expect(lock.version).toBe('1.
|
|
79
|
+
expect(lock.version).toBe('1.7.0')
|
|
80
80
|
expect(lock.syncState.status).toBe('clean')
|
|
81
81
|
expect(Object.keys(lock.functions).length).toBeGreaterThan(0)
|
|
82
82
|
expect(Object.keys(lock.modules).length).toBeGreaterThanOrEqual(1)
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { describe, it, expect } from 'bun:test'
|
|
2
|
+
import { DeadCodeDetector } from '../src/graph/dead-code-detector'
|
|
3
|
+
import { buildTestGraph, mockFunction } from './helpers'
|
|
4
|
+
import { GraphBuilder } from '../src/graph/graph-builder'
|
|
5
|
+
import type { MikkLock } from '../src/contract/schema'
|
|
6
|
+
|
|
7
|
+
/** Helper to generate a dummy lock file from graph nodes for the detector */
|
|
8
|
+
function generateDummyLock(graphNodes: Map<string, any>): MikkLock {
|
|
9
|
+
const lock: MikkLock = {
|
|
10
|
+
version: '1.0.0',
|
|
11
|
+
generatedAt: new Date().toISOString(),
|
|
12
|
+
generatorVersion: '1.0.0',
|
|
13
|
+
projectRoot: '/test',
|
|
14
|
+
syncState: {
|
|
15
|
+
status: 'clean',
|
|
16
|
+
lastSyncAt: new Date().toISOString(),
|
|
17
|
+
lockHash: 'x',
|
|
18
|
+
contractHash: 'x',
|
|
19
|
+
},
|
|
20
|
+
graph: {
|
|
21
|
+
nodes: 0,
|
|
22
|
+
edges: 0,
|
|
23
|
+
rootHash: 'x',
|
|
24
|
+
},
|
|
25
|
+
functions: {},
|
|
26
|
+
classes: {},
|
|
27
|
+
files: {},
|
|
28
|
+
modules: {},
|
|
29
|
+
routes: [],
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
for (const [id, node] of graphNodes.entries()) {
|
|
33
|
+
if (node.type === 'function') {
|
|
34
|
+
const name = node.label
|
|
35
|
+
const file = node.file
|
|
36
|
+
// We need to populate `calledBy` for transitive liveness checks
|
|
37
|
+
|
|
38
|
+
lock.functions[id] = {
|
|
39
|
+
id,
|
|
40
|
+
name,
|
|
41
|
+
file,
|
|
42
|
+
moduleId: node.moduleId ?? 'unknown',
|
|
43
|
+
startLine: 1, endLine: 10, hash: 'x',
|
|
44
|
+
calls: [],
|
|
45
|
+
calledBy: [],
|
|
46
|
+
isExported: node.metadata?.isExported ?? false,
|
|
47
|
+
isAsync: false,
|
|
48
|
+
params: [],
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return lock
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
describe('DeadCodeDetector', () => {
|
|
56
|
+
|
|
57
|
+
it('detects uncalled functions', () => {
|
|
58
|
+
// A calls B. C is isolated.
|
|
59
|
+
const graph = buildTestGraph([
|
|
60
|
+
['A', 'B'],
|
|
61
|
+
['B', 'nothing'],
|
|
62
|
+
['C', 'nothing']
|
|
63
|
+
])
|
|
64
|
+
|
|
65
|
+
const lock = generateDummyLock(graph.nodes)
|
|
66
|
+
|
|
67
|
+
// Add calledBy relationships since GraphBuilder test helper doesn't do reverse
|
|
68
|
+
// lookups for the lock structure (it only does it for the graph)
|
|
69
|
+
Object.values(lock.functions).forEach(fn => {
|
|
70
|
+
const inEdges = graph.inEdges.get(fn.id) ?? []
|
|
71
|
+
fn.calledBy = inEdges.filter(e => e.type === 'calls').map(e => e.source)
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
const detector = new DeadCodeDetector(graph, lock)
|
|
75
|
+
const result = detector.detect()
|
|
76
|
+
|
|
77
|
+
expect(result.deadFunctions.map(f => f.name)).toContain('C')
|
|
78
|
+
expect(result.deadFunctions.map(f => f.name)).toContain('A') // A has no callers, so it is dead code
|
|
79
|
+
expect(result.deadFunctions.map(f => f.name)).not.toContain('B') // B has a caller
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('exempts exported functions', () => {
|
|
83
|
+
const graph = buildTestGraph([
|
|
84
|
+
['D', 'nothing']
|
|
85
|
+
])
|
|
86
|
+
graph.nodes.get('fn:src/D.ts:D')!.metadata!.isExported = true
|
|
87
|
+
|
|
88
|
+
const lock = generateDummyLock(graph.nodes)
|
|
89
|
+
|
|
90
|
+
const detector = new DeadCodeDetector(graph, lock)
|
|
91
|
+
const result = detector.detect()
|
|
92
|
+
|
|
93
|
+
expect(result.deadFunctions).toHaveLength(0) // D is exported
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('exempts entry point name patterns', () => {
|
|
97
|
+
const graph = buildTestGraph([
|
|
98
|
+
['main', 'nothing'],
|
|
99
|
+
['loginHandler', 'nothing'],
|
|
100
|
+
['useAuth', 'nothing'] // React hook
|
|
101
|
+
])
|
|
102
|
+
|
|
103
|
+
const lock = generateDummyLock(graph.nodes)
|
|
104
|
+
|
|
105
|
+
const detector = new DeadCodeDetector(graph, lock)
|
|
106
|
+
const result = detector.detect()
|
|
107
|
+
|
|
108
|
+
expect(result.deadFunctions).toHaveLength(0) // All match exempt patterns
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('exempts functions called by exported functions in the same file', () => {
|
|
112
|
+
const graph = buildTestGraph([
|
|
113
|
+
['ExportedFn', 'InternalHelper'],
|
|
114
|
+
['InternalHelper', 'nothing']
|
|
115
|
+
])
|
|
116
|
+
|
|
117
|
+
// Both in same file by default from buildTestGraph
|
|
118
|
+
graph.nodes.get('fn:src/ExportedFn.ts:ExportedFn')!.metadata!.isExported = true
|
|
119
|
+
|
|
120
|
+
// In the lock, we must place them in the SAME file manually because buildTestGraph
|
|
121
|
+
// puts them in separate files based on name
|
|
122
|
+
const lock = generateDummyLock(graph.nodes)
|
|
123
|
+
lock.functions['fn:src/ExportedFn.ts:ExportedFn'].file = 'src/shared.ts'
|
|
124
|
+
lock.functions['fn:src/InternalHelper.ts:InternalHelper'].file = 'src/shared.ts'
|
|
125
|
+
|
|
126
|
+
// Set up the calledBy relation
|
|
127
|
+
lock.functions['fn:src/InternalHelper.ts:InternalHelper'].calledBy = ['fn:src/ExportedFn.ts:ExportedFn']
|
|
128
|
+
|
|
129
|
+
const detector = new DeadCodeDetector(graph, lock)
|
|
130
|
+
const result = detector.detect()
|
|
131
|
+
|
|
132
|
+
expect(result.deadFunctions).toHaveLength(0) // InternalHelper is called by exported fn in same file
|
|
133
|
+
})
|
|
134
|
+
})
|