@getmikk/core 1.8.1 → 1.8.3
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/contract-reader.ts +9 -9
- package/src/contract/lock-compiler.ts +8 -7
- package/src/contract/lock-reader.ts +9 -6
- package/src/contract/schema.ts +6 -1
- package/src/graph/dead-code-detector.ts +134 -53
- package/src/graph/graph-builder.ts +216 -61
- package/src/graph/impact-analyzer.ts +59 -21
- package/src/graph/types.ts +1 -0
- package/src/parser/boundary-checker.ts +3 -1
- package/src/parser/go/go-extractor.ts +10 -1
- package/src/parser/javascript/js-extractor.ts +22 -6
- package/src/parser/javascript/js-parser.ts +24 -17
- package/src/parser/javascript/js-resolver.ts +63 -22
- package/src/parser/parser-constants.ts +82 -0
- package/src/parser/tree-sitter/parser.ts +356 -69
- package/src/parser/types.ts +2 -0
- package/src/parser/typescript/ts-extractor.ts +109 -34
- package/src/parser/typescript/ts-parser.ts +22 -18
- package/src/parser/typescript/ts-resolver.ts +78 -45
- package/src/utils/json.ts +27 -0
- package/tests/js-parser.test.ts +23 -3
- package/tests/tree-sitter-parser.test.ts +11 -3
|
@@ -22,9 +22,10 @@ export class JavaScriptExtractor extends TypeScriptExtractor {
|
|
|
22
22
|
/** ESM functions + module.exports-assigned functions */
|
|
23
23
|
override extractFunctions(): ParsedFunction[] {
|
|
24
24
|
const fns = super.extractFunctions()
|
|
25
|
-
|
|
25
|
+
// Use id (which includes start line) to avoid false deduplication
|
|
26
|
+
const seen = new Set(fns.map(f => f.id))
|
|
26
27
|
for (const fn of this.extractCommonJsFunctions()) {
|
|
27
|
-
if (!seen.has(fn.
|
|
28
|
+
if (!seen.has(fn.id)) { fns.push(fn); seen.add(fn.id) }
|
|
28
29
|
}
|
|
29
30
|
return fns
|
|
30
31
|
}
|
|
@@ -42,9 +43,15 @@ export class JavaScriptExtractor extends TypeScriptExtractor {
|
|
|
42
43
|
/** ESM exports + CommonJS module.exports / exports.x */
|
|
43
44
|
override extractExports(): ParsedExport[] {
|
|
44
45
|
const esm = super.extractExports()
|
|
45
|
-
|
|
46
|
+
// Index by name; for default exports use type as secondary key to avoid
|
|
47
|
+
// a local function named 'default' from being incorrectly matched.
|
|
48
|
+
const seen = new Map(esm.map(e => [`${e.name}:${e.type}`, true]))
|
|
46
49
|
for (const exp of this.extractCommonJsExports()) {
|
|
47
|
-
|
|
50
|
+
const key = `${exp.name}:${exp.type}`
|
|
51
|
+
if (!seen.has(key)) {
|
|
52
|
+
esm.push(exp)
|
|
53
|
+
seen.set(key, true)
|
|
54
|
+
}
|
|
48
55
|
}
|
|
49
56
|
return esm
|
|
50
57
|
}
|
|
@@ -91,11 +98,20 @@ export class JavaScriptExtractor extends TypeScriptExtractor {
|
|
|
91
98
|
private getRequireBindingNames(call: ts.CallExpression): string[] {
|
|
92
99
|
const parent = call.parent
|
|
93
100
|
if (!parent || !ts.isVariableDeclaration(parent)) return []
|
|
94
|
-
// const { a, b } = require('...') → ['a', 'b']
|
|
101
|
+
// const { a: myA, b } = require('...') → ['a', 'b'] (use the SOURCE name, not the alias)
|
|
102
|
+
// The source name (propertyName) is what the module exports.
|
|
103
|
+
// The local alias (element.name) is only visible in this file.
|
|
95
104
|
if (ts.isObjectBindingPattern(parent.name)) {
|
|
96
105
|
return parent.name.elements
|
|
97
106
|
.filter(e => ts.isIdentifier(e.name))
|
|
98
|
-
.map(e =>
|
|
107
|
+
.map(e => {
|
|
108
|
+
// If there is a property name (the "a" in "a: myA"), use it.
|
|
109
|
+
// Otherwise the binding uses the same name for both sides.
|
|
110
|
+
if (e.propertyName && ts.isIdentifier(e.propertyName)) {
|
|
111
|
+
return e.propertyName.text
|
|
112
|
+
}
|
|
113
|
+
return (e.name as ts.Identifier).text
|
|
114
|
+
})
|
|
99
115
|
}
|
|
100
116
|
// const x = require('...') → ['x']
|
|
101
117
|
if (ts.isIdentifier(parent.name)) return [parent.name.text]
|
|
@@ -5,6 +5,7 @@ import { JavaScriptExtractor } from './js-extractor.js'
|
|
|
5
5
|
import { JavaScriptResolver } from './js-resolver.js'
|
|
6
6
|
import { hashContent } from '../../hash/file-hasher.js'
|
|
7
7
|
import type { ParsedFile } from '../types.js'
|
|
8
|
+
import { MIN_FILES_FOR_COMPLETE_SCAN, parseJsonWithComments } from '../parser-constants.js'
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
11
|
* JavaScriptParser -- implements BaseParser for .js / .mjs / .cjs / .jsx files.
|
|
@@ -18,18 +19,25 @@ export class JavaScriptParser extends BaseParser {
|
|
|
18
19
|
const extractor = new JavaScriptExtractor(filePath, content)
|
|
19
20
|
|
|
20
21
|
const functions = extractor.extractFunctions()
|
|
21
|
-
const classes
|
|
22
|
-
const generics
|
|
23
|
-
const imports
|
|
24
|
-
const exports
|
|
25
|
-
const routes
|
|
22
|
+
const classes = extractor.extractClasses()
|
|
23
|
+
const generics = extractor.extractGenerics()
|
|
24
|
+
const imports = extractor.extractImports()
|
|
25
|
+
const exports = extractor.extractExports()
|
|
26
|
+
const routes = extractor.extractRoutes()
|
|
26
27
|
|
|
27
28
|
// Cross-reference: CJS exports may mark a name exported even when the
|
|
28
29
|
// declaration itself had no `export` keyword.
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
30
|
+
//
|
|
31
|
+
// We only mark a symbol as exported when the export list contains an
|
|
32
|
+
// entry with BOTH a matching name AND a non-default type. This prevents
|
|
33
|
+
// `module.exports = function() {}` (which produces name='default', type='default')
|
|
34
|
+
// from accidentally marking an unrelated local function called 'default' as exported.
|
|
35
|
+
const exportedNonDefault = new Set(
|
|
36
|
+
exports.filter(e => e.type !== 'default').map(e => e.name)
|
|
37
|
+
)
|
|
38
|
+
for (const fn of functions) { if (!fn.isExported && exportedNonDefault.has(fn.name)) fn.isExported = true }
|
|
39
|
+
for (const cls of classes) { if (!cls.isExported && exportedNonDefault.has(cls.name)) cls.isExported = true }
|
|
40
|
+
for (const gen of generics) { if (!gen.isExported && exportedNonDefault.has(gen.name)) gen.isExported = true }
|
|
33
41
|
|
|
34
42
|
return {
|
|
35
43
|
path: filePath,
|
|
@@ -47,7 +55,10 @@ export class JavaScriptParser extends BaseParser {
|
|
|
47
55
|
|
|
48
56
|
resolveImports(files: ParsedFile[], projectRoot: string): ParsedFile[] {
|
|
49
57
|
const aliases = loadAliases(projectRoot)
|
|
50
|
-
|
|
58
|
+
// Only pass the file list when it represents a reasonably complete scan.
|
|
59
|
+
// A sparse list (< MIN_FILES_FOR_COMPLETE_SCAN files) causes valid alias-resolved
|
|
60
|
+
// imports to return '' because the target file is not in the partial list.
|
|
61
|
+
const allFilePaths = files.length >= MIN_FILES_FOR_COMPLETE_SCAN ? files.map(f => f.path) : []
|
|
51
62
|
const resolver = new JavaScriptResolver(projectRoot, aliases)
|
|
52
63
|
return files.map(file => ({
|
|
53
64
|
...file,
|
|
@@ -62,8 +73,7 @@ export class JavaScriptParser extends BaseParser {
|
|
|
62
73
|
|
|
63
74
|
/**
|
|
64
75
|
* Load path aliases from jsconfig.json → tsconfig.json → tsconfig.base.json.
|
|
65
|
-
* Strips
|
|
66
|
-
* Falls back to raw content if comment-stripping breaks a URL.
|
|
76
|
+
* Strips JSON5 comments via the shared helper and falls back to raw content if parsing fails.
|
|
67
77
|
* Returns {} when no config is found.
|
|
68
78
|
*/
|
|
69
79
|
function loadAliases(projectRoot: string): Record<string, string[]> {
|
|
@@ -71,12 +81,9 @@ function loadAliases(projectRoot: string): Record<string, string[]> {
|
|
|
71
81
|
const configPath = path.join(projectRoot, name)
|
|
72
82
|
try {
|
|
73
83
|
const raw = fs.readFileSync(configPath, 'utf-8')
|
|
74
|
-
const
|
|
75
|
-
let config: any
|
|
76
|
-
try { config = JSON.parse(stripped) }
|
|
77
|
-
catch { config = JSON.parse(raw) } // URL stripping may have broken JSON
|
|
84
|
+
const config: any = parseJsonWithComments(raw)
|
|
78
85
|
|
|
79
|
-
const options
|
|
86
|
+
const options = config.compilerOptions ?? {}
|
|
80
87
|
const rawPaths: Record<string, string[]> = options.paths ?? {}
|
|
81
88
|
if (Object.keys(rawPaths).length === 0) continue
|
|
82
89
|
|
|
@@ -7,11 +7,15 @@ import type { ParsedImport } from '../types.js'
|
|
|
7
7
|
* Handles:
|
|
8
8
|
* - Relative ESM imports: import './utils' → ./utils.js / ./utils/index.js / ...
|
|
9
9
|
* - CommonJS require(): require('./db') → same resolution order
|
|
10
|
-
* - Path aliases from jsconfig.json / tsconfig.json
|
|
10
|
+
* - Path aliases from jsconfig.json / tsconfig.json (all targets tried, not just first)
|
|
11
11
|
* - Mixed TS/JS projects: falls back to .ts/.tsx if no JS file matched
|
|
12
12
|
*
|
|
13
13
|
* Extension probe order: .js → .jsx → .mjs → .cjs → index.js → index.jsx →
|
|
14
14
|
* .ts → .tsx → index.ts → index.tsx
|
|
15
|
+
*
|
|
16
|
+
* Performance: allProjectFiles is converted to a Set internally for O(1) membership
|
|
17
|
+
* checks. Pass the same array every call — the Set is built per-call and is cheap
|
|
18
|
+
* for the sizes we deal with, or cache the Set externally and pass it directly.
|
|
15
19
|
*/
|
|
16
20
|
export class JavaScriptResolver {
|
|
17
21
|
constructor(
|
|
@@ -28,39 +32,77 @@ export class JavaScriptResolver {
|
|
|
28
32
|
) {
|
|
29
33
|
return { ...imp, resolvedPath: '' }
|
|
30
34
|
}
|
|
31
|
-
|
|
35
|
+
// Build Set once per resolve call. For large repos callers should cache this.
|
|
36
|
+
const fileSet = allProjectFiles.length > 0 ? new Set(allProjectFiles) : null
|
|
37
|
+
return { ...imp, resolvedPath: this.resolvePath(imp.source, fromFile, fileSet) }
|
|
32
38
|
}
|
|
33
39
|
|
|
34
40
|
resolveAll(imports: ParsedImport[], fromFile: string, allProjectFiles: string[] = []): ParsedImport[] {
|
|
35
|
-
|
|
41
|
+
// Build Set once for the entire batch — O(n) instead of O(n * imports * probes)
|
|
42
|
+
const fileSet = allProjectFiles.length > 0 ? new Set(allProjectFiles) : null
|
|
43
|
+
return imports.map(imp => {
|
|
44
|
+
if (
|
|
45
|
+
!imp.source.startsWith('.') &&
|
|
46
|
+
!imp.source.startsWith('/') &&
|
|
47
|
+
!this.matchesAlias(imp.source)
|
|
48
|
+
) {
|
|
49
|
+
return { ...imp, resolvedPath: '' }
|
|
50
|
+
}
|
|
51
|
+
return { ...imp, resolvedPath: this.resolvePath(imp.source, fromFile, fileSet) }
|
|
52
|
+
})
|
|
36
53
|
}
|
|
37
54
|
|
|
38
|
-
private resolvePath(source: string, fromFile: string,
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
// 1. Alias substitution
|
|
55
|
+
private resolvePath(source: string, fromFile: string, fileSet: Set<string> | null): string {
|
|
56
|
+
// 1. Alias substitution — try ALL targets in order, not just targets[0]
|
|
42
57
|
for (const [alias, targets] of Object.entries(this.aliases)) {
|
|
43
58
|
const prefix = alias.replace('/*', '')
|
|
44
59
|
if (source.startsWith(prefix)) {
|
|
45
|
-
|
|
46
|
-
|
|
60
|
+
for (const target of targets) {
|
|
61
|
+
const substituted = target.replace('/*', '') + source.slice(prefix.length)
|
|
62
|
+
const resolved = this.normalizePath(substituted, fromFile)
|
|
63
|
+
const found = this.probeExtensions(resolved, fileSet)
|
|
64
|
+
if (found) return found
|
|
65
|
+
}
|
|
66
|
+
// All alias targets failed — fall through to return unresolved indicator
|
|
67
|
+
// rather than silently returning a path that doesn't exist
|
|
68
|
+
return ''
|
|
47
69
|
}
|
|
48
70
|
}
|
|
49
71
|
|
|
50
|
-
// 2. Build absolute-like posix path
|
|
72
|
+
// 2. Build absolute-like posix path from relative source
|
|
73
|
+
const resolved = this.normalizePath(source, fromFile)
|
|
74
|
+
|
|
75
|
+
// 3. Already has a concrete extension — validate existence and return
|
|
76
|
+
const knownExts = ['.js', '.mjs', '.cjs', '.jsx', '.ts', '.tsx']
|
|
77
|
+
if (knownExts.some(e => resolved.endsWith(e))) {
|
|
78
|
+
// If we have a file list, validate the path exists in it
|
|
79
|
+
if (fileSet && !fileSet.has(resolved)) return ''
|
|
80
|
+
return resolved
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// 4. Probe extensions in priority order
|
|
84
|
+
return this.probeExtensions(resolved, fileSet) ?? ''
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Normalize a source path to a posix-style project-relative path */
|
|
88
|
+
private normalizePath(source: string, fromFile: string): string {
|
|
51
89
|
let resolved: string
|
|
52
|
-
if (
|
|
53
|
-
resolved = path.posix.normalize(
|
|
90
|
+
if (source.startsWith('.')) {
|
|
91
|
+
resolved = path.posix.normalize(
|
|
92
|
+
path.posix.join(path.dirname(fromFile.replace(/\\/g, '/')), source)
|
|
93
|
+
)
|
|
54
94
|
} else {
|
|
55
|
-
resolved =
|
|
95
|
+
resolved = source
|
|
56
96
|
}
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
// 3. Already has a concrete extension — return as-is
|
|
60
|
-
const knownExts = ['.js', '.mjs', '.cjs', '.jsx', '.ts', '.tsx']
|
|
61
|
-
if (knownExts.some(e => resolved.endsWith(e))) return resolved
|
|
97
|
+
return resolved.replace(/\\/g, '/')
|
|
98
|
+
}
|
|
62
99
|
|
|
63
|
-
|
|
100
|
+
/**
|
|
101
|
+
* Probe file extensions in priority order.
|
|
102
|
+
* Returns the first candidate that exists in the file set,
|
|
103
|
+
* or the first candidate if no file set is provided (legacy behaviour).
|
|
104
|
+
*/
|
|
105
|
+
private probeExtensions(resolved: string, fileSet: Set<string> | null): string | null {
|
|
64
106
|
const probeOrder = [
|
|
65
107
|
'.js', '.jsx', '.mjs', '.cjs',
|
|
66
108
|
'/index.js', '/index.jsx', '/index.mjs',
|
|
@@ -69,12 +111,11 @@ export class JavaScriptResolver {
|
|
|
69
111
|
]
|
|
70
112
|
for (const ext of probeOrder) {
|
|
71
113
|
const candidate = resolved + ext
|
|
72
|
-
if (
|
|
114
|
+
if (fileSet === null || fileSet.has(candidate)) {
|
|
73
115
|
return candidate
|
|
74
116
|
}
|
|
75
117
|
}
|
|
76
|
-
|
|
77
|
-
return resolved + '.js'
|
|
118
|
+
return null
|
|
78
119
|
}
|
|
79
120
|
|
|
80
121
|
private matchesAlias(source: string): boolean {
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared parser utilities/constants.
|
|
3
|
+
* Keeps common thresholds and comment-stripping helpers in one place so
|
|
4
|
+
* JavaScript and TypeScript parsers behave consistently.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/** Files count threshold that signals a reasonably complete project scan. */
|
|
8
|
+
export const MIN_FILES_FOR_COMPLETE_SCAN = 10
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Remove JSON5-style comments while preserving string literals.
|
|
12
|
+
* Handles single/double/backtick strings plus escaped characters.
|
|
13
|
+
*/
|
|
14
|
+
export function stripJsonComments(raw: string): string {
|
|
15
|
+
let result = ''
|
|
16
|
+
let i = 0
|
|
17
|
+
let stringChar: string | null = null
|
|
18
|
+
let escaped = false
|
|
19
|
+
|
|
20
|
+
while (i < raw.length) {
|
|
21
|
+
const char = raw[i]
|
|
22
|
+
const next = i + 1 < raw.length ? raw[i + 1] : ''
|
|
23
|
+
|
|
24
|
+
if (stringChar) {
|
|
25
|
+
result += char
|
|
26
|
+
if (escaped) {
|
|
27
|
+
escaped = false
|
|
28
|
+
} else if (char === '\\') {
|
|
29
|
+
escaped = true
|
|
30
|
+
} else if (char === stringChar) {
|
|
31
|
+
stringChar = null
|
|
32
|
+
}
|
|
33
|
+
i += 1
|
|
34
|
+
continue
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (char === '"' || char === "'" || char === '`') {
|
|
38
|
+
stringChar = char
|
|
39
|
+
result += char
|
|
40
|
+
i += 1
|
|
41
|
+
continue
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (char === '/' && next === '*') {
|
|
45
|
+
i += 2
|
|
46
|
+
while (i < raw.length) {
|
|
47
|
+
if (raw[i] === '*' && raw[i + 1] === '/') {
|
|
48
|
+
i += 2
|
|
49
|
+
break
|
|
50
|
+
}
|
|
51
|
+
i += 1
|
|
52
|
+
}
|
|
53
|
+
continue
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (char === '/' && next === '/') {
|
|
57
|
+
i += 2
|
|
58
|
+
while (i < raw.length && raw[i] !== '\n' && raw[i] !== '\r') {
|
|
59
|
+
i += 1
|
|
60
|
+
}
|
|
61
|
+
continue
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
result += char
|
|
65
|
+
i += 1
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return result
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Parse JSON config files while tolerating JSON5 comments.
|
|
73
|
+
* Falls back to the raw content if comment stripping breaks URLs.
|
|
74
|
+
*/
|
|
75
|
+
export function parseJsonWithComments<T = any>(raw: string): T {
|
|
76
|
+
const stripped = stripJsonComments(raw)
|
|
77
|
+
try {
|
|
78
|
+
return JSON.parse(stripped)
|
|
79
|
+
} catch {
|
|
80
|
+
return JSON.parse(raw)
|
|
81
|
+
}
|
|
82
|
+
}
|