@getmikk/core 1.8.3 → 1.9.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 +6 -4
- package/src/constants.ts +285 -0
- package/src/contract/contract-generator.ts +7 -0
- package/src/contract/index.ts +2 -3
- package/src/contract/lock-compiler.ts +66 -35
- package/src/contract/lock-reader.ts +30 -5
- package/src/contract/schema.ts +21 -0
- package/src/error-handler.ts +432 -0
- package/src/graph/cluster-detector.ts +52 -22
- package/src/graph/confidence-engine.ts +85 -0
- package/src/graph/graph-builder.ts +298 -255
- package/src/graph/impact-analyzer.ts +132 -119
- package/src/graph/index.ts +4 -0
- package/src/graph/memory-manager.ts +186 -0
- package/src/graph/query-engine.ts +76 -0
- package/src/graph/risk-engine.ts +86 -0
- package/src/graph/types.ts +89 -65
- package/src/index.ts +2 -0
- package/src/parser/change-detector.ts +99 -0
- package/src/parser/go/go-extractor.ts +18 -8
- package/src/parser/go/go-parser.ts +2 -0
- package/src/parser/index.ts +86 -36
- package/src/parser/javascript/js-extractor.ts +1 -1
- package/src/parser/javascript/js-parser.ts +2 -0
- package/src/parser/oxc-parser.ts +708 -0
- package/src/parser/oxc-resolver.ts +83 -0
- package/src/parser/tree-sitter/parser.ts +19 -10
- package/src/parser/types.ts +100 -73
- package/src/parser/typescript/ts-extractor.ts +229 -589
- package/src/parser/typescript/ts-parser.ts +16 -171
- package/src/parser/typescript/ts-resolver.ts +11 -1
- package/src/search/bm25.ts +16 -4
- package/src/utils/minimatch.ts +1 -1
- package/tests/contract.test.ts +2 -2
- package/tests/dead-code.test.ts +7 -7
- package/tests/esm-resolver.test.ts +75 -0
- package/tests/graph.test.ts +20 -20
- package/tests/helpers.ts +11 -6
- package/tests/impact-classified.test.ts +37 -41
- package/tests/parser.test.ts +7 -5
- package/tests/ts-parser.test.ts +27 -52
- package/test-output.txt +0 -373
|
@@ -1,191 +1,36 @@
|
|
|
1
|
-
import * as path from 'node:path'
|
|
2
|
-
import * as fs from 'node:fs'
|
|
3
1
|
import { BaseParser } from '../base-parser.js'
|
|
2
|
+
import type { ParsedFile } from '../types.js'
|
|
4
3
|
import { TypeScriptExtractor } from './ts-extractor.js'
|
|
5
4
|
import { TypeScriptResolver } from './ts-resolver.js'
|
|
6
5
|
import { hashContent } from '../../hash/file-hasher.js'
|
|
7
|
-
import type { ParsedFile } from '../types.js'
|
|
8
|
-
import { MIN_FILES_FOR_COMPLETE_SCAN, parseJsonWithComments } from '../parser-constants.js'
|
|
9
6
|
|
|
10
|
-
/**
|
|
11
|
-
* TypeScript parser — uses TS Compiler API to parse .ts/.tsx files
|
|
12
|
-
* and extract structured data (functions, classes, imports, exports).
|
|
13
|
-
*/
|
|
14
7
|
export class TypeScriptParser extends BaseParser {
|
|
15
|
-
|
|
16
|
-
async parse(filePath: string, content: string): Promise<ParsedFile> {
|
|
8
|
+
public async parse(filePath: string, content: string): Promise<ParsedFile> {
|
|
17
9
|
const extractor = new TypeScriptExtractor(filePath, content)
|
|
18
|
-
|
|
19
|
-
const classes = extractor.extractClasses()
|
|
20
|
-
const generics = extractor.extractGenerics()
|
|
21
|
-
const imports = extractor.extractImports()
|
|
22
|
-
const exports = extractor.extractExports()
|
|
23
|
-
const routes = extractor.extractRoutes()
|
|
24
|
-
|
|
25
|
-
// Cross-reference: re-export declarations (`export { Name }` or
|
|
26
|
-
// `export { X as Y } from './m'`) may refer to symbols whose declaration
|
|
27
|
-
// doesn't carry an export keyword. Mark them as exported here.
|
|
28
|
-
// Exclude `type: 'default'` to avoid marking an unrelated local called 'default'.
|
|
29
|
-
const exportedNonDefault = new Set(
|
|
30
|
-
exports.filter(e => e.type !== 'default').map(e => e.name)
|
|
31
|
-
)
|
|
32
|
-
for (const fn of functions) {
|
|
33
|
-
if (!fn.isExported && exportedNonDefault.has(fn.name)) fn.isExported = true
|
|
34
|
-
}
|
|
35
|
-
for (const cls of classes) {
|
|
36
|
-
if (!cls.isExported && exportedNonDefault.has(cls.name)) cls.isExported = true
|
|
37
|
-
}
|
|
38
|
-
for (const gen of generics) {
|
|
39
|
-
if (!gen.isExported && exportedNonDefault.has(gen.name)) gen.isExported = true
|
|
40
|
-
}
|
|
41
|
-
|
|
10
|
+
|
|
42
11
|
return {
|
|
43
12
|
path: filePath,
|
|
44
13
|
language: 'typescript',
|
|
45
|
-
functions,
|
|
46
|
-
classes,
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
14
|
+
functions: extractor.extractFunctions(),
|
|
15
|
+
classes: extractor.extractClasses(),
|
|
16
|
+
variables: extractor.extractVariables(),
|
|
17
|
+
generics: extractor.extractGenerics(),
|
|
18
|
+
imports: extractor.extractImports(),
|
|
19
|
+
exports: extractor.extractExports(),
|
|
20
|
+
routes: extractor.extractRoutes(),
|
|
21
|
+
calls: extractor.extractModuleCalls(),
|
|
51
22
|
hash: hashContent(content),
|
|
52
|
-
parsedAt: Date.now()
|
|
23
|
+
parsedAt: Date.now()
|
|
53
24
|
}
|
|
54
25
|
}
|
|
55
26
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
const resolver = new TypeScriptResolver(projectRoot, tsConfigPaths)
|
|
60
|
-
|
|
61
|
-
// Only pass the project file list when it is large enough to be a meaningful
|
|
62
|
-
// scan. Sparse lists (< MIN_FILES_FOR_COMPLETE_SCAN files) cause alias
|
|
63
|
-
// resolution lookups to fail with '', so we only trust the list once it is
|
|
64
|
-
// sufficiently large. With an empty list the resolver falls back to extension
|
|
65
|
-
// probing, which is safe for alias-defined paths.
|
|
66
|
-
const allFilePaths = files.length >= MIN_FILES_FOR_COMPLETE_SCAN ? files.map(f => f.path) : []
|
|
67
|
-
|
|
68
|
-
return files.map(file => ({
|
|
69
|
-
...file,
|
|
70
|
-
imports: file.imports.map(imp => resolver.resolve(imp, file.path, allFilePaths)),
|
|
71
|
-
}))
|
|
27
|
+
public resolveImports(files: ParsedFile[], projectRoot: string): ParsedFile[] {
|
|
28
|
+
const resolver = new TypeScriptResolver(projectRoot)
|
|
29
|
+
return resolver.resolveBatch(files)
|
|
72
30
|
}
|
|
73
31
|
|
|
74
|
-
getSupportedExtensions(): string[] {
|
|
32
|
+
public getSupportedExtensions(): string[] {
|
|
75
33
|
return ['.ts', '.tsx']
|
|
76
34
|
}
|
|
77
35
|
}
|
|
78
36
|
|
|
79
|
-
/**
|
|
80
|
-
* Read compilerOptions.paths from tsconfig.json in projectRoot.
|
|
81
|
-
* Recursively follows "extends" chains (e.g. extends ./tsconfig.base.json,
|
|
82
|
-
* extends @tsconfig/node-lts/tsconfig.json) and merges paths.
|
|
83
|
-
*
|
|
84
|
-
* Handles:
|
|
85
|
-
* - extends with relative paths (./tsconfig.base.json)
|
|
86
|
-
* - extends with node_modules packages (@tsconfig/node-lts)
|
|
87
|
-
* - baseUrl prefix so aliases like "@/*" → ["src/*"] resolve correctly
|
|
88
|
-
* - JSON5-style comments (line and block comments) via the shared helper
|
|
89
|
-
*/
|
|
90
|
-
function loadTsConfigPaths(projectRoot: string): Record<string, string[]> {
|
|
91
|
-
const candidates = ['tsconfig.json', 'tsconfig.base.json']
|
|
92
|
-
for (const name of candidates) {
|
|
93
|
-
const tsConfigPath = path.join(projectRoot, name)
|
|
94
|
-
try {
|
|
95
|
-
const merged = loadTsConfigWithExtends(tsConfigPath, new Set())
|
|
96
|
-
const options = merged.compilerOptions ?? {}
|
|
97
|
-
const rawPaths: Record<string, string[]> = options.paths ?? {}
|
|
98
|
-
if (Object.keys(rawPaths).length === 0) continue
|
|
99
|
-
|
|
100
|
-
const baseUrl: string = options.baseUrl ?? '.'
|
|
101
|
-
const resolved: Record<string, string[]> = {}
|
|
102
|
-
for (const [alias, targets] of Object.entries(rawPaths)) {
|
|
103
|
-
resolved[alias] = (targets as string[]).map(t =>
|
|
104
|
-
t.startsWith('.') ? path.posix.join(baseUrl, t) : t
|
|
105
|
-
)
|
|
106
|
-
}
|
|
107
|
-
return resolved
|
|
108
|
-
} catch { /* tsconfig not found or invalid — continue */ }
|
|
109
|
-
}
|
|
110
|
-
return {}
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
/**
|
|
114
|
-
* Recursively load a tsconfig, following the "extends" chain.
|
|
115
|
-
* Merges compilerOptions from parent → child (child wins on conflict).
|
|
116
|
-
* Prevents infinite loops via a visited set.
|
|
117
|
-
*/
|
|
118
|
-
function loadTsConfigWithExtends(configPath: string, visited: Set<string>): any {
|
|
119
|
-
const resolved = path.resolve(configPath)
|
|
120
|
-
if (visited.has(resolved)) return {}
|
|
121
|
-
visited.add(resolved)
|
|
122
|
-
|
|
123
|
-
let raw: string
|
|
124
|
-
try {
|
|
125
|
-
raw = fs.readFileSync(resolved, 'utf-8')
|
|
126
|
-
} catch {
|
|
127
|
-
return {}
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
let config: any
|
|
131
|
-
try {
|
|
132
|
-
config = parseJsonWithComments(raw)
|
|
133
|
-
} catch {
|
|
134
|
-
return {}
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
if (!config.extends) return config
|
|
138
|
-
|
|
139
|
-
// Resolve the parent config path
|
|
140
|
-
const extendsValue = config.extends
|
|
141
|
-
let parentPath: string
|
|
142
|
-
|
|
143
|
-
if (extendsValue.startsWith('.')) {
|
|
144
|
-
// Relative path: ./tsconfig.base.json or ../tsconfig.json
|
|
145
|
-
parentPath = path.resolve(path.dirname(resolved), extendsValue)
|
|
146
|
-
// Add .json if missing
|
|
147
|
-
if (!parentPath.endsWith('.json')) parentPath += '.json'
|
|
148
|
-
} else {
|
|
149
|
-
// Node module: @tsconfig/node-lts or @tsconfig/node-lts/tsconfig.json
|
|
150
|
-
try {
|
|
151
|
-
// Try resolving as a node module from projectRoot
|
|
152
|
-
const projectRoot = path.dirname(resolved)
|
|
153
|
-
const modulePath = path.join(projectRoot, 'node_modules', extendsValue)
|
|
154
|
-
if (fs.existsSync(modulePath + '.json')) {
|
|
155
|
-
parentPath = modulePath + '.json'
|
|
156
|
-
} else if (fs.existsSync(path.join(modulePath, 'tsconfig.json'))) {
|
|
157
|
-
parentPath = path.join(modulePath, 'tsconfig.json')
|
|
158
|
-
} else if (fs.existsSync(modulePath)) {
|
|
159
|
-
parentPath = modulePath
|
|
160
|
-
} else {
|
|
161
|
-
// Can't resolve — skip extends
|
|
162
|
-
delete config.extends
|
|
163
|
-
return config
|
|
164
|
-
}
|
|
165
|
-
} catch {
|
|
166
|
-
delete config.extends
|
|
167
|
-
return config
|
|
168
|
-
}
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
// Load parent recursively
|
|
172
|
-
const parent = loadTsConfigWithExtends(parentPath, visited)
|
|
173
|
-
|
|
174
|
-
// Merge: parent compilerOptions → child compilerOptions (child wins)
|
|
175
|
-
const merged = { ...config }
|
|
176
|
-
delete merged.extends
|
|
177
|
-
merged.compilerOptions = {
|
|
178
|
-
...(parent.compilerOptions ?? {}),
|
|
179
|
-
...(config.compilerOptions ?? {}),
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
// Merge paths specifically (child paths override parent paths for same alias)
|
|
183
|
-
if (parent.compilerOptions?.paths || config.compilerOptions?.paths) {
|
|
184
|
-
merged.compilerOptions.paths = {
|
|
185
|
-
...(parent.compilerOptions?.paths ?? {}),
|
|
186
|
-
...(config.compilerOptions?.paths ?? {}),
|
|
187
|
-
}
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
return merged
|
|
191
|
-
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import * as path from 'node:path'
|
|
2
|
-
import type { ParsedImport } from '../types.js'
|
|
2
|
+
import type { ParsedImport, ParsedFile } from '../types.js'
|
|
3
3
|
|
|
4
4
|
interface TSConfigPaths {
|
|
5
5
|
[alias: string]: string[]
|
|
@@ -26,6 +26,16 @@ export class TypeScriptResolver {
|
|
|
26
26
|
this.aliases = tsConfigPaths ?? {}
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
/** Resolve all imports for a batch of files */
|
|
30
|
+
public resolveBatch(files: ParsedFile[]): ParsedFile[] {
|
|
31
|
+
const allFilePaths = files.map(f => f.path)
|
|
32
|
+
return files.map(file => ({
|
|
33
|
+
...file,
|
|
34
|
+
imports: this.resolveAll(file.imports, file.path, allFilePaths)
|
|
35
|
+
}))
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
|
|
29
39
|
/** Resolve a single import relative to the importing file */
|
|
30
40
|
resolve(imp: ParsedImport, fromFile: string, allProjectFiles: string[] = []): ParsedImport {
|
|
31
41
|
if (
|
package/src/search/bm25.ts
CHANGED
|
@@ -43,12 +43,14 @@ export class BM25Index {
|
|
|
43
43
|
private documents: BM25Document[] = []
|
|
44
44
|
private documentFrequency = new Map<string, number>() // term → how many docs contain it
|
|
45
45
|
private avgDocLength = 0
|
|
46
|
+
private totalDocLength = 0 // running total — avoids O(n²) recompute on every addDocument
|
|
46
47
|
|
|
47
48
|
/** Clear the index */
|
|
48
49
|
clear(): void {
|
|
49
50
|
this.documents = []
|
|
50
51
|
this.documentFrequency.clear()
|
|
51
52
|
this.avgDocLength = 0
|
|
53
|
+
this.totalDocLength = 0
|
|
52
54
|
}
|
|
53
55
|
|
|
54
56
|
/** Add a document with pre-tokenized terms */
|
|
@@ -62,8 +64,9 @@ export class BM25Index {
|
|
|
62
64
|
this.documentFrequency.set(term, (this.documentFrequency.get(term) ?? 0) + 1)
|
|
63
65
|
}
|
|
64
66
|
|
|
65
|
-
//
|
|
66
|
-
this.
|
|
67
|
+
// O(1) running average — was O(n) reduce over all documents on every insert
|
|
68
|
+
this.totalDocLength += normalizedTokens.length
|
|
69
|
+
this.avgDocLength = this.totalDocLength / this.documents.length
|
|
67
70
|
}
|
|
68
71
|
|
|
69
72
|
/** Search the index and return ranked results */
|
|
@@ -92,7 +95,14 @@ export class BM25Index {
|
|
|
92
95
|
|
|
93
96
|
// BM25 score component
|
|
94
97
|
const tfNorm = (tf * (K1 + 1)) / (tf + K1 * (1 - B + B * (doc.length / this.avgDocLength)))
|
|
95
|
-
|
|
98
|
+
let termScore = idf * tfNorm
|
|
99
|
+
|
|
100
|
+
// Bonus for direct name match in the ID
|
|
101
|
+
if (doc.id.toLowerCase().includes(term.toLowerCase())) {
|
|
102
|
+
termScore += 0.5
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
score += termScore
|
|
96
106
|
}
|
|
97
107
|
|
|
98
108
|
if (score > 0) {
|
|
@@ -178,7 +188,9 @@ export function buildFunctionTokens(fn: {
|
|
|
178
188
|
|
|
179
189
|
// Function name tokens (highest signal)
|
|
180
190
|
parts.push(...tokenize(fn.name))
|
|
181
|
-
parts.push(...tokenize(fn.name))
|
|
191
|
+
parts.push(...tokenize(fn.name))
|
|
192
|
+
parts.push(...tokenize(fn.name)) // Triple-weight the name
|
|
193
|
+
parts.push(`name_exact:${fn.name.toLowerCase()}`)
|
|
182
194
|
|
|
183
195
|
// File path tokens
|
|
184
196
|
const filename = fn.file.split('/').pop() ?? fn.file
|
package/src/utils/minimatch.ts
CHANGED
package/tests/contract.test.ts
CHANGED
|
@@ -76,7 +76,7 @@ describe('LockCompiler', () => {
|
|
|
76
76
|
const graph = new GraphBuilder().build(files)
|
|
77
77
|
const lock = compiler.compile(graph, contract, files)
|
|
78
78
|
|
|
79
|
-
expect(lock.version).toBe('
|
|
79
|
+
expect(lock.version).toBe('2.0.0')
|
|
80
80
|
expect(lock.syncState.status).toBe('clean')
|
|
81
81
|
expect(Object.keys(lock.functions).length).toBeGreaterThan(0)
|
|
82
82
|
expect(Object.keys(lock.modules).length).toBeGreaterThanOrEqual(1)
|
|
@@ -89,7 +89,7 @@ describe('LockCompiler', () => {
|
|
|
89
89
|
]
|
|
90
90
|
const graph = new GraphBuilder().build(files)
|
|
91
91
|
const lock = compiler.compile(graph, contract, files)
|
|
92
|
-
expect(lock.functions['fn:src/auth/verify.ts:
|
|
92
|
+
expect(lock.functions['fn:src/auth/verify.ts:verifytoken']?.moduleId).toBe('auth')
|
|
93
93
|
})
|
|
94
94
|
|
|
95
95
|
it('computes stable module hash', () => {
|
package/tests/dead-code.test.ts
CHANGED
|
@@ -31,7 +31,7 @@ function generateDummyLock(graphNodes: Map<string, any>): MikkLock {
|
|
|
31
31
|
|
|
32
32
|
for (const [id, node] of graphNodes.entries()) {
|
|
33
33
|
if (node.type === 'function') {
|
|
34
|
-
const name = node.
|
|
34
|
+
const name = node.name
|
|
35
35
|
const file = node.file
|
|
36
36
|
// We need to populate `calledBy` for transitive liveness checks
|
|
37
37
|
|
|
@@ -68,7 +68,7 @@ describe('DeadCodeDetector', () => {
|
|
|
68
68
|
// lookups for the lock structure (it only does it for the graph)
|
|
69
69
|
Object.values(lock.functions).forEach(fn => {
|
|
70
70
|
const inEdges = graph.inEdges.get(fn.id) ?? []
|
|
71
|
-
fn.calledBy = inEdges.filter(e => e.type === 'calls').map(e => e.
|
|
71
|
+
fn.calledBy = inEdges.filter(e => e.type === 'calls').map(e => e.from)
|
|
72
72
|
})
|
|
73
73
|
|
|
74
74
|
const detector = new DeadCodeDetector(graph, lock)
|
|
@@ -83,7 +83,7 @@ describe('DeadCodeDetector', () => {
|
|
|
83
83
|
const graph = buildTestGraph([
|
|
84
84
|
['D', 'nothing']
|
|
85
85
|
])
|
|
86
|
-
graph.nodes.get('fn:src/
|
|
86
|
+
graph.nodes.get('fn:src/d.ts:d')!.metadata!.isExported = true
|
|
87
87
|
|
|
88
88
|
const lock = generateDummyLock(graph.nodes)
|
|
89
89
|
|
|
@@ -115,16 +115,16 @@ describe('DeadCodeDetector', () => {
|
|
|
115
115
|
])
|
|
116
116
|
|
|
117
117
|
// Both in same file by default from buildTestGraph
|
|
118
|
-
graph.nodes.get('fn:src/
|
|
118
|
+
graph.nodes.get('fn:src/exportedfn.ts:exportedfn')!.metadata!.isExported = true
|
|
119
119
|
|
|
120
120
|
// In the lock, we must place them in the SAME file manually because buildTestGraph
|
|
121
121
|
// puts them in separate files based on name
|
|
122
122
|
const lock = generateDummyLock(graph.nodes)
|
|
123
|
-
lock.functions['fn:src/
|
|
124
|
-
lock.functions['fn:src/
|
|
123
|
+
lock.functions['fn:src/exportedfn.ts:exportedfn'].file = 'src/shared.ts'
|
|
124
|
+
lock.functions['fn:src/internalhelper.ts:internalhelper'].file = 'src/shared.ts'
|
|
125
125
|
|
|
126
126
|
// Set up the calledBy relation
|
|
127
|
-
lock.functions['fn:src/
|
|
127
|
+
lock.functions['fn:src/internalhelper.ts:internalhelper'].calledBy = ['fn:src/exportedfn.ts:exportedfn']
|
|
128
128
|
|
|
129
129
|
const detector = new DeadCodeDetector(graph, lock)
|
|
130
130
|
const result = detector.detect()
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll, afterAll } from 'bun:test'
|
|
2
|
+
import * as path from 'node:path'
|
|
3
|
+
import * as fs from 'node:fs/promises'
|
|
4
|
+
import { OxcResolver } from '../src/parser/oxc-resolver'
|
|
5
|
+
|
|
6
|
+
describe('OxcResolver - ESM and CJS Resolution', () => {
|
|
7
|
+
const FIXTURE_DIR = path.join(process.cwd(), '.test-fixture-esm')
|
|
8
|
+
|
|
9
|
+
beforeAll(async () => {
|
|
10
|
+
await fs.mkdir(FIXTURE_DIR, { recursive: true })
|
|
11
|
+
|
|
12
|
+
// 1. Create a "dependency" package with 'exports'
|
|
13
|
+
const depDir = path.join(FIXTURE_DIR, 'node_modules', 'some-pkg')
|
|
14
|
+
await fs.mkdir(depDir, { recursive: true })
|
|
15
|
+
|
|
16
|
+
await fs.writeFile(
|
|
17
|
+
path.join(depDir, 'package.json'),
|
|
18
|
+
JSON.stringify({
|
|
19
|
+
name: 'some-pkg',
|
|
20
|
+
type: 'module',
|
|
21
|
+
exports: {
|
|
22
|
+
'.': {
|
|
23
|
+
import: './dist/esm/index.js',
|
|
24
|
+
require: './dist/cjs/index.cjs',
|
|
25
|
+
types: './dist/index.d.ts'
|
|
26
|
+
},
|
|
27
|
+
'./subpath': './dist/sub.js'
|
|
28
|
+
}
|
|
29
|
+
})
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
await fs.mkdir(path.join(depDir, 'dist', 'esm'), { recursive: true })
|
|
33
|
+
await fs.mkdir(path.join(depDir, 'dist', 'cjs'), { recursive: true })
|
|
34
|
+
await fs.writeFile(path.join(depDir, 'dist', 'esm', 'index.js'), 'export const a = 1')
|
|
35
|
+
await fs.writeFile(path.join(depDir, 'dist', 'cjs', 'index.cjs'), 'exports.a = 1')
|
|
36
|
+
await fs.writeFile(path.join(depDir, 'dist', 'sub.js'), 'export const sub = 1')
|
|
37
|
+
|
|
38
|
+
// 2. Create a main package.json
|
|
39
|
+
await fs.writeFile(
|
|
40
|
+
path.join(FIXTURE_DIR, 'package.json'),
|
|
41
|
+
JSON.stringify({
|
|
42
|
+
name: 'main-pkg',
|
|
43
|
+
type: 'module'
|
|
44
|
+
})
|
|
45
|
+
)
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
afterAll(async () => {
|
|
49
|
+
await fs.rm(FIXTURE_DIR, { recursive: true, force: true })
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('resolves ESM exports correctly', () => {
|
|
53
|
+
const resolver = new OxcResolver(FIXTURE_DIR)
|
|
54
|
+
|
|
55
|
+
// Resolve 'some-pkg' (should hit exports['.'].import)
|
|
56
|
+
const res = resolver.resolve('some-pkg', path.join(FIXTURE_DIR, 'index.ts'))
|
|
57
|
+
expect(res).toContain('node_modules/some-pkg/dist/esm/index.js')
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('resolves subpath exports correctly', () => {
|
|
61
|
+
const resolver = new OxcResolver(FIXTURE_DIR)
|
|
62
|
+
|
|
63
|
+
// Resolve 'some-pkg/subpath'
|
|
64
|
+
const res = resolver.resolve('some-pkg/subpath', path.join(FIXTURE_DIR, 'index.ts'))
|
|
65
|
+
expect(res).toContain('node_modules/some-pkg/dist/sub.js')
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('resolves relative imports with extension probing', async () => {
|
|
69
|
+
const resolver = new OxcResolver(FIXTURE_DIR)
|
|
70
|
+
await fs.writeFile(path.join(FIXTURE_DIR, 'local.ts'), 'export const x = 1')
|
|
71
|
+
|
|
72
|
+
const res = resolver.resolve('./local', path.join(FIXTURE_DIR, 'index.ts'))
|
|
73
|
+
expect(res).toContain('.test-fixture-esm/local.ts')
|
|
74
|
+
})
|
|
75
|
+
})
|
package/tests/graph.test.ts
CHANGED
|
@@ -19,8 +19,8 @@ describe('GraphBuilder', () => {
|
|
|
19
19
|
mockParsedFile('src/auth.ts', [mockFunction('verifyToken', [], 'src/auth.ts')]),
|
|
20
20
|
]
|
|
21
21
|
const graph = builder.build(files)
|
|
22
|
-
expect(graph.nodes.has('fn:src/auth.ts:
|
|
23
|
-
expect(graph.nodes.get('fn:src/auth.ts:
|
|
22
|
+
expect(graph.nodes.has('fn:src/auth.ts:verifytoken')).toBe(true)
|
|
23
|
+
expect(graph.nodes.get('fn:src/auth.ts:verifytoken')!.type).toBe('function')
|
|
24
24
|
})
|
|
25
25
|
|
|
26
26
|
it('creates edges for imports', () => {
|
|
@@ -35,8 +35,8 @@ describe('GraphBuilder', () => {
|
|
|
35
35
|
const graph = builder.build(files)
|
|
36
36
|
const importEdges = graph.edges.filter(e => e.type === 'imports')
|
|
37
37
|
expect(importEdges.length).toBeGreaterThanOrEqual(1)
|
|
38
|
-
expect(importEdges[0].
|
|
39
|
-
expect(importEdges[0].
|
|
38
|
+
expect(importEdges[0].from).toBe('src/auth.ts')
|
|
39
|
+
expect(importEdges[0].to).toBe('src/utils/jwt.ts')
|
|
40
40
|
})
|
|
41
41
|
|
|
42
42
|
it('creates edges for function calls via imports', () => {
|
|
@@ -51,8 +51,8 @@ describe('GraphBuilder', () => {
|
|
|
51
51
|
const graph = builder.build(files)
|
|
52
52
|
const callEdges = graph.edges.filter(e => e.type === 'calls')
|
|
53
53
|
expect(callEdges.length).toBeGreaterThanOrEqual(1)
|
|
54
|
-
expect(callEdges[0].
|
|
55
|
-
expect(callEdges[0].
|
|
54
|
+
expect(callEdges[0].from).toBe('fn:src/auth.ts:verifytoken')
|
|
55
|
+
expect(callEdges[0].to).toBe('fn:src/utils/jwt.ts:jwtdecode')
|
|
56
56
|
})
|
|
57
57
|
|
|
58
58
|
it('creates containment edges', () => {
|
|
@@ -62,8 +62,8 @@ describe('GraphBuilder', () => {
|
|
|
62
62
|
const graph = builder.build(files)
|
|
63
63
|
const containEdges = graph.edges.filter(e => e.type === 'contains')
|
|
64
64
|
expect(containEdges.length).toBeGreaterThanOrEqual(1)
|
|
65
|
-
expect(containEdges[0].
|
|
66
|
-
expect(containEdges[0].
|
|
65
|
+
expect(containEdges[0].from).toBe('src/auth.ts')
|
|
66
|
+
expect(containEdges[0].to).toBe('fn:src/auth.ts:verifytoken')
|
|
67
67
|
})
|
|
68
68
|
|
|
69
69
|
it('builds adjacency maps', () => {
|
|
@@ -72,7 +72,7 @@ describe('GraphBuilder', () => {
|
|
|
72
72
|
]
|
|
73
73
|
const graph = builder.build(files)
|
|
74
74
|
expect(graph.outEdges.has('src/auth.ts')).toBe(true)
|
|
75
|
-
expect(graph.inEdges.has('fn:src/auth.ts:
|
|
75
|
+
expect(graph.inEdges.has('fn:src/auth.ts:verifytoken')).toBe(true)
|
|
76
76
|
})
|
|
77
77
|
})
|
|
78
78
|
|
|
@@ -83,8 +83,8 @@ describe('ImpactAnalyzer', () => {
|
|
|
83
83
|
['B', 'nothing'],
|
|
84
84
|
])
|
|
85
85
|
const analyzer = new ImpactAnalyzer(graph)
|
|
86
|
-
const result = analyzer.analyze(['fn:src/
|
|
87
|
-
expect(result.impacted).toContain('fn:src/
|
|
86
|
+
const result = analyzer.analyze(['fn:src/b.ts:b'])
|
|
87
|
+
expect(result.impacted).toContain('fn:src/a.ts:a')
|
|
88
88
|
})
|
|
89
89
|
|
|
90
90
|
it('finds transitive dependents', () => {
|
|
@@ -94,9 +94,9 @@ describe('ImpactAnalyzer', () => {
|
|
|
94
94
|
['C', 'nothing'],
|
|
95
95
|
])
|
|
96
96
|
const analyzer = new ImpactAnalyzer(graph)
|
|
97
|
-
const result = analyzer.analyze(['fn:src/
|
|
98
|
-
expect(result.impacted).toContain('fn:src/
|
|
99
|
-
expect(result.impacted).toContain('fn:src/
|
|
97
|
+
const result = analyzer.analyze(['fn:src/c.ts:c'])
|
|
98
|
+
expect(result.impacted).toContain('fn:src/b.ts:b')
|
|
99
|
+
expect(result.impacted).toContain('fn:src/a.ts:a')
|
|
100
100
|
})
|
|
101
101
|
|
|
102
102
|
it('reports correct depth', () => {
|
|
@@ -107,7 +107,7 @@ describe('ImpactAnalyzer', () => {
|
|
|
107
107
|
['D', 'nothing'],
|
|
108
108
|
])
|
|
109
109
|
const analyzer = new ImpactAnalyzer(graph)
|
|
110
|
-
const result = analyzer.analyze(['fn:src/
|
|
110
|
+
const result = analyzer.analyze(['fn:src/d.ts:d'])
|
|
111
111
|
expect(result.depth).toBeGreaterThanOrEqual(3)
|
|
112
112
|
})
|
|
113
113
|
|
|
@@ -117,8 +117,8 @@ describe('ImpactAnalyzer', () => {
|
|
|
117
117
|
['B', 'nothing'],
|
|
118
118
|
])
|
|
119
119
|
const analyzer = new ImpactAnalyzer(graph)
|
|
120
|
-
const result = analyzer.analyze(['fn:src/
|
|
121
|
-
expect(result.confidence).
|
|
120
|
+
const result = analyzer.analyze(['fn:src/b.ts:b'])
|
|
121
|
+
expect(result.confidence).toBeGreaterThanOrEqual(0.8)
|
|
122
122
|
})
|
|
123
123
|
|
|
124
124
|
it('does not include changed nodes in impacted', () => {
|
|
@@ -127,9 +127,9 @@ describe('ImpactAnalyzer', () => {
|
|
|
127
127
|
['B', 'nothing'],
|
|
128
128
|
])
|
|
129
129
|
const analyzer = new ImpactAnalyzer(graph)
|
|
130
|
-
const result = analyzer.analyze(['fn:src/
|
|
131
|
-
expect(result.impacted).not.toContain('fn:src/
|
|
132
|
-
expect(result.changed).toContain('fn:src/
|
|
130
|
+
const result = analyzer.analyze(['fn:src/b.ts:b'])
|
|
131
|
+
expect(result.impacted).not.toContain('fn:src/b.ts:b')
|
|
132
|
+
expect(result.changed).toContain('fn:src/b.ts:b')
|
|
133
133
|
})
|
|
134
134
|
})
|
|
135
135
|
|
package/tests/helpers.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { hashContent } from '../src/hash/file-hasher'
|
|
2
|
-
import type { ParsedFile, ParsedFunction, ParsedImport } from '../src/parser/types'
|
|
2
|
+
import type { ParsedFile, ParsedFunction, ParsedImport, CallExpression } from '../src/parser/types'
|
|
3
3
|
import { GraphBuilder } from '../src/graph/graph-builder'
|
|
4
4
|
import type { DependencyGraph } from '../src/graph/types'
|
|
5
5
|
|
|
@@ -10,7 +10,7 @@ export function mockParsedFile(
|
|
|
10
10
|
imports: ParsedImport[] = []
|
|
11
11
|
): ParsedFile {
|
|
12
12
|
return {
|
|
13
|
-
path: filePath,
|
|
13
|
+
path: filePath.replace(/\\/g, '/'),
|
|
14
14
|
language: 'typescript',
|
|
15
15
|
functions,
|
|
16
16
|
classes: [],
|
|
@@ -26,25 +26,30 @@ export function mockParsedFile(
|
|
|
26
26
|
/** Build a minimal ParsedFunction */
|
|
27
27
|
export function mockFunction(
|
|
28
28
|
name: string,
|
|
29
|
-
calls: string[] = [],
|
|
29
|
+
calls: (string | CallExpression)[] = [],
|
|
30
30
|
file: string = 'src/test.ts',
|
|
31
31
|
isExported: boolean = false
|
|
32
32
|
): ParsedFunction {
|
|
33
|
+
const mappedCalls: CallExpression[] = calls.map(c =>
|
|
34
|
+
typeof c === 'string' ? { name: c, line: 1, type: 'function' } : c
|
|
35
|
+
)
|
|
36
|
+
const normalizedPath = file.replace(/\\/g, '/')
|
|
33
37
|
return {
|
|
34
|
-
id: `fn:${
|
|
38
|
+
id: `fn:${normalizedPath}:${name}`.toLowerCase(),
|
|
35
39
|
name,
|
|
36
|
-
file,
|
|
40
|
+
file: normalizedPath,
|
|
37
41
|
startLine: 1,
|
|
38
42
|
endLine: 10,
|
|
39
43
|
params: [],
|
|
40
44
|
returnType: 'void',
|
|
41
45
|
isExported,
|
|
42
46
|
isAsync: false,
|
|
43
|
-
calls,
|
|
47
|
+
calls: mappedCalls,
|
|
44
48
|
hash: hashContent(name),
|
|
45
49
|
purpose: '',
|
|
46
50
|
edgeCasesHandled: [],
|
|
47
51
|
errorHandling: [],
|
|
52
|
+
detailedLines: [],
|
|
48
53
|
}
|
|
49
54
|
}
|
|
50
55
|
|