@getmikk/core 1.6.0 → 1.7.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.6.0",
3
+ "version": "1.7.0",
4
4
  "license": "Apache-2.0",
5
5
  "repository": {
6
6
  "type": "git",
@@ -1,4 +1,4 @@
1
- import * as path from 'node:path'
1
+ import * as path from 'node:path'
2
2
  import { createHash } from 'node:crypto'
3
3
  import type { MikkContract, MikkLock } from './schema.js'
4
4
  import type { DependencyGraph } from '../graph/types.js'
@@ -131,7 +131,7 @@ export class LockCompiler {
131
131
  }
132
132
 
133
133
  const lockData: MikkLock = {
134
- version: '1.0.0',
134
+ version: '1.7.0',
135
135
  generatedAt: new Date().toISOString(),
136
136
  generatorVersion: VERSION,
137
137
  projectRoot: contract.project.name,
@@ -146,7 +146,9 @@ export class LockCompiler {
146
146
  classes: Object.keys(classes).length > 0 ? classes : undefined,
147
147
  generics: Object.keys(generics).length > 0 ? generics : undefined,
148
148
  files,
149
- contextFiles: contextFiles && contextFiles.length > 0 ? contextFiles : undefined,
149
+ contextFiles: contextFiles && contextFiles.length > 0
150
+ ? contextFiles.map(({ path, type, size }) => ({ path, type, size }))
151
+ : undefined,
150
152
  routes: routes.length > 0 ? routes : undefined,
151
153
  graph: {
152
154
  nodes: graph.nodes.size,
@@ -205,7 +207,6 @@ export class LockCompiler {
205
207
  ),
206
208
  edgeCasesHandled: node.metadata.edgeCasesHandled,
207
209
  errorHandling: node.metadata.errorHandling,
208
- detailedLines: node.metadata.detailedLines,
209
210
  }
210
211
  }
211
212
 
@@ -333,7 +334,7 @@ export class LockCompiler {
333
334
  path: file.path,
334
335
  hash: file.hash,
335
336
  moduleId: moduleId || 'unknown',
336
- lastModified: new Date().toISOString(),
337
+ lastModified: new Date(file.parsedAt).toISOString(),
337
338
  ...(importedFiles.length > 0 ? { imports: importedFiles } : {}),
338
339
  }
339
340
  }
@@ -32,7 +32,7 @@ export class LockReader {
32
32
  /** Write lock file to disk in compact format */
33
33
  async write(lock: MikkLock, lockPath: string): Promise<void> {
34
34
  const compact = compactifyLock(lock)
35
- const json = JSON.stringify(compact, null, 2)
35
+ const json = JSON.stringify(compact)
36
36
  await fs.writeFile(lockPath, json, 'utf-8')
37
37
  }
38
38
  }
@@ -59,17 +59,23 @@ function compactifyLock(lock: MikkLock): any {
59
59
  graph: lock.graph,
60
60
  }
61
61
 
62
+ // P7: Build fnIndex for integer edge references
63
+ const fnKeys = Object.keys(lock.functions)
64
+ const fnIndexMap = new Map<string, number>()
65
+ fnKeys.forEach((k, i) => fnIndexMap.set(k, i))
66
+ out.fnIndex = fnKeys
67
+
62
68
  // Functions — biggest savings
63
69
  out.functions = {}
64
- for (const [key, fn] of Object.entries(lock.functions)) {
70
+ for (let idx = 0; idx < fnKeys.length; idx++) {
71
+ const fn = lock.functions[fnKeys[idx]]
65
72
  const c: any = {
66
73
  lines: [fn.startLine, fn.endLine],
67
- hash: fn.hash,
74
+ // P4: no hash, P6: no moduleId
68
75
  }
69
- // Only write non-default fields
70
- if (fn.moduleId && fn.moduleId !== 'unknown') c.moduleId = fn.moduleId
71
- if (fn.calls.length > 0) c.calls = fn.calls
72
- if (fn.calledBy.length > 0) c.calledBy = fn.calledBy
76
+ // P7: integer calls/calledBy referencing fnIndex positions
77
+ if (fn.calls.length > 0) c.calls = fn.calls.map(id => fnIndexMap.get(id) ?? -1).filter((n: number) => n >= 0)
78
+ if (fn.calledBy.length > 0) c.calledBy = fn.calledBy.map(id => fnIndexMap.get(id) ?? -1).filter((n: number) => n >= 0)
73
79
  if (fn.params && fn.params.length > 0) c.params = fn.params
74
80
  if (fn.returnType) c.returnType = fn.returnType
75
81
  if (fn.isAsync) c.isAsync = true
@@ -79,10 +85,8 @@ function compactifyLock(lock: MikkLock): any {
79
85
  if (fn.errorHandling && fn.errorHandling.length > 0) {
80
86
  c.errors = fn.errorHandling.map(e => [e.line, e.type, e.detail])
81
87
  }
82
- if (fn.detailedLines && fn.detailedLines.length > 0) {
83
- c.details = fn.detailedLines.map(d => [d.startLine, d.endLine, d.blockType])
84
- }
85
- out.functions[key] = c
88
+ // P2: no c.details (detailedLines removed)
89
+ out.functions[String(idx)] = c
86
90
  }
87
91
 
88
92
  // Classes
@@ -134,9 +138,9 @@ function compactifyLock(lock: MikkLock): any {
134
138
  out.files[key] = c
135
139
  }
136
140
 
137
- // Context files — keep as-is (content is the bulk, no savings)
141
+ // Context files — paths/type only, no content
138
142
  if (lock.contextFiles && lock.contextFiles.length > 0) {
139
- out.contextFiles = lock.contextFiles
143
+ out.contextFiles = lock.contextFiles.map(({ path, type, size }) => ({ path, type, size }))
140
144
  }
141
145
 
142
146
  // Routes — keep as-is (already compact)
@@ -166,23 +170,37 @@ function hydrateLock(raw: any): any {
166
170
  graph: raw.graph,
167
171
  }
168
172
 
173
+ // P7: function index for integer edge resolution
174
+ const fnIndex: string[] = raw.fnIndex || []
175
+ const hasFnIndex = fnIndex.length > 0
176
+
177
+ // P6: build file→moduleId map before function loop
178
+ const fileModuleMap: Record<string, string> = {}
179
+ for (const [key, c] of Object.entries(raw.files || {}) as [string, any][]) {
180
+ fileModuleMap[key] = c.moduleId || 'unknown'
181
+ }
182
+
169
183
  // Hydrate functions
170
184
  out.functions = {}
171
185
  for (const [key, c] of Object.entries(raw.functions || {}) as [string, any][]) {
172
- // Parse key: "fn:filepath:functionName"
173
- const { name, file } = parseEntityKey(key, 'fn:')
186
+ // P7: key is integer index → look up full ID via fnIndex
187
+ const fullId = hasFnIndex ? (fnIndex[parseInt(key)] || key) : key
188
+ const { name, file } = parseEntityKey(fullId, 'fn:')
174
189
  const lines = c.lines || [c.startLine || 0, c.endLine || 0]
190
+ // P7: integer calls/calledBy → resolve to full string IDs (backward compat: strings pass through)
191
+ const calls = (c.calls || []).map((v: any) => typeof v === 'number' ? (fnIndex[v] ?? null) : v).filter(Boolean)
192
+ const calledBy = (c.calledBy || []).map((v: any) => typeof v === 'number' ? (fnIndex[v] ?? null) : v).filter(Boolean)
175
193
 
176
- out.functions[key] = {
177
- id: key,
194
+ out.functions[fullId] = {
195
+ id: fullId,
178
196
  name,
179
197
  file,
180
198
  startLine: lines[0],
181
199
  endLine: lines[1],
182
- hash: c.hash || '',
183
- calls: c.calls || [],
184
- calledBy: c.calledBy || [],
185
- moduleId: c.moduleId || 'unknown',
200
+ hash: c.hash || '', // P4: empty string when not stored
201
+ calls,
202
+ calledBy,
203
+ moduleId: fileModuleMap[file] || c.moduleId || 'unknown', // P6: derive from file
186
204
  ...(c.params ? { params: c.params } : {}),
187
205
  ...(c.returnType ? { returnType: c.returnType } : {}),
188
206
  ...(c.isAsync ? { isAsync: true } : {}),
@@ -194,11 +212,7 @@ function hydrateLock(raw: any): any {
194
212
  line: e[0], type: e[1], detail: e[2]
195
213
  }))
196
214
  } : {}),
197
- ...(c.details && c.details.length > 0 ? {
198
- detailedLines: c.details.map((d: any) => ({
199
- startLine: d[0], endLine: d[1], blockType: d[2]
200
- }))
201
- } : {}),
215
+ // P2: no detailedLines restoration
202
216
  }
203
217
  }
204
218
 
@@ -74,11 +74,6 @@ export const MikkLockFunctionSchema = z.object({
74
74
  type: z.enum(['try-catch', 'throw']),
75
75
  detail: z.string(),
76
76
  })).optional(),
77
- detailedLines: z.array(z.object({
78
- startLine: z.number(),
79
- endLine: z.number(),
80
- blockType: z.string(),
81
- })).optional()
82
77
  })
83
78
 
84
79
  export const MikkLockModuleSchema = z.object({
@@ -129,9 +124,9 @@ export const MikkLockGenericSchema = z.object({
129
124
 
130
125
  export const MikkLockContextFileSchema = z.object({
131
126
  path: z.string(),
132
- content: z.string(),
127
+ content: z.string().optional(),
133
128
  type: z.enum(['schema', 'model', 'types', 'routes', 'config', 'api-spec', 'migration', 'docker']),
134
- size: z.number(),
129
+ size: z.number().optional(),
135
130
  })
136
131
 
137
132
  export const MikkLockRouteSchema = z.object({
package/src/index.ts CHANGED
@@ -10,4 +10,4 @@ export * from './utils/logger.js'
10
10
  export { discoverFiles, discoverContextFiles, readFileContent, writeFileContent, fileExists, setupMikkDirectory, readMikkIgnore, parseMikkIgnore, detectProjectLanguage, getDiscoveryPatterns, generateMikkIgnore } from './utils/fs.js'
11
11
  export type { ContextFile, ContextFileType, ProjectLanguage } from './utils/fs.js'
12
12
  export { minimatch } from './utils/minimatch.js'
13
- export { scoreFunctions, findFuzzyMatches, levenshtein, splitCamelCase, extractKeywords } from './utils/fuzzy-match.js'
13
+ export { scoreFunctions, findFuzzyMatches, levenshtein, splitCamelCase, extractKeywords } from './utils/fuzzy-match.js'
@@ -2,6 +2,7 @@ import * as path from 'node:path'
2
2
  import { BaseParser } from './base-parser.js'
3
3
  import { TypeScriptParser } from './typescript/ts-parser.js'
4
4
  import { GoParser } from './go/go-parser.js'
5
+ import { JavaScriptParser } from './javascript/js-parser.js'
5
6
  import { UnsupportedLanguageError } from '../utils/errors.js'
6
7
  import type { ParsedFile } from './types.js'
7
8
 
@@ -13,6 +14,9 @@ export { TypeScriptResolver } from './typescript/ts-resolver.js'
13
14
  export { GoParser } from './go/go-parser.js'
14
15
  export { GoExtractor } from './go/go-extractor.js'
15
16
  export { GoResolver } from './go/go-resolver.js'
17
+ export { JavaScriptParser } from './javascript/js-parser.js'
18
+ export { JavaScriptExtractor } from './javascript/js-extractor.js'
19
+ export { JavaScriptResolver } from './javascript/js-resolver.js'
16
20
  export { BoundaryChecker } from './boundary-checker.js'
17
21
 
18
22
  /** Get the appropriate parser for a file based on its extension */
@@ -22,6 +26,11 @@ export function getParser(filePath: string): BaseParser {
22
26
  case '.ts':
23
27
  case '.tsx':
24
28
  return new TypeScriptParser()
29
+ case '.js':
30
+ case '.mjs':
31
+ case '.cjs':
32
+ case '.jsx':
33
+ return new JavaScriptParser()
25
34
  case '.go':
26
35
  return new GoParser()
27
36
  default:
@@ -36,8 +45,10 @@ export async function parseFiles(
36
45
  readFile: (fp: string) => Promise<string>
37
46
  ): Promise<ParsedFile[]> {
38
47
  const tsParser = new TypeScriptParser()
48
+ const jsParser = new JavaScriptParser()
39
49
  const goParser = new GoParser()
40
50
  const tsFiles: ParsedFile[] = []
51
+ const jsFiles: ParsedFile[] = []
41
52
  const goFiles: ParsedFile[] = []
42
53
 
43
54
  for (const fp of filePaths) {
@@ -49,6 +60,13 @@ export async function parseFiles(
49
60
  } catch {
50
61
  // Skip unreadable files (permissions, binary, etc.) — don't abort the whole parse
51
62
  }
63
+ } else if (ext === '.js' || ext === '.mjs' || ext === '.cjs' || ext === '.jsx') {
64
+ try {
65
+ const content = await readFile(path.join(projectRoot, fp))
66
+ jsFiles.push(jsParser.parse(fp, content))
67
+ } catch {
68
+ // Skip unreadable files
69
+ }
52
70
  } else if (ext === '.go') {
53
71
  try {
54
72
  const content = await readFile(path.join(projectRoot, fp))
@@ -61,7 +79,8 @@ export async function parseFiles(
61
79
 
62
80
  // Resolve imports per language after all files of that language are parsed
63
81
  const resolvedTs = tsParser.resolveImports(tsFiles, projectRoot)
82
+ const resolvedJs = jsParser.resolveImports(jsFiles, projectRoot)
64
83
  const resolvedGo = goParser.resolveImports(goFiles, projectRoot)
65
84
 
66
- return [...resolvedTs, ...resolvedGo]
85
+ return [...resolvedTs, ...resolvedJs, ...resolvedGo]
67
86
  }
@@ -0,0 +1,262 @@
1
+ import ts from 'typescript'
2
+ import { TypeScriptExtractor } from '../typescript/ts-extractor.js'
3
+ import { hashContent } from '../../hash/file-hasher.js'
4
+ import type { ParsedFunction, ParsedImport, ParsedExport } from '../types.js'
5
+
6
+ /**
7
+ * JavaScriptExtractor — extends TypeScriptExtractor to add CommonJS support on top of
8
+ * the TypeScript Compiler API's native JS/JSX parsing.
9
+ *
10
+ * Extra patterns handled:
11
+ * - require() imports: const x = require('./m') / const { a } = require('./m')
12
+ * - module.exports = { foo, bar } / module.exports = function() {}
13
+ * - exports.foo = function() {}
14
+ *
15
+ * All ESM patterns (import/export, arrow functions, classes) are inherited from the
16
+ * TypeScriptExtractor which already handles ScriptKind.JS and ScriptKind.JSX.
17
+ */
18
+ export class JavaScriptExtractor extends TypeScriptExtractor {
19
+
20
+ // ── Public overrides ───────────────────────────────────────────────────────
21
+
22
+ /** ESM functions + module.exports-assigned functions */
23
+ override extractFunctions(): ParsedFunction[] {
24
+ const fns = super.extractFunctions()
25
+ const seen = new Set(fns.map(f => f.name))
26
+ for (const fn of this.extractCommonJsFunctions()) {
27
+ if (!seen.has(fn.name)) { fns.push(fn); seen.add(fn.name) }
28
+ }
29
+ return fns
30
+ }
31
+
32
+ /** ESM imports + CommonJS require() calls */
33
+ override extractImports(): ParsedImport[] {
34
+ const esm = super.extractImports()
35
+ const seen = new Set(esm.map(i => i.source))
36
+ for (const imp of this.extractRequireImports()) {
37
+ if (!seen.has(imp.source)) { esm.push(imp); seen.add(imp.source) }
38
+ }
39
+ return esm
40
+ }
41
+
42
+ /** ESM exports + CommonJS module.exports / exports.x */
43
+ override extractExports(): ParsedExport[] {
44
+ const esm = super.extractExports()
45
+ const seen = new Set(esm.map(e => e.name))
46
+ for (const exp of this.extractCommonJsExports()) {
47
+ if (!seen.has(exp.name)) { esm.push(exp); seen.add(exp.name) }
48
+ }
49
+ return esm
50
+ }
51
+
52
+ // ── CommonJS: require() ────────────────────────────────────────────────────
53
+
54
+ private extractRequireImports(): ParsedImport[] {
55
+ const imports: ParsedImport[] = []
56
+ const walk = (node: ts.Node) => {
57
+ if (
58
+ ts.isCallExpression(node) &&
59
+ ts.isIdentifier(node.expression) &&
60
+ node.expression.text === 'require' &&
61
+ node.arguments.length === 1 &&
62
+ ts.isStringLiteral(node.arguments[0])
63
+ ) {
64
+ // Ignore require.resolve(), require.cache etc. — those are property accesses
65
+ // on the result, not on `require` itself: require.resolve() has a
66
+ // PropertyAccessExpression as node.expression, not an Identifier.
67
+ const source = (node.arguments[0] as ts.StringLiteral).text
68
+ const names = this.getRequireBindingNames(node)
69
+ // isDefault = true when binding is a plain identifier (const x = require(...))
70
+ // or when there's no binding at all (require(...) used for side effects).
71
+ // Only destructured object bindings (const { a } = require(...)) are named imports.
72
+ const parent = node.parent
73
+ const isDestructured = parent &&
74
+ ts.isVariableDeclaration(parent) &&
75
+ ts.isObjectBindingPattern(parent.name)
76
+ imports.push({
77
+ source,
78
+ resolvedPath: '',
79
+ names,
80
+ isDefault: !isDestructured,
81
+ isDynamic: false,
82
+ })
83
+ }
84
+ ts.forEachChild(node, walk)
85
+ }
86
+ ts.forEachChild(this.sourceFile, walk)
87
+ return imports
88
+ }
89
+
90
+ /** Names extracted from the variable declaration that receives the require() call. */
91
+ private getRequireBindingNames(call: ts.CallExpression): string[] {
92
+ const parent = call.parent
93
+ if (!parent || !ts.isVariableDeclaration(parent)) return []
94
+ // const { a, b } = require('...') → ['a', 'b']
95
+ if (ts.isObjectBindingPattern(parent.name)) {
96
+ return parent.name.elements
97
+ .filter(e => ts.isIdentifier(e.name))
98
+ .map(e => (e.name as ts.Identifier).text)
99
+ }
100
+ // const x = require('...') → ['x']
101
+ if (ts.isIdentifier(parent.name)) return [parent.name.text]
102
+ return []
103
+ }
104
+
105
+ // ── CommonJS: module.exports / exports.x exports ─────────────────────────
106
+
107
+ private extractCommonJsExports(): ParsedExport[] {
108
+ const result: ParsedExport[] = []
109
+ const fp = this.filePath
110
+
111
+ const walk = (node: ts.Node) => {
112
+ if (
113
+ ts.isExpressionStatement(node) &&
114
+ ts.isBinaryExpression(node.expression) &&
115
+ node.expression.operatorToken.kind === ts.SyntaxKind.EqualsToken
116
+ ) {
117
+ const lhs = node.expression.left
118
+ const rhs = node.expression.right
119
+
120
+ // ── module.exports = ... ────────────────────────────────────
121
+ if (isModuleExports(lhs)) {
122
+ if (ts.isObjectLiteralExpression(rhs)) {
123
+ // module.exports = { foo, bar }
124
+ for (const prop of rhs.properties) {
125
+ if (ts.isShorthandPropertyAssignment(prop)) {
126
+ result.push({ name: prop.name.text, type: 'const', file: fp })
127
+ } else if (ts.isPropertyAssignment(prop) && ts.isIdentifier(prop.name)) {
128
+ const isFunc = ts.isFunctionExpression(prop.initializer) || ts.isArrowFunction(prop.initializer)
129
+ result.push({ name: prop.name.text, type: isFunc ? 'function' : 'const', file: fp })
130
+ }
131
+ }
132
+ } else if (ts.isFunctionExpression(rhs) || ts.isArrowFunction(rhs)) {
133
+ // module.exports = function name() {} → use function name or 'default'
134
+ const name = ts.isFunctionExpression(rhs) && rhs.name ? rhs.name.text : 'default'
135
+ result.push({ name, type: 'default', file: fp })
136
+ } else if (ts.isClassExpression(rhs) && rhs.name) {
137
+ result.push({ name: rhs.name.text, type: 'class', file: fp })
138
+ } else if (ts.isIdentifier(rhs)) {
139
+ result.push({ name: rhs.text, type: 'default', file: fp })
140
+ } else {
141
+ result.push({ name: 'default', type: 'default', file: fp })
142
+ }
143
+ }
144
+
145
+ // ── exports.foo = ... ───────────────────────────────────────
146
+ if (isExportsDotProp(lhs)) {
147
+ const prop = lhs as ts.PropertyAccessExpression
148
+ const isFunc = ts.isFunctionExpression(rhs) || ts.isArrowFunction(rhs)
149
+ result.push({ name: prop.name.text, type: isFunc ? 'function' : 'const', file: fp })
150
+ }
151
+
152
+ // ── module.exports.foo = ... ────────────────────────────────
153
+ if (isModuleExportsDotProp(lhs)) {
154
+ const prop = lhs as ts.PropertyAccessExpression
155
+ const isFunc = ts.isFunctionExpression(rhs) || ts.isArrowFunction(rhs)
156
+ result.push({ name: prop.name.text, type: isFunc ? 'function' : 'const', file: fp })
157
+ }
158
+ }
159
+ ts.forEachChild(node, walk)
160
+ }
161
+
162
+ ts.forEachChild(this.sourceFile, walk)
163
+ return result
164
+ }
165
+
166
+ // ── CommonJS: module.exports / exports.x function bodies ──────────────────
167
+
168
+ /**
169
+ * Detect functions directly assigned via module.exports or exports.x:
170
+ * module.exports = function handleLogin(req, res) { ... }
171
+ * module.exports = function() { ... } ← name = 'default'
172
+ * exports.createUser = function(data) { ... }
173
+ * exports.createUser = (data) => { ... }
174
+ */
175
+ private extractCommonJsFunctions(): ParsedFunction[] {
176
+ const result: ParsedFunction[] = []
177
+
178
+ const walk = (node: ts.Node) => {
179
+ if (
180
+ ts.isExpressionStatement(node) &&
181
+ ts.isBinaryExpression(node.expression) &&
182
+ node.expression.operatorToken.kind === ts.SyntaxKind.EqualsToken
183
+ ) {
184
+ const lhs = node.expression.left
185
+ const rhs = node.expression.right
186
+
187
+ if (!ts.isFunctionExpression(rhs) && !ts.isArrowFunction(rhs)) {
188
+ ts.forEachChild(node, walk)
189
+ return
190
+ }
191
+
192
+ let fnName: string | null = null
193
+ if (isModuleExports(lhs)) {
194
+ // module.exports = function name() {} / function() {}
195
+ fnName = ts.isFunctionExpression(rhs) && rhs.name ? rhs.name.text : 'default'
196
+ } else if (isExportsDotProp(lhs)) {
197
+ // exports.foo = function() {}
198
+ fnName = (lhs as ts.PropertyAccessExpression).name.text
199
+ } else if (isModuleExportsDotProp(lhs)) {
200
+ // module.exports.foo = function() {}
201
+ fnName = (lhs as ts.PropertyAccessExpression).name.text
202
+ }
203
+
204
+ if (fnName !== null) {
205
+ const startLine = this.getLineNumber(node.getStart())
206
+ const endLine = this.getLineNumber(node.getEnd())
207
+ const isAsync = !!(rhs.modifiers?.some((m: ts.Modifier) => m.kind === ts.SyntaxKind.AsyncKeyword))
208
+ result.push({
209
+ id: `fn:${this.filePath}:${fnName}`,
210
+ name: fnName,
211
+ file: this.filePath,
212
+ startLine,
213
+ endLine,
214
+ params: this.extractParams(rhs.parameters),
215
+ returnType: rhs.type ? rhs.type.getText(this.sourceFile) : 'void',
216
+ isExported: true,
217
+ isAsync,
218
+ calls: this.extractCalls(rhs),
219
+ hash: hashContent(rhs.getText(this.sourceFile)),
220
+ purpose: this.extractPurpose(node),
221
+ edgeCasesHandled: this.extractEdgeCases(rhs),
222
+ errorHandling: this.extractErrorHandling(rhs),
223
+ detailedLines: this.extractDetailedLines(rhs),
224
+ })
225
+ }
226
+ }
227
+ ts.forEachChild(node, walk)
228
+ }
229
+
230
+ ts.forEachChild(this.sourceFile, walk)
231
+ return result
232
+ }
233
+ }
234
+
235
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
236
+
237
+ /** node is `module.exports` */
238
+ function isModuleExports(node: ts.Node): boolean {
239
+ return (
240
+ ts.isPropertyAccessExpression(node) &&
241
+ ts.isIdentifier(node.expression) &&
242
+ node.expression.text === 'module' &&
243
+ node.name.text === 'exports'
244
+ )
245
+ }
246
+
247
+ /** node is `exports.something` */
248
+ function isExportsDotProp(node: ts.Node): boolean {
249
+ return (
250
+ ts.isPropertyAccessExpression(node) &&
251
+ ts.isIdentifier(node.expression) &&
252
+ node.expression.text === 'exports'
253
+ )
254
+ }
255
+
256
+ /** node is `module.exports.something` */
257
+ function isModuleExportsDotProp(node: ts.Node): boolean {
258
+ return (
259
+ ts.isPropertyAccessExpression(node) &&
260
+ isModuleExports(node.expression)
261
+ )
262
+ }
@@ -0,0 +1,92 @@
1
+ import * as path from 'node:path'
2
+ import * as fs from 'node:fs'
3
+ import { BaseParser } from '../base-parser.js'
4
+ import { JavaScriptExtractor } from './js-extractor.js'
5
+ import { JavaScriptResolver } from './js-resolver.js'
6
+ import { hashContent } from '../../hash/file-hasher.js'
7
+ import type { ParsedFile } from '../types.js'
8
+
9
+ /**
10
+ * JavaScriptParser — implements BaseParser for .js / .mjs / .cjs / .jsx files.
11
+ *
12
+ * Uses the TypeScript Compiler API (ScriptKind.JS / ScriptKind.JSX) which correctly
13
+ * parses JavaScript without type annotations. JavaScriptExtractor extends
14
+ * TypeScriptExtractor and adds CommonJS require() / module.exports support.
15
+ */
16
+ export class JavaScriptParser extends BaseParser {
17
+ parse(filePath: string, content: string): ParsedFile {
18
+ const extractor = new JavaScriptExtractor(filePath, content)
19
+
20
+ const functions = extractor.extractFunctions()
21
+ const classes = extractor.extractClasses()
22
+ const generics = extractor.extractGenerics()
23
+ const imports = extractor.extractImports()
24
+ const exports = extractor.extractExports()
25
+ const routes = extractor.extractRoutes()
26
+
27
+ // Cross-reference: CJS exports may mark a name exported even when the
28
+ // declaration itself had no `export` keyword.
29
+ const exportedNames = new Set(exports.map(e => e.name))
30
+ for (const fn of functions) { if (!fn.isExported && exportedNames.has(fn.name)) fn.isExported = true }
31
+ for (const cls of classes) { if (!cls.isExported && exportedNames.has(cls.name)) cls.isExported = true }
32
+ for (const gen of generics) { if (!gen.isExported && exportedNames.has(gen.name)) gen.isExported = true }
33
+
34
+ return {
35
+ path: filePath,
36
+ language: 'javascript',
37
+ functions,
38
+ classes,
39
+ generics,
40
+ imports,
41
+ exports,
42
+ routes,
43
+ hash: hashContent(content),
44
+ parsedAt: Date.now(),
45
+ }
46
+ }
47
+
48
+ resolveImports(files: ParsedFile[], projectRoot: string): ParsedFile[] {
49
+ const aliases = loadAliases(projectRoot)
50
+ const allFilePaths = files.map(f => f.path)
51
+ const resolver = new JavaScriptResolver(projectRoot, aliases)
52
+ return files.map(file => ({
53
+ ...file,
54
+ imports: resolver.resolveAll(file.imports, file.path, allFilePaths),
55
+ }))
56
+ }
57
+
58
+ getSupportedExtensions(): string[] {
59
+ return ['.js', '.mjs', '.cjs', '.jsx']
60
+ }
61
+ }
62
+
63
+ /**
64
+ * Load path aliases from jsconfig.json → tsconfig.json → tsconfig.base.json.
65
+ * Strips JS/block comments before parsing (both formats allow them).
66
+ * Falls back to raw content if comment-stripping breaks a URL.
67
+ * Returns {} when no config is found.
68
+ */
69
+ function loadAliases(projectRoot: string): Record<string, string[]> {
70
+ for (const name of ['jsconfig.json', 'tsconfig.json', 'tsconfig.base.json']) {
71
+ const configPath = path.join(projectRoot, name)
72
+ try {
73
+ const raw = fs.readFileSync(configPath, 'utf-8')
74
+ const stripped = raw.replace(/\/\/.*$/gm, '').replace(/\/\*[\s\S]*?\*\//g, '')
75
+ let config: any
76
+ try { config = JSON.parse(stripped) }
77
+ catch { config = JSON.parse(raw) } // URL stripping may have broken JSON
78
+
79
+ const options = config.compilerOptions ?? {}
80
+ const rawPaths: Record<string, string[]> = options.paths ?? {}
81
+ if (Object.keys(rawPaths).length === 0) continue
82
+
83
+ const baseUrl = options.baseUrl ?? '.'
84
+ const resolved: Record<string, string[]> = {}
85
+ for (const [alias, targets] of Object.entries(rawPaths)) {
86
+ resolved[alias] = (targets as string[]).map((t: string) => path.posix.join(baseUrl, t))
87
+ }
88
+ return resolved
89
+ } catch { /* config absent or unreadable — try next */ }
90
+ }
91
+ return {}
92
+ }