@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@getmikk/core",
3
- "version": "1.8.0",
3
+ "version": "1.8.2",
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
  }
@@ -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
@@ -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.
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
- * 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)
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,72 @@ 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
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
- private inferReason(fn: MikkLock['functions'][string], id: string): string {
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
- // 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`
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
  }