@getmikk/core 1.6.0 → 1.7.1
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/lock-compiler.ts +6 -5
- package/src/contract/lock-reader.ts +40 -26
- package/src/contract/schema.ts +2 -7
- package/src/index.ts +1 -1
- package/src/parser/index.ts +20 -1
- package/src/parser/javascript/js-extractor.ts +262 -0
- package/src/parser/javascript/js-parser.ts +92 -0
- package/src/parser/javascript/js-resolver.ts +83 -0
- package/src/parser/types.ts +1 -1
- package/src/parser/typescript/ts-extractor.ts +29 -25
- package/src/parser/typescript/ts-parser.ts +93 -14
- package/tests/contract.test.ts +1 -1
- package/tests/helpers.ts +0 -1
- package/tests/js-parser.test.ts +616 -0
- package/tests/ts-parser.test.ts +93 -0
package/package.json
CHANGED
|
@@ -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.
|
|
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
|
|
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
|
|
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 (
|
|
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
|
-
|
|
74
|
+
// P4: no hash, P6: no moduleId
|
|
68
75
|
}
|
|
69
|
-
//
|
|
70
|
-
if (fn.
|
|
71
|
-
if (fn.
|
|
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
|
-
|
|
83
|
-
|
|
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 —
|
|
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
|
-
//
|
|
173
|
-
const
|
|
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[
|
|
177
|
-
id:
|
|
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
|
|
184
|
-
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
|
-
|
|
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
|
|
package/src/contract/schema.ts
CHANGED
|
@@ -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'
|
package/src/parser/index.ts
CHANGED
|
@@ -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
|
+
}
|