@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.
@@ -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
- private sourceFile: ts.SourceFile
10
+ protected readonly sourceFile: ts.SourceFile
11
11
 
12
12
  constructor(
13
- private filePath: string,
14
- private content: string
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
- private isVariableFunction(node: ts.VariableStatement): boolean {
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
- // ─── Private Helpers ──────────────────────────────────────
312
+ // ─── Protected Helpers ─────────────────────────────────────
313
313
 
314
- private parseFunctionDeclaration(node: ts.FunctionDeclaration): ParsedFunction {
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
- private parseVariableFunction(
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
- private parseClass(node: ts.ClassDeclaration): ParsedClass {
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
- private parseImport(node: ts.ImportDeclaration): ParsedImport | null {
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
- private extractCalls(node: ts.Node): string[] {
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
- private extractPurpose(node: ts.Node): string {
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 (!commentRanges || commentRanges.length === 0) return ''
538
-
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
- }
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
- // Skip divider lines (lines with 3+ repeated special characters)
550
- if (/^[─\-_=\*]{3,}$/.test(clean)) continue
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
- if (clean) meaningfulLines.push(clean)
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
- // Return the first meaningful line in JSDoc, the first line is the summary.
556
- // e.g. "Decrypt data using AES-256-GCM" (not a later detail line)
557
- return meaningfulLines.length > 0 ? meaningfulLines[0].split('\n')[0].trim() : ''
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
- private extractEdgeCases(node: ts.Node): string[] {
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
- private extractErrorHandling(node: ts.Node): { line: number, type: 'try-catch' | 'throw', detail: string }[] {
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
- private extractDetailedLines(node: ts.Node): { startLine: number, endLine: number, blockType: string }[] {
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
- private extractTypeParameters(typeParams: ts.NodeArray<ts.TypeParameterDeclaration> | undefined): string[] {
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
- private extractDecorators(node: ts.ClassDeclaration): string[] {
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
- private extractParams(params: ts.NodeArray<ts.ParameterDeclaration>): ParsedParam[] {
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
- private hasExportModifier(node: ts.Node): boolean {
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
- private getLineNumber(pos: number): number {
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
- private walkNode(node: ts.Node, callback: (node: ts.Node) => void): void {
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 resolver = new TypeScriptResolver(projectRoot)
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
+ }
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
+ })
@@ -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.0.0')
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
+ })