@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@getmikk/core",
3
- "version": "1.8.1",
3
+ "version": "1.8.3",
4
4
  "license": "Apache-2.0",
5
5
  "repository": {
6
6
  "type": "git",
@@ -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 content: string
11
+ let json: any
12
12
  try {
13
- content = await fs.readFile(contractPath, 'utf-8')
14
- } catch {
15
- throw new ContractNotFoundError(contractPath)
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 graph's import edges
329
- const outEdges = graph.outEdges.get(file.path) || []
330
- const importedFiles = outEdges
331
- .filter(e => e.type === 'imports')
332
- .map(e => e.target)
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
- ...(importedFiles.length > 0 ? { imports: importedFiles } : {}),
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 content: string
14
+ let json: any
14
15
  try {
15
- content = await fs.readFile(lockPath, 'utf-8')
16
- } catch {
17
- throw new LockNotFoundError()
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
@@ -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(z.string()).optional(),
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
- /** Common entry-point function names that are never "dead" even with 0 callers */
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, // Express/Koa/Hono handlers
43
+ /Handler$/i,
30
44
  /Middleware$/i,
31
45
  /Controller$/i,
32
- /^use[A-Z]/, // React hooks
33
- /^handle[A-Z]/, // Event handlers
34
- /^on[A-Z]/, // Event listeners
46
+ /^use[A-Z]/, // React hooks
47
+ /^handle[A-Z]/, // Event handlers
48
+ /^on[A-Z]/, // Event listeners
35
49
  ]
36
50
 
37
- /** Common test function patterns */
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 and finds functions
49
- * with zero incoming `calls` edges after applying multi-pass exemptions.
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 (may be consumed externally)
53
- * 2. Entry point patterns (main, handler, middleware, hooks, etc.)
54
- * 3. Route handlers (detected HTTP routes)
55
- * 4. Test functions (describe, it, test, etc.)
56
- * 5. Decorated classes/functions (typically framework-managed)
57
- * 6. Constructor methods (called implicitly)
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 if this function has any incoming call edges
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, id),
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
- // Also check classes (if present in lock)
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
- // ─── Exemption checks ──────────────────────────────────────────
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
178
- for (const callerId of fn.calledBy) {
179
- const caller = this.lock.functions[callerId]
180
- if (caller && caller.isExported && caller.file === fn.file) {
181
- return true
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
- private inferReason(fn: MikkLock['functions'][string], id: string): string {
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
- // calledBy has entries but they didn't resolve to graph edges
192
- return `${fn.calledBy.length} references exist but none resolved to active call edges`
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
  }