@getmikk/core 1.8.0 → 1.8.2
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-reader.ts +9 -6
- package/src/graph/dead-code-detector.ts +111 -52
- package/src/graph/graph-builder.ts +199 -61
- package/src/graph/impact-analyzer.ts +48 -16
- package/src/index.ts +1 -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 +353 -69
- package/src/parser/types.ts +2 -0
- package/src/parser/typescript/ts-extractor.ts +17 -6
- package/src/parser/typescript/ts-parser.ts +22 -18
- package/src/parser/typescript/ts-resolver.ts +78 -45
- package/src/utils/fs.ts +64 -0
- package/src/utils/json.ts +27 -0
- package/tests/js-parser.test.ts +23 -3
- package/tests/tree-sitter-parser.test.ts +11 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import * as fs from 'node:fs/promises'
|
|
2
1
|
import { MikkContractSchema, type MikkContract } from './schema.js'
|
|
3
2
|
import { ContractNotFoundError } from '../utils/errors.js'
|
|
3
|
+
import { readJsonSafe } from '../utils/json.js'
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* ContractReader -- reads and validates mikk.json from disk.
|
|
@@ -8,21 +8,21 @@ import { ContractNotFoundError } from '../utils/errors.js'
|
|
|
8
8
|
export class ContractReader {
|
|
9
9
|
/** Read and validate mikk.json */
|
|
10
10
|
async read(contractPath: string): Promise<MikkContract> {
|
|
11
|
-
let
|
|
11
|
+
let json: any
|
|
12
12
|
try {
|
|
13
|
-
|
|
14
|
-
} catch {
|
|
15
|
-
|
|
13
|
+
json = await readJsonSafe(contractPath, 'mikk.json')
|
|
14
|
+
} catch (e: any) {
|
|
15
|
+
if (e.code === 'ENOENT') {
|
|
16
|
+
throw new ContractNotFoundError(contractPath)
|
|
17
|
+
}
|
|
18
|
+
throw e
|
|
16
19
|
}
|
|
17
20
|
|
|
18
|
-
const json = JSON.parse(content.replace(/^\uFEFF/, ''))
|
|
19
21
|
const result = MikkContractSchema.safeParse(json)
|
|
20
|
-
|
|
21
22
|
if (!result.success) {
|
|
22
23
|
const errors = result.error.issues.map(i => ` ${i.path.join('.')}: ${i.message}`).join('\n')
|
|
23
|
-
throw new Error(`Invalid mikk.json:\n${errors}`)
|
|
24
|
+
throw new Error(`Invalid mikk.json structure:\n${errors}`)
|
|
24
25
|
}
|
|
25
|
-
|
|
26
26
|
return result.data
|
|
27
27
|
}
|
|
28
28
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import * as fs from 'node:fs/promises'
|
|
2
2
|
import { MikkLockSchema, type MikkLock } from './schema.js'
|
|
3
3
|
import { LockNotFoundError } from '../utils/errors.js'
|
|
4
|
+
import { readJsonSafe } from '../utils/json.js'
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* LockReader -- reads and validates mikk.lock.json from disk.
|
|
@@ -10,20 +11,22 @@ import { LockNotFoundError } from '../utils/errors.js'
|
|
|
10
11
|
export class LockReader {
|
|
11
12
|
/** Read and validate mikk.lock.json */
|
|
12
13
|
async read(lockPath: string): Promise<MikkLock> {
|
|
13
|
-
let
|
|
14
|
+
let json: any
|
|
14
15
|
try {
|
|
15
|
-
|
|
16
|
-
} catch {
|
|
17
|
-
|
|
16
|
+
json = await readJsonSafe(lockPath, 'mikk.lock.json')
|
|
17
|
+
} catch (e: any) {
|
|
18
|
+
if (e.code === 'ENOENT') {
|
|
19
|
+
throw new LockNotFoundError()
|
|
20
|
+
}
|
|
21
|
+
throw e
|
|
18
22
|
}
|
|
19
23
|
|
|
20
|
-
const json = JSON.parse(content.replace(/^\uFEFF/, ''))
|
|
21
24
|
const hydrated = hydrateLock(json)
|
|
22
25
|
const result = MikkLockSchema.safeParse(hydrated)
|
|
23
26
|
|
|
24
27
|
if (!result.success) {
|
|
25
28
|
const errors = result.error.issues.map(i => ` ${i.path.join('.')}: ${i.message}`).join('\n')
|
|
26
|
-
throw new Error(`Invalid mikk.lock.json:\n${errors}`)
|
|
29
|
+
throw new Error(`Invalid mikk.lock.json structure:\n${errors}`)
|
|
27
30
|
}
|
|
28
31
|
|
|
29
32
|
return result.data
|
|
@@ -3,6 +3,19 @@ import type { MikkLock } from '../contract/schema.js'
|
|
|
3
3
|
|
|
4
4
|
// ─── Types ──────────────────────────────────────────────────────────
|
|
5
5
|
|
|
6
|
+
/**
|
|
7
|
+
* Confidence level for a dead code finding.
|
|
8
|
+
*
|
|
9
|
+
* high — zero callers, no dynamic patterns, no unresolved imports in the file.
|
|
10
|
+
* Safe to remove.
|
|
11
|
+
* medium — zero callers via graph, but the file has unresolved imports
|
|
12
|
+
* (some calls may not have been traced). Review before removing.
|
|
13
|
+
* low — zero graph callers, but the function name or context suggests it
|
|
14
|
+
* may be used dynamically (generic names, lifecycle hooks, etc.).
|
|
15
|
+
* Do not remove without manual verification.
|
|
16
|
+
*/
|
|
17
|
+
export type DeadCodeConfidence = 'high' | 'medium' | 'low'
|
|
18
|
+
|
|
6
19
|
export interface DeadCodeEntry {
|
|
7
20
|
id: string
|
|
8
21
|
name: string
|
|
@@ -10,6 +23,7 @@ export interface DeadCodeEntry {
|
|
|
10
23
|
moduleId?: string
|
|
11
24
|
type: 'function' | 'class'
|
|
12
25
|
reason: string
|
|
26
|
+
confidence: DeadCodeConfidence
|
|
13
27
|
}
|
|
14
28
|
|
|
15
29
|
export interface DeadCodeResult {
|
|
@@ -22,19 +36,19 @@ export interface DeadCodeResult {
|
|
|
22
36
|
|
|
23
37
|
// ─── Exemption patterns ────────────────────────────────────────────
|
|
24
38
|
|
|
25
|
-
/**
|
|
39
|
+
/** Entry-point function names that are never "dead" even with 0 graph callers */
|
|
26
40
|
const ENTRY_POINT_PATTERNS = [
|
|
27
41
|
/^(main|bootstrap|start|init|setup|configure|register|mount)$/i,
|
|
28
42
|
/^(app|server|index|mod|program)$/i,
|
|
29
|
-
/Handler$/i,
|
|
43
|
+
/Handler$/i,
|
|
30
44
|
/Middleware$/i,
|
|
31
45
|
/Controller$/i,
|
|
32
|
-
/^use[A-Z]/,
|
|
33
|
-
/^handle[A-Z]/,
|
|
34
|
-
/^on[A-Z]/,
|
|
46
|
+
/^use[A-Z]/, // React hooks
|
|
47
|
+
/^handle[A-Z]/, // Event handlers
|
|
48
|
+
/^on[A-Z]/, // Event listeners
|
|
35
49
|
]
|
|
36
50
|
|
|
37
|
-
/**
|
|
51
|
+
/** Test function patterns */
|
|
38
52
|
const TEST_PATTERNS = [
|
|
39
53
|
/^(it|describe|test|beforeAll|afterAll|beforeEach|afterEach)$/,
|
|
40
54
|
/\.test\./,
|
|
@@ -42,31 +56,56 @@ const TEST_PATTERNS = [
|
|
|
42
56
|
/__test__/,
|
|
43
57
|
]
|
|
44
58
|
|
|
59
|
+
/**
|
|
60
|
+
* Names that are commonly used via dynamic dispatch, string-keyed maps, or
|
|
61
|
+
* framework injection. A function matching these patterns gets LOW confidence
|
|
62
|
+
* even if no graph callers exist, because static analysis may have missed it.
|
|
63
|
+
*/
|
|
64
|
+
const DYNAMIC_USAGE_PATTERNS = [
|
|
65
|
+
/^addEventListener$/i,
|
|
66
|
+
/^removeEventListener$/i,
|
|
67
|
+
/^on[A-Z]/,
|
|
68
|
+
/(invoke|dispatch|emit|call|apply)/i,
|
|
69
|
+
/^ngOnInit$/i,
|
|
70
|
+
/^componentDidMount$/i,
|
|
71
|
+
/^componentWillUnmount$/i,
|
|
72
|
+
]
|
|
73
|
+
|
|
45
74
|
// ─── Detector ──────────────────────────────────────────────────────
|
|
46
75
|
|
|
47
76
|
/**
|
|
48
|
-
* DeadCodeDetector — walks the dependency graph
|
|
49
|
-
*
|
|
77
|
+
* DeadCodeDetector — walks the dependency graph to find functions with zero
|
|
78
|
+
* incoming `calls` edges after applying multi-pass exemptions.
|
|
79
|
+
*
|
|
80
|
+
* Exemptions (function is NOT reported as dead):
|
|
81
|
+
* 1. Exported symbols — may be consumed by external packages
|
|
82
|
+
* 2. Entry point name patterns — main, handler, middleware, hooks, etc.
|
|
83
|
+
* 3. Route handlers — detected via HTTP route registrations in the lock
|
|
84
|
+
* 4. Test functions — describe, it, test, lifecycle hooks
|
|
85
|
+
* 5. Constructors — called implicitly by `new`
|
|
86
|
+
* 6. Called by an exported function in the same file (transitive liveness)
|
|
50
87
|
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
* 4. Test functions (describe, it, test, etc.)
|
|
56
|
-
* 5. Decorated classes/functions (typically framework-managed)
|
|
57
|
-
* 6. Constructor methods (called implicitly)
|
|
88
|
+
* Each dead entry includes a confidence level:
|
|
89
|
+
* high — safe to remove
|
|
90
|
+
* medium — file has unresolved imports; verify before removing
|
|
91
|
+
* low — dynamic usage patterns detected; manual review required
|
|
58
92
|
*/
|
|
59
93
|
export class DeadCodeDetector {
|
|
60
94
|
private routeHandlers: Set<string>
|
|
95
|
+
/** Files that have at least one unresolved import (empty resolvedPath) */
|
|
96
|
+
private filesWithUnresolvedImports: Set<string>
|
|
61
97
|
|
|
62
98
|
constructor(
|
|
63
99
|
private graph: DependencyGraph,
|
|
64
100
|
private lock: MikkLock,
|
|
65
101
|
) {
|
|
66
|
-
// Build a set of handler function names from detected routes
|
|
67
102
|
this.routeHandlers = new Set(
|
|
68
103
|
(lock.routes ?? []).map(r => r.handler).filter(Boolean),
|
|
69
104
|
)
|
|
105
|
+
|
|
106
|
+
// Pre-compute which files have unresolved imports so confidence can
|
|
107
|
+
// be lowered for all functions in those files without scanning per-function.
|
|
108
|
+
this.filesWithUnresolvedImports = this.buildUnresolvedImportFileSet()
|
|
70
109
|
}
|
|
71
110
|
|
|
72
111
|
detect(): DeadCodeResult {
|
|
@@ -78,35 +117,34 @@ export class DeadCodeDetector {
|
|
|
78
117
|
totalFunctions++
|
|
79
118
|
const moduleId = fn.moduleId ?? 'unknown'
|
|
80
119
|
|
|
81
|
-
// Initialize module bucket
|
|
82
120
|
if (!byModule[moduleId]) {
|
|
83
121
|
byModule[moduleId] = { dead: 0, total: 0, items: [] }
|
|
84
122
|
}
|
|
85
123
|
byModule[moduleId].total++
|
|
86
124
|
|
|
87
|
-
// Check
|
|
125
|
+
// Check for incoming call edges in the graph
|
|
88
126
|
const inEdges = this.graph.inEdges.get(id) || []
|
|
89
127
|
const hasCallers = inEdges.some(e => e.type === 'calls')
|
|
128
|
+
if (hasCallers) continue
|
|
90
129
|
|
|
91
|
-
if (hasCallers) continue // Not dead
|
|
92
|
-
|
|
93
|
-
// Apply exemptions
|
|
94
130
|
if (this.isExempt(fn, id)) continue
|
|
95
131
|
|
|
132
|
+
const confidence = this.inferConfidence(fn)
|
|
96
133
|
const entry: DeadCodeEntry = {
|
|
97
134
|
id,
|
|
98
135
|
name: fn.name,
|
|
99
136
|
file: fn.file,
|
|
100
137
|
moduleId,
|
|
101
138
|
type: 'function',
|
|
102
|
-
reason: this.inferReason(fn
|
|
139
|
+
reason: this.inferReason(fn),
|
|
140
|
+
confidence,
|
|
103
141
|
}
|
|
104
142
|
dead.push(entry)
|
|
105
143
|
byModule[moduleId].dead++
|
|
106
144
|
byModule[moduleId].items.push(entry)
|
|
107
145
|
}
|
|
108
146
|
|
|
109
|
-
//
|
|
147
|
+
// Check classes
|
|
110
148
|
if (this.lock.classes) {
|
|
111
149
|
for (const [id, cls] of Object.entries(this.lock.classes)) {
|
|
112
150
|
const moduleId = cls.moduleId ?? 'unknown'
|
|
@@ -116,9 +154,7 @@ export class DeadCodeDetector {
|
|
|
116
154
|
|
|
117
155
|
const inEdges = this.graph.inEdges.get(id) || []
|
|
118
156
|
const hasCallers = inEdges.some(e => e.type === 'calls' || e.type === 'imports')
|
|
119
|
-
|
|
120
|
-
if (hasCallers) continue
|
|
121
|
-
if (cls.isExported) continue // Exported classes are exempt
|
|
157
|
+
if (hasCallers || cls.isExported) continue
|
|
122
158
|
|
|
123
159
|
const entry: DeadCodeEntry = {
|
|
124
160
|
id,
|
|
@@ -127,6 +163,7 @@ export class DeadCodeDetector {
|
|
|
127
163
|
moduleId,
|
|
128
164
|
type: 'class',
|
|
129
165
|
reason: 'Class has no callers or importers and is not exported',
|
|
166
|
+
confidence: this.filesWithUnresolvedImports.has(cls.file) ? 'medium' : 'high',
|
|
130
167
|
}
|
|
131
168
|
dead.push(entry)
|
|
132
169
|
byModule[moduleId].dead++
|
|
@@ -145,50 +182,72 @@ export class DeadCodeDetector {
|
|
|
145
182
|
}
|
|
146
183
|
}
|
|
147
184
|
|
|
148
|
-
// ───
|
|
185
|
+
// ─── Private helpers ───────────────────────────────────────────
|
|
149
186
|
|
|
150
187
|
private isExempt(fn: MikkLock['functions'][string], id: string): boolean {
|
|
151
|
-
// 1. Exported functions — may be consumed by external packages
|
|
152
188
|
if (fn.isExported) return true
|
|
153
|
-
|
|
154
|
-
// 2. Entry point patterns
|
|
155
189
|
if (ENTRY_POINT_PATTERNS.some(p => p.test(fn.name))) return true
|
|
156
|
-
|
|
157
|
-
// 3. Route handlers
|
|
158
190
|
if (this.routeHandlers.has(fn.name)) return true
|
|
159
|
-
|
|
160
|
-
// 4. Test functions or in test files
|
|
161
191
|
if (TEST_PATTERNS.some(p => p.test(fn.name) || p.test(fn.file))) return true
|
|
162
|
-
|
|
163
|
-
// 5. Constructor methods
|
|
164
192
|
if (fn.name === 'constructor' || fn.name === '__init__') return true
|
|
165
|
-
|
|
166
|
-
// 6. Functions called by exported functions in the same file
|
|
167
|
-
// (transitive liveness — if an exported fn calls this, it's alive)
|
|
168
|
-
if (this.isCalledByExportedInSameFile(fn, id)) return true
|
|
169
|
-
|
|
193
|
+
if (this.isCalledByExportedInSameFile(fn)) return true
|
|
170
194
|
return false
|
|
171
195
|
}
|
|
172
196
|
|
|
173
|
-
private isCalledByExportedInSameFile(
|
|
174
|
-
fn: MikkLock['functions'][string],
|
|
175
|
-
fnId: string,
|
|
176
|
-
): boolean {
|
|
177
|
-
// Check calledBy — if any caller is exported and in the same file, exempt
|
|
197
|
+
private isCalledByExportedInSameFile(fn: MikkLock['functions'][string]): boolean {
|
|
178
198
|
for (const callerId of fn.calledBy) {
|
|
179
199
|
const caller = this.lock.functions[callerId]
|
|
180
|
-
if (caller && caller.isExported && caller.file === fn.file)
|
|
181
|
-
return true
|
|
182
|
-
}
|
|
200
|
+
if (caller && caller.isExported && caller.file === fn.file) return true
|
|
183
201
|
}
|
|
184
202
|
return false
|
|
185
203
|
}
|
|
186
204
|
|
|
187
|
-
|
|
205
|
+
/**
|
|
206
|
+
* Assign a confidence level to a dead code finding.
|
|
207
|
+
*
|
|
208
|
+
* Priority (first match wins):
|
|
209
|
+
* medium — lock.calledBy has entries that didn't become graph edges:
|
|
210
|
+
* something references this function but resolution failed.
|
|
211
|
+
* medium — file has unresolved imports: the graph may be incomplete.
|
|
212
|
+
* low — function name matches common dynamic-dispatch patterns.
|
|
213
|
+
* high — none of the above: safe to remove.
|
|
214
|
+
*/
|
|
215
|
+
private inferConfidence(fn: MikkLock['functions'][string]): DeadCodeConfidence {
|
|
216
|
+
if (fn.calledBy.length > 0) return 'medium'
|
|
217
|
+
if (this.filesWithUnresolvedImports.has(fn.file)) return 'medium'
|
|
218
|
+
if (DYNAMIC_USAGE_PATTERNS.some(p => p.test(fn.name))) return 'low'
|
|
219
|
+
return 'high'
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
private inferReason(fn: MikkLock['functions'][string]): string {
|
|
188
223
|
if (fn.calledBy.length === 0) {
|
|
189
224
|
return 'No callers found anywhere in the codebase'
|
|
190
225
|
}
|
|
191
|
-
|
|
192
|
-
|
|
226
|
+
return `${fn.calledBy.length} reference(s) in lock but none resolved to active call edges`
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Build the set of file paths that have at least one import whose
|
|
231
|
+
* resolvedPath is empty. Used to downgrade confidence for all dead
|
|
232
|
+
* findings in those files, since the graph may be incomplete.
|
|
233
|
+
*
|
|
234
|
+
* We derive this from the lock's file entries. Each file entry stores
|
|
235
|
+
* its imports; any import with an empty resolvedPath (or no match in
|
|
236
|
+
* the graph nodes) indicates an unresolved dependency.
|
|
237
|
+
*/
|
|
238
|
+
private buildUnresolvedImportFileSet(): Set<string> {
|
|
239
|
+
const result = new Set<string>()
|
|
240
|
+
if (!this.lock.files) return result
|
|
241
|
+
|
|
242
|
+
for (const [filePath, fileInfo] of Object.entries(this.lock.files)) {
|
|
243
|
+
const imports = (fileInfo as any).imports ?? []
|
|
244
|
+
for (const imp of imports) {
|
|
245
|
+
if (!imp.resolvedPath || imp.resolvedPath === '') {
|
|
246
|
+
result.add(filePath)
|
|
247
|
+
break // One unresolved import is enough to flag the file
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
return result
|
|
193
252
|
}
|
|
194
253
|
}
|