@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
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
|
}
|
|
@@ -325,18 +325,19 @@ export class LockCompiler {
|
|
|
325
325
|
for (const file of parsedFiles) {
|
|
326
326
|
const moduleId = this.findModule(file.path, contract.declared.modules)
|
|
327
327
|
|
|
328
|
-
// Collect file-level imports from the
|
|
329
|
-
|
|
330
|
-
const
|
|
331
|
-
|
|
332
|
-
.
|
|
333
|
-
|
|
328
|
+
// Collect file-level imports from the parsed file info directly
|
|
329
|
+
// to include both source and resolvedPath for unresolved analysis.
|
|
330
|
+
const imports = file.imports.map(imp => ({
|
|
331
|
+
source: imp.source,
|
|
332
|
+
resolvedPath: imp.resolvedPath || undefined,
|
|
333
|
+
}))
|
|
334
|
+
|
|
334
335
|
result[file.path] = {
|
|
335
336
|
path: file.path,
|
|
336
337
|
hash: file.hash,
|
|
337
338
|
moduleId: moduleId || 'unknown',
|
|
338
339
|
lastModified: new Date(file.parsedAt).toISOString(),
|
|
339
|
-
...(
|
|
340
|
+
...(imports.length > 0 ? { imports } : {}),
|
|
340
341
|
}
|
|
341
342
|
}
|
|
342
343
|
|
|
@@ -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
|
package/src/contract/schema.ts
CHANGED
|
@@ -83,12 +83,17 @@ export const MikkLockModuleSchema = z.object({
|
|
|
83
83
|
fragmentPath: z.string(),
|
|
84
84
|
})
|
|
85
85
|
|
|
86
|
+
export const MikkLockImportSchema = z.object({
|
|
87
|
+
source: z.string(),
|
|
88
|
+
resolvedPath: z.string().optional(),
|
|
89
|
+
})
|
|
90
|
+
|
|
86
91
|
export const MikkLockFileSchema = z.object({
|
|
87
92
|
path: z.string(),
|
|
88
93
|
hash: z.string(),
|
|
89
94
|
moduleId: z.string(),
|
|
90
95
|
lastModified: z.string(),
|
|
91
|
-
imports: z.array(
|
|
96
|
+
imports: z.array(MikkLockImportSchema).optional(),
|
|
92
97
|
})
|
|
93
98
|
|
|
94
99
|
export const MikkLockClassSchema = z.object({
|
|
@@ -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.
|
|
50
79
|
*
|
|
51
|
-
* Exemptions:
|
|
52
|
-
* 1. Exported symbols
|
|
53
|
-
* 2. Entry point patterns
|
|
54
|
-
* 3. Route handlers
|
|
55
|
-
* 4. Test functions
|
|
56
|
-
* 5.
|
|
57
|
-
* 6.
|
|
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)
|
|
87
|
+
*
|
|
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,94 @@ 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
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
197
|
+
private isCalledByExportedInSameFile(fn: MikkLock['functions'][string]): boolean {
|
|
198
|
+
// Multi-pass transitive liveness: propagate liveness through the full calledBy
|
|
199
|
+
// chain until no new live functions are discovered. A single-hop check misses
|
|
200
|
+
// patterns like: exportedFn → internalA → internalB (internalB is still live).
|
|
201
|
+
const file = fn.file
|
|
202
|
+
const visited = new Set<string>()
|
|
203
|
+
const queue: string[] = [fn.id]
|
|
204
|
+
|
|
205
|
+
while (queue.length > 0) {
|
|
206
|
+
const currentId = queue.pop()!
|
|
207
|
+
if (visited.has(currentId)) continue
|
|
208
|
+
visited.add(currentId)
|
|
209
|
+
|
|
210
|
+
const current = this.lock.functions[currentId]
|
|
211
|
+
if (!current) continue
|
|
212
|
+
|
|
213
|
+
for (const callerId of current.calledBy) {
|
|
214
|
+
if (visited.has(callerId)) continue
|
|
215
|
+
const caller = this.lock.functions[callerId]
|
|
216
|
+
if (!caller) continue
|
|
217
|
+
// Only follow the chain within the same file
|
|
218
|
+
if (caller.file !== file) continue
|
|
219
|
+
// Found a live exported caller in the same file — the original fn is live
|
|
220
|
+
if (caller.isExported) return true
|
|
221
|
+
queue.push(callerId)
|
|
182
222
|
}
|
|
183
223
|
}
|
|
184
224
|
return false
|
|
185
225
|
}
|
|
186
226
|
|
|
187
|
-
|
|
227
|
+
/**
|
|
228
|
+
* Assign a confidence level to a dead code finding.
|
|
229
|
+
*
|
|
230
|
+
* Priority (first match wins):
|
|
231
|
+
* medium — lock.calledBy has entries that didn't become graph edges:
|
|
232
|
+
* something references this function but resolution failed.
|
|
233
|
+
* medium — file has unresolved imports: the graph may be incomplete.
|
|
234
|
+
* low — function name matches common dynamic-dispatch patterns.
|
|
235
|
+
* high — none of the above: safe to remove.
|
|
236
|
+
*/
|
|
237
|
+
private inferConfidence(fn: MikkLock['functions'][string]): DeadCodeConfidence {
|
|
238
|
+
if (DYNAMIC_USAGE_PATTERNS.some(p => p.test(fn.name))) return 'low'
|
|
239
|
+
if (fn.calledBy.length > 0) return 'medium'
|
|
240
|
+
if (this.filesWithUnresolvedImports.has(fn.file)) return 'medium'
|
|
241
|
+
return 'high'
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
private inferReason(fn: MikkLock['functions'][string]): string {
|
|
188
245
|
if (fn.calledBy.length === 0) {
|
|
189
246
|
return 'No callers found anywhere in the codebase'
|
|
190
247
|
}
|
|
191
|
-
|
|
192
|
-
|
|
248
|
+
return `${fn.calledBy.length} reference(s) in lock but none resolved to active call edges`
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Build the set of file paths that have at least one import whose
|
|
253
|
+
* resolvedPath is empty. Used to downgrade confidence for all dead
|
|
254
|
+
* findings in those files, since the graph may be incomplete.
|
|
255
|
+
*
|
|
256
|
+
* We derive this from the lock's file entries. Each file entry stores
|
|
257
|
+
* its imports; any import with an empty resolvedPath (or no match in
|
|
258
|
+
* the graph nodes) indicates an unresolved dependency.
|
|
259
|
+
*/
|
|
260
|
+
private buildUnresolvedImportFileSet(): Set<string> {
|
|
261
|
+
const result = new Set<string>()
|
|
262
|
+
if (!this.lock.files) return result
|
|
263
|
+
|
|
264
|
+
for (const [filePath, fileInfo] of Object.entries(this.lock.files)) {
|
|
265
|
+
const imports = fileInfo.imports ?? []
|
|
266
|
+
for (const imp of imports) {
|
|
267
|
+
if (!imp.resolvedPath || imp.resolvedPath === '') {
|
|
268
|
+
result.add(filePath)
|
|
269
|
+
break // One unresolved import is enough to flag the file
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
return result
|
|
193
274
|
}
|
|
194
275
|
}
|