@getmikk/core 1.3.1 → 1.5.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@getmikk/core",
3
- "version": "1.3.1",
3
+ "version": "1.5.0",
4
4
  "license": "Apache-2.0",
5
5
  "repository": {
6
6
  "type": "git",
@@ -2,6 +2,31 @@ import type { MikkContract } from './schema.js'
2
2
  import type { ModuleCluster } from '../graph/types.js'
3
3
  import type { ParsedFile } from '../parser/types.js'
4
4
 
5
+ /** Common entry point filenames across ecosystems (without extensions) */
6
+ const ENTRY_BASENAMES = ['index', 'main', 'app', 'server', 'mod', 'lib', '__init__', 'manage', 'program', 'startup']
7
+
8
+ /** Infer the project language from the file extensions present */
9
+ function inferLanguageFromFiles(parsedFiles: ParsedFile[]): string {
10
+ const extCounts = new Map<string, number>()
11
+ for (const f of parsedFiles) {
12
+ const ext = f.path.split('.').pop()?.toLowerCase() || ''
13
+ extCounts.set(ext, (extCounts.get(ext) || 0) + 1)
14
+ }
15
+ // Determine dominant extension
16
+ let maxExt = 'ts'
17
+ let maxCount = 0
18
+ for (const [ext, count] of extCounts) {
19
+ if (count > maxCount) { maxExt = ext; maxCount = count }
20
+ }
21
+ const extToLang: Record<string, string> = {
22
+ ts: 'typescript', tsx: 'typescript', js: 'javascript', jsx: 'javascript',
23
+ mjs: 'javascript', cjs: 'javascript', py: 'python', go: 'go',
24
+ rs: 'rust', java: 'java', kt: 'kotlin', rb: 'ruby', php: 'php',
25
+ cs: 'csharp', swift: 'swift', dart: 'dart', ex: 'elixir', exs: 'elixir',
26
+ }
27
+ return extToLang[maxExt] || maxExt
28
+ }
29
+
5
30
  /**
6
31
  * ContractGenerator — generates a mikk.json skeleton from graph analysis.
7
32
  * Takes detected module clusters and produces a human-refinable contract.
@@ -11,32 +36,36 @@ export class ContractGenerator {
11
36
  generateFromClusters(
12
37
  clusters: ModuleCluster[],
13
38
  parsedFiles: ParsedFile[],
14
- projectName: string
39
+ projectName: string,
40
+ packageJsonDescription?: string
15
41
  ): MikkContract {
16
42
  const modules = clusters.map(cluster => ({
17
43
  id: cluster.id,
18
44
  name: cluster.suggestedName,
19
- description: `Contains ${cluster.files.length} files with ${cluster.functions.length} functions`,
45
+ description: this.inferModuleDescription(cluster, parsedFiles),
20
46
  intent: '',
21
47
  paths: this.inferPaths(cluster.files),
22
48
  entryFunctions: this.inferEntryFunctions(cluster, parsedFiles),
23
49
  }))
24
50
 
25
- // Detect entry points (files with no importedBy)
51
+ // Detect entry points language-agnostic basename matching
26
52
  const entryPoints = parsedFiles
27
53
  .filter(f => {
28
- const basename = f.path.split('/').pop() || ''
29
- return basename === 'index.ts' || basename === 'server.ts' || basename === 'main.ts' || basename === 'app.ts'
54
+ const basename = (f.path.split('/').pop() || '').replace(/\.[^.]+$/, '')
55
+ return ENTRY_BASENAMES.includes(basename)
30
56
  })
31
57
  .map(f => f.path)
32
58
 
59
+ const detectedLanguage = inferLanguageFromFiles(parsedFiles)
60
+ const fallbackEntry = parsedFiles[0]?.path ?? 'src/index'
61
+
33
62
  return {
34
63
  version: '1.0.0',
35
64
  project: {
36
65
  name: projectName,
37
- description: '',
38
- language: 'typescript',
39
- entryPoints: entryPoints.length > 0 ? entryPoints : [parsedFiles[0]?.path ?? 'src/index.ts'],
66
+ description: packageJsonDescription || '',
67
+ language: detectedLanguage,
68
+ entryPoints: entryPoints.length > 0 ? entryPoints : [fallbackEntry],
40
69
  },
41
70
  declared: {
42
71
  modules,
@@ -50,6 +79,56 @@ export class ContractGenerator {
50
79
  }
51
80
  }
52
81
 
82
+ /**
83
+ * Infer a meaningful description for a module from its functions.
84
+ * Analyses function names, purposes, and patterns to produce
85
+ * something like "Handles user authentication and JWT verification"
86
+ * instead of "Contains 4 files with 12 functions".
87
+ */
88
+ private inferModuleDescription(cluster: ModuleCluster, parsedFiles: ParsedFile[]): string {
89
+ const clusterFileSet = new Set(cluster.files)
90
+ const purposes: string[] = []
91
+ const fnNames: string[] = []
92
+ let hasExported = 0
93
+ let totalFunctions = 0
94
+
95
+ for (const file of parsedFiles) {
96
+ if (!clusterFileSet.has(file.path)) continue
97
+ for (const fn of file.functions) {
98
+ totalFunctions++
99
+ fnNames.push(fn.name)
100
+ if (fn.isExported) hasExported++
101
+ if (fn.purpose) purposes.push(fn.purpose)
102
+ }
103
+ }
104
+
105
+ // If we have good JSDoc purposes, summarise the top ones
106
+ if (purposes.length > 0) {
107
+ // Deduplicate and pick up to 3 unique purpose summaries
108
+ const unique = [...new Set(purposes)]
109
+ const short = unique.slice(0, 3).map(p => {
110
+ // Take first sentence, max 60 chars
111
+ const first = p.split(/[.!?]/)[0].trim()
112
+ return first.length > 60 ? first.slice(0, 57) + '...' : first
113
+ })
114
+ return short.join('; ')
115
+ }
116
+
117
+ // Fallback: describe by dominant verb patterns
118
+ const verbs = new Map<string, number>()
119
+ for (const name of fnNames) {
120
+ const first = name.replace(/([a-z])([A-Z])/g, '$1 $2').split(/[\s_-]/)[0].toLowerCase()
121
+ verbs.set(first, (verbs.get(first) || 0) + 1)
122
+ }
123
+ const sorted = [...verbs.entries()].sort((a, b) => b[1] - a[1])
124
+ if (sorted.length > 0) {
125
+ const top = sorted.slice(0, 3).map(([v]) => v)
126
+ return `primarily ${top.join(', ')} operations across ${cluster.files.length} files`
127
+ }
128
+
129
+ return `${cluster.files.length} files, ${totalFunctions} functions`
130
+ }
131
+
53
132
  /** Infer path patterns from a list of files */
54
133
  private inferPaths(files: string[]): string[] {
55
134
  // Find common directory prefix
@@ -3,12 +3,109 @@ import { createHash } from 'node:crypto'
3
3
  import type { MikkContract, MikkLock } from './schema.js'
4
4
  import type { DependencyGraph } from '../graph/types.js'
5
5
  import type { ParsedFile } from '../parser/types.js'
6
+ import type { ContextFile } from '../utils/fs.js'
6
7
  import { hashContent } from '../hash/file-hasher.js'
7
8
  import { computeModuleHash, computeRootHash } from '../hash/tree-hasher.js'
8
9
  import { minimatch } from '../utils/minimatch.js'
9
10
 
10
11
  const VERSION = '@getmikk/cli@1.2.1'
11
12
 
13
+ // ─── Heuristic purpose inference ────────────────────────────────────
14
+ // When JSDoc is missing we derive a short purpose string from:
15
+ // 1. camelCase / PascalCase function name → natural language
16
+ // 2. parameter names (context clue)
17
+ // 3. return type (if present)
18
+ //
19
+ // Examples:
20
+ // "getUserProjectRole" + params:["userId","projectId"] → "Get user project role (userId, projectId)"
21
+ // "DashboardPage" + returnType:"JSX.Element" → "Dashboard page component"
22
+ // ────────────────────────────────────────────────────────────────────
23
+
24
+ /** Split camelCase/PascalCase identifier into lowercase words */
25
+ function splitIdentifier(name: string): string[] {
26
+ return name
27
+ .replace(/([a-z0-9])([A-Z])/g, '$1 $2') // camelCase boundary
28
+ .replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2') // ABCDef → ABC Def
29
+ .split(/[\s_-]+/)
30
+ .map(w => w.toLowerCase())
31
+ .filter(Boolean)
32
+ }
33
+
34
+ const JSX_RETURN_TYPES = new Set([
35
+ 'jsx.element', 'react.reactnode', 'reactnode', 'react.jsx.element',
36
+ 'react.fc', 'reactelement',
37
+ ])
38
+
39
+ const HOOK_PREFIXES = ['use']
40
+ const HANDLER_PREFIXES = ['handle', 'on']
41
+ const GETTER_PREFIXES = ['get', 'fetch', 'load', 'find', 'query', 'retrieve', 'read']
42
+ const SETTER_PREFIXES = ['set', 'update', 'save', 'write', 'put', 'patch', 'create', 'delete', 'remove']
43
+ const CHECKER_PREFIXES = ['is', 'has', 'can', 'should', 'check', 'validate']
44
+
45
+ /** Infer a short purpose string from function metadata when JSDoc is missing */
46
+ function inferPurpose(
47
+ name: string,
48
+ params?: { name: string; type?: string }[],
49
+ returnType?: string,
50
+ isAsync?: boolean,
51
+ ): string | undefined {
52
+ if (!name) return undefined
53
+
54
+ const words = splitIdentifier(name)
55
+ if (words.length === 0) return undefined
56
+ const firstWord = words[0]
57
+
58
+ // Check if it's a React component (PascalCase + JSX return)
59
+ const isComponent = /^[A-Z]/.test(name) &&
60
+ returnType && JSX_RETURN_TYPES.has(returnType.toLowerCase())
61
+
62
+ if (isComponent) {
63
+ const readable = words.join(' ')
64
+ return capitalise(`${readable} component`)
65
+ }
66
+
67
+ // Check if it's a hook (React, Vue composables, etc.)
68
+ if (HOOK_PREFIXES.includes(firstWord) && words.length > 1) {
69
+ const subject = words.slice(1).join(' ')
70
+ return capitalise(`Hook for ${subject}`)
71
+ }
72
+
73
+ // Build base description from name words
74
+ let base: string
75
+ if (HANDLER_PREFIXES.includes(firstWord)) {
76
+ const event = words.slice(1).join(' ')
77
+ base = `Handle ${event}`
78
+ } else if (GETTER_PREFIXES.includes(firstWord)) {
79
+ const subject = words.slice(1).join(' ')
80
+ base = `${capitalise(firstWord)} ${subject}`
81
+ } else if (SETTER_PREFIXES.includes(firstWord)) {
82
+ const subject = words.slice(1).join(' ')
83
+ base = `${capitalise(firstWord)} ${subject}`
84
+ } else if (CHECKER_PREFIXES.includes(firstWord)) {
85
+ const subject = words.slice(1).join(' ')
86
+ base = `Check ${firstWord === 'is' || firstWord === 'has' || firstWord === 'can' ? 'if' : ''} ${subject}`.replace(/ +/g, ' ')
87
+ } else {
88
+ // Generic — just humanise the name
89
+ base = capitalise(words.join(' '))
90
+ }
91
+
92
+ // Append param hint if ≤3 params and they have meaningful names
93
+ if (params && params.length > 0 && params.length <= 3) {
94
+ const meaningful = params
95
+ .map(p => p.name)
96
+ .filter(n => !['e', 'event', 'ctx', 'props', 'args', '_'].includes(n))
97
+ if (meaningful.length > 0) {
98
+ base += ` (${meaningful.join(', ')})`
99
+ }
100
+ }
101
+
102
+ return base.trim() || undefined
103
+ }
104
+
105
+ function capitalise(s: string): string {
106
+ return s.charAt(0).toUpperCase() + s.slice(1)
107
+ }
108
+
12
109
  /**
13
110
  * LockCompiler — takes a DependencyGraph and a MikkContract
14
111
  * and compiles the complete mikk.lock.json.
@@ -18,13 +115,15 @@ export class LockCompiler {
18
115
  compile(
19
116
  graph: DependencyGraph,
20
117
  contract: MikkContract,
21
- parsedFiles: ParsedFile[]
118
+ parsedFiles: ParsedFile[],
119
+ contextFiles?: ContextFile[]
22
120
  ): MikkLock {
23
121
  const functions = this.compileFunctions(graph, contract)
24
122
  const classes = this.compileClasses(graph, contract)
25
123
  const generics = this.compileGenerics(graph, contract)
26
124
  const modules = this.compileModules(contract, parsedFiles)
27
- const files = this.compileFiles(parsedFiles, contract)
125
+ const files = this.compileFiles(parsedFiles, contract, graph)
126
+ const routes = this.compileRoutes(parsedFiles)
28
127
 
29
128
  const moduleHashes: Record<string, string> = {}
30
129
  for (const [id, mod] of Object.entries(modules)) {
@@ -47,6 +146,8 @@ export class LockCompiler {
47
146
  classes: Object.keys(classes).length > 0 ? classes : undefined,
48
147
  generics: Object.keys(generics).length > 0 ? generics : undefined,
49
148
  files,
149
+ contextFiles: contextFiles && contextFiles.length > 0 ? contextFiles : undefined,
150
+ routes: routes.length > 0 ? routes : undefined,
50
151
  graph: {
51
152
  nodes: graph.nodes.size,
52
153
  edges: graph.edges.length,
@@ -90,7 +191,18 @@ export class LockCompiler {
90
191
  calls: outEdges.filter(e => e.type === 'calls').map(e => e.target),
91
192
  calledBy: inEdges.filter(e => e.type === 'calls').map(e => e.source),
92
193
  moduleId: moduleId || 'unknown',
93
- purpose: node.metadata.purpose,
194
+ ...(node.metadata.params && node.metadata.params.length > 0
195
+ ? { params: node.metadata.params }
196
+ : {}),
197
+ ...(node.metadata.returnType ? { returnType: node.metadata.returnType } : {}),
198
+ ...(node.metadata.isAsync ? { isAsync: true } : {}),
199
+ ...(node.metadata.isExported ? { isExported: true } : {}),
200
+ purpose: node.metadata.purpose || inferPurpose(
201
+ node.label,
202
+ node.metadata.params,
203
+ node.metadata.returnType,
204
+ node.metadata.isAsync,
205
+ ),
94
206
  edgeCasesHandled: node.metadata.edgeCasesHandled,
95
207
  errorHandling: node.metadata.errorHandling,
96
208
  detailedLines: node.metadata.detailedLines,
@@ -116,7 +228,7 @@ export class LockCompiler {
116
228
  endLine: node.metadata.endLine ?? 0,
117
229
  moduleId: moduleId || 'unknown',
118
230
  isExported: node.metadata.isExported ?? false,
119
- purpose: node.metadata.purpose,
231
+ purpose: node.metadata.purpose || inferPurpose(node.label),
120
232
  edgeCasesHandled: node.metadata.edgeCasesHandled,
121
233
  errorHandling: node.metadata.errorHandling,
122
234
  }
@@ -128,11 +240,14 @@ export class LockCompiler {
128
240
  graph: DependencyGraph,
129
241
  contract: MikkContract
130
242
  ): Record<string, any> {
131
- const result: Record<string, any> = {}
243
+ const raw: Record<string, any> = {}
132
244
  for (const [id, node] of graph.nodes) {
133
245
  if (node.type !== 'generic') continue
246
+ // Only include exported generics — non-exported types/interfaces are
247
+ // internal implementation details that add noise without value.
248
+ if (!node.metadata.isExported) continue
134
249
  const moduleId = this.findModule(node.file, contract.declared.modules)
135
- result[id] = {
250
+ raw[id] = {
136
251
  id,
137
252
  name: node.label,
138
253
  type: node.metadata.hash ?? 'generic', // we stored type name in hash
@@ -141,9 +256,31 @@ export class LockCompiler {
141
256
  endLine: node.metadata.endLine ?? 0,
142
257
  moduleId: moduleId || 'unknown',
143
258
  isExported: node.metadata.isExported ?? false,
144
- purpose: node.metadata.purpose,
259
+ purpose: node.metadata.purpose || inferPurpose(node.label),
260
+ }
261
+ }
262
+
263
+ // Dedup: group generics with the same name + type that appear in multiple files.
264
+ // Keep the first occurrence and add an `alsoIn` array for the duplicate files.
265
+ const byNameType = new Map<string, { key: string; entry: any; others: string[] }>()
266
+ for (const [key, entry] of Object.entries(raw)) {
267
+ const dedup = `${entry.name}::${entry.type}`
268
+ const existing = byNameType.get(dedup)
269
+ if (existing) {
270
+ existing.others.push(entry.file)
271
+ } else {
272
+ byNameType.set(dedup, { key, entry, others: [] })
273
+ }
274
+ }
275
+
276
+ const result: Record<string, any> = {}
277
+ for (const { key, entry, others } of byNameType.values()) {
278
+ if (others.length > 0) {
279
+ entry.alsoIn = others
145
280
  }
281
+ result[key] = entry
146
282
  }
283
+
147
284
  return result
148
285
  }
149
286
 
@@ -178,23 +315,52 @@ export class LockCompiler {
178
315
  /** Compile file entries */
179
316
  private compileFiles(
180
317
  parsedFiles: ParsedFile[],
181
- contract: MikkContract
318
+ contract: MikkContract,
319
+ graph: DependencyGraph
182
320
  ): Record<string, MikkLock['files'][string]> {
183
321
  const result: Record<string, MikkLock['files'][string]> = {}
184
322
 
185
323
  for (const file of parsedFiles) {
186
324
  const moduleId = this.findModule(file.path, contract.declared.modules)
325
+
326
+ // Collect file-level imports from the graph's import edges
327
+ const outEdges = graph.outEdges.get(file.path) || []
328
+ const importedFiles = outEdges
329
+ .filter(e => e.type === 'imports')
330
+ .map(e => e.target)
331
+
187
332
  result[file.path] = {
188
333
  path: file.path,
189
334
  hash: file.hash,
190
335
  moduleId: moduleId || 'unknown',
191
336
  lastModified: new Date().toISOString(),
337
+ ...(importedFiles.length > 0 ? { imports: importedFiles } : {}),
192
338
  }
193
339
  }
194
340
 
195
341
  return result
196
342
  }
197
343
 
344
+ /** Compile route registrations from all parsed files */
345
+ private compileRoutes(parsedFiles: ParsedFile[]): MikkLock['routes'] & any[] {
346
+ const routes: any[] = []
347
+ for (const file of parsedFiles) {
348
+ if (file.routes && file.routes.length > 0) {
349
+ for (const route of file.routes) {
350
+ routes.push({
351
+ method: route.method,
352
+ path: route.path,
353
+ handler: route.handler,
354
+ middlewares: route.middlewares,
355
+ file: route.file,
356
+ line: route.line,
357
+ })
358
+ }
359
+ }
360
+ }
361
+ return routes
362
+ }
363
+
198
364
  /** Find which module a file belongs to based on path patterns */
199
365
  private findModule(
200
366
  filePath: string,