@getmikk/core 2.0.14 → 2.0.16
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/README.md +4 -4
- package/package.json +2 -1
- package/src/analysis/type-flow.ts +1 -1
- package/src/cache/incremental-cache.ts +86 -80
- package/src/contract/contract-reader.ts +1 -0
- package/src/contract/lock-compiler.ts +95 -13
- package/src/contract/schema.ts +2 -0
- package/src/error-handler.ts +2 -1
- package/src/graph/cluster-detector.ts +2 -4
- package/src/graph/dead-code-detector.ts +303 -117
- package/src/graph/graph-builder.ts +21 -161
- package/src/graph/impact-analyzer.ts +1 -0
- package/src/graph/index.ts +2 -0
- package/src/graph/rich-function-index.ts +1080 -0
- package/src/graph/symbol-table.ts +252 -0
- package/src/hash/hash-store.ts +1 -0
- package/src/index.ts +2 -0
- package/src/parser/base-extractor.ts +19 -0
- package/src/parser/boundary-checker.ts +31 -12
- package/src/parser/error-recovery.ts +5 -4
- package/src/parser/function-body-extractor.ts +248 -0
- package/src/parser/go/go-extractor.ts +249 -676
- package/src/parser/index.ts +132 -318
- package/src/parser/language-registry.ts +57 -0
- package/src/parser/oxc-parser.ts +166 -28
- package/src/parser/oxc-resolver.ts +179 -11
- package/src/parser/parser-constants.ts +1 -0
- package/src/parser/rust/rust-extractor.ts +109 -0
- package/src/parser/tree-sitter/parser.ts +369 -62
- package/src/parser/tree-sitter/queries.ts +106 -10
- package/src/parser/types.ts +20 -1
- package/src/search/bm25.ts +21 -8
- package/src/search/direct-search.ts +472 -0
- package/src/search/embedding-provider.ts +249 -0
- package/src/search/index.ts +12 -0
- package/src/search/semantic-search.ts +435 -0
- package/src/utils/artifact-transaction.ts +1 -0
- package/src/utils/atomic-write.ts +1 -0
- package/src/utils/errors.ts +89 -4
- package/src/utils/fs.ts +104 -50
- package/src/utils/json.ts +1 -0
- package/src/utils/language-registry.ts +84 -6
- package/src/utils/path.ts +26 -0
- package/tests/dead-code.test.ts +3 -2
- package/tests/direct-search.test.ts +435 -0
- package/tests/error-recovery.test.ts +143 -0
- package/tests/fixtures/simple-api/src/index.ts +1 -1
- package/tests/go-parser.test.ts +19 -335
- package/tests/js-parser.test.ts +18 -1089
- package/tests/language-registry-all.test.ts +276 -0
- package/tests/language-registry.test.ts +6 -4
- package/tests/parse-diagnostics.test.ts +9 -96
- package/tests/parser.test.ts +42 -771
- package/tests/polyglot-parser.test.ts +117 -0
- package/tests/rich-function-index.test.ts +703 -0
- package/tests/tree-sitter-parser.test.ts +108 -80
- package/tests/ts-parser.test.ts +8 -8
- package/tests/verification.test.ts +175 -0
- package/src/parser/base-parser.ts +0 -16
- package/src/parser/go/go-parser.ts +0 -43
- package/src/parser/javascript/js-extractor.ts +0 -278
- package/src/parser/javascript/js-parser.ts +0 -101
- package/src/parser/typescript/ts-extractor.ts +0 -447
- package/src/parser/typescript/ts-parser.ts +0 -36
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import type { ParsedFile, ParsedImport } from '../parser/types.js'
|
|
2
|
+
import { normalizePathQuiet } from '../utils/path.js'
|
|
3
|
+
|
|
4
|
+
export interface SymbolDefinition {
|
|
5
|
+
id: string
|
|
6
|
+
name: string
|
|
7
|
+
type: 'function' | 'class' | 'variable' | 'interface' | 'type' | 'enum'
|
|
8
|
+
file: string
|
|
9
|
+
isExported: boolean
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface ImportAlias {
|
|
13
|
+
localName: string
|
|
14
|
+
exportedName: string
|
|
15
|
+
sourcePath: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* GlobalSymbolTable — The brain of Mikk's semantic reasoning.
|
|
20
|
+
* Indexes all exported symbols and provides resolution logic for calls.
|
|
21
|
+
* Supports: named imports, default imports, aliased imports, local symbols, and fuzzy matching.
|
|
22
|
+
*
|
|
23
|
+
* Case sensitivity: Symbol names are case-sensitive for accurate resolution.
|
|
24
|
+
* Fallback to case-insensitive matching only when no exact match exists.
|
|
25
|
+
*/
|
|
26
|
+
export class GlobalSymbolTable {
|
|
27
|
+
private exportsByFile = new Map<string, Map<string, SymbolDefinition>>()
|
|
28
|
+
private symbolsByName = new Map<string, SymbolDefinition[]>()
|
|
29
|
+
private importAliases: ImportAlias[] = []
|
|
30
|
+
private reexportsByFile = new Map<string, Map<string, string>>()
|
|
31
|
+
private resolveCache = new Map<string, string | null>()
|
|
32
|
+
private resolveCacheOrder: string[] = []
|
|
33
|
+
private importsByFile = new Map<string, ParsedImport[]>()
|
|
34
|
+
|
|
35
|
+
private static readonly MAX_RESOLVE_CACHE_SIZE = 10000
|
|
36
|
+
|
|
37
|
+
private cacheResolve(key: string, value: string | null): void {
|
|
38
|
+
if (this.resolveCache.size >= GlobalSymbolTable.MAX_RESOLVE_CACHE_SIZE) {
|
|
39
|
+
const oldestKey = this.resolveCacheOrder.shift()
|
|
40
|
+
if (oldestKey) this.resolveCache.delete(oldestKey)
|
|
41
|
+
}
|
|
42
|
+
this.resolveCache.set(key, value)
|
|
43
|
+
this.resolveCacheOrder.push(key)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
register(file: ParsedFile): void {
|
|
47
|
+
const fileExports = new Map<string, SymbolDefinition>()
|
|
48
|
+
const filePath = normalizePathQuiet(file.path)
|
|
49
|
+
const fileReexports = new Map<string, string>()
|
|
50
|
+
|
|
51
|
+
const registerSymbol = (name: string, type: SymbolDefinition['type'], id: string, isExported: boolean) => {
|
|
52
|
+
const def: SymbolDefinition = { id, name, type, file: filePath, isExported }
|
|
53
|
+
|
|
54
|
+
if (isExported) {
|
|
55
|
+
fileExports.set(name, def)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const byName = this.symbolsByName.get(name) ?? []
|
|
59
|
+
byName.push(def)
|
|
60
|
+
this.symbolsByName.set(name, byName)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
for (const fn of file.functions) registerSymbol(fn.name, 'function', fn.id, fn.isExported)
|
|
64
|
+
for (const cls of file.classes ?? []) {
|
|
65
|
+
registerSymbol(cls.name, 'class', cls.id, cls.isExported)
|
|
66
|
+
for (const m of cls.methods) {
|
|
67
|
+
const fullName = `${cls.name}.${m.name}`
|
|
68
|
+
registerSymbol(fullName, 'function', m.id, false)
|
|
69
|
+
const byName = this.symbolsByName.get(m.name) ?? []
|
|
70
|
+
byName.push({ id: m.id, name: m.name, type: 'function', file: filePath, isExported: false })
|
|
71
|
+
this.symbolsByName.set(m.name, byName)
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
for (const gen of file.generics ?? []) {
|
|
75
|
+
const validType = gen.type === 'interface' || gen.type === 'type' ? gen.type : 'type'
|
|
76
|
+
registerSymbol(gen.name, validType, gen.id, gen.isExported)
|
|
77
|
+
}
|
|
78
|
+
for (const v of file.variables ?? []) registerSymbol(v.name, 'variable', v.id, v.isExported)
|
|
79
|
+
|
|
80
|
+
for (const imp of file.imports) {
|
|
81
|
+
if (!imp.resolvedPath || imp.isDynamic) continue
|
|
82
|
+
const resolvedPath = normalizePathQuiet(imp.resolvedPath)
|
|
83
|
+
for (const spec of imp.specifiers ?? []) {
|
|
84
|
+
if (spec.imported && spec.local && spec.imported !== spec.local) {
|
|
85
|
+
this.importAliases.push({
|
|
86
|
+
localName: spec.local,
|
|
87
|
+
exportedName: spec.imported,
|
|
88
|
+
sourcePath: resolvedPath,
|
|
89
|
+
})
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
for (const re of file.reexports ?? []) {
|
|
95
|
+
if (re.sourceResolved) {
|
|
96
|
+
fileReexports.set(re.name, normalizePathQuiet(re.sourceResolved))
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
this.exportsByFile.set(filePath, fileExports)
|
|
101
|
+
if (fileReexports.size > 0) {
|
|
102
|
+
this.reexportsByFile.set(filePath, fileReexports)
|
|
103
|
+
}
|
|
104
|
+
const fileImports = file.imports.filter(i => i.resolvedPath && !i.isDynamic)
|
|
105
|
+
this.importsByFile.set(filePath, fileImports)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
resolve(callName: string, contextFile: string, imports: ParsedImport[]): string | null {
|
|
109
|
+
const hasDot = callName.includes('.')
|
|
110
|
+
const receiver = hasDot ? callName.split('.')[0] : null
|
|
111
|
+
const simpleName = hasDot ? callName.split('.').pop()! : callName
|
|
112
|
+
const normalizedContextFile = normalizePathQuiet(contextFile)
|
|
113
|
+
|
|
114
|
+
const cacheKey = `${normalizedContextFile}:${callName}`
|
|
115
|
+
if (this.resolveCache.has(cacheKey)) {
|
|
116
|
+
return this.resolveCache.get(cacheKey)!
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const importsToUse = this.importsByFile.get(normalizedContextFile) ?? imports.filter(i => i.resolvedPath && !i.isDynamic)
|
|
120
|
+
let result: string | null = null
|
|
121
|
+
|
|
122
|
+
for (const imp of importsToUse) {
|
|
123
|
+
if (!imp.resolvedPath) continue
|
|
124
|
+
const resolvedPath = normalizePathQuiet(imp.resolvedPath)
|
|
125
|
+
|
|
126
|
+
if (imp.isDynamic) continue
|
|
127
|
+
|
|
128
|
+
if (hasDot && receiver) {
|
|
129
|
+
if (imp.isDefault && imp.names.some(n => n === receiver || n.toLowerCase() === receiver.toLowerCase())) {
|
|
130
|
+
const target = this.getExport(resolvedPath, simpleName)
|
|
131
|
+
if (target) { result = target.id; break }
|
|
132
|
+
const methodTarget = this.getMethodByClassCaseInsensitive(resolvedPath, receiver, simpleName)
|
|
133
|
+
if (methodTarget) { result = methodTarget; break }
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const alias = this.importAliases.find(
|
|
137
|
+
a => a.localName === receiver && a.sourcePath === resolvedPath
|
|
138
|
+
)
|
|
139
|
+
if (alias) {
|
|
140
|
+
const target = this.getExport(resolvedPath, alias.exportedName)
|
|
141
|
+
if (target) { result = target.id; break }
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const receiverLower = receiver.toLowerCase()
|
|
145
|
+
const aliasLower = this.importAliases.find(
|
|
146
|
+
a => a.localName.toLowerCase() === receiverLower && a.sourcePath === resolvedPath
|
|
147
|
+
)
|
|
148
|
+
if (aliasLower) {
|
|
149
|
+
const target = this.getExport(resolvedPath, aliasLower.exportedName)
|
|
150
|
+
if (target) { result = target.id; break }
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const methodTarget = this.getMethodByClassCaseInsensitive(resolvedPath, receiver, simpleName)
|
|
154
|
+
if (methodTarget) { result = methodTarget; break }
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const matchedLocal = imp.names.find(n =>
|
|
158
|
+
n === callName ||
|
|
159
|
+
n === simpleName ||
|
|
160
|
+
n.toLowerCase() === callName.toLowerCase() ||
|
|
161
|
+
n.toLowerCase() === simpleName.toLowerCase() ||
|
|
162
|
+
this.importAliases.some(a => a.localName === n && (a.exportedName === callName || a.exportedName === simpleName)) ||
|
|
163
|
+
this.importAliases.some(a => a.localName.toLowerCase() === n.toLowerCase() && (a.exportedName.toLowerCase() === callName.toLowerCase() || a.exportedName.toLowerCase() === simpleName.toLowerCase()))
|
|
164
|
+
)
|
|
165
|
+
if (matchedLocal) {
|
|
166
|
+
const alias = this.importAliases.find(a => a.localName === matchedLocal)
|
|
167
|
+
const exportedName = alias ? alias.exportedName : simpleName
|
|
168
|
+
const target = this.getExport(resolvedPath, exportedName)
|
|
169
|
+
if (target) { result = target.id; break }
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (!result) {
|
|
174
|
+
const localTarget = this.getExport(normalizedContextFile, callName)
|
|
175
|
+
if (localTarget) { result = localTarget.id }
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (!result) {
|
|
179
|
+
const globalMatches = this.symbolsByName.get(callName) ?? this.symbolsByName.get(simpleName)
|
|
180
|
+
if (globalMatches?.length === 1) {
|
|
181
|
+
result = globalMatches[0].id
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (!result) {
|
|
186
|
+
const lowerCallName = callName.toLowerCase()
|
|
187
|
+
const lowerSimpleName = simpleName.toLowerCase()
|
|
188
|
+
const lowerGlobalMatches = this.symbolsByName.get(lowerCallName) ?? this.symbolsByName.get(lowerSimpleName)
|
|
189
|
+
if (lowerGlobalMatches?.length === 1) {
|
|
190
|
+
result = lowerGlobalMatches[0].id
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (!result && hasDot && receiver) {
|
|
195
|
+
const methodTarget = this.getMethodGlobally(receiver, simpleName)
|
|
196
|
+
if (methodTarget) { result = methodTarget }
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
this.cacheResolve(cacheKey, result)
|
|
200
|
+
return result
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
private getExport(filePath: string, name: string, visited: Set<string> = new Set()): SymbolDefinition | undefined {
|
|
204
|
+
if (visited.has(filePath + ':' + name)) return undefined
|
|
205
|
+
visited.add(filePath + ':' + name)
|
|
206
|
+
|
|
207
|
+
const exports = this.exportsByFile.get(filePath)
|
|
208
|
+
if (exports) {
|
|
209
|
+
const def = exports.get(name) ?? exports.get(name.toLowerCase())
|
|
210
|
+
if (def) return def
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const reexports = this.reexportsByFile.get(filePath)
|
|
214
|
+
if (reexports) {
|
|
215
|
+
const reexportPath = reexports.get(name) ?? reexports.get(name.toLowerCase())
|
|
216
|
+
if (reexportPath) {
|
|
217
|
+
return this.getExport(reexportPath, name, visited)
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return undefined
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
private getMethodByClassCaseInsensitive(filePath: string, className: string, methodName: string): string | null {
|
|
225
|
+
const exports = this.exportsByFile.get(filePath)
|
|
226
|
+
if (!exports) return null
|
|
227
|
+
|
|
228
|
+
for (const [name, def] of exports) {
|
|
229
|
+
if (def.type === 'class' && name.toLowerCase() === className.toLowerCase()) {
|
|
230
|
+
const methodFullName = `${name}.${methodName}`
|
|
231
|
+
const method = this.symbolsByName.get(methodFullName)
|
|
232
|
+
if (method?.length === 1) return method[0].id
|
|
233
|
+
|
|
234
|
+
const methodLowerName = `${name.toLowerCase()}.${methodName.toLowerCase()}`
|
|
235
|
+
const methodLower = this.symbolsByName.get(methodLowerName)
|
|
236
|
+
if (methodLower?.length === 1) return methodLower[0].id
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
return null
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
private getMethodGlobally(className: string, methodName: string): string | null {
|
|
243
|
+
for (const [name, defs] of this.symbolsByName) {
|
|
244
|
+
if (defs[0]?.type === 'class' && name.toLowerCase() === className.toLowerCase()) {
|
|
245
|
+
const methodFullName = `${name}.${methodName}`
|
|
246
|
+
const method = this.symbolsByName.get(methodFullName)
|
|
247
|
+
if (method?.length === 1) return method[0].id
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
return null
|
|
251
|
+
}
|
|
252
|
+
}
|
package/src/hash/hash-store.ts
CHANGED
package/src/index.ts
CHANGED
|
@@ -8,6 +8,7 @@ export * from './hash/index.js'
|
|
|
8
8
|
export * from './search/index.js'
|
|
9
9
|
export * from './cache/index.js'
|
|
10
10
|
export * from './security/index.js'
|
|
11
|
+
export * from './analysis/index.js'
|
|
11
12
|
export * from './utils/errors.js'
|
|
12
13
|
export * from './utils/logger.js'
|
|
13
14
|
export { MikkError, ErrorHandler, ErrorBuilder, ErrorCategory, FileSystemError, ModuleLoadError, GraphError, TokenBudgetError, ValidationError, createDefaultErrorListener, createFileNotFoundError, createFileTooLargeError, createPermissionDeniedError, createModuleNotFoundError, createModuleLoadFailedError, createGraphBuildFailedError, createNodeNotFoundError, createTokenBudgetExceededError, createValidationError, isMikkError, getRootCause, toMikkError } from './error-handler.js'
|
|
@@ -18,6 +19,7 @@ export { minimatch } from './utils/minimatch.js'
|
|
|
18
19
|
export { scoreFunctions, findFuzzyMatches, levenshtein, splitCamelCase, extractKeywords } from './utils/fuzzy-match.js'
|
|
19
20
|
export { writeFileAtomic, writeJsonAtomic } from './utils/atomic-write.js'
|
|
20
21
|
export type { AtomicWriteOptions } from './utils/atomic-write.js'
|
|
22
|
+
export { normalizeSlashes, normalizePath, normalizePathQuiet, getPathKey, pathsEqual, isSubPath } from './utils/path.js'
|
|
21
23
|
export {
|
|
22
24
|
runArtifactWriteTransaction,
|
|
23
25
|
recoverArtifactWriteTransactions,
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { ParsedFile } from './types.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Abstract base class for language-specific metadata extraction.
|
|
5
|
+
* Extractors can use different engines (Tree-Sitter, OXC, etc.) internally.
|
|
6
|
+
*/
|
|
7
|
+
export abstract class BaseExtractor {
|
|
8
|
+
/**
|
|
9
|
+
* Main entry point to parse and extract metadata from a file
|
|
10
|
+
*/
|
|
11
|
+
abstract extract(filePath: string, content: string): Promise<ParsedFile>;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Resolve imports for a list of files (optional, default to simple pass-through)
|
|
15
|
+
*/
|
|
16
|
+
async resolveImports(files: ParsedFile[], _projectRoot: string): Promise<ParsedFile[]> {
|
|
17
|
+
return files;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -53,21 +53,40 @@ function parseConstraint(constraint: string): ParsedRule | null {
|
|
|
53
53
|
export class BoundaryChecker {
|
|
54
54
|
private rules: ParsedRule[]
|
|
55
55
|
private moduleNames: Map<string, string>
|
|
56
|
+
private fileModuleMap: Map<string, string>
|
|
56
57
|
|
|
57
58
|
constructor(private contract: MikkContract, private lock: MikkLock) {
|
|
58
59
|
this.rules = contract.declared.constraints.map(parseConstraint).filter((r): r is ParsedRule => r !== null)
|
|
59
60
|
this.moduleNames = new Map(contract.declared.modules.map(m => [m.id, m.name]))
|
|
61
|
+
|
|
62
|
+
this.fileModuleMap = new Map()
|
|
63
|
+
for (const [filePath, fileData] of Object.entries(this.lock.files)) {
|
|
64
|
+
if (fileData.moduleId && fileData.moduleId !== 'unknown') {
|
|
65
|
+
this.fileModuleMap.set(filePath.toLowerCase(), fileData.moduleId)
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
private getModuleId(fn: any): string {
|
|
71
|
+
if (fn.moduleId && fn.moduleId !== 'unknown') return fn.moduleId
|
|
72
|
+
const normalized = (fn.file ?? '').replace(/\\/g, '/').toLowerCase()
|
|
73
|
+
return this.fileModuleMap.get(normalized) ?? 'unknown'
|
|
60
74
|
}
|
|
61
75
|
|
|
62
76
|
check(): BoundaryCheckResult {
|
|
63
77
|
const violations: BoundaryViolation[] = []
|
|
78
|
+
const fnIndex = (this.lock as any).fnIndex ?? []
|
|
64
79
|
|
|
65
80
|
for (const fn of Object.values(this.lock.functions)) {
|
|
66
|
-
|
|
67
|
-
|
|
81
|
+
const fnModuleId = this.getModuleId(fn)
|
|
82
|
+
if (fnModuleId === 'unknown') continue
|
|
83
|
+
const calls = (fn.calls ?? []).map((c: any) => typeof c === 'number' ? fnIndex[c] : c).filter(Boolean)
|
|
84
|
+
for (const calleeId of calls) {
|
|
68
85
|
const callee = this.lock.functions[calleeId]
|
|
69
|
-
if (!callee
|
|
70
|
-
const
|
|
86
|
+
if (!callee) continue
|
|
87
|
+
const calleeModuleId = this.getModuleId(callee)
|
|
88
|
+
if (calleeModuleId === 'unknown' || fnModuleId === calleeModuleId) continue
|
|
89
|
+
const v = this.checkCall(fn, callee, fnModuleId, calleeModuleId)
|
|
71
90
|
if (v) violations.push(v)
|
|
72
91
|
}
|
|
73
92
|
}
|
|
@@ -97,21 +116,21 @@ export class BoundaryChecker {
|
|
|
97
116
|
const errorCount = unique.filter(v => v.severity === 'error').length
|
|
98
117
|
const warnCount = unique.filter(v => v.severity === 'warning').length
|
|
99
118
|
const summary = unique.length === 0
|
|
100
|
-
? `All module boundaries respected (${fnCount}
|
|
101
|
-
: `${errorCount}
|
|
119
|
+
? `All module boundaries respected (${fnCount} fns, ${fileCount} files checked)`
|
|
120
|
+
: `${errorCount} error(s), ${warnCount} warning(s) found`
|
|
102
121
|
|
|
103
122
|
return { pass: errorCount === 0, violations: unique, summary }
|
|
104
123
|
}
|
|
105
124
|
|
|
106
|
-
private checkCall(caller:
|
|
125
|
+
private checkCall(caller: any, callee: any, callerModuleId: string, calleeModuleId: string): BoundaryViolation | null {
|
|
107
126
|
for (const rule of this.rules) {
|
|
108
|
-
if (rule.fromModuleId !==
|
|
127
|
+
if (rule.fromModuleId !== callerModuleId) continue
|
|
109
128
|
const forbidden = rule.type === 'isolated' ? true
|
|
110
|
-
: rule.type === 'deny' ? rule.toModuleIds.includes(
|
|
111
|
-
: !rule.toModuleIds.includes(
|
|
129
|
+
: rule.type === 'deny' ? rule.toModuleIds.includes(calleeModuleId)
|
|
130
|
+
: !rule.toModuleIds.includes(calleeModuleId)
|
|
112
131
|
if (forbidden) return {
|
|
113
|
-
from: { functionId: caller.id, functionName: caller.name, file: caller.file, moduleId:
|
|
114
|
-
to: { functionId: callee.id, functionName: callee.name, file: callee.file, moduleId:
|
|
132
|
+
from: { functionId: caller.id, functionName: caller.name, file: caller.file, moduleId: callerModuleId, moduleName: this.moduleNames.get(callerModuleId) ?? callerModuleId },
|
|
133
|
+
to: { functionId: callee.id, functionName: callee.name, file: callee.file, moduleId: calleeModuleId, moduleName: this.moduleNames.get(calleeModuleId) ?? calleeModuleId },
|
|
115
134
|
rule: rule.raw, severity: 'error'
|
|
116
135
|
}
|
|
117
136
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import type { ParsedFile, ParsedFunction, ParsedClass, ParsedImport, ParsedParam
|
|
1
|
+
import type { ParsedFile, ParsedFunction, ParsedClass, ParsedImport, ParsedParam } from './types.js'
|
|
2
2
|
import * as path from 'node:path'
|
|
3
3
|
import { hashContent } from '../hash/file-hasher.js'
|
|
4
|
+
import { toParsedFileLanguage, type RegistryLanguage } from '../utils/language-registry.js'
|
|
4
5
|
|
|
5
6
|
// ---------------------------------------------------------------------------
|
|
6
7
|
// Error Recovery Engine — graceful degradation when parsing fails
|
|
@@ -64,7 +65,7 @@ export class ErrorRecoveryEngine {
|
|
|
64
65
|
strategy: 'regex-recovery',
|
|
65
66
|
parsed: {
|
|
66
67
|
path: filePath,
|
|
67
|
-
language: language as
|
|
68
|
+
language: toParsedFileLanguage(language as RegistryLanguage),
|
|
68
69
|
hash: hashContent(content),
|
|
69
70
|
parsedAt: Date.now(),
|
|
70
71
|
functions,
|
|
@@ -92,7 +93,7 @@ export class ErrorRecoveryEngine {
|
|
|
92
93
|
strategy: 'minimal-fallback',
|
|
93
94
|
parsed: {
|
|
94
95
|
path: filePath,
|
|
95
|
-
language: language as
|
|
96
|
+
language: toParsedFileLanguage(language as RegistryLanguage),
|
|
96
97
|
hash: hashContent(content),
|
|
97
98
|
parsedAt: Date.now(),
|
|
98
99
|
functions: [],
|
|
@@ -510,7 +511,7 @@ export class ErrorRecoveryEngine {
|
|
|
510
511
|
lines: string[],
|
|
511
512
|
functions: ParsedFunction[],
|
|
512
513
|
classes: ParsedClass[],
|
|
513
|
-
|
|
514
|
+
_imports: ParsedImport[]
|
|
514
515
|
): void {
|
|
515
516
|
const funcPatterns = [
|
|
516
517
|
/function\s+(\w+)\s*\(/,
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises'
|
|
2
|
+
import { existsSync } from 'node:fs'
|
|
3
|
+
import type { RichFunction } from '../graph/rich-function-index.js'
|
|
4
|
+
|
|
5
|
+
export interface FunctionBody {
|
|
6
|
+
id: string
|
|
7
|
+
name: string
|
|
8
|
+
file: string
|
|
9
|
+
startLine: number
|
|
10
|
+
endLine: number
|
|
11
|
+
body: string
|
|
12
|
+
fullSource: string
|
|
13
|
+
isComplete: boolean
|
|
14
|
+
trimmedLines: number
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface BodyExtractOptions {
|
|
18
|
+
maxLines?: number
|
|
19
|
+
includeComments?: boolean
|
|
20
|
+
includeImports?: boolean
|
|
21
|
+
contextLines?: number
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export class FunctionBodyExtractor {
|
|
25
|
+
private cache: Map<string, string> = new Map()
|
|
26
|
+
private bodyCache: Map<string, FunctionBody> = new Map()
|
|
27
|
+
|
|
28
|
+
async extractBody(fn: RichFunction, options: BodyExtractOptions = {}): Promise<FunctionBody | null> {
|
|
29
|
+
const cacheKey = `${fn.id}:${fn.startLine}:${fn.endLine}`
|
|
30
|
+
|
|
31
|
+
if (this.bodyCache.has(cacheKey)) {
|
|
32
|
+
return this.bodyCache.get(cacheKey)!
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const { maxLines = 500, contextLines = 0 } = options
|
|
36
|
+
|
|
37
|
+
const source = await this.readSource(fn.file)
|
|
38
|
+
if (!source) {
|
|
39
|
+
return null
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const lines = source.split('\n')
|
|
43
|
+
|
|
44
|
+
const startLine = Math.max(0, fn.startLine - 1 - contextLines)
|
|
45
|
+
const endLine = Math.min(lines.length, fn.endLine + contextLines)
|
|
46
|
+
|
|
47
|
+
const bodyLines = lines.slice(startLine, endLine)
|
|
48
|
+
const body = bodyLines.join('\n')
|
|
49
|
+
|
|
50
|
+
const isComplete = fn.endLine <= lines.length && bodyLines.length >= fn.endLine - fn.startLine
|
|
51
|
+
|
|
52
|
+
const result: FunctionBody = {
|
|
53
|
+
id: fn.id,
|
|
54
|
+
name: fn.name,
|
|
55
|
+
file: fn.file,
|
|
56
|
+
startLine: fn.startLine,
|
|
57
|
+
endLine: fn.endLine,
|
|
58
|
+
body,
|
|
59
|
+
fullSource: source,
|
|
60
|
+
isComplete,
|
|
61
|
+
trimmedLines: lines.length - endLine,
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
this.bodyCache.set(cacheKey, result)
|
|
65
|
+
return result
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async extractBodies(fns: RichFunction[], options: BodyExtractOptions = {}): Promise<Map<string, FunctionBody>> {
|
|
69
|
+
const results = new Map<string, FunctionBody>()
|
|
70
|
+
|
|
71
|
+
for (const fn of fns) {
|
|
72
|
+
const body = await this.extractBody(fn, options)
|
|
73
|
+
if (body) {
|
|
74
|
+
results.set(fn.id, body)
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return results
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async extractBodiesByIds(ids: string[], getFn: (id: string) => RichFunction | undefined, options: BodyExtractOptions = {}): Promise<Map<string, FunctionBody>> {
|
|
82
|
+
const results = new Map<string, FunctionBody>()
|
|
83
|
+
|
|
84
|
+
const fns = ids.map(id => getFn(id)).filter(Boolean) as RichFunction[]
|
|
85
|
+
const bodies = await this.extractBodies(fns, options)
|
|
86
|
+
|
|
87
|
+
for (const [id, body] of bodies) {
|
|
88
|
+
results.set(id, body)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return results
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
private async readSource(filePath: string): Promise<string | null> {
|
|
95
|
+
if (this.cache.has(filePath)) {
|
|
96
|
+
return this.cache.get(filePath)!
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (!existsSync(filePath)) {
|
|
100
|
+
return null
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
try {
|
|
104
|
+
const content = await readFile(filePath, 'utf-8')
|
|
105
|
+
this.cache.set(filePath, content)
|
|
106
|
+
return content
|
|
107
|
+
} catch {
|
|
108
|
+
return null
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
clearCache(): void {
|
|
113
|
+
this.cache.clear()
|
|
114
|
+
this.bodyCache.clear()
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
getCachedCount(): number {
|
|
118
|
+
return this.cache.size
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
getBodyCacheCount(): number {
|
|
122
|
+
return this.bodyCache.size
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export interface ExtractResult {
|
|
127
|
+
success: boolean
|
|
128
|
+
body?: string
|
|
129
|
+
signature?: string
|
|
130
|
+
params?: string
|
|
131
|
+
returnType?: string
|
|
132
|
+
docComment?: string
|
|
133
|
+
error?: string
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export async function extractFunction(
|
|
137
|
+
filePath: string,
|
|
138
|
+
functionName: string,
|
|
139
|
+
options: BodyExtractOptions = {}
|
|
140
|
+
): Promise<ExtractResult> {
|
|
141
|
+
if (!existsSync(filePath)) {
|
|
142
|
+
return { success: false, error: 'File not found' }
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
try {
|
|
146
|
+
const content = await readFile(filePath, 'utf-8')
|
|
147
|
+
const lines = content.split('\n')
|
|
148
|
+
|
|
149
|
+
let startLine = -1
|
|
150
|
+
let endLine = -1
|
|
151
|
+
let braceCount = 0
|
|
152
|
+
let inFunction = false
|
|
153
|
+
let foundOpening = false
|
|
154
|
+
|
|
155
|
+
for (let i = 0; i < lines.length; i++) {
|
|
156
|
+
const line = lines[i]
|
|
157
|
+
|
|
158
|
+
if (!inFunction && (line.includes(`function ${functionName}`) || line.includes(`const ${functionName}`) || line.includes(`${functionName}(`) || line.includes(`async ${functionName}`))) {
|
|
159
|
+
if (line.includes(`function ${functionName}`) || line.includes(`async function ${functionName}`) || line.match(new RegExp(`(const|let|var)\\s+${functionName}\\s*=`)) || line.match(new RegExp(`${functionName}\\s*\\(`))) {
|
|
160
|
+
inFunction = true
|
|
161
|
+
startLine = i
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (inFunction) {
|
|
166
|
+
for (const char of line) {
|
|
167
|
+
if (char === '{') {
|
|
168
|
+
braceCount++
|
|
169
|
+
foundOpening = true
|
|
170
|
+
} else if (char === '}') {
|
|
171
|
+
braceCount--
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (foundOpening && braceCount === 0) {
|
|
176
|
+
endLine = i + 1
|
|
177
|
+
break
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (startLine === -1 || endLine === -1) {
|
|
183
|
+
return { success: false, error: 'Function not found' }
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const bodyLines = lines.slice(startLine, endLine)
|
|
187
|
+
const body = bodyLines.join('\n')
|
|
188
|
+
|
|
189
|
+
const maxLines = options.maxLines || 500
|
|
190
|
+
const trimmedBody = bodyLines.length > maxLines
|
|
191
|
+
? bodyLines.slice(0, maxLines).join('\n') + '\n// ... (truncated)'
|
|
192
|
+
: body
|
|
193
|
+
|
|
194
|
+
return {
|
|
195
|
+
success: true,
|
|
196
|
+
body: trimmedBody,
|
|
197
|
+
signature: extractSignature(bodyLines[0] || ''),
|
|
198
|
+
docComment: extractDocComment(lines, startLine),
|
|
199
|
+
}
|
|
200
|
+
} catch (err: any) {
|
|
201
|
+
return { success: false, error: err.message }
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function extractSignature(line: string): string {
|
|
206
|
+
const match = line.match(/(?:async\s+)?(?:function\s+)?(?:\w+\s+)?(\w+)\s*\([^)]*\)\s*(?::\s*\w+)?/)
|
|
207
|
+
return match ? match[0] : line.trim()
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function extractDocComment(lines: string[], startLine: number): string | undefined {
|
|
211
|
+
if (startLine === 0) return undefined
|
|
212
|
+
|
|
213
|
+
const prevLine = lines[startLine - 1]
|
|
214
|
+
if (prevLine && (prevLine.includes('/**') || prevLine.includes('///') || prevLine.startsWith('//'))) {
|
|
215
|
+
const commentLines: string[] = []
|
|
216
|
+
let i = startLine - 1
|
|
217
|
+
|
|
218
|
+
while (i >= 0) {
|
|
219
|
+
const line = lines[i]
|
|
220
|
+
if (line.includes('/**') || line.includes("'''")) {
|
|
221
|
+
commentLines.unshift(line)
|
|
222
|
+
break
|
|
223
|
+
} else if (line.trim().startsWith('*') || line.trim().startsWith('//')) {
|
|
224
|
+
commentLines.unshift(line)
|
|
225
|
+
} else {
|
|
226
|
+
break
|
|
227
|
+
}
|
|
228
|
+
i--
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return commentLines.join('\n') || undefined
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return undefined
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export function trimBody(body: string, maxLines: number): string {
|
|
238
|
+
const lines = body.split('\n')
|
|
239
|
+
if (lines.length <= maxLines) return body
|
|
240
|
+
|
|
241
|
+
return lines.slice(0, maxLines).join('\n') + '\n// ...'
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export function getBodyPreview(body: string, maxChars: number = 200): string {
|
|
245
|
+
const firstLine = body.split('\n')[0]
|
|
246
|
+
if (firstLine.length <= maxChars) return firstLine
|
|
247
|
+
return firstLine.slice(0, maxChars) + '...'
|
|
248
|
+
}
|