deslop-js 0.0.12 → 0.0.14-dev.bd94e5e
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/dist/index.cjs +2883 -106
- package/dist/index.d.cts +165 -2
- package/dist/index.d.mts +165 -2
- package/dist/index.mjs +2884 -107
- package/package.json +10 -1
package/dist/index.mjs
CHANGED
|
@@ -3,9 +3,9 @@ import { existsSync, readFileSync, statSync } from "node:fs";
|
|
|
3
3
|
import fg from "fast-glob";
|
|
4
4
|
import { readFile } from "node:fs/promises";
|
|
5
5
|
import { parseSync } from "oxc-parser";
|
|
6
|
+
import ts from "typescript";
|
|
6
7
|
import { ResolverFactory } from "oxc-resolver";
|
|
7
8
|
import { minimatch } from "minimatch";
|
|
8
|
-
import ts from "typescript";
|
|
9
9
|
|
|
10
10
|
//#region src/constants.ts
|
|
11
11
|
const DEFAULT_EXTENSIONS = [
|
|
@@ -219,6 +219,7 @@ const BUILTIN_MODULES = new Set([
|
|
|
219
219
|
]);
|
|
220
220
|
const PLATFORM_SUFFIXES = [
|
|
221
221
|
".web",
|
|
222
|
+
".react-native",
|
|
222
223
|
".native",
|
|
223
224
|
".ios",
|
|
224
225
|
".android",
|
|
@@ -226,6 +227,7 @@ const PLATFORM_SUFFIXES = [
|
|
|
226
227
|
".windows",
|
|
227
228
|
".macos",
|
|
228
229
|
".any",
|
|
230
|
+
".react-server",
|
|
229
231
|
".server",
|
|
230
232
|
".client"
|
|
231
233
|
];
|
|
@@ -921,7 +923,7 @@ const visitFunctionParameters = (parameters, captures, functionName) => {
|
|
|
921
923
|
inspectTypeAnnotation(parameter.typeAnnotation, captures, "function-parameter", functionName ? `${functionName}(${parameterIdentifierName ?? "?"})` : parameterIdentifierName);
|
|
922
924
|
}
|
|
923
925
|
};
|
|
924
|
-
const visitFunctionLike = (functionNode, captures, functionName) => {
|
|
926
|
+
const visitFunctionLike$1 = (functionNode, captures, functionName) => {
|
|
925
927
|
const parameters = functionNode.params;
|
|
926
928
|
visitFunctionParameters(parameters, captures, functionName);
|
|
927
929
|
const returnTypeNode = functionNode.returnType;
|
|
@@ -936,7 +938,7 @@ const visitVariableDeclaration = (declarationNode, captures, enclosingName) => {
|
|
|
936
938
|
const declarationName = getIdentifierName(declarator.id);
|
|
937
939
|
inspectTypeAnnotation(declarator.typeAnnotation ?? (declarator.id && isOxcAstNode(declarator.id) ? declarator.id.typeAnnotation : void 0), captures, "variable-annotation", declarationName);
|
|
938
940
|
const initializerNode = declarator.init;
|
|
939
|
-
if (isOxcAstNode(initializerNode)) if (initializerNode.type === "ArrowFunctionExpression" || initializerNode.type === "FunctionExpression") visitFunctionLike(initializerNode, captures, declarationName ?? enclosingName);
|
|
941
|
+
if (isOxcAstNode(initializerNode)) if (initializerNode.type === "ArrowFunctionExpression" || initializerNode.type === "FunctionExpression") visitFunctionLike$1(initializerNode, captures, declarationName ?? enclosingName);
|
|
940
942
|
else walkExpressionForInlineTypes(initializerNode, captures, declarationName ?? enclosingName);
|
|
941
943
|
}
|
|
942
944
|
};
|
|
@@ -948,7 +950,7 @@ const walkBodyForInlineTypes = (bodyNode, captures, enclosingName, recursionDept
|
|
|
948
950
|
for (const statement of statements) {
|
|
949
951
|
if (!isOxcAstNode(statement)) continue;
|
|
950
952
|
if (statement.type === "VariableDeclaration") visitVariableDeclaration(statement, captures, enclosingName);
|
|
951
|
-
else if (statement.type === "FunctionDeclaration") visitFunctionLike(statement, captures, getIdentifierName(statement.id) ?? enclosingName);
|
|
953
|
+
else if (statement.type === "FunctionDeclaration") visitFunctionLike$1(statement, captures, getIdentifierName(statement.id) ?? enclosingName);
|
|
952
954
|
else if (statement.type === "TSTypeAliasDeclaration") {
|
|
953
955
|
const typeAliasName = getIdentifierName(statement.id);
|
|
954
956
|
captureIfTypeLiteral(statement.typeAnnotation, captures, "local-type-alias", typeAliasName);
|
|
@@ -961,7 +963,7 @@ const walkExpressionForInlineTypes = (expressionNode, captures, enclosingName, r
|
|
|
961
963
|
if (recursionDepth > 200) return;
|
|
962
964
|
if (!isOxcAstNode(expressionNode)) return;
|
|
963
965
|
if (expressionNode.type === "ArrowFunctionExpression" || expressionNode.type === "FunctionExpression") {
|
|
964
|
-
visitFunctionLike(expressionNode, captures, enclosingName);
|
|
966
|
+
visitFunctionLike$1(expressionNode, captures, enclosingName);
|
|
965
967
|
return;
|
|
966
968
|
}
|
|
967
969
|
for (const value of Object.values(expressionNode)) if (Array.isArray(value)) for (const element of value) walkExpressionForInlineTypes(element, captures, enclosingName, recursionDepth + 1);
|
|
@@ -972,7 +974,7 @@ const visitTopLevelStatement = (statementNode, captures) => {
|
|
|
972
974
|
const innerNode = statementNode.type === "ExportNamedDeclaration" || statementNode.type === "ExportDefaultDeclaration" ? statementNode.declaration ?? statementNode : statementNode;
|
|
973
975
|
const targetNode = isOxcAstNode(innerNode) ? innerNode : statementNode;
|
|
974
976
|
if (targetNode.type === "FunctionDeclaration") {
|
|
975
|
-
visitFunctionLike(targetNode, captures, getIdentifierName(targetNode.id));
|
|
977
|
+
visitFunctionLike$1(targetNode, captures, getIdentifierName(targetNode.id));
|
|
976
978
|
return;
|
|
977
979
|
}
|
|
978
980
|
if (targetNode.type === "VariableDeclaration") {
|
|
@@ -992,7 +994,7 @@ const visitTopLevelStatement = (statementNode, captures) => {
|
|
|
992
994
|
}
|
|
993
995
|
if (memberCandidate.type === "MethodDefinition" || memberCandidate.type === "TSAbstractMethodDefinition") {
|
|
994
996
|
const methodValue = memberCandidate.value;
|
|
995
|
-
if (isOxcAstNode(methodValue)) visitFunctionLike(methodValue, captures, qualifiedName);
|
|
997
|
+
if (isOxcAstNode(methodValue)) visitFunctionLike$1(methodValue, captures, qualifiedName);
|
|
996
998
|
}
|
|
997
999
|
}
|
|
998
1000
|
return;
|
|
@@ -1542,10 +1544,24 @@ const extractMdxImportsExports = (sourceText) => {
|
|
|
1542
1544
|
return statements.join("\n");
|
|
1543
1545
|
};
|
|
1544
1546
|
const ASTRO_FRONTMATTER_PATTERN = /^---\r?\n([\s\S]*?)\r?\n---/;
|
|
1545
|
-
const
|
|
1547
|
+
const ASTRO_SCRIPT_TAG_PATTERN = /<script\b([^>]*?)\/>|<script\b([^>]*)>([\s\S]*?)<\/script>/gi;
|
|
1548
|
+
const ASTRO_SCRIPT_SRC_ATTRIBUTE_PATTERN = /\bsrc\s*=\s*["']([^"']+)["']/i;
|
|
1549
|
+
const extractAstroSources = (sourceText) => {
|
|
1550
|
+
const sections = [];
|
|
1546
1551
|
const frontmatterMatch = sourceText.match(ASTRO_FRONTMATTER_PATTERN);
|
|
1547
|
-
if (
|
|
1548
|
-
|
|
1552
|
+
if (frontmatterMatch) sections.push(frontmatterMatch[1]);
|
|
1553
|
+
ASTRO_SCRIPT_TAG_PATTERN.lastIndex = 0;
|
|
1554
|
+
let scriptMatch;
|
|
1555
|
+
while ((scriptMatch = ASTRO_SCRIPT_TAG_PATTERN.exec(sourceText)) !== null) {
|
|
1556
|
+
const selfClosingAttributes = scriptMatch[1];
|
|
1557
|
+
const pairedAttributes = scriptMatch[2];
|
|
1558
|
+
const attributes = selfClosingAttributes ?? pairedAttributes ?? "";
|
|
1559
|
+
const body = selfClosingAttributes === void 0 ? scriptMatch[3] ?? "" : "";
|
|
1560
|
+
const srcMatch = attributes.match(ASTRO_SCRIPT_SRC_ATTRIBUTE_PATTERN);
|
|
1561
|
+
if (srcMatch) sections.push(`import ${JSON.stringify(srcMatch[1])};`);
|
|
1562
|
+
if (body) sections.push(body);
|
|
1563
|
+
}
|
|
1564
|
+
return sections.join("\n");
|
|
1549
1565
|
};
|
|
1550
1566
|
const VUE_SCRIPT_PATTERN = /<script[^>]*(?:lang=["'](?:ts|tsx)["'][^>]*)?>([\s\S]*?)<\/script>/gi;
|
|
1551
1567
|
const extractVueScriptContent = (sourceText) => {
|
|
@@ -1763,7 +1779,8 @@ const parseSourceFile = (filePath) => {
|
|
|
1763
1779
|
const isAstro = filePath.endsWith(".astro");
|
|
1764
1780
|
const isVue = filePath.endsWith(".vue");
|
|
1765
1781
|
const isSvelte = filePath.endsWith(".svelte");
|
|
1766
|
-
const
|
|
1782
|
+
const isPreprocessed = isMdx || isAstro || isVue || isSvelte;
|
|
1783
|
+
const textToParse = isMdx ? extractMdxImportsExports(sourceText) : isAstro ? extractAstroSources(sourceText) : isVue ? extractVueScriptContent(sourceText) : isSvelte ? extractSvelteScriptContent(sourceText) : sourceText;
|
|
1767
1784
|
const parseFileName = isMdx || isAstro || isVue || isSvelte ? filePath.replace(/\.(mdx|astro|vue|svelte)$/, ".tsx") : filePath;
|
|
1768
1785
|
let result;
|
|
1769
1786
|
try {
|
|
@@ -1787,7 +1804,7 @@ const parseSourceFile = (filePath) => {
|
|
|
1787
1804
|
if (tsxResult.errors.length === 0) result = tsxResult;
|
|
1788
1805
|
}
|
|
1789
1806
|
} catch {}
|
|
1790
|
-
if (result.errors.length > 0) return {
|
|
1807
|
+
if (result.errors.length > 0 && !isPreprocessed) return {
|
|
1791
1808
|
...createEmptyParsedSource(),
|
|
1792
1809
|
imports,
|
|
1793
1810
|
exports,
|
|
@@ -1799,6 +1816,12 @@ const parseSourceFile = (filePath) => {
|
|
|
1799
1816
|
path: filePath
|
|
1800
1817
|
})]
|
|
1801
1818
|
};
|
|
1819
|
+
if (result.errors.length > 0) earlyErrors.push(new ParseError({
|
|
1820
|
+
code: "parse-recovered-partial",
|
|
1821
|
+
severity: "info",
|
|
1822
|
+
message: `oxc-parser reported ${result.errors.length} syntax issue(s) in extracted ${isAstro ? "Astro" : isVue ? "Vue" : isSvelte ? "Svelte" : "MDX"} sources; continuing with partial AST`,
|
|
1823
|
+
path: filePath
|
|
1824
|
+
}));
|
|
1802
1825
|
const program = result.program;
|
|
1803
1826
|
if (!program?.body) return {
|
|
1804
1827
|
...createEmptyParsedSource(),
|
|
@@ -3091,6 +3114,7 @@ const EXPO_ENTRY_PATTERNS = [
|
|
|
3091
3114
|
];
|
|
3092
3115
|
const EXPO_ROUTER_ENTRY_PATTERNS = [
|
|
3093
3116
|
"app/**/*.{ts,tsx,js,jsx}",
|
|
3117
|
+
"src/app/**/*.{ts,tsx,js,jsx}",
|
|
3094
3118
|
"app.config.{ts,js,mjs,cjs}",
|
|
3095
3119
|
"metro.config.{ts,js,mjs,cjs}",
|
|
3096
3120
|
"babel.config.{ts,js,mjs,cjs}"
|
|
@@ -3133,6 +3157,196 @@ const discoverMobileEntryPoints = (directory) => {
|
|
|
3133
3157
|
}
|
|
3134
3158
|
};
|
|
3135
3159
|
|
|
3160
|
+
//#endregion
|
|
3161
|
+
//#region src/collect/expo-config-plugin-entries.ts
|
|
3162
|
+
const EXPO_CONFIG_FILE_GLOBS = ["app.config.{ts,mts,cts,js,mjs,cjs}", "app.json"];
|
|
3163
|
+
const NESTED_EXPO_CONFIG_FILE_GLOBS = [
|
|
3164
|
+
...EXPO_CONFIG_FILE_GLOBS,
|
|
3165
|
+
"**/app.config.{ts,mts,cts,js,mjs,cjs}",
|
|
3166
|
+
"**/app.json"
|
|
3167
|
+
];
|
|
3168
|
+
const EXPO_REACT_NATIVE_DEPENDENCIES = new Set(["expo", "react-native"]);
|
|
3169
|
+
const EXPO_PLUGIN_RESOLVABLE_EXTENSIONS = SOURCE_EXTENSIONS$3.map((sourceExtension) => `.${sourceExtension}`);
|
|
3170
|
+
const isRecord = (value) => typeof value === "object" && value !== null;
|
|
3171
|
+
const isExpoOrReactNativeWorkspace = (dependencies) => [...EXPO_REACT_NATIVE_DEPENDENCIES].some((dependencyName) => dependencyName in dependencies);
|
|
3172
|
+
const isLocalExpoPluginPath = (value) => (value.startsWith("./") || value.startsWith("../")) && !value.includes("*") && !value.includes("?");
|
|
3173
|
+
const isFile = (filePath) => {
|
|
3174
|
+
try {
|
|
3175
|
+
return statSync(filePath).isFile();
|
|
3176
|
+
} catch {
|
|
3177
|
+
return false;
|
|
3178
|
+
}
|
|
3179
|
+
};
|
|
3180
|
+
const resolveExpoPluginPath = (configDirectory, pluginPath) => {
|
|
3181
|
+
const candidatePath = resolve(configDirectory, pluginPath);
|
|
3182
|
+
if (isFile(candidatePath)) return candidatePath;
|
|
3183
|
+
for (const extension of EXPO_PLUGIN_RESOLVABLE_EXTENSIONS) {
|
|
3184
|
+
const candidatePathWithExtension = `${candidatePath}${extension}`;
|
|
3185
|
+
if (isFile(candidatePathWithExtension)) return candidatePathWithExtension;
|
|
3186
|
+
}
|
|
3187
|
+
for (const extension of EXPO_PLUGIN_RESOLVABLE_EXTENSIONS) {
|
|
3188
|
+
const indexCandidatePath = join(candidatePath, `index${extension}`);
|
|
3189
|
+
if (isFile(indexCandidatePath)) return indexCandidatePath;
|
|
3190
|
+
}
|
|
3191
|
+
};
|
|
3192
|
+
const addExpoPluginEntry = (entries, rootDirectory, configDirectory, pluginPath) => {
|
|
3193
|
+
if (!isLocalExpoPluginPath(pluginPath)) return;
|
|
3194
|
+
const resolvedPath = resolveExpoPluginPath(configDirectory, pluginPath);
|
|
3195
|
+
if (!resolvedPath) return;
|
|
3196
|
+
const relativePath = relative(rootDirectory, resolvedPath);
|
|
3197
|
+
if (relativePath !== "" && (relativePath.startsWith("..") || isAbsolute(relativePath))) return;
|
|
3198
|
+
entries.add(resolvedPath);
|
|
3199
|
+
};
|
|
3200
|
+
const getPropertyName = (name) => {
|
|
3201
|
+
if (ts.isIdentifier(name) || ts.isStringLiteral(name) || ts.isNumericLiteral(name)) return name.text;
|
|
3202
|
+
};
|
|
3203
|
+
const unwrapExpression = (expression) => {
|
|
3204
|
+
let currentExpression = expression;
|
|
3205
|
+
while (ts.isParenthesizedExpression(currentExpression)) currentExpression = currentExpression.expression;
|
|
3206
|
+
return currentExpression;
|
|
3207
|
+
};
|
|
3208
|
+
const collectExpoPluginPathsFromArray = (array, entries, rootDirectory, configDirectory) => {
|
|
3209
|
+
for (const element of array.elements) {
|
|
3210
|
+
if (ts.isStringLiteral(element) || ts.isNoSubstitutionTemplateLiteral(element)) {
|
|
3211
|
+
addExpoPluginEntry(entries, rootDirectory, configDirectory, element.text);
|
|
3212
|
+
continue;
|
|
3213
|
+
}
|
|
3214
|
+
if (ts.isArrayLiteralExpression(element)) {
|
|
3215
|
+
const [pluginName] = element.elements;
|
|
3216
|
+
if (pluginName && (ts.isStringLiteral(pluginName) || ts.isNoSubstitutionTemplateLiteral(pluginName))) addExpoPluginEntry(entries, rootDirectory, configDirectory, pluginName.text);
|
|
3217
|
+
}
|
|
3218
|
+
}
|
|
3219
|
+
};
|
|
3220
|
+
const collectExpoPluginPathsFromConfigObject = (objectLiteral, entries, rootDirectory, configDirectory) => {
|
|
3221
|
+
for (const property of objectLiteral.properties) if (ts.isPropertyAssignment(property) && getPropertyName(property.name) === "plugins" && ts.isArrayLiteralExpression(property.initializer)) collectExpoPluginPathsFromArray(property.initializer, entries, rootDirectory, configDirectory);
|
|
3222
|
+
};
|
|
3223
|
+
const collectReturnedExpoConfigPluginPaths = (body, entries, rootDirectory, configDirectory) => {
|
|
3224
|
+
if (!ts.isBlock(body)) {
|
|
3225
|
+
const expression = unwrapExpression(body);
|
|
3226
|
+
if (ts.isObjectLiteralExpression(expression)) collectExpoPluginPathsFromConfigObject(expression, entries, rootDirectory, configDirectory);
|
|
3227
|
+
return;
|
|
3228
|
+
}
|
|
3229
|
+
const visit = (node) => {
|
|
3230
|
+
if (ts.isFunctionDeclaration(node) || ts.isFunctionExpression(node) || ts.isArrowFunction(node)) return;
|
|
3231
|
+
if (ts.isReturnStatement(node) && node.expression) {
|
|
3232
|
+
const expression = unwrapExpression(node.expression);
|
|
3233
|
+
if (ts.isObjectLiteralExpression(expression)) collectExpoPluginPathsFromConfigObject(expression, entries, rootDirectory, configDirectory);
|
|
3234
|
+
return;
|
|
3235
|
+
}
|
|
3236
|
+
ts.forEachChild(node, visit);
|
|
3237
|
+
};
|
|
3238
|
+
visit(body);
|
|
3239
|
+
};
|
|
3240
|
+
const collectExpoPluginPathsFromConfigExpression = (expression, entries, rootDirectory, configDirectory, bindings, seenIdentifiers = /* @__PURE__ */ new Set()) => {
|
|
3241
|
+
const configExpression = unwrapExpression(expression);
|
|
3242
|
+
if (ts.isObjectLiteralExpression(configExpression)) {
|
|
3243
|
+
collectExpoPluginPathsFromConfigObject(configExpression, entries, rootDirectory, configDirectory);
|
|
3244
|
+
return;
|
|
3245
|
+
}
|
|
3246
|
+
if (ts.isIdentifier(configExpression)) {
|
|
3247
|
+
if (seenIdentifiers.has(configExpression.text)) return;
|
|
3248
|
+
seenIdentifiers.add(configExpression.text);
|
|
3249
|
+
const boundExpression = bindings.expressions.get(configExpression.text);
|
|
3250
|
+
if (boundExpression) {
|
|
3251
|
+
collectExpoPluginPathsFromConfigExpression(boundExpression, entries, rootDirectory, configDirectory, bindings, seenIdentifiers);
|
|
3252
|
+
return;
|
|
3253
|
+
}
|
|
3254
|
+
const boundFunction = bindings.functions.get(configExpression.text);
|
|
3255
|
+
if (boundFunction?.body) collectReturnedExpoConfigPluginPaths(boundFunction.body, entries, rootDirectory, configDirectory);
|
|
3256
|
+
return;
|
|
3257
|
+
}
|
|
3258
|
+
if (ts.isArrowFunction(configExpression)) {
|
|
3259
|
+
collectReturnedExpoConfigPluginPaths(configExpression.body, entries, rootDirectory, configDirectory);
|
|
3260
|
+
return;
|
|
3261
|
+
}
|
|
3262
|
+
if (ts.isFunctionExpression(configExpression) && configExpression.body) collectReturnedExpoConfigPluginPaths(configExpression.body, entries, rootDirectory, configDirectory);
|
|
3263
|
+
};
|
|
3264
|
+
const hasDefaultExportModifier = (node) => Boolean(ts.canHaveModifiers(node) && ts.getModifiers(node)?.some((modifier) => modifier.kind === ts.SyntaxKind.DefaultKeyword));
|
|
3265
|
+
const isModuleExportsAssignmentTarget = (node) => ts.isPropertyAccessExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === "module" && node.name.text === "exports";
|
|
3266
|
+
const collectStaticConfigBindings = (sourceFile) => {
|
|
3267
|
+
const expressions = /* @__PURE__ */ new Map();
|
|
3268
|
+
const functions = /* @__PURE__ */ new Map();
|
|
3269
|
+
for (const statement of sourceFile.statements) {
|
|
3270
|
+
if (ts.isVariableStatement(statement)) {
|
|
3271
|
+
for (const declaration of statement.declarationList.declarations) if (ts.isIdentifier(declaration.name) && declaration.initializer) expressions.set(declaration.name.text, declaration.initializer);
|
|
3272
|
+
continue;
|
|
3273
|
+
}
|
|
3274
|
+
if (ts.isFunctionDeclaration(statement) && statement.name) functions.set(statement.name.text, statement);
|
|
3275
|
+
}
|
|
3276
|
+
return {
|
|
3277
|
+
expressions,
|
|
3278
|
+
functions
|
|
3279
|
+
};
|
|
3280
|
+
};
|
|
3281
|
+
const collectExpoPluginPathsFromAppConfig = (configPath, entries, rootDirectory) => {
|
|
3282
|
+
const extension = extname(configPath);
|
|
3283
|
+
const sourceFile = ts.createSourceFile(configPath, readFileSync(configPath, "utf8"), ts.ScriptTarget.Latest, true, extension === ".ts" || extension === ".mts" || extension === ".cts" ? ts.ScriptKind.TS : ts.ScriptKind.JS);
|
|
3284
|
+
const configDirectory = dirname(configPath);
|
|
3285
|
+
const bindings = collectStaticConfigBindings(sourceFile);
|
|
3286
|
+
const visit = (node) => {
|
|
3287
|
+
if (ts.isExportAssignment(node)) {
|
|
3288
|
+
collectExpoPluginPathsFromConfigExpression(node.expression, entries, rootDirectory, configDirectory, bindings);
|
|
3289
|
+
return;
|
|
3290
|
+
}
|
|
3291
|
+
if (ts.isFunctionDeclaration(node) && hasDefaultExportModifier(node) && node.body) {
|
|
3292
|
+
collectReturnedExpoConfigPluginPaths(node.body, entries, rootDirectory, configDirectory);
|
|
3293
|
+
return;
|
|
3294
|
+
}
|
|
3295
|
+
if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.EqualsToken && isModuleExportsAssignmentTarget(node.left)) {
|
|
3296
|
+
collectExpoPluginPathsFromConfigExpression(node.right, entries, rootDirectory, configDirectory, bindings);
|
|
3297
|
+
return;
|
|
3298
|
+
}
|
|
3299
|
+
ts.forEachChild(node, visit);
|
|
3300
|
+
};
|
|
3301
|
+
visit(sourceFile);
|
|
3302
|
+
};
|
|
3303
|
+
const collectPluginPathsFromJsonValue = (value) => {
|
|
3304
|
+
if (!Array.isArray(value)) return [];
|
|
3305
|
+
const pluginPaths = [];
|
|
3306
|
+
for (const plugin of value) {
|
|
3307
|
+
if (typeof plugin === "string") {
|
|
3308
|
+
pluginPaths.push(plugin);
|
|
3309
|
+
continue;
|
|
3310
|
+
}
|
|
3311
|
+
if (Array.isArray(plugin) && typeof plugin[0] === "string") pluginPaths.push(plugin[0]);
|
|
3312
|
+
}
|
|
3313
|
+
return pluginPaths;
|
|
3314
|
+
};
|
|
3315
|
+
const collectExpoPluginPathsFromAppJson = (configPath, entries, rootDirectory) => {
|
|
3316
|
+
const parsedJson = JSON.parse(readFileSync(configPath, "utf8"));
|
|
3317
|
+
const configDirectory = dirname(configPath);
|
|
3318
|
+
if (!isRecord(parsedJson)) return;
|
|
3319
|
+
const expoConfig = parsedJson.expo;
|
|
3320
|
+
const expoPluginPaths = isRecord(expoConfig) ? collectPluginPathsFromJsonValue(expoConfig.plugins) : [];
|
|
3321
|
+
for (const pluginPath of [...expoPluginPaths, ...collectPluginPathsFromJsonValue(parsedJson.plugins)]) addExpoPluginEntry(entries, rootDirectory, configDirectory, pluginPath);
|
|
3322
|
+
};
|
|
3323
|
+
const collectExpoPluginPathsFromConfig = (configPath, entries, rootDirectory) => {
|
|
3324
|
+
try {
|
|
3325
|
+
if (basename(configPath) === "app.json") {
|
|
3326
|
+
collectExpoPluginPathsFromAppJson(configPath, entries, rootDirectory);
|
|
3327
|
+
return;
|
|
3328
|
+
}
|
|
3329
|
+
collectExpoPluginPathsFromAppConfig(configPath, entries, rootDirectory);
|
|
3330
|
+
} catch {}
|
|
3331
|
+
};
|
|
3332
|
+
const extractExpoConfigPluginEntries = (directory, dependencies, rootDirectory = directory, includeNestedConfigs = true) => {
|
|
3333
|
+
if (!isExpoOrReactNativeWorkspace(dependencies)) return [];
|
|
3334
|
+
const entries = /* @__PURE__ */ new Set();
|
|
3335
|
+
const configPaths = fg.sync(includeNestedConfigs ? NESTED_EXPO_CONFIG_FILE_GLOBS : EXPO_CONFIG_FILE_GLOBS, {
|
|
3336
|
+
cwd: directory,
|
|
3337
|
+
absolute: true,
|
|
3338
|
+
onlyFiles: true,
|
|
3339
|
+
ignore: [
|
|
3340
|
+
"**/node_modules/**",
|
|
3341
|
+
"**/dist/**",
|
|
3342
|
+
"**/build/**"
|
|
3343
|
+
],
|
|
3344
|
+
deep: 6
|
|
3345
|
+
});
|
|
3346
|
+
for (const configPath of configPaths) collectExpoPluginPathsFromConfig(configPath, entries, rootDirectory);
|
|
3347
|
+
return [...entries];
|
|
3348
|
+
};
|
|
3349
|
+
|
|
3136
3350
|
//#endregion
|
|
3137
3351
|
//#region src/resolver/source-path.ts
|
|
3138
3352
|
const SOURCE_EXTENSIONS$1 = [
|
|
@@ -3173,6 +3387,8 @@ const resolveSourcePath = (distPath, directory) => {
|
|
|
3173
3387
|
const sourceCandidate = resolve(directory, withoutExtension + sourceExtension);
|
|
3174
3388
|
if (existsSync(sourceCandidate)) return sourceCandidate;
|
|
3175
3389
|
}
|
|
3390
|
+
const indexPrefixedCandidate = resolveWithIndexPrefix(withoutExtension, directory);
|
|
3391
|
+
if (indexPrefixedCandidate) return indexPrefixedCandidate;
|
|
3176
3392
|
}
|
|
3177
3393
|
if (matchesOutputDirectory(relativeToDist)) for (const stem of SOURCE_INDEX_FALLBACK_STEMS) for (const sourceExtension of SOURCE_EXTENSIONS$1) {
|
|
3178
3394
|
const fallbackCandidate = resolve(directory, stem + sourceExtension);
|
|
@@ -3186,6 +3402,15 @@ const resolveSourcePath = (distPath, directory) => {
|
|
|
3186
3402
|
}
|
|
3187
3403
|
const indexCandidate = resolve(directory, withoutExtension, "index.ts");
|
|
3188
3404
|
if (existsSync(indexCandidate)) return indexCandidate;
|
|
3405
|
+
const indexPrefixedCandidate = resolveWithIndexPrefix(withoutExtension, directory);
|
|
3406
|
+
if (indexPrefixedCandidate) return indexPrefixedCandidate;
|
|
3407
|
+
}
|
|
3408
|
+
};
|
|
3409
|
+
const resolveWithIndexPrefix = (stemPath, directory) => {
|
|
3410
|
+
const indexPrefixedStem = `${dirname(stemPath)}/index.${basename(stemPath)}`;
|
|
3411
|
+
for (const sourceExtension of SOURCE_EXTENSIONS$1) {
|
|
3412
|
+
const candidate = resolve(directory, indexPrefixedStem + sourceExtension);
|
|
3413
|
+
if (existsSync(candidate)) return candidate;
|
|
3189
3414
|
}
|
|
3190
3415
|
};
|
|
3191
3416
|
|
|
@@ -3429,6 +3654,10 @@ const extractSectionsModuleEntries = (directory) => {
|
|
|
3429
3654
|
return [...entries];
|
|
3430
3655
|
};
|
|
3431
3656
|
|
|
3657
|
+
//#endregion
|
|
3658
|
+
//#region src/utils/to-posix-path.ts
|
|
3659
|
+
const toPosixPath = (filePath) => filePath.replace(/\\/g, "/");
|
|
3660
|
+
|
|
3432
3661
|
//#endregion
|
|
3433
3662
|
//#region src/collect/entries.ts
|
|
3434
3663
|
const collectSourceFiles = async (config) => {
|
|
@@ -3545,6 +3774,11 @@ const resolveEntries = async (config) => {
|
|
|
3545
3774
|
for (const workspacePackage of entryEligiblePackages) tsConfigIncludeEntries.push(...extractTsConfigIncludeFilesEntries(workspacePackage.directory));
|
|
3546
3775
|
const configStringEntries = extractConfigStringReferencedEntries(absoluteRoot);
|
|
3547
3776
|
for (const workspacePackage of entryEligiblePackages) configStringEntries.push(...extractConfigStringReferencedEntries(workspacePackage.directory));
|
|
3777
|
+
const expoConfigPluginEntries = extractExpoConfigPluginEntries(absoluteRoot, readPackageJsonDependencies(join(absoluteRoot, "package.json")), absoluteRoot, false);
|
|
3778
|
+
for (const workspacePackage of entryEligiblePackages) {
|
|
3779
|
+
const workspacePackageDependencies = readPackageJsonDependencies(join(workspacePackage.directory, "package.json"));
|
|
3780
|
+
expoConfigPluginEntries.push(...extractExpoConfigPluginEntries(workspacePackage.directory, workspacePackageDependencies, absoluteRoot));
|
|
3781
|
+
}
|
|
3548
3782
|
const sectionsModuleEntries = extractSectionsModuleEntries(absoluteRoot);
|
|
3549
3783
|
const wranglerEntries = extractWranglerEntries(absoluteRoot);
|
|
3550
3784
|
for (const workspacePackage of entryEligiblePackages) wranglerEntries.push(...extractWranglerEntries(workspacePackage.directory));
|
|
@@ -3555,7 +3789,7 @@ const resolveEntries = async (config) => {
|
|
|
3555
3789
|
const testRunnerDiscovery = discoverTestRunnerEntryPoints(absoluteRoot, entryEligiblePackages);
|
|
3556
3790
|
const toolingDiscovery = discoverToolingEntryPoints(absoluteRoot, entryEligiblePackages);
|
|
3557
3791
|
const ciEntries = extractCiWorkflowEntries(absoluteRoot);
|
|
3558
|
-
const testEntries = [...new Set([...testRunnerDiscovery.entryFiles, ...testSetupEntries])];
|
|
3792
|
+
const testEntries = [...new Set([...testRunnerDiscovery.entryFiles, ...testSetupEntries].map(toPosixPath))];
|
|
3559
3793
|
const testEntryPathSet = new Set(testEntries);
|
|
3560
3794
|
return {
|
|
3561
3795
|
productionEntries: [...new Set([
|
|
@@ -3573,14 +3807,15 @@ const resolveEntries = async (config) => {
|
|
|
3573
3807
|
...webWorkerEntries,
|
|
3574
3808
|
...tsConfigIncludeEntries,
|
|
3575
3809
|
...configStringEntries,
|
|
3810
|
+
...expoConfigPluginEntries,
|
|
3576
3811
|
...sectionsModuleEntries,
|
|
3577
3812
|
...wranglerEntries,
|
|
3578
3813
|
...pluginFileEntries,
|
|
3579
3814
|
...toolingDiscovery.entryFiles,
|
|
3580
3815
|
...ciEntries
|
|
3581
|
-
])].filter((entryPath) => !testEntryPathSet.has(entryPath)),
|
|
3816
|
+
].map(toPosixPath))].filter((entryPath) => !testEntryPathSet.has(entryPath)),
|
|
3582
3817
|
testEntries,
|
|
3583
|
-
alwaysUsedFiles: [...new Set([...toolingDiscovery.alwaysUsedFiles, ...testRunnerDiscovery.alwaysUsedFiles])]
|
|
3818
|
+
alwaysUsedFiles: [...new Set([...toolingDiscovery.alwaysUsedFiles, ...testRunnerDiscovery.alwaysUsedFiles].map(toPosixPath))]
|
|
3584
3819
|
};
|
|
3585
3820
|
};
|
|
3586
3821
|
const DEFAULT_INDEX_PATTERNS = [
|
|
@@ -3792,6 +4027,7 @@ const SCRIPT_MULTIPLEXERS = new Set([
|
|
|
3792
4027
|
"lerna",
|
|
3793
4028
|
"ultra"
|
|
3794
4029
|
]);
|
|
4030
|
+
const TSCONFIG_PROJECT_FLAGS = new Set(["--project", "-p"]);
|
|
3795
4031
|
const CONFIG_LIKE_FLAGS = new Set([
|
|
3796
4032
|
"--config",
|
|
3797
4033
|
"-c",
|
|
@@ -3937,7 +4173,8 @@ const extractScriptFileArguments = (scriptCommand, directory) => {
|
|
|
3937
4173
|
const configPath = tokens[tokenIndex + 1].replace(/^['"]|['"]$/g, "");
|
|
3938
4174
|
if (looksLikeFilePath(configPath)) {
|
|
3939
4175
|
const absoluteConfigPath = resolve(directory, configPath);
|
|
3940
|
-
if (existsSync(absoluteConfigPath)) entries.push(absoluteConfigPath);
|
|
4176
|
+
if (existsSync(absoluteConfigPath)) if (TSCONFIG_PROJECT_FLAGS.has(token) && TSCONFIG_PROJECT_PATTERN.test(absoluteConfigPath)) entries.push(...expandTsConfigProjectEntries(absoluteConfigPath));
|
|
4177
|
+
else entries.push(absoluteConfigPath);
|
|
3941
4178
|
}
|
|
3942
4179
|
tokenIndex++;
|
|
3943
4180
|
}
|
|
@@ -3946,9 +4183,11 @@ const extractScriptFileArguments = (scriptCommand, directory) => {
|
|
|
3946
4183
|
const equalsIndex = token.indexOf("=");
|
|
3947
4184
|
if (equalsIndex > 0 && CONFIG_LIKE_FLAGS.has(token.slice(0, equalsIndex))) {
|
|
3948
4185
|
const configValue = token.slice(equalsIndex + 1);
|
|
4186
|
+
const flagName = token.slice(0, equalsIndex);
|
|
3949
4187
|
if (configValue && looksLikeFilePath(configValue)) {
|
|
3950
4188
|
const absoluteConfigPath = resolve(directory, configValue);
|
|
3951
|
-
if (existsSync(absoluteConfigPath)) entries.push(absoluteConfigPath);
|
|
4189
|
+
if (existsSync(absoluteConfigPath)) if (TSCONFIG_PROJECT_FLAGS.has(flagName) && TSCONFIG_PROJECT_PATTERN.test(absoluteConfigPath)) entries.push(...expandTsConfigProjectEntries(absoluteConfigPath));
|
|
4190
|
+
else entries.push(absoluteConfigPath);
|
|
3952
4191
|
}
|
|
3953
4192
|
continue;
|
|
3954
4193
|
}
|
|
@@ -4251,6 +4490,7 @@ const extractScriptTagsFromHtmlFile = (htmlFilePath) => {
|
|
|
4251
4490
|
return entries;
|
|
4252
4491
|
};
|
|
4253
4492
|
const TSCONFIG_FILENAME_GLOBS = ["tsconfig.json", "tsconfig.*.json"];
|
|
4493
|
+
const TSCONFIG_PROJECT_PATTERN = /(?:^|[\\/])tsconfig(?:\.[^.]+)?\.json$/;
|
|
4254
4494
|
const stripJsoncCommentsLocal = (sourceText) => {
|
|
4255
4495
|
let result = "";
|
|
4256
4496
|
let insideString = false;
|
|
@@ -4322,6 +4562,34 @@ const extractTsConfigIncludeFilesEntries = (directory) => {
|
|
|
4322
4562
|
} catch {}
|
|
4323
4563
|
return entries;
|
|
4324
4564
|
};
|
|
4565
|
+
const expandTsConfigProjectEntries = (tsconfigAbsolutePath) => {
|
|
4566
|
+
const entries = [];
|
|
4567
|
+
try {
|
|
4568
|
+
const cleaned = stripJsoncCommentsLocal(readFileSync(tsconfigAbsolutePath, "utf-8"));
|
|
4569
|
+
const tsconfigJson = JSON.parse(cleaned);
|
|
4570
|
+
const tsconfigDir = dirname(tsconfigAbsolutePath);
|
|
4571
|
+
if (Array.isArray(tsconfigJson.files)) for (const fileItem of tsconfigJson.files) {
|
|
4572
|
+
if (typeof fileItem !== "string") continue;
|
|
4573
|
+
const candidatePath = resolve(tsconfigDir, fileItem);
|
|
4574
|
+
if (existsSync(candidatePath)) entries.push(candidatePath);
|
|
4575
|
+
}
|
|
4576
|
+
if (Array.isArray(tsconfigJson.include)) for (const includePattern of tsconfigJson.include) {
|
|
4577
|
+
if (typeof includePattern !== "string") continue;
|
|
4578
|
+
const expandedFiles = fg.sync(includePattern, {
|
|
4579
|
+
cwd: tsconfigDir,
|
|
4580
|
+
absolute: true,
|
|
4581
|
+
onlyFiles: true,
|
|
4582
|
+
ignore: [
|
|
4583
|
+
"**/node_modules/**",
|
|
4584
|
+
"**/dist/**",
|
|
4585
|
+
"**/build/**"
|
|
4586
|
+
]
|
|
4587
|
+
});
|
|
4588
|
+
entries.push(...expandedFiles);
|
|
4589
|
+
}
|
|
4590
|
+
} catch {}
|
|
4591
|
+
return entries;
|
|
4592
|
+
};
|
|
4325
4593
|
const WRANGLER_TOML_MAIN_PATTERN = /^\s*main\s*=\s*['"]([^'"\n]+)['"]/m;
|
|
4326
4594
|
const WRANGLER_JSON_MAIN_PATTERN = /"main"\s*:\s*"([^"]+)"/;
|
|
4327
4595
|
const WRANGLER_SERVICE_BINDINGS_PATTERN = /entry_point\s*=\s*['"]([^'"\n]+)['"]/g;
|
|
@@ -5415,7 +5683,7 @@ const FRAMEWORK_PATTERNS = [
|
|
|
5415
5683
|
"app/_layout.{ts,tsx,js,jsx}",
|
|
5416
5684
|
"app/index.{ts,tsx,js,jsx}"
|
|
5417
5685
|
],
|
|
5418
|
-
alwaysUsed: ["app.json", "app.config.{ts,js}"]
|
|
5686
|
+
alwaysUsed: ["app.json", "app.config.{ts,mts,cts,js,mjs,cjs}"]
|
|
5419
5687
|
},
|
|
5420
5688
|
{
|
|
5421
5689
|
enablers: ["wrangler"],
|
|
@@ -5752,6 +6020,40 @@ const discoverToolingEntryPoints = (rootDir, workspacePackages) => {
|
|
|
5752
6020
|
};
|
|
5753
6021
|
};
|
|
5754
6022
|
|
|
6023
|
+
//#endregion
|
|
6024
|
+
//#region src/utils/is-platform-builtin-or-virtual.ts
|
|
6025
|
+
const BUILTIN_SUBPATH_NODE_MODULES = new Set([
|
|
6026
|
+
"fs",
|
|
6027
|
+
"dns",
|
|
6028
|
+
"stream",
|
|
6029
|
+
"readline",
|
|
6030
|
+
"timers",
|
|
6031
|
+
"util",
|
|
6032
|
+
"test",
|
|
6033
|
+
"assert",
|
|
6034
|
+
"inspector",
|
|
6035
|
+
"path"
|
|
6036
|
+
]);
|
|
6037
|
+
/**
|
|
6038
|
+
* True for module specifiers that don't correspond to a real on-disk
|
|
6039
|
+
* package — Node / Bun / Cloudflare / Sass built-ins, the Deno `std`
|
|
6040
|
+
* bare specifier, and Vite `virtual:` modules — so they aren't mistakenly
|
|
6041
|
+
* surfaced as `unused-dependency` or `unresolved-import`.
|
|
6042
|
+
*/
|
|
6043
|
+
const isPlatformBuiltinOrVirtualSpecifier = (specifier) => {
|
|
6044
|
+
if (specifier.startsWith("virtual:")) return true;
|
|
6045
|
+
if (specifier === "bun" || specifier.startsWith("bun:")) return true;
|
|
6046
|
+
if (specifier.startsWith("cloudflare:")) return true;
|
|
6047
|
+
if (specifier.startsWith("sass:")) return true;
|
|
6048
|
+
if (specifier === "std" || specifier.startsWith("std/")) return true;
|
|
6049
|
+
const stripped = specifier.startsWith("node:") ? specifier.slice(5) : specifier;
|
|
6050
|
+
const slashIndex = stripped.indexOf("/");
|
|
6051
|
+
if (slashIndex === -1) return BUILTIN_MODULES.has(stripped);
|
|
6052
|
+
const baseName = stripped.slice(0, slashIndex);
|
|
6053
|
+
if (!BUILTIN_MODULES.has(baseName)) return false;
|
|
6054
|
+
return BUILTIN_SUBPATH_NODE_MODULES.has(baseName);
|
|
6055
|
+
};
|
|
6056
|
+
|
|
5755
6057
|
//#endregion
|
|
5756
6058
|
//#region src/resolver/resolve.ts
|
|
5757
6059
|
const fileExistsCache = /* @__PURE__ */ new Map();
|
|
@@ -6388,9 +6690,10 @@ const createResolver = (config, workspacePackages = [], options = {}) => {
|
|
|
6388
6690
|
try {
|
|
6389
6691
|
const resolverResult = activeResolver.sync(fromDir, cleanedSpecifier);
|
|
6390
6692
|
if (resolverResult.path) {
|
|
6391
|
-
const
|
|
6693
|
+
const normalizedResolvedPath = toPosixPath(resolverResult.path);
|
|
6694
|
+
const isInsideNodeModules = normalizedResolvedPath.includes("/node_modules/");
|
|
6392
6695
|
return {
|
|
6393
|
-
resolvedPath: isInsideNodeModules ? void 0 :
|
|
6696
|
+
resolvedPath: isInsideNodeModules ? void 0 : normalizedResolvedPath,
|
|
6394
6697
|
isExternal: isInsideNodeModules,
|
|
6395
6698
|
packageName: isInsideNodeModules ? extractPackageNameFromSpecifier(cleanedSpecifier) : void 0
|
|
6396
6699
|
};
|
|
@@ -6505,7 +6808,14 @@ const createResolver = (config, workspacePackages = [], options = {}) => {
|
|
|
6505
6808
|
resolveResultCache.set(cacheKey, unresolvedResult);
|
|
6506
6809
|
return unresolvedResult;
|
|
6507
6810
|
};
|
|
6508
|
-
|
|
6811
|
+
const resolveModuleWithPosixPath = (specifier, fromFile) => {
|
|
6812
|
+
const resolved = resolveModule(specifier, fromFile);
|
|
6813
|
+
return resolved.resolvedPath ? {
|
|
6814
|
+
...resolved,
|
|
6815
|
+
resolvedPath: toPosixPath(resolved.resolvedPath)
|
|
6816
|
+
} : resolved;
|
|
6817
|
+
};
|
|
6818
|
+
return { resolveModule: resolveModuleWithPosixPath };
|
|
6509
6819
|
};
|
|
6510
6820
|
const stripJsonComments = (content) => {
|
|
6511
6821
|
let result = "";
|
|
@@ -6546,21 +6856,7 @@ const stripJsonComments = (content) => {
|
|
|
6546
6856
|
}
|
|
6547
6857
|
return result.replace(/,(\s*[}\]])/g, "$1");
|
|
6548
6858
|
};
|
|
6549
|
-
const
|
|
6550
|
-
"fs",
|
|
6551
|
-
"dns",
|
|
6552
|
-
"stream",
|
|
6553
|
-
"readline",
|
|
6554
|
-
"timers",
|
|
6555
|
-
"util"
|
|
6556
|
-
]);
|
|
6557
|
-
const isBuiltinModule = (specifier) => {
|
|
6558
|
-
if (specifier.startsWith("node:")) return true;
|
|
6559
|
-
const baseName = specifier.split("/")[0];
|
|
6560
|
-
if (!BUILTIN_MODULES.has(baseName)) return false;
|
|
6561
|
-
if (!specifier.includes("/")) return true;
|
|
6562
|
-
return BUILTIN_SUBPATH_MODULES.has(baseName);
|
|
6563
|
-
};
|
|
6859
|
+
const isBuiltinModule = (specifier) => isPlatformBuiltinOrVirtualSpecifier(specifier);
|
|
6564
6860
|
const isBareSpecifier = (specifier) => !specifier.startsWith(".") && !specifier.startsWith("/");
|
|
6565
6861
|
const extractPackageNameFromSpecifier = (specifier) => {
|
|
6566
6862
|
if (specifier.startsWith("node:")) return specifier.slice(5).split("/")[0];
|
|
@@ -6585,7 +6881,7 @@ const isConfigFile = (filePath) => {
|
|
|
6585
6881
|
//#region src/linker/build.ts
|
|
6586
6882
|
const buildDependencyGraph = (inputs) => {
|
|
6587
6883
|
const fileIdMap = /* @__PURE__ */ new Map();
|
|
6588
|
-
for (const input of inputs) fileIdMap.set(input.fileId.path, input.fileId.index);
|
|
6884
|
+
for (const input of inputs) fileIdMap.set(toPosixPath(input.fileId.path), input.fileId.index);
|
|
6589
6885
|
const modules = inputs.map((input) => ({
|
|
6590
6886
|
fileId: input.fileId,
|
|
6591
6887
|
imports: input.parsed.imports,
|
|
@@ -6631,7 +6927,7 @@ const buildDependencyGraph = (inputs) => {
|
|
|
6631
6927
|
const sourceDir = path.dirname(input.fileId.path);
|
|
6632
6928
|
const globPattern = importInfo.specifier;
|
|
6633
6929
|
for (const [filePath] of fileIdMap) {
|
|
6634
|
-
const relativePath = path.relative(sourceDir, filePath);
|
|
6930
|
+
const relativePath = toPosixPath(path.relative(sourceDir, filePath));
|
|
6635
6931
|
if (minimatch(relativePath.startsWith(".") ? relativePath : `./${relativePath}`, globPattern)) {
|
|
6636
6932
|
const targetIndex = fileIdMap.get(filePath);
|
|
6637
6933
|
if (targetIndex !== void 0) addEdge(sourceIndex, targetIndex, []);
|
|
@@ -6641,7 +6937,7 @@ const buildDependencyGraph = (inputs) => {
|
|
|
6641
6937
|
}
|
|
6642
6938
|
const resolved = input.resolvedImports.get(importInfo.specifier);
|
|
6643
6939
|
if (!resolved?.resolvedPath) continue;
|
|
6644
|
-
const targetIndex = fileIdMap.get(resolved.resolvedPath);
|
|
6940
|
+
const targetIndex = fileIdMap.get(toPosixPath(resolved.resolvedPath));
|
|
6645
6941
|
if (targetIndex === void 0) continue;
|
|
6646
6942
|
addEdge(sourceIndex, targetIndex, importInfo.importedNames.map((importedName) => ({
|
|
6647
6943
|
importedName: importedName.name,
|
|
@@ -6656,7 +6952,7 @@ const buildDependencyGraph = (inputs) => {
|
|
|
6656
6952
|
if (!exportInfo.isReExport || !exportInfo.reExportSource) continue;
|
|
6657
6953
|
const resolved = input.resolvedImports.get(exportInfo.reExportSource);
|
|
6658
6954
|
if (!resolved?.resolvedPath) continue;
|
|
6659
|
-
const targetIndex = fileIdMap.get(resolved.resolvedPath);
|
|
6955
|
+
const targetIndex = fileIdMap.get(toPosixPath(resolved.resolvedPath));
|
|
6660
6956
|
if (targetIndex === void 0) continue;
|
|
6661
6957
|
const exportedName = exportInfo.isNamespaceReExport ? "*" : exportInfo.name;
|
|
6662
6958
|
const originalName = exportInfo.isNamespaceReExport ? "*" : exportInfo.reExportOriginalName ?? exportInfo.name;
|
|
@@ -8445,77 +8741,2429 @@ const detectDuplicateInlineTypes = (graph) => {
|
|
|
8445
8741
|
};
|
|
8446
8742
|
|
|
8447
8743
|
//#endregion
|
|
8448
|
-
//#region src/
|
|
8449
|
-
const
|
|
8450
|
-
|
|
8451
|
-
|
|
8452
|
-
|
|
8453
|
-
|
|
8454
|
-
|
|
8455
|
-
|
|
8456
|
-
|
|
8744
|
+
//#region src/report/cross-file-duplicate-exports.ts
|
|
8745
|
+
const buildReExportSourceSets = (graph) => {
|
|
8746
|
+
const reExportSources = /* @__PURE__ */ new Map();
|
|
8747
|
+
for (const edge of graph.edges) {
|
|
8748
|
+
if (!edge.isReExportEdge) continue;
|
|
8749
|
+
const existing = reExportSources.get(edge.source);
|
|
8750
|
+
if (existing) existing.add(edge.target);
|
|
8751
|
+
else reExportSources.set(edge.source, new Set([edge.target]));
|
|
8752
|
+
}
|
|
8753
|
+
return reExportSources;
|
|
8754
|
+
};
|
|
8755
|
+
/**
|
|
8756
|
+
* Two duplicate-export files "share a common importer" when there exists a
|
|
8757
|
+
* third file that imports from both, OR one duplicate file imports another.
|
|
8758
|
+
* This filters out coincidental duplicates among unrelated leaf modules
|
|
8759
|
+
* (SvelteKit/Next.js route files, scripts in different parts of a monorepo,
|
|
8760
|
+
* etc.) that happen to export the same name but can never be confused at any
|
|
8761
|
+
* import site.
|
|
8762
|
+
*/
|
|
8763
|
+
const hasCommonImporter = (moduleIndices, graph) => {
|
|
8764
|
+
if (moduleIndices.length <= 1) return false;
|
|
8765
|
+
const duplicateModuleSet = new Set(moduleIndices);
|
|
8766
|
+
const importerOwner = /* @__PURE__ */ new Map();
|
|
8767
|
+
for (const moduleIndex of moduleIndices) {
|
|
8768
|
+
const importers = graph.reverseEdges.get(moduleIndex) ?? [];
|
|
8769
|
+
for (const importerIndex of importers) {
|
|
8770
|
+
if (duplicateModuleSet.has(importerIndex)) return true;
|
|
8771
|
+
const previousOwner = importerOwner.get(importerIndex);
|
|
8772
|
+
if (previousOwner === void 0) importerOwner.set(importerIndex, moduleIndex);
|
|
8773
|
+
else if (previousOwner !== moduleIndex) return true;
|
|
8774
|
+
}
|
|
8775
|
+
}
|
|
8776
|
+
return false;
|
|
8777
|
+
};
|
|
8778
|
+
/**
|
|
8779
|
+
* Cross-file duplicate exports: the same exported name lives in 2+ files.
|
|
8780
|
+
*
|
|
8781
|
+
* Filters applied (to keep the rule actionable):
|
|
8782
|
+
* - default exports are skipped (every module gets one and it's not actionable)
|
|
8783
|
+
* - re-export chains are pruned: if module A re-exports `Foo` from module B,
|
|
8784
|
+
* the (A, B) pair is one chain, not two real declarations
|
|
8785
|
+
* - TypeScript value/type namespace split: `export const X` and `export type X`
|
|
8786
|
+
* in the same file are distinct in TS's value/type namespaces; same name in a
|
|
8787
|
+
* value file and a type file is not a true duplicate either
|
|
8788
|
+
* - common-importer filter: only report duplicates where two of the duplicate
|
|
8789
|
+
* files share an importer or one imports another, so unrelated route files in
|
|
8790
|
+
* different parts of a repo don't get flagged
|
|
8791
|
+
*/
|
|
8792
|
+
const detectCrossFileDuplicateExports = (graph) => {
|
|
8793
|
+
const reExportSources = buildReExportSourceSets(graph);
|
|
8794
|
+
const exportEntriesByName = /* @__PURE__ */ new Map();
|
|
8795
|
+
for (const module of graph.modules) {
|
|
8796
|
+
if (!module.isReachable) continue;
|
|
8797
|
+
if (module.isDeclarationFile) continue;
|
|
8798
|
+
if (module.isEntryPoint) continue;
|
|
8799
|
+
for (const exportInfo of module.exports) {
|
|
8800
|
+
if (exportInfo.isDefault) continue;
|
|
8801
|
+
if (exportInfo.isSynthetic) continue;
|
|
8802
|
+
if (exportInfo.name === "*") continue;
|
|
8803
|
+
if (exportInfo.isReExport) continue;
|
|
8804
|
+
const entry = {
|
|
8805
|
+
moduleIndex: module.fileId.index,
|
|
8806
|
+
path: module.fileId.path,
|
|
8807
|
+
line: exportInfo.line,
|
|
8808
|
+
column: exportInfo.column,
|
|
8809
|
+
isTypeOnly: exportInfo.isTypeOnly
|
|
8810
|
+
};
|
|
8811
|
+
const existing = exportEntriesByName.get(exportInfo.name);
|
|
8812
|
+
if (existing) existing.push(entry);
|
|
8813
|
+
else exportEntriesByName.set(exportInfo.name, [entry]);
|
|
8814
|
+
}
|
|
8815
|
+
}
|
|
8816
|
+
const findings = [];
|
|
8817
|
+
const sortedEntries = [...exportEntriesByName.entries()].sort(([nameA], [nameB]) => nameA.localeCompare(nameB));
|
|
8818
|
+
for (const [name, entries] of sortedEntries) {
|
|
8819
|
+
if (entries.length <= 1) continue;
|
|
8820
|
+
const hasValueExport = entries.some((entry) => !entry.isTypeOnly);
|
|
8821
|
+
const hasTypeExport = entries.some((entry) => entry.isTypeOnly);
|
|
8822
|
+
if (hasValueExport && hasTypeExport) {
|
|
8823
|
+
const valueModuleIndices = new Set(entries.filter((entry) => !entry.isTypeOnly).map((entry) => entry.moduleIndex));
|
|
8824
|
+
const typeModuleIndices = new Set(entries.filter((entry) => entry.isTypeOnly).map((entry) => entry.moduleIndex));
|
|
8825
|
+
if (valueModuleIndices.size <= 1 && typeModuleIndices.size <= 1) continue;
|
|
8826
|
+
}
|
|
8827
|
+
const moduleIndexSet = new Set(entries.map((entry) => entry.moduleIndex));
|
|
8828
|
+
const independentEntries = entries.filter((entry) => {
|
|
8829
|
+
const sources = reExportSources.get(entry.moduleIndex);
|
|
8830
|
+
if (!sources) return true;
|
|
8831
|
+
for (const sourceIndex of sources) if (moduleIndexSet.has(sourceIndex)) return false;
|
|
8832
|
+
return true;
|
|
8833
|
+
});
|
|
8834
|
+
if (independentEntries.length <= 1) continue;
|
|
8835
|
+
if (!hasCommonImporter(independentEntries.map((entry) => entry.moduleIndex), graph)) continue;
|
|
8836
|
+
const locations = independentEntries.map((entry) => ({
|
|
8837
|
+
path: entry.path,
|
|
8838
|
+
line: entry.line,
|
|
8839
|
+
column: entry.column,
|
|
8840
|
+
isTypeOnly: entry.isTypeOnly
|
|
8457
8841
|
}));
|
|
8458
|
-
|
|
8842
|
+
findings.push({
|
|
8843
|
+
name,
|
|
8844
|
+
locations,
|
|
8845
|
+
confidence: "medium",
|
|
8846
|
+
reason: `"${name}" is exported from ${locations.length} files that share a common importer — consumers may import the wrong one`
|
|
8847
|
+
});
|
|
8459
8848
|
}
|
|
8849
|
+
return findings;
|
|
8460
8850
|
};
|
|
8461
8851
|
|
|
8462
8852
|
//#endregion
|
|
8463
|
-
//#region src/
|
|
8464
|
-
const
|
|
8853
|
+
//#region src/utils/compute-line-starts.ts
|
|
8854
|
+
const LINE_FEED_CHAR_CODE = 10;
|
|
8855
|
+
const computeLineStarts = (sourceText) => {
|
|
8856
|
+
const lineStarts = [0];
|
|
8857
|
+
for (let charIndex = 0; charIndex < sourceText.length; charIndex++) if (sourceText.charCodeAt(charIndex) === LINE_FEED_CHAR_CODE) lineStarts.push(charIndex + 1);
|
|
8858
|
+
return lineStarts;
|
|
8859
|
+
};
|
|
8860
|
+
|
|
8861
|
+
//#endregion
|
|
8862
|
+
//#region src/utils/offset-to-line-column.ts
|
|
8863
|
+
const offsetToLineColumn = (byteOffset, lineStarts) => {
|
|
8864
|
+
let lowIndex = 0;
|
|
8865
|
+
let highIndex = lineStarts.length - 1;
|
|
8866
|
+
while (lowIndex < highIndex) {
|
|
8867
|
+
const middleIndex = lowIndex + highIndex + 1 >>> 1;
|
|
8868
|
+
if (lineStarts[middleIndex] <= byteOffset) lowIndex = middleIndex;
|
|
8869
|
+
else highIndex = middleIndex - 1;
|
|
8870
|
+
}
|
|
8465
8871
|
return {
|
|
8466
|
-
|
|
8467
|
-
|
|
8468
|
-
error: new TypeScriptError({
|
|
8469
|
-
code: {
|
|
8470
|
-
"no-tsconfig": "tsconfig-not-found",
|
|
8471
|
-
"tsconfig-parse-error": "tsconfig-parse-failed",
|
|
8472
|
-
"program-creation-failed": "ts-program-creation-failed",
|
|
8473
|
-
"too-many-files": "ts-program-too-large",
|
|
8474
|
-
"typescript-load-failed": "ts-not-loadable"
|
|
8475
|
-
}[reason],
|
|
8476
|
-
severity: reason === "no-tsconfig" ? "info" : "warning",
|
|
8477
|
-
message,
|
|
8478
|
-
path: options.rootDir || void 0,
|
|
8479
|
-
detail: options.detail
|
|
8480
|
-
})
|
|
8872
|
+
line: lowIndex + 1,
|
|
8873
|
+
column: byteOffset - lineStarts[lowIndex]
|
|
8481
8874
|
};
|
|
8482
8875
|
};
|
|
8483
|
-
|
|
8484
|
-
|
|
8485
|
-
|
|
8486
|
-
|
|
8487
|
-
|
|
8876
|
+
|
|
8877
|
+
//#endregion
|
|
8878
|
+
//#region src/duplicate-blocks/concatenate.ts
|
|
8879
|
+
const SENTINEL_FILE_INDEX = Number.MAX_SAFE_INTEGER;
|
|
8880
|
+
/**
|
|
8881
|
+
* Rank-reduce token hashes to dense 0..K-1 integers and concatenate every
|
|
8882
|
+
* file's reduced sequence with a unique negative sentinel between files. Dense
|
|
8883
|
+
* ranks shrink the suffix-array's bucket counters from ~4 billion to a few
|
|
8884
|
+
* thousand (the standard prefix-doubling speedup), and negative sentinels
|
|
8885
|
+
* guarantee no real-token suffix can match across a file boundary.
|
|
8886
|
+
*/
|
|
8887
|
+
const rankReduceAndConcatenate = (filesHashedTokens) => {
|
|
8888
|
+
const uniqueHashes = /* @__PURE__ */ new Set();
|
|
8889
|
+
for (const fileTokens of filesHashedTokens) for (const hashedToken of fileTokens) uniqueHashes.add(hashedToken.hash);
|
|
8890
|
+
const sortedUniqueHashes = [...uniqueHashes].sort((leftHash, rightHash) => leftHash - rightHash);
|
|
8891
|
+
const hashToRank = /* @__PURE__ */ new Map();
|
|
8892
|
+
for (let rankIndex = 0; rankIndex < sortedUniqueHashes.length; rankIndex++) hashToRank.set(sortedUniqueHashes[rankIndex], rankIndex + 1);
|
|
8893
|
+
const sequenceLength = filesHashedTokens.reduce((runningSum, fileTokens) => runningSum + fileTokens.length, 0) + Math.max(0, filesHashedTokens.length - 1);
|
|
8894
|
+
const tokenSequence = new Array(sequenceLength);
|
|
8895
|
+
const fileOf = new Array(sequenceLength);
|
|
8896
|
+
const fileOffsets = new Array(filesHashedTokens.length);
|
|
8897
|
+
let writeCursor = 0;
|
|
8898
|
+
let nextSentinelValue = -1;
|
|
8899
|
+
for (let fileIndex = 0; fileIndex < filesHashedTokens.length; fileIndex++) {
|
|
8900
|
+
fileOffsets[fileIndex] = writeCursor;
|
|
8901
|
+
const fileTokens = filesHashedTokens[fileIndex];
|
|
8902
|
+
for (const hashedToken of fileTokens) {
|
|
8903
|
+
tokenSequence[writeCursor] = hashToRank.get(hashedToken.hash) ?? 0;
|
|
8904
|
+
fileOf[writeCursor] = fileIndex;
|
|
8905
|
+
writeCursor++;
|
|
8906
|
+
}
|
|
8907
|
+
if (fileIndex < filesHashedTokens.length - 1) {
|
|
8908
|
+
tokenSequence[writeCursor] = nextSentinelValue;
|
|
8909
|
+
fileOf[writeCursor] = SENTINEL_FILE_INDEX;
|
|
8910
|
+
writeCursor++;
|
|
8911
|
+
nextSentinelValue--;
|
|
8912
|
+
}
|
|
8488
8913
|
}
|
|
8489
|
-
|
|
8490
|
-
|
|
8491
|
-
|
|
8914
|
+
return {
|
|
8915
|
+
tokenSequence,
|
|
8916
|
+
fileOf,
|
|
8917
|
+
fileOffsets
|
|
8918
|
+
};
|
|
8919
|
+
};
|
|
8920
|
+
const SENTINEL_FILE_MARKER = SENTINEL_FILE_INDEX;
|
|
8921
|
+
|
|
8922
|
+
//#endregion
|
|
8923
|
+
//#region src/duplicate-blocks/extract.ts
|
|
8924
|
+
const buildRawBlock = (suffixArray, fileOf, fileOffsets, filesTokenCounts, intervalBegin, intervalEnd, tokenLength) => {
|
|
8925
|
+
const candidateInstances = [];
|
|
8926
|
+
for (let suffixIndex = intervalBegin; suffixIndex < intervalEnd; suffixIndex++) {
|
|
8927
|
+
const startPosition = suffixArray[suffixIndex];
|
|
8928
|
+
const fileIndex = fileOf[startPosition];
|
|
8929
|
+
if (fileIndex === SENTINEL_FILE_MARKER) continue;
|
|
8930
|
+
const tokenOffsetWithinFile = startPosition - fileOffsets[fileIndex];
|
|
8931
|
+
if (tokenOffsetWithinFile + tokenLength > filesTokenCounts[fileIndex]) continue;
|
|
8932
|
+
candidateInstances.push({
|
|
8933
|
+
fileIndex,
|
|
8934
|
+
tokenOffsetWithinFile
|
|
8935
|
+
});
|
|
8936
|
+
}
|
|
8937
|
+
if (candidateInstances.length < 2) return void 0;
|
|
8938
|
+
candidateInstances.sort((leftInstance, rightInstance) => {
|
|
8939
|
+
if (leftInstance.fileIndex !== rightInstance.fileIndex) return leftInstance.fileIndex - rightInstance.fileIndex;
|
|
8940
|
+
return leftInstance.tokenOffsetWithinFile - rightInstance.tokenOffsetWithinFile;
|
|
8941
|
+
});
|
|
8942
|
+
const dedupedInstances = [];
|
|
8943
|
+
for (const instance of candidateInstances) {
|
|
8944
|
+
const lastInstance = dedupedInstances[dedupedInstances.length - 1];
|
|
8945
|
+
if (lastInstance !== void 0 && lastInstance.fileIndex === instance.fileIndex && instance.tokenOffsetWithinFile < lastInstance.tokenOffsetWithinFile + tokenLength) continue;
|
|
8946
|
+
dedupedInstances.push(instance);
|
|
8947
|
+
}
|
|
8948
|
+
if (dedupedInstances.length < 2) return void 0;
|
|
8949
|
+
return {
|
|
8950
|
+
instances: dedupedInstances,
|
|
8951
|
+
tokenLength
|
|
8952
|
+
};
|
|
8953
|
+
};
|
|
8954
|
+
/**
|
|
8955
|
+
* Walks `lcpArray` with a monotone stack to materialize every maximal
|
|
8956
|
+
* interval `[i, j]` whose minimum LCP is >= `minTokens`. Within-file
|
|
8957
|
+
* overlapping occurrences are dropped (keep the earliest non-overlapping
|
|
8958
|
+
* prefix), and any block left with fewer than two occurrences is discarded.
|
|
8959
|
+
*/
|
|
8960
|
+
const extractRawDuplicateBlocks = (suffixArray, lcpArray, fileOf, fileOffsets, filesTokenCounts, minTokens) => {
|
|
8961
|
+
const sequenceLength = suffixArray.length;
|
|
8962
|
+
if (sequenceLength < 2) return [];
|
|
8963
|
+
const rawBlocks = [];
|
|
8964
|
+
const monotoneStack = [];
|
|
8965
|
+
for (let scanIndex = 1; scanIndex <= sequenceLength; scanIndex++) {
|
|
8966
|
+
const currentLcp = scanIndex < sequenceLength ? lcpArray[scanIndex] : 0;
|
|
8967
|
+
let intervalStart = scanIndex;
|
|
8968
|
+
while (monotoneStack.length > 0 && monotoneStack[monotoneStack.length - 1].lcpValue > currentLcp) {
|
|
8969
|
+
const popped = monotoneStack.pop();
|
|
8970
|
+
intervalStart = popped.startIndex;
|
|
8971
|
+
if (popped.lcpValue >= minTokens) {
|
|
8972
|
+
const candidate = buildRawBlock(suffixArray, fileOf, fileOffsets, filesTokenCounts, intervalStart - 1, scanIndex, popped.lcpValue);
|
|
8973
|
+
if (candidate) rawBlocks.push(candidate);
|
|
8974
|
+
}
|
|
8975
|
+
}
|
|
8976
|
+
if (scanIndex < sequenceLength) monotoneStack.push({
|
|
8977
|
+
lcpValue: currentLcp,
|
|
8978
|
+
startIndex: intervalStart
|
|
8979
|
+
});
|
|
8492
8980
|
}
|
|
8981
|
+
return rawBlocks;
|
|
8493
8982
|
};
|
|
8494
|
-
|
|
8495
|
-
|
|
8496
|
-
|
|
8497
|
-
|
|
8498
|
-
|
|
8983
|
+
|
|
8984
|
+
//#endregion
|
|
8985
|
+
//#region src/duplicate-blocks/clusters.ts
|
|
8986
|
+
const baseName = (filePath) => {
|
|
8987
|
+
const trailingSlashIndex = filePath.lastIndexOf("/");
|
|
8988
|
+
return trailingSlashIndex === -1 ? filePath : filePath.slice(trailingSlashIndex + 1);
|
|
8989
|
+
};
|
|
8990
|
+
const buildSuggestions = (files, blocks, totalDuplicatedLines) => {
|
|
8991
|
+
const fileBaseNames = files.map((filePath) => baseName(filePath));
|
|
8992
|
+
if (files.length >= 2 && totalDuplicatedLines >= 50) {
|
|
8993
|
+
const estimatedSavings = blocks.reduce((runningSum, block) => runningSum + block.lineCount * Math.max(0, block.instances.length - 1), 0);
|
|
8994
|
+
return [{
|
|
8995
|
+
kind: "extract-module",
|
|
8996
|
+
description: `Extract ${blocks.length} shared duplicate block${blocks.length === 1 ? "" : "s"} (${totalDuplicatedLines} lines) from ${fileBaseNames.join(", ")} into a shared module`,
|
|
8997
|
+
estimatedSavings
|
|
8998
|
+
}];
|
|
8999
|
+
}
|
|
9000
|
+
return blocks.map((block) => ({
|
|
9001
|
+
kind: "extract-function",
|
|
9002
|
+
description: `Extract shared function (${block.lineCount} lines) from ${fileBaseNames.join(", ")}`,
|
|
9003
|
+
estimatedSavings: block.lineCount * Math.max(0, block.instances.length - 1)
|
|
9004
|
+
}));
|
|
9005
|
+
};
|
|
9006
|
+
const groupDuplicateBlocksIntoClusters = (duplicateBlocks) => {
|
|
9007
|
+
if (duplicateBlocks.length === 0) return [];
|
|
9008
|
+
const fileSetKeyToBucket = /* @__PURE__ */ new Map();
|
|
9009
|
+
for (const block of duplicateBlocks) {
|
|
9010
|
+
const sortedFiles = [...new Set(block.instances.map((instance) => instance.path))].sort();
|
|
9011
|
+
const fileSetKey = sortedFiles.join("|");
|
|
9012
|
+
const existing = fileSetKeyToBucket.get(fileSetKey);
|
|
9013
|
+
if (existing) existing.blocks.push(block);
|
|
9014
|
+
else fileSetKeyToBucket.set(fileSetKey, {
|
|
9015
|
+
files: sortedFiles,
|
|
9016
|
+
blocks: [block]
|
|
9017
|
+
});
|
|
9018
|
+
}
|
|
9019
|
+
const clusters = [];
|
|
9020
|
+
for (const bucket of fileSetKeyToBucket.values()) {
|
|
9021
|
+
const totalDuplicatedLines = bucket.blocks.reduce((runningSum, block) => runningSum + block.lineCount, 0);
|
|
9022
|
+
const totalDuplicatedTokens = bucket.blocks.reduce((runningSum, block) => runningSum + block.tokenCount, 0);
|
|
9023
|
+
clusters.push({
|
|
9024
|
+
files: bucket.files,
|
|
9025
|
+
groups: bucket.blocks,
|
|
9026
|
+
totalDuplicatedLines,
|
|
9027
|
+
totalDuplicatedTokens,
|
|
9028
|
+
suggestions: buildSuggestions(bucket.files, bucket.blocks, totalDuplicatedLines)
|
|
9029
|
+
});
|
|
9030
|
+
}
|
|
9031
|
+
clusters.sort((leftCluster, rightCluster) => {
|
|
9032
|
+
if (leftCluster.totalDuplicatedLines !== rightCluster.totalDuplicatedLines) return rightCluster.totalDuplicatedLines - leftCluster.totalDuplicatedLines;
|
|
9033
|
+
return rightCluster.groups.length - leftCluster.groups.length;
|
|
9034
|
+
});
|
|
9035
|
+
return clusters;
|
|
9036
|
+
};
|
|
9037
|
+
|
|
9038
|
+
//#endregion
|
|
9039
|
+
//#region src/duplicate-blocks/shadowed-directory-pairs.ts
|
|
9040
|
+
const splitDirectoryAndFile = (filePath) => {
|
|
9041
|
+
const trailingSlashIndex = filePath.lastIndexOf("/");
|
|
9042
|
+
if (trailingSlashIndex === -1) return {
|
|
9043
|
+
directory: "",
|
|
9044
|
+
baseName: filePath
|
|
8499
9045
|
};
|
|
8500
|
-
|
|
8501
|
-
|
|
8502
|
-
|
|
8503
|
-
}
|
|
8504
|
-
|
|
8505
|
-
|
|
8506
|
-
|
|
8507
|
-
|
|
8508
|
-
|
|
8509
|
-
|
|
9046
|
+
return {
|
|
9047
|
+
directory: filePath.slice(0, trailingSlashIndex + 1),
|
|
9048
|
+
baseName: filePath.slice(trailingSlashIndex + 1)
|
|
9049
|
+
};
|
|
9050
|
+
};
|
|
9051
|
+
const toRelative = (filePath, rootDir) => {
|
|
9052
|
+
if (filePath.startsWith(rootDir + "/")) return filePath.slice(rootDir.length + 1);
|
|
9053
|
+
if (filePath === rootDir) return "";
|
|
9054
|
+
return filePath;
|
|
9055
|
+
};
|
|
9056
|
+
/**
|
|
9057
|
+
* Collapse N two-file duplicate-block clusters that share the same
|
|
9058
|
+
* `(directoryA, directoryB)` and matching basenames into a single
|
|
9059
|
+
* `ShadowedDirectoryPair` finding — the directories themselves drifted
|
|
9060
|
+
* (e.g. `src/` vs `deno/lib/`, a fork, a copy-paste of a route tree).
|
|
9061
|
+
*/
|
|
9062
|
+
const detectShadowedDirectoryPairs = (duplicateBlockClusters, rootDir) => {
|
|
9063
|
+
const directoryPairBuckets = /* @__PURE__ */ new Map();
|
|
9064
|
+
for (const cluster of duplicateBlockClusters) {
|
|
9065
|
+
if (cluster.files.length !== 2) continue;
|
|
9066
|
+
const [firstFile, secondFile] = cluster.files;
|
|
9067
|
+
const firstSplit = splitDirectoryAndFile(toRelative(firstFile, rootDir));
|
|
9068
|
+
const secondSplit = splitDirectoryAndFile(toRelative(secondFile, rootDir));
|
|
9069
|
+
if (firstSplit.baseName !== secondSplit.baseName) continue;
|
|
9070
|
+
const [smallerDirectory, largerDirectory] = firstSplit.directory <= secondSplit.directory ? [firstSplit.directory, secondSplit.directory] : [secondSplit.directory, firstSplit.directory];
|
|
9071
|
+
const pairKey = `${smallerDirectory}::${largerDirectory}`;
|
|
9072
|
+
const entry = {
|
|
9073
|
+
baseName: firstSplit.baseName,
|
|
9074
|
+
duplicatedLines: cluster.totalDuplicatedLines
|
|
8510
9075
|
};
|
|
9076
|
+
const existing = directoryPairBuckets.get(pairKey);
|
|
9077
|
+
if (existing) existing.push(entry);
|
|
9078
|
+
else directoryPairBuckets.set(pairKey, [entry]);
|
|
9079
|
+
}
|
|
9080
|
+
const shadowedDirectoryPairs = [];
|
|
9081
|
+
for (const [pairKey, entries] of directoryPairBuckets) {
|
|
9082
|
+
if (entries.length < 3) continue;
|
|
9083
|
+
const [directoryA, directoryB] = pairKey.split("::");
|
|
9084
|
+
const sharedBaseNames = [...new Set(entries.map((entry) => entry.baseName))].sort();
|
|
9085
|
+
const totalDuplicatedLines = entries.reduce((runningSum, entry) => runningSum + entry.duplicatedLines, 0);
|
|
9086
|
+
shadowedDirectoryPairs.push({
|
|
9087
|
+
directoryA,
|
|
9088
|
+
directoryB,
|
|
9089
|
+
sharedFiles: sharedBaseNames,
|
|
9090
|
+
totalDuplicatedLines
|
|
9091
|
+
});
|
|
8511
9092
|
}
|
|
8512
|
-
|
|
8513
|
-
|
|
8514
|
-
|
|
8515
|
-
|
|
8516
|
-
|
|
8517
|
-
|
|
8518
|
-
|
|
9093
|
+
shadowedDirectoryPairs.sort((leftPair, rightPair) => rightPair.totalDuplicatedLines - leftPair.totalDuplicatedLines);
|
|
9094
|
+
return shadowedDirectoryPairs;
|
|
9095
|
+
};
|
|
9096
|
+
|
|
9097
|
+
//#endregion
|
|
9098
|
+
//#region src/duplicate-blocks/normalize.ts
|
|
9099
|
+
/**
|
|
9100
|
+
* 32-bit FNV-1a. Collisions are tolerable: ties are broken back to the
|
|
9101
|
+
* original (path, offset) tuples downstream, so a rare collision inflates a
|
|
9102
|
+
* duplicate block with one extra spurious instance at worst.
|
|
9103
|
+
*/
|
|
9104
|
+
const FNV_OFFSET_BASIS = 2166136261;
|
|
9105
|
+
const FNV_PRIME = 16777619;
|
|
9106
|
+
const hashString = (input) => {
|
|
9107
|
+
let hash = FNV_OFFSET_BASIS;
|
|
9108
|
+
for (let charIndex = 0; charIndex < input.length; charIndex++) {
|
|
9109
|
+
hash ^= input.charCodeAt(charIndex);
|
|
9110
|
+
hash = Math.imul(hash, FNV_PRIME);
|
|
9111
|
+
}
|
|
9112
|
+
return hash >>> 0;
|
|
9113
|
+
};
|
|
9114
|
+
const resolveNormalization = (mode) => {
|
|
9115
|
+
if (mode === "strict") return {
|
|
9116
|
+
ignoreIdentifiers: false,
|
|
9117
|
+
ignoreStringValues: false,
|
|
9118
|
+
ignoreNumericValues: false
|
|
9119
|
+
};
|
|
9120
|
+
return {
|
|
9121
|
+
ignoreIdentifiers: true,
|
|
9122
|
+
ignoreStringValues: true,
|
|
9123
|
+
ignoreNumericValues: true
|
|
9124
|
+
};
|
|
9125
|
+
};
|
|
9126
|
+
const hashSourceToken = (sourceToken, normalization) => {
|
|
9127
|
+
switch (sourceToken.kind) {
|
|
9128
|
+
case "node-enter": return hashString(`n:${sourceToken.payload}`);
|
|
9129
|
+
case "identifier": return normalization.ignoreIdentifiers ? hashString("id:*") : hashString(`id:${sourceToken.payload}`);
|
|
9130
|
+
case "string-literal": return normalization.ignoreStringValues ? hashString("s:*") : hashString(`s:${sourceToken.payload}`);
|
|
9131
|
+
case "numeric-literal": return normalization.ignoreNumericValues ? hashString("num:*") : hashString(`num:${sourceToken.payload}`);
|
|
9132
|
+
case "boolean-literal": return hashString(`b:${sourceToken.payload}`);
|
|
9133
|
+
case "null-literal": return hashString("null");
|
|
9134
|
+
case "template-literal": return hashString("tpl");
|
|
9135
|
+
case "regexp-literal": return hashString("re");
|
|
9136
|
+
default: return hashString("?");
|
|
9137
|
+
}
|
|
9138
|
+
};
|
|
9139
|
+
const normalizeAndHashTokens = (tokens, mode) => {
|
|
9140
|
+
const normalization = resolveNormalization(mode);
|
|
9141
|
+
const hashedTokens = new Array(tokens.length);
|
|
9142
|
+
for (let tokenIndex = 0; tokenIndex < tokens.length; tokenIndex++) hashedTokens[tokenIndex] = {
|
|
9143
|
+
hash: hashSourceToken(tokens[tokenIndex], normalization),
|
|
9144
|
+
originalIndex: tokenIndex
|
|
9145
|
+
};
|
|
9146
|
+
return hashedTokens;
|
|
9147
|
+
};
|
|
9148
|
+
|
|
9149
|
+
//#endregion
|
|
9150
|
+
//#region src/duplicate-blocks/suffix-array.ts
|
|
9151
|
+
/**
|
|
9152
|
+
* Prefix-doubling suffix array with two-pass radix sort, O(N log N).
|
|
9153
|
+
*
|
|
9154
|
+
* Negative values in `tokenSequence` (file-separator sentinels emitted by
|
|
9155
|
+
* `rankReduceAndConcatenate`) are shifted up so all ranks are >= 0. The
|
|
9156
|
+
* shift preserves the property that sentinels sort before all real ranks,
|
|
9157
|
+
* which is what stops cross-file suffix matches.
|
|
9158
|
+
*/
|
|
9159
|
+
const buildSuffixArray = (tokenSequence) => {
|
|
9160
|
+
const sequenceLength = tokenSequence.length;
|
|
9161
|
+
if (sequenceLength === 0) return [];
|
|
9162
|
+
let minimumValue = 0;
|
|
9163
|
+
for (let scanIndex = 0; scanIndex < sequenceLength; scanIndex++) if (tokenSequence[scanIndex] < minimumValue) minimumValue = tokenSequence[scanIndex];
|
|
9164
|
+
let currentRanks = new Array(sequenceLength);
|
|
9165
|
+
for (let scanIndex = 0; scanIndex < sequenceLength; scanIndex++) currentRanks[scanIndex] = tokenSequence[scanIndex] - minimumValue;
|
|
9166
|
+
let suffixArray = new Array(sequenceLength);
|
|
9167
|
+
for (let positionIndex = 0; positionIndex < sequenceLength; positionIndex++) suffixArray[positionIndex] = positionIndex;
|
|
9168
|
+
let nextRanks = new Array(sequenceLength);
|
|
9169
|
+
let scratchSuffixArray = new Array(sequenceLength);
|
|
9170
|
+
let maximumRank = 0;
|
|
9171
|
+
for (let scanIndex = 0; scanIndex < sequenceLength; scanIndex++) if (currentRanks[scanIndex] > maximumRank) maximumRank = currentRanks[scanIndex];
|
|
9172
|
+
let stride = 1;
|
|
9173
|
+
while (stride < sequenceLength) {
|
|
9174
|
+
const bucketCount = maximumRank + 2;
|
|
9175
|
+
const buckets = new Array(bucketCount + 1).fill(0);
|
|
9176
|
+
for (let suffixIndex = 0; suffixIndex < sequenceLength; suffixIndex++) {
|
|
9177
|
+
const startPosition = suffixArray[suffixIndex];
|
|
9178
|
+
const secondaryKey = startPosition + stride < sequenceLength ? currentRanks[startPosition + stride] + 1 : 0;
|
|
9179
|
+
buckets[secondaryKey]++;
|
|
9180
|
+
}
|
|
9181
|
+
let prefixSum = 0;
|
|
9182
|
+
for (let bucketIndex = 0; bucketIndex < buckets.length; bucketIndex++) {
|
|
9183
|
+
const bucketCountValue = buckets[bucketIndex];
|
|
9184
|
+
buckets[bucketIndex] = prefixSum;
|
|
9185
|
+
prefixSum += bucketCountValue;
|
|
9186
|
+
}
|
|
9187
|
+
for (let suffixIndex = 0; suffixIndex < sequenceLength; suffixIndex++) {
|
|
9188
|
+
const startPosition = suffixArray[suffixIndex];
|
|
9189
|
+
const secondaryKey = startPosition + stride < sequenceLength ? currentRanks[startPosition + stride] + 1 : 0;
|
|
9190
|
+
scratchSuffixArray[buckets[secondaryKey]] = startPosition;
|
|
9191
|
+
buckets[secondaryKey]++;
|
|
9192
|
+
}
|
|
9193
|
+
for (let bucketIndex = 0; bucketIndex < buckets.length; bucketIndex++) buckets[bucketIndex] = 0;
|
|
9194
|
+
for (let suffixIndex = 0; suffixIndex < sequenceLength; suffixIndex++) {
|
|
9195
|
+
const startPosition = scratchSuffixArray[suffixIndex];
|
|
9196
|
+
buckets[currentRanks[startPosition]]++;
|
|
9197
|
+
}
|
|
9198
|
+
prefixSum = 0;
|
|
9199
|
+
for (let bucketIndex = 0; bucketIndex < buckets.length; bucketIndex++) {
|
|
9200
|
+
const bucketCountValue = buckets[bucketIndex];
|
|
9201
|
+
buckets[bucketIndex] = prefixSum;
|
|
9202
|
+
prefixSum += bucketCountValue;
|
|
9203
|
+
}
|
|
9204
|
+
for (let suffixIndex = 0; suffixIndex < sequenceLength; suffixIndex++) {
|
|
9205
|
+
const startPosition = scratchSuffixArray[suffixIndex];
|
|
9206
|
+
suffixArray[buckets[currentRanks[startPosition]]] = startPosition;
|
|
9207
|
+
buckets[currentRanks[startPosition]]++;
|
|
9208
|
+
}
|
|
9209
|
+
nextRanks[suffixArray[0]] = 0;
|
|
9210
|
+
for (let suffixIndex = 1; suffixIndex < sequenceLength; suffixIndex++) {
|
|
9211
|
+
const previousStart = suffixArray[suffixIndex - 1];
|
|
9212
|
+
const currentStart = suffixArray[suffixIndex];
|
|
9213
|
+
const previousSecondary = previousStart + stride < sequenceLength ? currentRanks[previousStart + stride] : -1;
|
|
9214
|
+
const currentSecondary = currentStart + stride < sequenceLength ? currentRanks[currentStart + stride] : -1;
|
|
9215
|
+
const isSameBucket = currentRanks[previousStart] === currentRanks[currentStart] && previousSecondary === currentSecondary;
|
|
9216
|
+
nextRanks[currentStart] = nextRanks[previousStart] + (isSameBucket ? 0 : 1);
|
|
9217
|
+
}
|
|
9218
|
+
const newMaximumRank = nextRanks[suffixArray[sequenceLength - 1]];
|
|
9219
|
+
[currentRanks, nextRanks] = [nextRanks, currentRanks];
|
|
9220
|
+
if (newMaximumRank === sequenceLength - 1) break;
|
|
9221
|
+
maximumRank = newMaximumRank;
|
|
9222
|
+
stride *= 2;
|
|
9223
|
+
}
|
|
9224
|
+
return suffixArray;
|
|
9225
|
+
};
|
|
9226
|
+
/**
|
|
9227
|
+
* Kasai's O(N) longest-common-prefix array. The `>= 0` check inside the inner
|
|
9228
|
+
* loop is the only non-textbook bit: it prevents a real-token LCP from
|
|
9229
|
+
* accidentally crossing a sentinel boundary (sentinels are negative).
|
|
9230
|
+
*/
|
|
9231
|
+
const buildLcpArray = (tokenSequence, suffixArray) => {
|
|
9232
|
+
const sequenceLength = tokenSequence.length;
|
|
9233
|
+
const inverseSuffixArray = new Array(sequenceLength);
|
|
9234
|
+
for (let arrayIndex = 0; arrayIndex < sequenceLength; arrayIndex++) inverseSuffixArray[suffixArray[arrayIndex]] = arrayIndex;
|
|
9235
|
+
const lcpArray = new Array(sequenceLength).fill(0);
|
|
9236
|
+
let runningLcp = 0;
|
|
9237
|
+
for (let positionIndex = 0; positionIndex < sequenceLength; positionIndex++) {
|
|
9238
|
+
if (inverseSuffixArray[positionIndex] === 0) {
|
|
9239
|
+
runningLcp = 0;
|
|
9240
|
+
continue;
|
|
9241
|
+
}
|
|
9242
|
+
const previousStart = suffixArray[inverseSuffixArray[positionIndex] - 1];
|
|
9243
|
+
while (positionIndex + runningLcp < sequenceLength && previousStart + runningLcp < sequenceLength && tokenSequence[positionIndex + runningLcp] === tokenSequence[previousStart + runningLcp] && tokenSequence[positionIndex + runningLcp] >= 0) runningLcp++;
|
|
9244
|
+
lcpArray[inverseSuffixArray[positionIndex]] = runningLcp;
|
|
9245
|
+
if (runningLcp > 0) runningLcp--;
|
|
9246
|
+
}
|
|
9247
|
+
return lcpArray;
|
|
9248
|
+
};
|
|
9249
|
+
|
|
9250
|
+
//#endregion
|
|
9251
|
+
//#region src/utils/is-ast-node.ts
|
|
9252
|
+
const isAstNode = (candidate) => typeof candidate === "object" && candidate !== null && "type" in candidate;
|
|
9253
|
+
|
|
9254
|
+
//#endregion
|
|
9255
|
+
//#region src/duplicate-blocks/token-visitor.ts
|
|
9256
|
+
const NODES_DROPPED_FROM_TOKEN_STREAM = new Set([
|
|
9257
|
+
"ImportDeclaration",
|
|
9258
|
+
"ExportAllDeclaration",
|
|
9259
|
+
"TSTypeAnnotation",
|
|
9260
|
+
"TSTypeAliasDeclaration",
|
|
9261
|
+
"TSInterfaceDeclaration",
|
|
9262
|
+
"TSTypeParameterDeclaration",
|
|
9263
|
+
"TSTypeParameterInstantiation",
|
|
9264
|
+
"TSTypeReference",
|
|
9265
|
+
"TSAnyKeyword",
|
|
9266
|
+
"TSUnknownKeyword",
|
|
9267
|
+
"TSStringKeyword",
|
|
9268
|
+
"TSNumberKeyword",
|
|
9269
|
+
"TSBooleanKeyword",
|
|
9270
|
+
"TSVoidKeyword",
|
|
9271
|
+
"TSUndefinedKeyword",
|
|
9272
|
+
"TSNullKeyword",
|
|
9273
|
+
"TSNeverKeyword",
|
|
9274
|
+
"TSUnionType",
|
|
9275
|
+
"TSIntersectionType",
|
|
9276
|
+
"TSLiteralType",
|
|
9277
|
+
"TSArrayType",
|
|
9278
|
+
"TSTupleType",
|
|
9279
|
+
"TSTypeLiteral",
|
|
9280
|
+
"TSPropertySignature",
|
|
9281
|
+
"TSMethodSignature",
|
|
9282
|
+
"TSCallSignatureDeclaration",
|
|
9283
|
+
"TSConstructSignatureDeclaration",
|
|
9284
|
+
"TSIndexSignature",
|
|
9285
|
+
"TSConditionalType",
|
|
9286
|
+
"TSMappedType",
|
|
9287
|
+
"TSInferType",
|
|
9288
|
+
"TSImportType",
|
|
9289
|
+
"TSQualifiedName",
|
|
9290
|
+
"TSTypeOperator",
|
|
9291
|
+
"TSTypePredicate",
|
|
9292
|
+
"TSFunctionType",
|
|
9293
|
+
"TSConstructorType"
|
|
9294
|
+
]);
|
|
9295
|
+
const visitChildrenRaw = (node, visit) => {
|
|
9296
|
+
if (!isAstNode(node)) return;
|
|
9297
|
+
for (const key of Object.keys(node)) {
|
|
9298
|
+
if (key === "type" || key === "start" || key === "end" || key === "loc" || key === "range") continue;
|
|
9299
|
+
const value = node[key];
|
|
9300
|
+
if (Array.isArray(value)) for (const item of value) visit(item);
|
|
9301
|
+
else if (value !== null && typeof value === "object") visit(value);
|
|
9302
|
+
}
|
|
9303
|
+
};
|
|
9304
|
+
const safeNumberOrZero = (candidate) => typeof candidate === "number" ? candidate : 0;
|
|
9305
|
+
/**
|
|
9306
|
+
* Walk an oxc AST and emit a flat token stream suitable for suffix-array-based
|
|
9307
|
+
* duplicate-block detection. Two structurally-identical regions of code produce the same
|
|
9308
|
+
* token sequence (modulo identifier/literal-value normalization, applied later
|
|
9309
|
+
* in `normalize.ts`).
|
|
9310
|
+
*
|
|
9311
|
+
* Implementation note: rather than a hand-written keyword/operator lexer-style
|
|
9312
|
+
* visitor, we walk the AST generically and emit one `node-enter` token per
|
|
9313
|
+
* visited node. This trades a slightly different token-density profile for
|
|
9314
|
+
* less code. AST-shape tokens still distinguish
|
|
9315
|
+
* `function add(a, b) { return a + b }` from `const add = (a, b) => a + b`.
|
|
9316
|
+
* Identifiers and value literals get dedicated tokens so semantic-mode
|
|
9317
|
+
* normalization can blind them.
|
|
9318
|
+
*
|
|
9319
|
+
* Imports and type-only constructs are dropped to keep import-block boilerplate
|
|
9320
|
+
* and ambient type declarations from inflating the noise floor.
|
|
9321
|
+
*/
|
|
9322
|
+
const tokenizeAst = (program) => {
|
|
9323
|
+
const tokens = [];
|
|
9324
|
+
const visit = (node) => {
|
|
9325
|
+
if (!isAstNode(node)) return;
|
|
9326
|
+
const nodeType = node.type;
|
|
9327
|
+
if (NODES_DROPPED_FROM_TOKEN_STREAM.has(nodeType)) return;
|
|
9328
|
+
const start = safeNumberOrZero(node.start);
|
|
9329
|
+
const end = safeNumberOrZero(node.end);
|
|
9330
|
+
if (nodeType === "Identifier" || nodeType === "PrivateIdentifier") {
|
|
9331
|
+
const identifierName = node.name;
|
|
9332
|
+
tokens.push({
|
|
9333
|
+
kind: "identifier",
|
|
9334
|
+
payload: typeof identifierName === "string" ? identifierName : "",
|
|
9335
|
+
start,
|
|
9336
|
+
end
|
|
9337
|
+
});
|
|
9338
|
+
return;
|
|
9339
|
+
}
|
|
9340
|
+
if (nodeType === "Literal") {
|
|
9341
|
+
const literalValue = node.value;
|
|
9342
|
+
if (typeof literalValue === "string") tokens.push({
|
|
9343
|
+
kind: "string-literal",
|
|
9344
|
+
payload: literalValue,
|
|
9345
|
+
start,
|
|
9346
|
+
end
|
|
9347
|
+
});
|
|
9348
|
+
else if (typeof literalValue === "number") tokens.push({
|
|
9349
|
+
kind: "numeric-literal",
|
|
9350
|
+
payload: String(literalValue),
|
|
9351
|
+
start,
|
|
9352
|
+
end
|
|
9353
|
+
});
|
|
9354
|
+
else if (typeof literalValue === "boolean") tokens.push({
|
|
9355
|
+
kind: "boolean-literal",
|
|
9356
|
+
payload: literalValue ? "true" : "false",
|
|
9357
|
+
start,
|
|
9358
|
+
end
|
|
9359
|
+
});
|
|
9360
|
+
else if (literalValue === null) tokens.push({
|
|
9361
|
+
kind: "null-literal",
|
|
9362
|
+
payload: "null",
|
|
9363
|
+
start,
|
|
9364
|
+
end
|
|
9365
|
+
});
|
|
9366
|
+
else if (node.regex) tokens.push({
|
|
9367
|
+
kind: "regexp-literal",
|
|
9368
|
+
payload: "regex",
|
|
9369
|
+
start,
|
|
9370
|
+
end
|
|
9371
|
+
});
|
|
9372
|
+
else tokens.push({
|
|
9373
|
+
kind: "node-enter",
|
|
9374
|
+
payload: nodeType,
|
|
9375
|
+
start,
|
|
9376
|
+
end
|
|
9377
|
+
});
|
|
9378
|
+
return;
|
|
9379
|
+
}
|
|
9380
|
+
if (nodeType === "TemplateLiteral") {
|
|
9381
|
+
tokens.push({
|
|
9382
|
+
kind: "template-literal",
|
|
9383
|
+
payload: "tpl",
|
|
9384
|
+
start,
|
|
9385
|
+
end
|
|
9386
|
+
});
|
|
9387
|
+
visitChildrenRaw(node, visit);
|
|
9388
|
+
return;
|
|
9389
|
+
}
|
|
9390
|
+
tokens.push({
|
|
9391
|
+
kind: "node-enter",
|
|
9392
|
+
payload: nodeType,
|
|
9393
|
+
start,
|
|
9394
|
+
end
|
|
9395
|
+
});
|
|
9396
|
+
visitChildrenRaw(node, visit);
|
|
9397
|
+
};
|
|
9398
|
+
visit(program);
|
|
9399
|
+
return tokens;
|
|
9400
|
+
};
|
|
9401
|
+
|
|
9402
|
+
//#endregion
|
|
9403
|
+
//#region src/duplicate-blocks/index.ts
|
|
9404
|
+
const isBinaryFile = (sourceText) => {
|
|
9405
|
+
const sampleEnd = Math.min(sourceText.length, BINARY_DETECTION_SAMPLE_BYTES);
|
|
9406
|
+
let nullByteCount = 0;
|
|
9407
|
+
for (let charIndex = 0; charIndex < sampleEnd; charIndex++) if (sourceText.charCodeAt(charIndex) === 0) {
|
|
9408
|
+
nullByteCount++;
|
|
9409
|
+
if (nullByteCount >= 4) return true;
|
|
9410
|
+
}
|
|
9411
|
+
return false;
|
|
9412
|
+
};
|
|
9413
|
+
const isMinifiedSource = (sourceText) => {
|
|
9414
|
+
if (sourceText.length < 5e3) return false;
|
|
9415
|
+
const lineCount = (sourceText.match(/\n/g)?.length ?? 0) + 1;
|
|
9416
|
+
return sourceText.length / lineCount > 500;
|
|
9417
|
+
};
|
|
9418
|
+
const tokenizeFile = (filePath) => {
|
|
9419
|
+
let sourceStat;
|
|
9420
|
+
try {
|
|
9421
|
+
sourceStat = statSync(filePath);
|
|
9422
|
+
} catch {
|
|
9423
|
+
return;
|
|
9424
|
+
}
|
|
9425
|
+
if (sourceStat.size > 2e6) return void 0;
|
|
9426
|
+
let sourceText;
|
|
9427
|
+
try {
|
|
9428
|
+
sourceText = readFileSync(filePath, "utf-8");
|
|
9429
|
+
} catch {
|
|
9430
|
+
return;
|
|
9431
|
+
}
|
|
9432
|
+
if (sourceText.length === 0) return void 0;
|
|
9433
|
+
if (isBinaryFile(sourceText)) return void 0;
|
|
9434
|
+
if (isMinifiedSource(sourceText)) return void 0;
|
|
9435
|
+
let parseResult;
|
|
9436
|
+
try {
|
|
9437
|
+
parseResult = parseSync(filePath, sourceText);
|
|
9438
|
+
} catch {
|
|
9439
|
+
return;
|
|
9440
|
+
}
|
|
9441
|
+
const sourceTokens = tokenizeAst(parseResult.program);
|
|
9442
|
+
if (sourceTokens.length === 0) return void 0;
|
|
9443
|
+
const lineStarts = computeLineStarts(sourceText);
|
|
9444
|
+
return {
|
|
9445
|
+
path: filePath,
|
|
9446
|
+
sourceTokens,
|
|
9447
|
+
lineStarts,
|
|
9448
|
+
lineCount: lineStarts.length
|
|
9449
|
+
};
|
|
9450
|
+
};
|
|
9451
|
+
const buildCloneInstance = (rawInstance, tokenLength, tokenizedFiles) => {
|
|
9452
|
+
const file = tokenizedFiles[rawInstance.fileIndex];
|
|
9453
|
+
const firstToken = file.sourceTokens[rawInstance.tokenOffsetWithinFile];
|
|
9454
|
+
const lastToken = file.sourceTokens[rawInstance.tokenOffsetWithinFile + tokenLength - 1];
|
|
9455
|
+
const startSpan = offsetToLineColumn(firstToken.start, file.lineStarts);
|
|
9456
|
+
const endSpan = offsetToLineColumn(lastToken.end, file.lineStarts);
|
|
9457
|
+
return {
|
|
9458
|
+
path: file.path,
|
|
9459
|
+
startLine: startSpan.line,
|
|
9460
|
+
endLine: endSpan.line,
|
|
9461
|
+
startColumn: startSpan.column,
|
|
9462
|
+
endColumn: endSpan.column
|
|
9463
|
+
};
|
|
9464
|
+
};
|
|
9465
|
+
const directoryOf = (filePath) => dirname(filePath);
|
|
9466
|
+
const filterRawBlocksToReportableDuplicates = (rawBlocks, tokenizedFiles, config) => {
|
|
9467
|
+
const duplicateBlocks = [];
|
|
9468
|
+
for (const rawBlock of rawBlocks) {
|
|
9469
|
+
const instances = rawBlock.instances.map((rawInstance) => buildCloneInstance(rawInstance, rawBlock.tokenLength, tokenizedFiles));
|
|
9470
|
+
let lineCount = 0;
|
|
9471
|
+
for (const instance of instances) {
|
|
9472
|
+
const instanceLineCount = instance.endLine - instance.startLine + 1;
|
|
9473
|
+
if (instanceLineCount > lineCount) lineCount = instanceLineCount;
|
|
9474
|
+
}
|
|
9475
|
+
if (lineCount < config.minLines) continue;
|
|
9476
|
+
if (instances.length < config.minOccurrences) continue;
|
|
9477
|
+
if (config.skipLocal) {
|
|
9478
|
+
if (new Set(instances.map((instance) => directoryOf(instance.path))).size < 2) continue;
|
|
9479
|
+
}
|
|
9480
|
+
const distinctFiles = new Set(instances.map((instance) => instance.path));
|
|
9481
|
+
const confidence = distinctFiles.size >= 2 ? "high" : "medium";
|
|
9482
|
+
duplicateBlocks.push({
|
|
9483
|
+
instances,
|
|
9484
|
+
tokenCount: rawBlock.tokenLength,
|
|
9485
|
+
lineCount,
|
|
9486
|
+
confidence,
|
|
9487
|
+
reason: distinctFiles.size >= 2 ? `${instances.length} occurrences spanning ${distinctFiles.size} files (≥${rawBlock.tokenLength} tokens, ${lineCount} lines)` : `${instances.length} occurrences within a single file (≥${rawBlock.tokenLength} tokens, ${lineCount} lines)`
|
|
9488
|
+
});
|
|
9489
|
+
}
|
|
9490
|
+
const maximalBlocks = dropBlocksSubsumedByLongerSibling(duplicateBlocks);
|
|
9491
|
+
maximalBlocks.sort((firstClone, secondClone) => {
|
|
9492
|
+
if (firstClone.lineCount !== secondClone.lineCount) return secondClone.lineCount - firstClone.lineCount;
|
|
9493
|
+
return secondClone.tokenCount - firstClone.tokenCount;
|
|
9494
|
+
});
|
|
9495
|
+
return maximalBlocks;
|
|
9496
|
+
};
|
|
9497
|
+
/**
|
|
9498
|
+
* The suffix-array + LCP-interval scan emits one block per LCP interval, but
|
|
9499
|
+
* nested intervals routinely yield the same set of source spans at multiple
|
|
9500
|
+
* lengths (the same maximal repeat reported at L, L-1, L-2, …). Drop any
|
|
9501
|
+
* block whose every instance is spatially contained inside some other block's
|
|
9502
|
+
* matching instance — that other block is strictly more informative.
|
|
9503
|
+
*
|
|
9504
|
+
* O(N²) worst-case, but N here is post-filter blocks (typically <1000 even on
|
|
9505
|
+
* large monorepos), and the early-exit on instance-count mismatch keeps it
|
|
9506
|
+
* tight in practice.
|
|
9507
|
+
*/
|
|
9508
|
+
const dropBlocksSubsumedByLongerSibling = (blocks) => {
|
|
9509
|
+
const sorted = [...blocks].sort((firstBlock, secondBlock) => {
|
|
9510
|
+
if (firstBlock.tokenCount !== secondBlock.tokenCount) return secondBlock.tokenCount - firstBlock.tokenCount;
|
|
9511
|
+
return secondBlock.lineCount - firstBlock.lineCount;
|
|
9512
|
+
});
|
|
9513
|
+
const survivors = [];
|
|
9514
|
+
for (const candidate of sorted) {
|
|
9515
|
+
let subsumed = false;
|
|
9516
|
+
for (const survivor of survivors) {
|
|
9517
|
+
if (survivor.instances.length !== candidate.instances.length) continue;
|
|
9518
|
+
if (allInstancesContainedIn(candidate, survivor)) {
|
|
9519
|
+
subsumed = true;
|
|
9520
|
+
break;
|
|
9521
|
+
}
|
|
9522
|
+
}
|
|
9523
|
+
if (!subsumed) survivors.push(candidate);
|
|
9524
|
+
}
|
|
9525
|
+
return survivors;
|
|
9526
|
+
};
|
|
9527
|
+
const allInstancesContainedIn = (candidate, longer) => {
|
|
9528
|
+
for (const candidateInstance of candidate.instances) {
|
|
9529
|
+
let matched = false;
|
|
9530
|
+
for (const longerInstance of longer.instances) if (candidateInstance.path === longerInstance.path && isSpanContained(candidateInstance, longerInstance)) {
|
|
9531
|
+
matched = true;
|
|
9532
|
+
break;
|
|
9533
|
+
}
|
|
9534
|
+
if (!matched) return false;
|
|
9535
|
+
}
|
|
9536
|
+
return true;
|
|
9537
|
+
};
|
|
9538
|
+
const isSpanContained = (inner, outer) => {
|
|
9539
|
+
const innerStartsAfterOuter = inner.startLine > outer.startLine || inner.startLine === outer.startLine && inner.startColumn >= outer.startColumn;
|
|
9540
|
+
const innerEndsBeforeOuter = inner.endLine < outer.endLine || inner.endLine === outer.endLine && inner.endColumn <= outer.endColumn;
|
|
9541
|
+
return innerStartsAfterOuter && innerEndsBeforeOuter;
|
|
9542
|
+
};
|
|
9543
|
+
/**
|
|
9544
|
+
* Token-based duplicate block detector.
|
|
9545
|
+
*
|
|
9546
|
+
* Pipeline:
|
|
9547
|
+
* 1. Tokenize each file with the AST visitor in `token-visitor.ts`
|
|
9548
|
+
* 2. Hash + normalize tokens with the chosen detection mode
|
|
9549
|
+
* 3. Concatenate every file's hashed tokens with unique negative sentinels
|
|
9550
|
+
* 4. Build a suffix array (prefix doubling + radix sort) and LCP array
|
|
9551
|
+
* 5. Stack-based LCP-interval scan extracts maximal duplicate blocks
|
|
9552
|
+
* 6. Filter on min-tokens / min-lines / min-occurrences / skip-local
|
|
9553
|
+
* 7. Group clones into families; collapse N two-file families with matching
|
|
9554
|
+
* basenames into a `ShadowedDirectoryPair` finding
|
|
9555
|
+
*
|
|
9556
|
+
* Returns empty arrays when `config.enabled` is false.
|
|
9557
|
+
*/
|
|
9558
|
+
const detectDuplicateBlocks = (graph, config, rootDir) => {
|
|
9559
|
+
if (!config || !config.enabled) return {
|
|
9560
|
+
duplicateBlocks: [],
|
|
9561
|
+
duplicateBlockClusters: [],
|
|
9562
|
+
shadowedDirectoryPairs: []
|
|
9563
|
+
};
|
|
9564
|
+
const tokenizedFiles = [];
|
|
9565
|
+
for (const module of graph.modules) {
|
|
9566
|
+
if (module.isDeclarationFile) continue;
|
|
9567
|
+
if (module.isConfigFile) continue;
|
|
9568
|
+
const tokenizedFile = tokenizeFile(module.fileId.path);
|
|
9569
|
+
if (!tokenizedFile) continue;
|
|
9570
|
+
tokenizedFiles.push(tokenizedFile);
|
|
9571
|
+
}
|
|
9572
|
+
if (tokenizedFiles.length === 0) return {
|
|
9573
|
+
duplicateBlocks: [],
|
|
9574
|
+
duplicateBlockClusters: [],
|
|
9575
|
+
shadowedDirectoryPairs: []
|
|
9576
|
+
};
|
|
9577
|
+
const filesHashedTokens = tokenizedFiles.map((file) => normalizeAndHashTokens(file.sourceTokens, config.mode));
|
|
9578
|
+
const filesTokenCounts = filesHashedTokens.map((fileTokens) => fileTokens.length);
|
|
9579
|
+
if (!filesTokenCounts.some((count) => count >= config.minTokens)) return {
|
|
9580
|
+
duplicateBlocks: [],
|
|
9581
|
+
duplicateBlockClusters: [],
|
|
9582
|
+
shadowedDirectoryPairs: []
|
|
9583
|
+
};
|
|
9584
|
+
const concatenation = rankReduceAndConcatenate(filesHashedTokens);
|
|
9585
|
+
if (concatenation.tokenSequence.length === 0) return {
|
|
9586
|
+
duplicateBlocks: [],
|
|
9587
|
+
duplicateBlockClusters: [],
|
|
9588
|
+
shadowedDirectoryPairs: []
|
|
9589
|
+
};
|
|
9590
|
+
const suffixArray = buildSuffixArray(concatenation.tokenSequence);
|
|
9591
|
+
const duplicateBlocks = filterRawBlocksToReportableDuplicates(extractRawDuplicateBlocks(suffixArray, buildLcpArray(concatenation.tokenSequence, suffixArray), concatenation.fileOf, concatenation.fileOffsets, filesTokenCounts, config.minTokens), tokenizedFiles, config);
|
|
9592
|
+
const duplicateBlockClusters = groupDuplicateBlocksIntoClusters(duplicateBlocks);
|
|
9593
|
+
return {
|
|
9594
|
+
duplicateBlocks,
|
|
9595
|
+
duplicateBlockClusters,
|
|
9596
|
+
shadowedDirectoryPairs: detectShadowedDirectoryPairs(duplicateBlockClusters, rootDir)
|
|
9597
|
+
};
|
|
9598
|
+
};
|
|
9599
|
+
|
|
9600
|
+
//#endregion
|
|
9601
|
+
//#region src/report/re-export-cycles.ts
|
|
9602
|
+
/**
|
|
9603
|
+
* Reports cycles in the subgraph of `isReExportEdge` edges only. These are
|
|
9604
|
+
* a strict subset of `circularDependencies` but worth separating: every
|
|
9605
|
+
* general cycle can have a legitimate bidirectional-collaboration reason,
|
|
9606
|
+
* but a re-export cycle has none — it always tanks tree-shaking and risks
|
|
9607
|
+
* the "Cannot access X before initialization" TDZ runtime error.
|
|
9608
|
+
*/
|
|
9609
|
+
const detectReExportCycles = (graph) => {
|
|
9610
|
+
const adjacency = Array.from({ length: graph.modules.length }, () => []);
|
|
9611
|
+
const reExportTargetSets = Array.from({ length: graph.modules.length }, () => /* @__PURE__ */ new Set());
|
|
9612
|
+
for (const edge of graph.edges) {
|
|
9613
|
+
if (!edge.isReExportEdge) continue;
|
|
9614
|
+
if (edge.target >= graph.modules.length) continue;
|
|
9615
|
+
if (reExportTargetSets[edge.source].has(edge.target)) continue;
|
|
9616
|
+
reExportTargetSets[edge.source].add(edge.target);
|
|
9617
|
+
adjacency[edge.source].push(edge.target);
|
|
9618
|
+
}
|
|
9619
|
+
const sccComponents = computeStronglyConnectedComponents(adjacency);
|
|
9620
|
+
const findings = [];
|
|
9621
|
+
for (const component of sccComponents) {
|
|
9622
|
+
if (component.length === 1) {
|
|
9623
|
+
const onlyNode = component[0];
|
|
9624
|
+
if (!adjacency[onlyNode].includes(onlyNode)) continue;
|
|
9625
|
+
const filePath = graph.modules[onlyNode].fileId.path;
|
|
9626
|
+
findings.push({
|
|
9627
|
+
files: [filePath],
|
|
9628
|
+
kind: "self-loop",
|
|
9629
|
+
confidence: "high",
|
|
9630
|
+
reason: `${filePath} re-exports from itself — the barrel imports its own root, which breaks bundler tree-shaking and risks TDZ runtime errors`
|
|
9631
|
+
});
|
|
9632
|
+
continue;
|
|
9633
|
+
}
|
|
9634
|
+
const sortedFiles = component.map((moduleIndex) => graph.modules[moduleIndex].fileId.path).sort();
|
|
9635
|
+
findings.push({
|
|
9636
|
+
files: sortedFiles,
|
|
9637
|
+
kind: "multi-node",
|
|
9638
|
+
confidence: "high",
|
|
9639
|
+
reason: `${sortedFiles.length} modules form a re-export cycle — refactor consumers to import from the leaf module instead of the barrel`
|
|
9640
|
+
});
|
|
9641
|
+
}
|
|
9642
|
+
findings.sort((firstFinding, secondFinding) => firstFinding.files[0].localeCompare(secondFinding.files[0]));
|
|
9643
|
+
return findings;
|
|
9644
|
+
};
|
|
9645
|
+
/**
|
|
9646
|
+
* Iterative Tarjan's SCC. Singleton components are returned too so the
|
|
9647
|
+
* caller can distinguish a real self-loop from a node with no edges.
|
|
9648
|
+
*/
|
|
9649
|
+
const computeStronglyConnectedComponents = (adjacency) => {
|
|
9650
|
+
const nodeCount = adjacency.length;
|
|
9651
|
+
if (nodeCount === 0) return [];
|
|
9652
|
+
const indices = new Array(nodeCount).fill(-1);
|
|
9653
|
+
const lowLinks = new Array(nodeCount).fill(0);
|
|
9654
|
+
const onStack = new Array(nodeCount).fill(false);
|
|
9655
|
+
const tarjanStack = [];
|
|
9656
|
+
const components = [];
|
|
9657
|
+
let nextIndex = 0;
|
|
9658
|
+
for (let startNode = 0; startNode < nodeCount; startNode++) {
|
|
9659
|
+
if (indices[startNode] !== -1) continue;
|
|
9660
|
+
const dfsStack = [{
|
|
9661
|
+
node: startNode,
|
|
9662
|
+
successorPosition: 0
|
|
9663
|
+
}];
|
|
9664
|
+
indices[startNode] = nextIndex;
|
|
9665
|
+
lowLinks[startNode] = nextIndex;
|
|
9666
|
+
nextIndex++;
|
|
9667
|
+
onStack[startNode] = true;
|
|
9668
|
+
tarjanStack.push(startNode);
|
|
9669
|
+
while (dfsStack.length > 0) {
|
|
9670
|
+
const frame = dfsStack[dfsStack.length - 1];
|
|
9671
|
+
const successors = adjacency[frame.node];
|
|
9672
|
+
if (frame.successorPosition < successors.length) {
|
|
9673
|
+
const successorNode = successors[frame.successorPosition];
|
|
9674
|
+
frame.successorPosition++;
|
|
9675
|
+
if (indices[successorNode] === -1) {
|
|
9676
|
+
indices[successorNode] = nextIndex;
|
|
9677
|
+
lowLinks[successorNode] = nextIndex;
|
|
9678
|
+
nextIndex++;
|
|
9679
|
+
onStack[successorNode] = true;
|
|
9680
|
+
tarjanStack.push(successorNode);
|
|
9681
|
+
dfsStack.push({
|
|
9682
|
+
node: successorNode,
|
|
9683
|
+
successorPosition: 0
|
|
9684
|
+
});
|
|
9685
|
+
} else if (onStack[successorNode]) {
|
|
9686
|
+
if (indices[successorNode] < lowLinks[frame.node]) lowLinks[frame.node] = indices[successorNode];
|
|
9687
|
+
}
|
|
9688
|
+
} else {
|
|
9689
|
+
if (lowLinks[frame.node] === indices[frame.node]) {
|
|
9690
|
+
const component = [];
|
|
9691
|
+
let popped;
|
|
9692
|
+
do {
|
|
9693
|
+
popped = tarjanStack.pop();
|
|
9694
|
+
onStack[popped] = false;
|
|
9695
|
+
component.push(popped);
|
|
9696
|
+
} while (popped !== frame.node);
|
|
9697
|
+
components.push(component);
|
|
9698
|
+
}
|
|
9699
|
+
dfsStack.pop();
|
|
9700
|
+
if (dfsStack.length > 0) {
|
|
9701
|
+
const parent = dfsStack[dfsStack.length - 1];
|
|
9702
|
+
if (lowLinks[frame.node] < lowLinks[parent.node]) lowLinks[parent.node] = lowLinks[frame.node];
|
|
9703
|
+
}
|
|
9704
|
+
}
|
|
9705
|
+
}
|
|
9706
|
+
}
|
|
9707
|
+
return components;
|
|
9708
|
+
};
|
|
9709
|
+
|
|
9710
|
+
//#endregion
|
|
9711
|
+
//#region src/report/feature-flags.ts
|
|
9712
|
+
const BUILTIN_SDK_PATTERNS = [
|
|
9713
|
+
{
|
|
9714
|
+
functionName: "useFlag",
|
|
9715
|
+
nameArgIndex: 0,
|
|
9716
|
+
provider: "LaunchDarkly"
|
|
9717
|
+
},
|
|
9718
|
+
{
|
|
9719
|
+
functionName: "useLDFlag",
|
|
9720
|
+
nameArgIndex: 0,
|
|
9721
|
+
provider: "LaunchDarkly"
|
|
9722
|
+
},
|
|
9723
|
+
{
|
|
9724
|
+
functionName: "useFeatureFlag",
|
|
9725
|
+
nameArgIndex: 0,
|
|
9726
|
+
provider: "LaunchDarkly"
|
|
9727
|
+
},
|
|
9728
|
+
{
|
|
9729
|
+
functionName: "variation",
|
|
9730
|
+
nameArgIndex: 0,
|
|
9731
|
+
provider: "LaunchDarkly"
|
|
9732
|
+
},
|
|
9733
|
+
{
|
|
9734
|
+
functionName: "boolVariation",
|
|
9735
|
+
nameArgIndex: 0,
|
|
9736
|
+
provider: "LaunchDarkly"
|
|
9737
|
+
},
|
|
9738
|
+
{
|
|
9739
|
+
functionName: "stringVariation",
|
|
9740
|
+
nameArgIndex: 0,
|
|
9741
|
+
provider: "LaunchDarkly"
|
|
9742
|
+
},
|
|
9743
|
+
{
|
|
9744
|
+
functionName: "numberVariation",
|
|
9745
|
+
nameArgIndex: 0,
|
|
9746
|
+
provider: "LaunchDarkly"
|
|
9747
|
+
},
|
|
9748
|
+
{
|
|
9749
|
+
functionName: "jsonVariation",
|
|
9750
|
+
nameArgIndex: 0,
|
|
9751
|
+
provider: "LaunchDarkly"
|
|
9752
|
+
},
|
|
9753
|
+
{
|
|
9754
|
+
functionName: "useGate",
|
|
9755
|
+
nameArgIndex: 0,
|
|
9756
|
+
provider: "Statsig"
|
|
9757
|
+
},
|
|
9758
|
+
{
|
|
9759
|
+
functionName: "checkGate",
|
|
9760
|
+
nameArgIndex: 0,
|
|
9761
|
+
provider: "Statsig"
|
|
9762
|
+
},
|
|
9763
|
+
{
|
|
9764
|
+
functionName: "useExperiment",
|
|
9765
|
+
nameArgIndex: 0,
|
|
9766
|
+
provider: "Statsig"
|
|
9767
|
+
},
|
|
9768
|
+
{
|
|
9769
|
+
functionName: "useConfig",
|
|
9770
|
+
nameArgIndex: 0,
|
|
9771
|
+
provider: "Statsig"
|
|
9772
|
+
},
|
|
9773
|
+
{
|
|
9774
|
+
functionName: "isEnabled",
|
|
9775
|
+
nameArgIndex: 0,
|
|
9776
|
+
provider: "Unleash"
|
|
9777
|
+
},
|
|
9778
|
+
{
|
|
9779
|
+
functionName: "getVariant",
|
|
9780
|
+
nameArgIndex: 0,
|
|
9781
|
+
provider: "Unleash"
|
|
9782
|
+
},
|
|
9783
|
+
{
|
|
9784
|
+
functionName: "isOn",
|
|
9785
|
+
nameArgIndex: 0,
|
|
9786
|
+
provider: "GrowthBook"
|
|
9787
|
+
},
|
|
9788
|
+
{
|
|
9789
|
+
functionName: "isOff",
|
|
9790
|
+
nameArgIndex: 0,
|
|
9791
|
+
provider: "GrowthBook"
|
|
9792
|
+
},
|
|
9793
|
+
{
|
|
9794
|
+
functionName: "getFeatureValue",
|
|
9795
|
+
nameArgIndex: 0,
|
|
9796
|
+
provider: "GrowthBook"
|
|
9797
|
+
},
|
|
9798
|
+
{
|
|
9799
|
+
functionName: "getTreatment",
|
|
9800
|
+
nameArgIndex: 0,
|
|
9801
|
+
provider: "Split"
|
|
9802
|
+
},
|
|
9803
|
+
{
|
|
9804
|
+
functionName: "useFeatureFlagEnabled",
|
|
9805
|
+
nameArgIndex: 0,
|
|
9806
|
+
provider: "PostHog"
|
|
9807
|
+
},
|
|
9808
|
+
{
|
|
9809
|
+
functionName: "useFeatureFlagPayload",
|
|
9810
|
+
nameArgIndex: 0,
|
|
9811
|
+
provider: "PostHog"
|
|
9812
|
+
},
|
|
9813
|
+
{
|
|
9814
|
+
functionName: "useFeatureFlagVariantKey",
|
|
9815
|
+
nameArgIndex: 0,
|
|
9816
|
+
provider: "PostHog"
|
|
9817
|
+
},
|
|
9818
|
+
{
|
|
9819
|
+
functionName: "getFeatureFlagPayload",
|
|
9820
|
+
nameArgIndex: 0,
|
|
9821
|
+
provider: "PostHog"
|
|
9822
|
+
},
|
|
9823
|
+
{
|
|
9824
|
+
functionName: "getValueAsync",
|
|
9825
|
+
nameArgIndex: 0,
|
|
9826
|
+
provider: "ConfigCat"
|
|
9827
|
+
},
|
|
9828
|
+
{
|
|
9829
|
+
functionName: "getValueDetailsAsync",
|
|
9830
|
+
nameArgIndex: 0,
|
|
9831
|
+
provider: "ConfigCat"
|
|
9832
|
+
},
|
|
9833
|
+
{
|
|
9834
|
+
functionName: "hasFeature",
|
|
9835
|
+
nameArgIndex: 0,
|
|
9836
|
+
provider: "Flagsmith"
|
|
9837
|
+
},
|
|
9838
|
+
{
|
|
9839
|
+
functionName: "useDecision",
|
|
9840
|
+
nameArgIndex: 0,
|
|
9841
|
+
provider: "Optimizely"
|
|
9842
|
+
},
|
|
9843
|
+
{
|
|
9844
|
+
functionName: "getFeatureVariable",
|
|
9845
|
+
nameArgIndex: 0,
|
|
9846
|
+
provider: "Optimizely"
|
|
9847
|
+
},
|
|
9848
|
+
{
|
|
9849
|
+
functionName: "getFeatureVariableBoolean",
|
|
9850
|
+
nameArgIndex: 0,
|
|
9851
|
+
provider: "Optimizely"
|
|
9852
|
+
},
|
|
9853
|
+
{
|
|
9854
|
+
functionName: "getFeatureVariableString",
|
|
9855
|
+
nameArgIndex: 0,
|
|
9856
|
+
provider: "Optimizely"
|
|
9857
|
+
},
|
|
9858
|
+
{
|
|
9859
|
+
functionName: "getFeatureVariableInteger",
|
|
9860
|
+
nameArgIndex: 0,
|
|
9861
|
+
provider: "Optimizely"
|
|
9862
|
+
},
|
|
9863
|
+
{
|
|
9864
|
+
functionName: "getFeatureVariableDouble",
|
|
9865
|
+
nameArgIndex: 0,
|
|
9866
|
+
provider: "Optimizely"
|
|
9867
|
+
},
|
|
9868
|
+
{
|
|
9869
|
+
functionName: "getFeatureVariableJson",
|
|
9870
|
+
nameArgIndex: 0,
|
|
9871
|
+
provider: "Optimizely"
|
|
9872
|
+
},
|
|
9873
|
+
{
|
|
9874
|
+
functionName: "getFeatureVariableJSON",
|
|
9875
|
+
nameArgIndex: 0,
|
|
9876
|
+
provider: "Optimizely"
|
|
9877
|
+
},
|
|
9878
|
+
{
|
|
9879
|
+
functionName: "getStringAssignment",
|
|
9880
|
+
nameArgIndex: 0,
|
|
9881
|
+
provider: "Eppo"
|
|
9882
|
+
},
|
|
9883
|
+
{
|
|
9884
|
+
functionName: "getBooleanAssignment",
|
|
9885
|
+
nameArgIndex: 0,
|
|
9886
|
+
provider: "Eppo"
|
|
9887
|
+
},
|
|
9888
|
+
{
|
|
9889
|
+
functionName: "getNumericAssignment",
|
|
9890
|
+
nameArgIndex: 0,
|
|
9891
|
+
provider: "Eppo"
|
|
9892
|
+
},
|
|
9893
|
+
{
|
|
9894
|
+
functionName: "getIntegerAssignment",
|
|
9895
|
+
nameArgIndex: 0,
|
|
9896
|
+
provider: "Eppo"
|
|
9897
|
+
},
|
|
9898
|
+
{
|
|
9899
|
+
functionName: "getJSONAssignment",
|
|
9900
|
+
nameArgIndex: 0,
|
|
9901
|
+
provider: "Eppo"
|
|
9902
|
+
}
|
|
9903
|
+
];
|
|
9904
|
+
const VERCEL_FLAGS_FUNCTION_NAMES = new Set(["flag", "evaluate"]);
|
|
9905
|
+
const BUILTIN_ENV_PREFIXES = [
|
|
9906
|
+
"FEATURE_",
|
|
9907
|
+
"NEXT_PUBLIC_FEATURE_",
|
|
9908
|
+
"NEXT_PUBLIC_ENABLE_",
|
|
9909
|
+
"REACT_APP_FEATURE_",
|
|
9910
|
+
"REACT_APP_ENABLE_",
|
|
9911
|
+
"VITE_FEATURE_",
|
|
9912
|
+
"VITE_ENABLE_",
|
|
9913
|
+
"NUXT_PUBLIC_FEATURE_",
|
|
9914
|
+
"ENABLE_",
|
|
9915
|
+
"FF_",
|
|
9916
|
+
"FLAG_",
|
|
9917
|
+
"TOGGLE_"
|
|
9918
|
+
];
|
|
9919
|
+
const CONFIG_OBJECT_KEYWORDS = new Set([
|
|
9920
|
+
"feature",
|
|
9921
|
+
"features",
|
|
9922
|
+
"featureflags",
|
|
9923
|
+
"featureflag",
|
|
9924
|
+
"flag",
|
|
9925
|
+
"flags",
|
|
9926
|
+
"toggle",
|
|
9927
|
+
"toggles"
|
|
9928
|
+
]);
|
|
9929
|
+
const getStaticName = (node) => {
|
|
9930
|
+
if (!isAstNode(node)) return void 0;
|
|
9931
|
+
if (node.type === "Identifier" || node.type === "PrivateIdentifier") {
|
|
9932
|
+
const identifierName = node.name;
|
|
9933
|
+
return typeof identifierName === "string" ? identifierName : void 0;
|
|
9934
|
+
}
|
|
9935
|
+
if (node.type === "Literal") {
|
|
9936
|
+
const literalValue = node.value;
|
|
9937
|
+
return typeof literalValue === "string" ? literalValue : void 0;
|
|
9938
|
+
}
|
|
9939
|
+
};
|
|
9940
|
+
const extractStringArgument = (callArguments, argumentIndex) => {
|
|
9941
|
+
if (!Array.isArray(callArguments)) return void 0;
|
|
9942
|
+
const argumentNode = callArguments[argumentIndex];
|
|
9943
|
+
if (!isAstNode(argumentNode)) return void 0;
|
|
9944
|
+
if (argumentNode.type === "Literal") {
|
|
9945
|
+
const literalValue = argumentNode.value;
|
|
9946
|
+
return typeof literalValue === "string" ? literalValue : void 0;
|
|
9947
|
+
}
|
|
9948
|
+
if (argumentNode.type === "ObjectExpression") {
|
|
9949
|
+
const properties = argumentNode.properties;
|
|
9950
|
+
if (!Array.isArray(properties)) return void 0;
|
|
9951
|
+
for (const property of properties) {
|
|
9952
|
+
if (!isAstNode(property)) continue;
|
|
9953
|
+
if (property.type !== "Property") continue;
|
|
9954
|
+
const propertyKey = getStaticName(property.key);
|
|
9955
|
+
if (propertyKey !== "key" && propertyKey !== "name") continue;
|
|
9956
|
+
const propertyValueName = getStaticName(property.value);
|
|
9957
|
+
if (propertyValueName !== void 0) return propertyValueName;
|
|
9958
|
+
}
|
|
9959
|
+
}
|
|
9960
|
+
};
|
|
9961
|
+
const extractProcessEnvName = (memberExpression) => {
|
|
9962
|
+
if (!isAstNode(memberExpression)) return void 0;
|
|
9963
|
+
if (memberExpression.type !== "MemberExpression" && memberExpression.type !== "StaticMemberExpression") return;
|
|
9964
|
+
const propertyName = getStaticName(memberExpression.property);
|
|
9965
|
+
if (propertyName === void 0) return void 0;
|
|
9966
|
+
const objectNode = memberExpression.object;
|
|
9967
|
+
if (!isAstNode(objectNode)) return void 0;
|
|
9968
|
+
if (objectNode.type !== "MemberExpression" && objectNode.type !== "StaticMemberExpression") return;
|
|
9969
|
+
const innerObjectName = getStaticName(objectNode.object);
|
|
9970
|
+
const innerPropertyName = getStaticName(objectNode.property);
|
|
9971
|
+
if (innerObjectName === "process" && innerPropertyName === "env") return propertyName;
|
|
9972
|
+
};
|
|
9973
|
+
const isFlagEnvName = (envName, extraEnvPrefixes) => {
|
|
9974
|
+
for (const prefix of BUILTIN_ENV_PREFIXES) if (envName.startsWith(prefix)) return true;
|
|
9975
|
+
for (const prefix of extraEnvPrefixes) if (envName.startsWith(prefix)) return true;
|
|
9976
|
+
return false;
|
|
9977
|
+
};
|
|
9978
|
+
const collectVercelFlagsImports = (programNode) => {
|
|
9979
|
+
const localNames = /* @__PURE__ */ new Set();
|
|
9980
|
+
if (!isAstNode(programNode)) return localNames;
|
|
9981
|
+
const body = programNode.body;
|
|
9982
|
+
if (!Array.isArray(body)) return localNames;
|
|
9983
|
+
for (const statement of body) {
|
|
9984
|
+
if (!isAstNode(statement)) continue;
|
|
9985
|
+
if (statement.type !== "ImportDeclaration") continue;
|
|
9986
|
+
const sourceLiteral = statement.source;
|
|
9987
|
+
const sourceValue = isAstNode(sourceLiteral) ? sourceLiteral.value : void 0;
|
|
9988
|
+
if (typeof sourceValue !== "string") continue;
|
|
9989
|
+
if (!(sourceValue === "flags" || sourceValue.startsWith("flags/") || sourceValue === "@vercel/flags" || sourceValue.startsWith("@vercel/flags/"))) continue;
|
|
9990
|
+
const specifiers = statement.specifiers;
|
|
9991
|
+
if (!Array.isArray(specifiers)) continue;
|
|
9992
|
+
for (const specifier of specifiers) {
|
|
9993
|
+
if (!isAstNode(specifier)) continue;
|
|
9994
|
+
if (specifier.type === "ImportSpecifier") {
|
|
9995
|
+
const imported = specifier.imported;
|
|
9996
|
+
const local = specifier.local;
|
|
9997
|
+
const importedName = getStaticName(imported);
|
|
9998
|
+
const localName = getStaticName(local);
|
|
9999
|
+
if (importedName && VERCEL_FLAGS_FUNCTION_NAMES.has(importedName) && localName) localNames.add(localName);
|
|
10000
|
+
}
|
|
10001
|
+
}
|
|
10002
|
+
}
|
|
10003
|
+
return localNames;
|
|
10004
|
+
};
|
|
10005
|
+
const visitChildrenWithGuard = (node, visitor) => {
|
|
10006
|
+
if (!isAstNode(node)) return;
|
|
10007
|
+
for (const key of Object.keys(node)) {
|
|
10008
|
+
if (key === "type" || key === "start" || key === "end" || key === "loc" || key === "range") continue;
|
|
10009
|
+
const value = node[key];
|
|
10010
|
+
if (Array.isArray(value)) for (const item of value) visitor(item);
|
|
10011
|
+
else if (value !== null && typeof value === "object") visitor(value);
|
|
10012
|
+
}
|
|
10013
|
+
};
|
|
10014
|
+
const recordFlag = (context, flagName, kind, byteOffset, sdkProvider) => {
|
|
10015
|
+
const { line, column } = offsetToLineColumn(byteOffset, context.lineStarts);
|
|
10016
|
+
context.results.push({
|
|
10017
|
+
path: context.filePath,
|
|
10018
|
+
name: flagName,
|
|
10019
|
+
kind,
|
|
10020
|
+
line,
|
|
10021
|
+
column,
|
|
10022
|
+
sdkProvider,
|
|
10023
|
+
guardLineStart: context.guard?.startLine,
|
|
10024
|
+
guardLineEnd: context.guard?.endLine,
|
|
10025
|
+
guardsDeadCode: false
|
|
10026
|
+
});
|
|
10027
|
+
};
|
|
10028
|
+
const visitNode$1 = (node, context) => {
|
|
10029
|
+
if (!isAstNode(node)) return;
|
|
10030
|
+
if (node.type === "IfStatement") {
|
|
10031
|
+
const start = node.start;
|
|
10032
|
+
const end = node.end;
|
|
10033
|
+
const guard = typeof start === "number" && typeof end === "number" ? {
|
|
10034
|
+
startLine: offsetToLineColumn(start, context.lineStarts).line,
|
|
10035
|
+
endLine: offsetToLineColumn(end, context.lineStarts).line
|
|
10036
|
+
} : void 0;
|
|
10037
|
+
const previousGuard = context.guard;
|
|
10038
|
+
context.guard = guard;
|
|
10039
|
+
visitNode$1(node.test, context);
|
|
10040
|
+
context.guard = previousGuard;
|
|
10041
|
+
visitNode$1(node.consequent, context);
|
|
10042
|
+
visitNode$1(node.alternate, context);
|
|
10043
|
+
return;
|
|
10044
|
+
}
|
|
10045
|
+
if (node.type === "ConditionalExpression") {
|
|
10046
|
+
const start = node.start;
|
|
10047
|
+
const end = node.end;
|
|
10048
|
+
const guard = typeof start === "number" && typeof end === "number" ? {
|
|
10049
|
+
startLine: offsetToLineColumn(start, context.lineStarts).line,
|
|
10050
|
+
endLine: offsetToLineColumn(end, context.lineStarts).line
|
|
10051
|
+
} : void 0;
|
|
10052
|
+
const previousGuard = context.guard;
|
|
10053
|
+
context.guard = guard;
|
|
10054
|
+
visitNode$1(node.test, context);
|
|
10055
|
+
context.guard = previousGuard;
|
|
10056
|
+
visitNode$1(node.consequent, context);
|
|
10057
|
+
visitNode$1(node.alternate, context);
|
|
10058
|
+
return;
|
|
10059
|
+
}
|
|
10060
|
+
visitFlagPatternsInExpression(node, context);
|
|
10061
|
+
visitChildrenWithGuard(node, (child) => visitNode$1(child, context));
|
|
10062
|
+
};
|
|
10063
|
+
const visitFlagPatternsInExpression = (node, context) => {
|
|
10064
|
+
if (!isAstNode(node)) return;
|
|
10065
|
+
if (node.type === "MemberExpression" || node.type === "StaticMemberExpression") {
|
|
10066
|
+
const envName = extractProcessEnvName(node);
|
|
10067
|
+
if (envName !== void 0 && isFlagEnvName(envName, context.envPrefixes)) {
|
|
10068
|
+
const start = node.start;
|
|
10069
|
+
if (typeof start === "number") recordFlag(context, envName, "env-var", start, void 0);
|
|
10070
|
+
} else if (context.detectConfigObjects) {
|
|
10071
|
+
const objectName = getStaticName(node.object);
|
|
10072
|
+
const propertyName = getStaticName(node.property);
|
|
10073
|
+
if (objectName && propertyName) {
|
|
10074
|
+
if (CONFIG_OBJECT_KEYWORDS.has(objectName.toLowerCase()) || CONFIG_OBJECT_KEYWORDS.has(propertyName.toLowerCase())) {
|
|
10075
|
+
const start = node.start;
|
|
10076
|
+
if (typeof start === "number") recordFlag(context, `${objectName}.${propertyName}`, "config-object", start, void 0);
|
|
10077
|
+
}
|
|
10078
|
+
}
|
|
10079
|
+
}
|
|
10080
|
+
}
|
|
10081
|
+
if (node.type === "CallExpression") {
|
|
10082
|
+
const callee = node.callee;
|
|
10083
|
+
let functionName;
|
|
10084
|
+
let calleeIsMemberExpression = false;
|
|
10085
|
+
let receiverIsItselfMemberExpression = false;
|
|
10086
|
+
if (isAstNode(callee)) {
|
|
10087
|
+
if (callee.type === "Identifier") functionName = getStaticName(callee);
|
|
10088
|
+
else if (callee.type === "MemberExpression" || callee.type === "StaticMemberExpression") {
|
|
10089
|
+
calleeIsMemberExpression = true;
|
|
10090
|
+
functionName = getStaticName(callee.property);
|
|
10091
|
+
const receiver = callee.object;
|
|
10092
|
+
if (isAstNode(receiver) && (receiver.type === "MemberExpression" || receiver.type === "StaticMemberExpression")) receiverIsItselfMemberExpression = true;
|
|
10093
|
+
}
|
|
10094
|
+
}
|
|
10095
|
+
if (functionName !== void 0) {
|
|
10096
|
+
if (!calleeIsMemberExpression && context.vercelFlagsLocalNames.has(functionName)) {
|
|
10097
|
+
const callArguments = node.arguments;
|
|
10098
|
+
const flagName = extractStringArgument(callArguments, 0);
|
|
10099
|
+
if (flagName !== void 0) {
|
|
10100
|
+
const start = node.start;
|
|
10101
|
+
if (typeof start === "number") recordFlag(context, flagName, "sdk-call", start, "Vercel Flags");
|
|
10102
|
+
}
|
|
10103
|
+
return;
|
|
10104
|
+
}
|
|
10105
|
+
if (calleeIsMemberExpression && receiverIsItselfMemberExpression) return;
|
|
10106
|
+
for (const sdkPattern of context.sdkPatterns) {
|
|
10107
|
+
if (sdkPattern.functionName !== functionName) continue;
|
|
10108
|
+
const callArguments = node.arguments;
|
|
10109
|
+
const flagName = extractStringArgument(callArguments, sdkPattern.nameArgIndex);
|
|
10110
|
+
if (flagName === void 0) continue;
|
|
10111
|
+
const start = node.start;
|
|
10112
|
+
if (typeof start === "number") recordFlag(context, flagName, "sdk-call", start, sdkPattern.provider === "" ? void 0 : sdkPattern.provider);
|
|
10113
|
+
break;
|
|
10114
|
+
}
|
|
10115
|
+
}
|
|
10116
|
+
}
|
|
10117
|
+
};
|
|
10118
|
+
const buildSdkPatterns = (extraSdkFunctionNames) => {
|
|
10119
|
+
const merged = [...BUILTIN_SDK_PATTERNS];
|
|
10120
|
+
for (const extraName of extraSdkFunctionNames) merged.push({
|
|
10121
|
+
functionName: extraName,
|
|
10122
|
+
nameArgIndex: 0,
|
|
10123
|
+
provider: ""
|
|
10124
|
+
});
|
|
10125
|
+
return merged;
|
|
10126
|
+
};
|
|
10127
|
+
const detectFeatureFlags = (graph, config) => {
|
|
10128
|
+
if (!config?.enabled) return [];
|
|
10129
|
+
const sdkPatterns = buildSdkPatterns(config.extraSdkFunctionNames);
|
|
10130
|
+
const collectedFlags = [];
|
|
10131
|
+
for (const module of graph.modules) {
|
|
10132
|
+
if (module.isDeclarationFile) continue;
|
|
10133
|
+
if (module.isConfigFile) continue;
|
|
10134
|
+
let sourceText;
|
|
10135
|
+
try {
|
|
10136
|
+
sourceText = readFileSync(module.fileId.path, "utf-8");
|
|
10137
|
+
} catch {
|
|
10138
|
+
continue;
|
|
10139
|
+
}
|
|
10140
|
+
let parseResult;
|
|
10141
|
+
try {
|
|
10142
|
+
parseResult = parseSync(module.fileId.path, sourceText);
|
|
10143
|
+
} catch {
|
|
10144
|
+
continue;
|
|
10145
|
+
}
|
|
10146
|
+
const lineStarts = computeLineStarts(sourceText);
|
|
10147
|
+
const vercelFlagsLocalNames = collectVercelFlagsImports(parseResult.program);
|
|
10148
|
+
const visitContext = {
|
|
10149
|
+
filePath: module.fileId.path,
|
|
10150
|
+
lineStarts,
|
|
10151
|
+
results: [],
|
|
10152
|
+
envPrefixes: config.extraEnvPrefixes,
|
|
10153
|
+
sdkPatterns,
|
|
10154
|
+
detectConfigObjects: config.detectConfigObjects,
|
|
10155
|
+
vercelFlagsLocalNames,
|
|
10156
|
+
guard: void 0
|
|
10157
|
+
};
|
|
10158
|
+
visitNode$1(parseResult.program, visitContext);
|
|
10159
|
+
collectedFlags.push(...visitContext.results);
|
|
10160
|
+
}
|
|
10161
|
+
collectedFlags.sort((leftFlag, rightFlag) => {
|
|
10162
|
+
if (leftFlag.path !== rightFlag.path) return leftFlag.path.localeCompare(rightFlag.path);
|
|
10163
|
+
if (leftFlag.line !== rightFlag.line) return leftFlag.line - rightFlag.line;
|
|
10164
|
+
return leftFlag.column - rightFlag.column;
|
|
10165
|
+
});
|
|
10166
|
+
return collectedFlags;
|
|
10167
|
+
};
|
|
10168
|
+
/**
|
|
10169
|
+
* Mark each flag whose guard span overlaps an unused export as
|
|
10170
|
+
* `guardsDeadCode: true`.
|
|
10171
|
+
*/
|
|
10172
|
+
const correlateFlagsWithDeadCode = (flags, scanResult) => {
|
|
10173
|
+
if (flags.length === 0 || scanResult.unusedExports.length === 0) return;
|
|
10174
|
+
const unusedByFile = /* @__PURE__ */ new Map();
|
|
10175
|
+
for (const unusedExport of scanResult.unusedExports) {
|
|
10176
|
+
const existing = unusedByFile.get(unusedExport.path);
|
|
10177
|
+
if (existing) existing.push(unusedExport.line);
|
|
10178
|
+
else unusedByFile.set(unusedExport.path, [unusedExport.line]);
|
|
10179
|
+
}
|
|
10180
|
+
for (const flag of flags) {
|
|
10181
|
+
if (flag.guardLineStart === void 0 || flag.guardLineEnd === void 0) continue;
|
|
10182
|
+
const linesInFile = unusedByFile.get(flag.path);
|
|
10183
|
+
if (!linesInFile) continue;
|
|
10184
|
+
const guardStart = flag.guardLineStart;
|
|
10185
|
+
const guardEnd = flag.guardLineEnd;
|
|
10186
|
+
for (const unusedLine of linesInFile) if (unusedLine >= guardStart && unusedLine <= guardEnd) {
|
|
10187
|
+
flag.guardsDeadCode = true;
|
|
10188
|
+
break;
|
|
10189
|
+
}
|
|
10190
|
+
}
|
|
10191
|
+
};
|
|
10192
|
+
|
|
10193
|
+
//#endregion
|
|
10194
|
+
//#region src/report/complexity.ts
|
|
10195
|
+
const incrementCyclomatic = (state) => {
|
|
10196
|
+
const topFrame = state.frameStack[state.frameStack.length - 1];
|
|
10197
|
+
if (topFrame) topFrame.cyclomaticComplexity++;
|
|
10198
|
+
};
|
|
10199
|
+
const incrementCognitiveWithNesting = (state) => {
|
|
10200
|
+
const topFrame = state.frameStack[state.frameStack.length - 1];
|
|
10201
|
+
if (topFrame) topFrame.cognitiveComplexity += 1 + topFrame.nestingLevel;
|
|
10202
|
+
};
|
|
10203
|
+
const incrementCognitiveFlat = (state) => {
|
|
10204
|
+
const topFrame = state.frameStack[state.frameStack.length - 1];
|
|
10205
|
+
if (topFrame) topFrame.cognitiveComplexity++;
|
|
10206
|
+
};
|
|
10207
|
+
const handleLogicalOperator = (operator, state) => {
|
|
10208
|
+
const topFrame = state.frameStack[state.frameStack.length - 1];
|
|
10209
|
+
if (!topFrame) return;
|
|
10210
|
+
if (topFrame.lastLogicalOperator === void 0) {
|
|
10211
|
+
topFrame.cognitiveComplexity++;
|
|
10212
|
+
topFrame.lastLogicalOperator = operator;
|
|
10213
|
+
return;
|
|
10214
|
+
}
|
|
10215
|
+
if (topFrame.lastLogicalOperator === operator) return;
|
|
10216
|
+
topFrame.cognitiveComplexity++;
|
|
10217
|
+
topFrame.lastLogicalOperator = operator;
|
|
10218
|
+
};
|
|
10219
|
+
const resetLogicalOperator = (state) => {
|
|
10220
|
+
const topFrame = state.frameStack[state.frameStack.length - 1];
|
|
10221
|
+
if (topFrame) topFrame.lastLogicalOperator = void 0;
|
|
10222
|
+
};
|
|
10223
|
+
const incrementNesting = (state) => {
|
|
10224
|
+
const topFrame = state.frameStack[state.frameStack.length - 1];
|
|
10225
|
+
if (topFrame) topFrame.nestingLevel++;
|
|
10226
|
+
};
|
|
10227
|
+
const decrementNesting = (state) => {
|
|
10228
|
+
const topFrame = state.frameStack[state.frameStack.length - 1];
|
|
10229
|
+
if (topFrame && topFrame.nestingLevel > 0) topFrame.nestingLevel--;
|
|
10230
|
+
};
|
|
10231
|
+
const countParameters = (parametersNode) => {
|
|
10232
|
+
if (!isAstNode(parametersNode)) return 0;
|
|
10233
|
+
const params = parametersNode;
|
|
10234
|
+
if (Array.isArray(params.params)) return params.params.length;
|
|
10235
|
+
if (Array.isArray(params.items)) return params.items.length;
|
|
10236
|
+
return 0;
|
|
10237
|
+
};
|
|
10238
|
+
const visitChildrenGeneric = (node, visitor) => {
|
|
10239
|
+
if (!isAstNode(node)) return;
|
|
10240
|
+
for (const key of Object.keys(node)) {
|
|
10241
|
+
if (key === "type" || key === "start" || key === "end" || key === "loc" || key === "range") continue;
|
|
10242
|
+
const value = node[key];
|
|
10243
|
+
if (Array.isArray(value)) for (const item of value) visitor(item);
|
|
10244
|
+
else if (value !== null && typeof value === "object") visitor(value);
|
|
10245
|
+
}
|
|
10246
|
+
};
|
|
10247
|
+
const pushFunctionFrame = (functionName, startOffset, endOffset, parameterCount, state) => {
|
|
10248
|
+
state.frameStack.push({
|
|
10249
|
+
functionName,
|
|
10250
|
+
startOffset,
|
|
10251
|
+
endOffset,
|
|
10252
|
+
cyclomaticComplexity: 1,
|
|
10253
|
+
cognitiveComplexity: 0,
|
|
10254
|
+
nestingLevel: 0,
|
|
10255
|
+
lastLogicalOperator: void 0,
|
|
10256
|
+
parameterCount
|
|
10257
|
+
});
|
|
10258
|
+
};
|
|
10259
|
+
const popFunctionFrame = (state) => {
|
|
10260
|
+
const completedFrame = state.frameStack.pop();
|
|
10261
|
+
if (!completedFrame) return;
|
|
10262
|
+
const { line, column } = offsetToLineColumn(completedFrame.startOffset, state.lineStarts);
|
|
10263
|
+
const endLine = offsetToLineColumn(completedFrame.endOffset, state.lineStarts).line;
|
|
10264
|
+
state.results.push({
|
|
10265
|
+
path: state.filePath,
|
|
10266
|
+
functionName: completedFrame.functionName,
|
|
10267
|
+
line,
|
|
10268
|
+
column,
|
|
10269
|
+
cyclomatic: completedFrame.cyclomaticComplexity,
|
|
10270
|
+
cognitive: completedFrame.cognitiveComplexity,
|
|
10271
|
+
lineCount: Math.max(1, endLine - line + 1),
|
|
10272
|
+
paramCount: completedFrame.parameterCount,
|
|
10273
|
+
confidence: "medium",
|
|
10274
|
+
reason: ""
|
|
10275
|
+
});
|
|
10276
|
+
};
|
|
10277
|
+
const visitFunctionLike = (node, kind, state) => {
|
|
10278
|
+
if (!isAstNode(node)) return;
|
|
10279
|
+
const functionName = state.pendingFunctionName ?? (() => {
|
|
10280
|
+
const idNode = node.id;
|
|
10281
|
+
const idName = isAstNode(idNode) ? idNode.name : void 0;
|
|
10282
|
+
return typeof idName === "string" ? idName : kind === "arrow" ? "<arrow>" : "<anonymous>";
|
|
10283
|
+
})();
|
|
10284
|
+
state.pendingFunctionName = void 0;
|
|
10285
|
+
const isNested = state.frameStack.length > 0;
|
|
10286
|
+
if (isNested) incrementNesting(state);
|
|
10287
|
+
const startOffset = node.start;
|
|
10288
|
+
const endOffset = node.end;
|
|
10289
|
+
const parameterCount = countParameters(node.params);
|
|
10290
|
+
pushFunctionFrame(functionName, typeof startOffset === "number" ? startOffset : 0, typeof endOffset === "number" ? endOffset : 0, parameterCount, state);
|
|
10291
|
+
visitChildrenGeneric(node, (child) => visitNode(child, state));
|
|
10292
|
+
popFunctionFrame(state);
|
|
10293
|
+
if (isNested) decrementNesting(state);
|
|
10294
|
+
};
|
|
10295
|
+
const visitNode = (node, state) => {
|
|
10296
|
+
if (!isAstNode(node)) return;
|
|
10297
|
+
switch (node.type) {
|
|
10298
|
+
case "FunctionDeclaration":
|
|
10299
|
+
case "FunctionExpression":
|
|
10300
|
+
case "MethodDefinition":
|
|
10301
|
+
if (node.type === "MethodDefinition") {
|
|
10302
|
+
const keyNode = node.key;
|
|
10303
|
+
const keyName = isAstNode(keyNode) ? keyNode.name ?? keyNode.value : void 0;
|
|
10304
|
+
if (typeof keyName === "string") state.pendingFunctionName = keyName;
|
|
10305
|
+
visitChildrenGeneric(node, (child) => visitNode(child, state));
|
|
10306
|
+
state.pendingFunctionName = void 0;
|
|
10307
|
+
return;
|
|
10308
|
+
}
|
|
10309
|
+
visitFunctionLike(node, "function", state);
|
|
10310
|
+
return;
|
|
10311
|
+
case "ArrowFunctionExpression":
|
|
10312
|
+
visitFunctionLike(node, "arrow", state);
|
|
10313
|
+
return;
|
|
10314
|
+
case "VariableDeclarator": {
|
|
10315
|
+
const declaratorId = node.id;
|
|
10316
|
+
const declaratorIdName = isAstNode(declaratorId) ? declaratorId.name : void 0;
|
|
10317
|
+
if (typeof declaratorIdName === "string") state.pendingFunctionName = declaratorIdName;
|
|
10318
|
+
visitChildrenGeneric(node, (child) => visitNode(child, state));
|
|
10319
|
+
state.pendingFunctionName = void 0;
|
|
10320
|
+
return;
|
|
10321
|
+
}
|
|
10322
|
+
case "PropertyDefinition": {
|
|
10323
|
+
const keyNode = node.key;
|
|
10324
|
+
const keyName = isAstNode(keyNode) ? keyNode.name : void 0;
|
|
10325
|
+
if (typeof keyName === "string") state.pendingFunctionName = keyName;
|
|
10326
|
+
visitChildrenGeneric(node, (child) => visitNode(child, state));
|
|
10327
|
+
state.pendingFunctionName = void 0;
|
|
10328
|
+
return;
|
|
10329
|
+
}
|
|
10330
|
+
case "IfStatement":
|
|
10331
|
+
incrementCyclomatic(state);
|
|
10332
|
+
incrementCognitiveWithNesting(state);
|
|
10333
|
+
incrementNesting(state);
|
|
10334
|
+
visitChildrenGeneric(node, (child) => visitNode(child, state));
|
|
10335
|
+
decrementNesting(state);
|
|
10336
|
+
resetLogicalOperator(state);
|
|
10337
|
+
return;
|
|
10338
|
+
case "ForStatement":
|
|
10339
|
+
case "ForInStatement":
|
|
10340
|
+
case "ForOfStatement":
|
|
10341
|
+
case "WhileStatement":
|
|
10342
|
+
case "DoWhileStatement":
|
|
10343
|
+
incrementCyclomatic(state);
|
|
10344
|
+
incrementCognitiveWithNesting(state);
|
|
10345
|
+
incrementNesting(state);
|
|
10346
|
+
visitChildrenGeneric(node, (child) => visitNode(child, state));
|
|
10347
|
+
decrementNesting(state);
|
|
10348
|
+
return;
|
|
10349
|
+
case "SwitchCase": {
|
|
10350
|
+
const testNode = node.test;
|
|
10351
|
+
if (testNode !== null && testNode !== void 0) {
|
|
10352
|
+
incrementCyclomatic(state);
|
|
10353
|
+
incrementCognitiveFlat(state);
|
|
10354
|
+
}
|
|
10355
|
+
visitChildrenGeneric(node, (child) => visitNode(child, state));
|
|
10356
|
+
return;
|
|
10357
|
+
}
|
|
10358
|
+
case "CatchClause":
|
|
10359
|
+
incrementCyclomatic(state);
|
|
10360
|
+
incrementCognitiveWithNesting(state);
|
|
10361
|
+
incrementNesting(state);
|
|
10362
|
+
visitChildrenGeneric(node, (child) => visitNode(child, state));
|
|
10363
|
+
decrementNesting(state);
|
|
10364
|
+
return;
|
|
10365
|
+
case "ConditionalExpression":
|
|
10366
|
+
incrementCyclomatic(state);
|
|
10367
|
+
incrementCognitiveWithNesting(state);
|
|
10368
|
+
visitChildrenGeneric(node, (child) => visitNode(child, state));
|
|
10369
|
+
return;
|
|
10370
|
+
case "LogicalExpression": {
|
|
10371
|
+
const operator = node.operator;
|
|
10372
|
+
if (operator === "&&" || operator === "||" || operator === "??") {
|
|
10373
|
+
incrementCyclomatic(state);
|
|
10374
|
+
handleLogicalOperator(operator, state);
|
|
10375
|
+
}
|
|
10376
|
+
visitChildrenGeneric(node, (child) => visitNode(child, state));
|
|
10377
|
+
return;
|
|
10378
|
+
}
|
|
10379
|
+
case "AssignmentExpression": {
|
|
10380
|
+
const operator = node.operator;
|
|
10381
|
+
if (operator === "&&=" || operator === "||=" || operator === "??=") incrementCyclomatic(state);
|
|
10382
|
+
visitChildrenGeneric(node, (child) => visitNode(child, state));
|
|
10383
|
+
return;
|
|
10384
|
+
}
|
|
10385
|
+
case "ChainExpression":
|
|
10386
|
+
incrementCyclomatic(state);
|
|
10387
|
+
visitChildrenGeneric(node, (child) => visitNode(child, state));
|
|
10388
|
+
return;
|
|
10389
|
+
default: visitChildrenGeneric(node, (child) => visitNode(child, state));
|
|
10390
|
+
}
|
|
10391
|
+
};
|
|
10392
|
+
const annotateConfidence = (finding, config) => {
|
|
10393
|
+
const breaches = [];
|
|
10394
|
+
if (finding.cyclomatic >= config.cyclomaticThreshold) breaches.push(`cyclomatic ${finding.cyclomatic} ≥ ${config.cyclomaticThreshold}`);
|
|
10395
|
+
if (finding.cognitive >= config.cognitiveThreshold) breaches.push(`cognitive ${finding.cognitive} ≥ ${config.cognitiveThreshold}`);
|
|
10396
|
+
if (finding.paramCount >= config.paramCountThreshold) breaches.push(`paramCount ${finding.paramCount} ≥ ${config.paramCountThreshold}`);
|
|
10397
|
+
if (finding.lineCount >= config.functionLineThreshold) breaches.push(`lineCount ${finding.lineCount} ≥ ${config.functionLineThreshold}`);
|
|
10398
|
+
return {
|
|
10399
|
+
confidence: breaches.length >= 2 ? "high" : "medium",
|
|
10400
|
+
reason: `${finding.functionName} breaches ${breaches.length} threshold${breaches.length === 1 ? "" : "s"}: ${breaches.join(", ")}`
|
|
10401
|
+
};
|
|
10402
|
+
};
|
|
10403
|
+
/**
|
|
10404
|
+
* Per-function cyclomatic + cognitive complexity.
|
|
10405
|
+
*
|
|
10406
|
+
* Cyclomatic (McCabe): 1 + decision points. Counts if/for/while/do/case/catch,
|
|
10407
|
+
* the ?: ternary, &&, ||, ??, &&=/||=/??=, and ?. (optional chaining).
|
|
10408
|
+
*
|
|
10409
|
+
* Cognitive (SonarSource): structural increments with nesting penalty.
|
|
10410
|
+
* Operator-sequence rule: a run of the same logical operator is +1 total;
|
|
10411
|
+
* each operator change adds another +1.
|
|
10412
|
+
*
|
|
10413
|
+
* Returns only functions whose metrics breach at least one threshold from
|
|
10414
|
+
* `config`. Threshold breach count tunes the `confidence` field.
|
|
10415
|
+
*/
|
|
10416
|
+
const detectComplexHotspots = (graph, config) => {
|
|
10417
|
+
if (!config?.enabled) return [];
|
|
10418
|
+
const hotspotFindings = [];
|
|
10419
|
+
for (const module of graph.modules) {
|
|
10420
|
+
if (module.isDeclarationFile) continue;
|
|
10421
|
+
if (module.isConfigFile) continue;
|
|
10422
|
+
let sourceText;
|
|
10423
|
+
try {
|
|
10424
|
+
sourceText = readFileSync(module.fileId.path, "utf-8");
|
|
10425
|
+
} catch {
|
|
10426
|
+
continue;
|
|
10427
|
+
}
|
|
10428
|
+
let parseResult;
|
|
10429
|
+
try {
|
|
10430
|
+
parseResult = parseSync(module.fileId.path, sourceText);
|
|
10431
|
+
} catch {
|
|
10432
|
+
continue;
|
|
10433
|
+
}
|
|
10434
|
+
const visitState = {
|
|
10435
|
+
filePath: module.fileId.path,
|
|
10436
|
+
lineStarts: computeLineStarts(sourceText),
|
|
10437
|
+
results: [],
|
|
10438
|
+
frameStack: [],
|
|
10439
|
+
pendingFunctionName: void 0
|
|
10440
|
+
};
|
|
10441
|
+
visitNode(parseResult.program, visitState);
|
|
10442
|
+
for (const result of visitState.results) {
|
|
10443
|
+
if (!(result.cyclomatic >= config.cyclomaticThreshold || result.cognitive >= config.cognitiveThreshold || result.paramCount >= config.paramCountThreshold || result.lineCount >= config.functionLineThreshold)) continue;
|
|
10444
|
+
const annotated = annotateConfidence(result, config);
|
|
10445
|
+
hotspotFindings.push({
|
|
10446
|
+
...result,
|
|
10447
|
+
confidence: annotated.confidence,
|
|
10448
|
+
reason: annotated.reason
|
|
10449
|
+
});
|
|
10450
|
+
}
|
|
10451
|
+
}
|
|
10452
|
+
hotspotFindings.sort((leftFinding, rightFinding) => {
|
|
10453
|
+
const leftScore = leftFinding.cyclomatic + leftFinding.cognitive;
|
|
10454
|
+
const rightScore = rightFinding.cyclomatic + rightFinding.cognitive;
|
|
10455
|
+
if (leftScore !== rightScore) return rightScore - leftScore;
|
|
10456
|
+
if (leftFinding.path !== rightFinding.path) return leftFinding.path.localeCompare(rightFinding.path);
|
|
10457
|
+
return leftFinding.line - rightFinding.line;
|
|
10458
|
+
});
|
|
10459
|
+
return hotspotFindings;
|
|
10460
|
+
};
|
|
10461
|
+
|
|
10462
|
+
//#endregion
|
|
10463
|
+
//#region src/report/private-type-leaks.ts
|
|
10464
|
+
const extractIdentifierName = (node) => {
|
|
10465
|
+
if (!isAstNode(node)) return void 0;
|
|
10466
|
+
if (node.type === "Identifier") {
|
|
10467
|
+
const identifierName = node.name;
|
|
10468
|
+
return typeof identifierName === "string" ? identifierName : void 0;
|
|
10469
|
+
}
|
|
10470
|
+
};
|
|
10471
|
+
const collectTypeReferenceNamesFromTypeNode = (typeNode, into) => {
|
|
10472
|
+
if (!isAstNode(typeNode)) return;
|
|
10473
|
+
if (typeNode.type === "TSTypeReference") {
|
|
10474
|
+
const referencedTypeName = typeNode.typeName;
|
|
10475
|
+
if (isAstNode(referencedTypeName) && referencedTypeName.type === "Identifier") {
|
|
10476
|
+
const name = referencedTypeName.name;
|
|
10477
|
+
if (typeof name === "string") into.add(name);
|
|
10478
|
+
}
|
|
10479
|
+
}
|
|
10480
|
+
for (const key of Object.keys(typeNode)) {
|
|
10481
|
+
if (key === "type" || key === "start" || key === "end") continue;
|
|
10482
|
+
const value = typeNode[key];
|
|
10483
|
+
if (Array.isArray(value)) for (const item of value) collectTypeReferenceNamesFromTypeNode(item, into);
|
|
10484
|
+
else if (value !== null && typeof value === "object") collectTypeReferenceNamesFromTypeNode(value, into);
|
|
10485
|
+
}
|
|
10486
|
+
};
|
|
10487
|
+
const isExportedDeclaration = (statement) => {
|
|
10488
|
+
if (!isAstNode(statement)) return false;
|
|
10489
|
+
return statement.type === "ExportNamedDeclaration" || statement.type === "ExportDefaultDeclaration";
|
|
10490
|
+
};
|
|
10491
|
+
const declarationOf = (statement) => {
|
|
10492
|
+
if (!isAstNode(statement)) return void 0;
|
|
10493
|
+
return statement.declaration;
|
|
10494
|
+
};
|
|
10495
|
+
const exportedNameOfDeclaration = (declarationNode) => {
|
|
10496
|
+
if (!isAstNode(declarationNode)) return void 0;
|
|
10497
|
+
if (declarationNode.type === "FunctionDeclaration" || declarationNode.type === "ClassDeclaration") return extractIdentifierName(declarationNode.id);
|
|
10498
|
+
if (declarationNode.type === "VariableDeclaration") {
|
|
10499
|
+
const declarators = declarationNode.declarations;
|
|
10500
|
+
if (Array.isArray(declarators) && declarators.length > 0) {
|
|
10501
|
+
const firstDeclarator = declarators[0];
|
|
10502
|
+
if (isAstNode(firstDeclarator)) return extractIdentifierName(firstDeclarator.id);
|
|
10503
|
+
}
|
|
10504
|
+
}
|
|
10505
|
+
if (declarationNode.type === "TSInterfaceDeclaration" || declarationNode.type === "TSTypeAliasDeclaration") return extractIdentifierName(declarationNode.id);
|
|
10506
|
+
};
|
|
10507
|
+
const collectFromFunctionLikeSignature = (functionLikeNode, exportName, collected) => {
|
|
10508
|
+
if (!isAstNode(functionLikeNode)) return;
|
|
10509
|
+
const params = functionLikeNode.params;
|
|
10510
|
+
if (Array.isArray(params)) for (const param of params) collectFromParameter(param, exportName, collected);
|
|
10511
|
+
const returnTypeAnnotation = functionLikeNode.returnType;
|
|
10512
|
+
if (isAstNode(returnTypeAnnotation)) {
|
|
10513
|
+
const annotation = returnTypeAnnotation.typeAnnotation;
|
|
10514
|
+
pushTypeReferences(annotation, exportName, collected, returnTypeAnnotation);
|
|
10515
|
+
}
|
|
10516
|
+
};
|
|
10517
|
+
const collectFromParameter = (parameterNode, exportName, collected) => {
|
|
10518
|
+
if (!isAstNode(parameterNode)) return;
|
|
10519
|
+
const annotation = parameterNode.typeAnnotation;
|
|
10520
|
+
if (isAstNode(annotation)) {
|
|
10521
|
+
const innerTypeNode = annotation.typeAnnotation;
|
|
10522
|
+
pushTypeReferences(innerTypeNode, exportName, collected, annotation);
|
|
10523
|
+
}
|
|
10524
|
+
};
|
|
10525
|
+
const pushTypeReferences = (typeNode, exportName, collected, spanFallbackNode) => {
|
|
10526
|
+
if (!isAstNode(typeNode)) return;
|
|
10527
|
+
const referencedTypeNames = /* @__PURE__ */ new Set();
|
|
10528
|
+
collectTypeReferenceNamesFromTypeNode(typeNode, referencedTypeNames);
|
|
10529
|
+
for (const referencedName of referencedTypeNames) {
|
|
10530
|
+
const offset = typeNode.start;
|
|
10531
|
+
const fallbackOffset = isAstNode(spanFallbackNode) && typeof spanFallbackNode.start === "number" ? spanFallbackNode.start : 0;
|
|
10532
|
+
collected.push({
|
|
10533
|
+
exportName,
|
|
10534
|
+
typeName: referencedName,
|
|
10535
|
+
byteOffset: typeof offset === "number" ? offset : fallbackOffset
|
|
10536
|
+
});
|
|
10537
|
+
}
|
|
10538
|
+
};
|
|
10539
|
+
const collectPublicSignatureReferences = (programNode) => {
|
|
10540
|
+
const collected = [];
|
|
10541
|
+
if (!isAstNode(programNode)) return collected;
|
|
10542
|
+
const programBody = programNode.body;
|
|
10543
|
+
if (!Array.isArray(programBody)) return collected;
|
|
10544
|
+
for (const statement of programBody) {
|
|
10545
|
+
if (!isExportedDeclaration(statement)) continue;
|
|
10546
|
+
const declarationNode = declarationOf(statement);
|
|
10547
|
+
if (declarationNode === void 0 || declarationNode === null) continue;
|
|
10548
|
+
const exportedName = exportedNameOfDeclaration(declarationNode);
|
|
10549
|
+
if (!exportedName) continue;
|
|
10550
|
+
if (isAstNode(declarationNode)) {
|
|
10551
|
+
if (declarationNode.type === "FunctionDeclaration" || declarationNode.type === "ArrowFunctionExpression" || declarationNode.type === "FunctionExpression") {
|
|
10552
|
+
collectFromFunctionLikeSignature(declarationNode, exportedName, collected);
|
|
10553
|
+
continue;
|
|
10554
|
+
}
|
|
10555
|
+
if (declarationNode.type === "VariableDeclaration") {
|
|
10556
|
+
const declarators = declarationNode.declarations;
|
|
10557
|
+
if (Array.isArray(declarators)) for (const declarator of declarators) {
|
|
10558
|
+
if (!isAstNode(declarator)) continue;
|
|
10559
|
+
const id = declarator.id;
|
|
10560
|
+
if (isAstNode(id)) {
|
|
10561
|
+
const annotation = id.typeAnnotation;
|
|
10562
|
+
if (isAstNode(annotation)) {
|
|
10563
|
+
const inner = annotation.typeAnnotation;
|
|
10564
|
+
pushTypeReferences(inner, exportedName, collected, annotation);
|
|
10565
|
+
}
|
|
10566
|
+
}
|
|
10567
|
+
const init = declarator.init;
|
|
10568
|
+
if (isAstNode(init) && (init.type === "ArrowFunctionExpression" || init.type === "FunctionExpression")) collectFromFunctionLikeSignature(init, exportedName, collected);
|
|
10569
|
+
}
|
|
10570
|
+
continue;
|
|
10571
|
+
}
|
|
10572
|
+
if (declarationNode.type === "ClassDeclaration") {
|
|
10573
|
+
const classBody = declarationNode.body;
|
|
10574
|
+
if (isAstNode(classBody)) {
|
|
10575
|
+
const members = classBody.body;
|
|
10576
|
+
if (Array.isArray(members)) for (const member of members) {
|
|
10577
|
+
if (!isAstNode(member)) continue;
|
|
10578
|
+
if (member.type === "MethodDefinition") {
|
|
10579
|
+
const value = member.value;
|
|
10580
|
+
collectFromFunctionLikeSignature(value, exportedName, collected);
|
|
10581
|
+
} else if (member.type === "PropertyDefinition") {
|
|
10582
|
+
const annotation = member.typeAnnotation;
|
|
10583
|
+
if (isAstNode(annotation)) {
|
|
10584
|
+
const inner = annotation.typeAnnotation;
|
|
10585
|
+
pushTypeReferences(inner, exportedName, collected, annotation);
|
|
10586
|
+
}
|
|
10587
|
+
}
|
|
10588
|
+
}
|
|
10589
|
+
}
|
|
10590
|
+
}
|
|
10591
|
+
}
|
|
10592
|
+
}
|
|
10593
|
+
return collected;
|
|
10594
|
+
};
|
|
10595
|
+
const collectLocalTypeNames = (programNode) => {
|
|
10596
|
+
const localTypeNames = /* @__PURE__ */ new Set();
|
|
10597
|
+
const exportedNames = /* @__PURE__ */ new Set();
|
|
10598
|
+
if (!isAstNode(programNode)) return {
|
|
10599
|
+
localTypeNames,
|
|
10600
|
+
exportedNames
|
|
10601
|
+
};
|
|
10602
|
+
const programBody = programNode.body;
|
|
10603
|
+
if (!Array.isArray(programBody)) return {
|
|
10604
|
+
localTypeNames,
|
|
10605
|
+
exportedNames
|
|
10606
|
+
};
|
|
10607
|
+
for (const statement of programBody) {
|
|
10608
|
+
if (!isAstNode(statement)) continue;
|
|
10609
|
+
if (statement.type === "TSInterfaceDeclaration" || statement.type === "TSTypeAliasDeclaration") {
|
|
10610
|
+
const name = extractIdentifierName(statement.id);
|
|
10611
|
+
if (name) localTypeNames.add(name);
|
|
10612
|
+
continue;
|
|
10613
|
+
}
|
|
10614
|
+
if (statement.type === "ExportNamedDeclaration") {
|
|
10615
|
+
const declarationNode = statement.declaration;
|
|
10616
|
+
if (isAstNode(declarationNode)) {
|
|
10617
|
+
if (declarationNode.type === "TSInterfaceDeclaration" || declarationNode.type === "TSTypeAliasDeclaration") {
|
|
10618
|
+
const name = extractIdentifierName(declarationNode.id);
|
|
10619
|
+
if (name) exportedNames.add(name);
|
|
10620
|
+
continue;
|
|
10621
|
+
}
|
|
10622
|
+
const declaredName = exportedNameOfDeclaration(declarationNode);
|
|
10623
|
+
if (declaredName) exportedNames.add(declaredName);
|
|
10624
|
+
}
|
|
10625
|
+
const specifiers = statement.specifiers;
|
|
10626
|
+
if (Array.isArray(specifiers)) for (const specifier of specifiers) {
|
|
10627
|
+
if (!isAstNode(specifier)) continue;
|
|
10628
|
+
if (specifier.type === "ExportSpecifier") {
|
|
10629
|
+
const exported = specifier.exported;
|
|
10630
|
+
const exportedNameValue = extractIdentifierName(exported);
|
|
10631
|
+
if (exportedNameValue) exportedNames.add(exportedNameValue);
|
|
10632
|
+
}
|
|
10633
|
+
}
|
|
10634
|
+
}
|
|
10635
|
+
}
|
|
10636
|
+
return {
|
|
10637
|
+
localTypeNames,
|
|
10638
|
+
exportedNames
|
|
10639
|
+
};
|
|
10640
|
+
};
|
|
10641
|
+
/**
|
|
10642
|
+
* Storybook CSF3 convention: a story file declares
|
|
10643
|
+
*
|
|
10644
|
+
* const meta = { ... } satisfies Meta<...>;
|
|
10645
|
+
* export default meta;
|
|
10646
|
+
* type Story = StoryObj<typeof meta>;
|
|
10647
|
+
* export const Primary: Story = { ... };
|
|
10648
|
+
*
|
|
10649
|
+
* `Story` is intentionally a local alias — consumers don't import it; the
|
|
10650
|
+
* Storybook runtime reads the default export. Flagging this as a leak
|
|
10651
|
+
* produces near-100% false positives on Storybook codebases, so skip story
|
|
10652
|
+
* files entirely. Storybook supports both common glob conventions — match
|
|
10653
|
+
* the `*.stories.{ext}` style as well as a bare `stories.{ext}` basename.
|
|
10654
|
+
*/
|
|
10655
|
+
const STORYBOOK_STORY_BASENAME_PATTERN = /^(?:.*\.)?stories\.(?:[cm]?ts|[cm]?js|tsx|jsx)$/i;
|
|
10656
|
+
const isStorybookStoryFile = (filePath) => {
|
|
10657
|
+
const lastSlash = filePath.lastIndexOf("/");
|
|
10658
|
+
const basename = lastSlash === -1 ? filePath : filePath.slice(lastSlash + 1);
|
|
10659
|
+
return STORYBOOK_STORY_BASENAME_PATTERN.test(basename);
|
|
10660
|
+
};
|
|
10661
|
+
/**
|
|
10662
|
+
* Detect TypeScript "private type leak": an exported declaration's signature
|
|
10663
|
+
* references a type that was declared locally in the same module but is not
|
|
10664
|
+
* itself exported. Consumers of the export need that type to satisfy the
|
|
10665
|
+
* signature, but cannot import it.
|
|
10666
|
+
*
|
|
10667
|
+
* Skips declaration files (`.d.ts`) — they are pure type modules where this
|
|
10668
|
+
* pattern is the norm. Keeps it simple: doesn't try to chase aliased re-export
|
|
10669
|
+
* paths (deslop-js's broader resolver work covers that elsewhere); a leak
|
|
10670
|
+
* that's actually re-exported gets filtered out at the `exportedNames` set.
|
|
10671
|
+
*/
|
|
10672
|
+
const detectPrivateTypeLeaks = (graph) => {
|
|
10673
|
+
const findings = [];
|
|
10674
|
+
for (const module of graph.modules) {
|
|
10675
|
+
if (module.isDeclarationFile) continue;
|
|
10676
|
+
if (module.isConfigFile) continue;
|
|
10677
|
+
if (!module.isReachable) continue;
|
|
10678
|
+
if (isStorybookStoryFile(module.fileId.path)) continue;
|
|
10679
|
+
let sourceText;
|
|
10680
|
+
try {
|
|
10681
|
+
sourceText = readFileSync(module.fileId.path, "utf-8");
|
|
10682
|
+
} catch {
|
|
10683
|
+
continue;
|
|
10684
|
+
}
|
|
10685
|
+
let parseResult;
|
|
10686
|
+
try {
|
|
10687
|
+
parseResult = parseSync(module.fileId.path, sourceText);
|
|
10688
|
+
} catch {
|
|
10689
|
+
continue;
|
|
10690
|
+
}
|
|
10691
|
+
const programNode = parseResult.program;
|
|
10692
|
+
const { localTypeNames, exportedNames } = collectLocalTypeNames(programNode);
|
|
10693
|
+
if (localTypeNames.size === 0) continue;
|
|
10694
|
+
const publicSignatureReferences = collectPublicSignatureReferences(programNode);
|
|
10695
|
+
if (publicSignatureReferences.length === 0) continue;
|
|
10696
|
+
const lineStarts = computeLineStarts(sourceText);
|
|
10697
|
+
const seenPairs = /* @__PURE__ */ new Set();
|
|
10698
|
+
for (const reference of publicSignatureReferences) {
|
|
10699
|
+
if (!localTypeNames.has(reference.typeName)) continue;
|
|
10700
|
+
if (exportedNames.has(reference.typeName)) continue;
|
|
10701
|
+
const pairKey = `${reference.exportName}::${reference.typeName}`;
|
|
10702
|
+
if (seenPairs.has(pairKey)) continue;
|
|
10703
|
+
seenPairs.add(pairKey);
|
|
10704
|
+
const { line, column } = offsetToLineColumn(reference.byteOffset, lineStarts);
|
|
10705
|
+
findings.push({
|
|
10706
|
+
path: module.fileId.path,
|
|
10707
|
+
exportName: reference.exportName,
|
|
10708
|
+
typeName: reference.typeName,
|
|
10709
|
+
line,
|
|
10710
|
+
column,
|
|
10711
|
+
confidence: "high",
|
|
10712
|
+
reason: `${reference.exportName}'s signature references ${reference.typeName}, declared locally but not exported — consumers can't satisfy the type without importing it`
|
|
10713
|
+
});
|
|
10714
|
+
}
|
|
10715
|
+
}
|
|
10716
|
+
findings.sort((leftLeak, rightLeak) => {
|
|
10717
|
+
if (leftLeak.path !== rightLeak.path) return leftLeak.path.localeCompare(rightLeak.path);
|
|
10718
|
+
return leftLeak.line - rightLeak.line;
|
|
10719
|
+
});
|
|
10720
|
+
return findings;
|
|
10721
|
+
};
|
|
10722
|
+
|
|
10723
|
+
//#endregion
|
|
10724
|
+
//#region src/report/typescript-smells.ts
|
|
10725
|
+
const parseSource = (filePath) => {
|
|
10726
|
+
let sourceText;
|
|
10727
|
+
try {
|
|
10728
|
+
sourceText = readFileSync(filePath, "utf-8");
|
|
10729
|
+
} catch {
|
|
10730
|
+
return;
|
|
10731
|
+
}
|
|
10732
|
+
let parseResult;
|
|
10733
|
+
try {
|
|
10734
|
+
parseResult = parseSync(filePath, sourceText);
|
|
10735
|
+
} catch {
|
|
10736
|
+
return;
|
|
10737
|
+
}
|
|
10738
|
+
const rawComments = parseResult.comments;
|
|
10739
|
+
const comments = Array.isArray(rawComments) ? rawComments.filter(isParsedSourceComment) : [];
|
|
10740
|
+
return {
|
|
10741
|
+
programNode: parseResult.program,
|
|
10742
|
+
sourceText,
|
|
10743
|
+
lineStarts: computeLineStarts(sourceText),
|
|
10744
|
+
comments
|
|
10745
|
+
};
|
|
10746
|
+
};
|
|
10747
|
+
const isParsedSourceComment = (candidate) => {
|
|
10748
|
+
if (typeof candidate !== "object" || candidate === null) return false;
|
|
10749
|
+
const fields = candidate;
|
|
10750
|
+
return (fields.type === "Line" || fields.type === "Block") && typeof fields.value === "string" && typeof fields.start === "number" && typeof fields.end === "number";
|
|
10751
|
+
};
|
|
10752
|
+
const sliceSnippet = (sourceText, start, end) => {
|
|
10753
|
+
const SNIPPET_BUDGET_CHARS = 80;
|
|
10754
|
+
const raw = sourceText.slice(start, Math.min(end, start + SNIPPET_BUDGET_CHARS)).replace(/\s+/g, " ").trim();
|
|
10755
|
+
return end - start > SNIPPET_BUDGET_CHARS ? `${raw}…` : raw;
|
|
10756
|
+
};
|
|
10757
|
+
const isAnyOrUnknownTypeAnnotation = (typeAnnotation) => {
|
|
10758
|
+
if (!isAstNode(typeAnnotation)) return void 0;
|
|
10759
|
+
if (typeAnnotation.type === "TSAnyKeyword") return "any";
|
|
10760
|
+
if (typeAnnotation.type === "TSUnknownKeyword") return "unknown";
|
|
10761
|
+
};
|
|
10762
|
+
const isLiteralLikeNonNull = (expression) => {
|
|
10763
|
+
if (!isAstNode(expression)) return false;
|
|
10764
|
+
if (expression.type === "Literal") return expression.value !== null;
|
|
10765
|
+
if (expression.type === "TemplateLiteral" || expression.type === "ArrayExpression" || expression.type === "ObjectExpression" || expression.type === "FunctionExpression" || expression.type === "ArrowFunctionExpression" || expression.type === "ClassExpression") return true;
|
|
10766
|
+
return false;
|
|
10767
|
+
};
|
|
10768
|
+
const collectUnnecessaryAssertionsInNode = (node, filePath, sourceText, lineStarts, results) => {
|
|
10769
|
+
if (!isAstNode(node)) return;
|
|
10770
|
+
if (node.type === "TSAsExpression" || node.type === "TSSatisfiesExpression") {
|
|
10771
|
+
const innerExpression = node.expression;
|
|
10772
|
+
const typeAnnotation = node.typeAnnotation;
|
|
10773
|
+
if (node.type === "TSAsExpression") {
|
|
10774
|
+
const outerKind = isAnyOrUnknownTypeAnnotation(typeAnnotation);
|
|
10775
|
+
if (outerKind === void 0 && isAstNode(innerExpression) && innerExpression.type === "TSAsExpression") {
|
|
10776
|
+
const innerTypeAnnotation = innerExpression.typeAnnotation;
|
|
10777
|
+
const innerKind = isAnyOrUnknownTypeAnnotation(innerTypeAnnotation);
|
|
10778
|
+
if (innerKind !== void 0) pushAssertion(node, "redundant-double-assertion", `\`x as ${innerKind} as T\` first widens to ${innerKind} just to assert to T — drop the intermediate ${innerKind} and assert directly`, "if you must assert, write `x as T` directly", filePath, sourceText, lineStarts, results);
|
|
10779
|
+
}
|
|
10780
|
+
if (outerKind === "any") pushAssertion(node, "assertion-to-any", "`as any` opts out of TypeScript's type system — narrow to a specific type or use `unknown`", "replace `as any` with the actual type, or use `as unknown as T` only when you genuinely need to discard the inferred type", filePath, sourceText, lineStarts, results);
|
|
10781
|
+
}
|
|
10782
|
+
}
|
|
10783
|
+
if (node.type === "TSTypeAssertion") pushAssertion(node, "angle-bracket-assertion", "`<T>x` style assertion is parsed as a JSX tag in `.tsx` and is deprecated in mixed-extension projects — prefer `x as T`", "rewrite `<T>x` as `x as T`", filePath, sourceText, lineStarts, results);
|
|
10784
|
+
if (node.type === "TSNonNullExpression") {
|
|
10785
|
+
const innerExpression = node.expression;
|
|
10786
|
+
if (isAstNode(innerExpression) && innerExpression.type === "TSNonNullExpression") pushAssertion(node, "double-non-null", "`x!!` is the non-null assertion applied twice — the second `!` is always a no-op", "drop one of the `!` operators", filePath, sourceText, lineStarts, results);
|
|
10787
|
+
else if (isLiteralLikeNonNull(innerExpression)) pushAssertion(node, "redundant-non-null-on-literal", "`!` after a literal / array / object / function expression is redundant — those values are never null", "remove the trailing `!`", filePath, sourceText, lineStarts, results);
|
|
10788
|
+
}
|
|
10789
|
+
};
|
|
10790
|
+
const pushAssertion = (node, kind, reason, suggestion, filePath, sourceText, lineStarts, results) => {
|
|
10791
|
+
if (!isAstNode(node)) return;
|
|
10792
|
+
const startOffset = node.start;
|
|
10793
|
+
const endOffset = node.end;
|
|
10794
|
+
if (typeof startOffset !== "number" || typeof endOffset !== "number") return;
|
|
10795
|
+
const { line, column } = offsetToLineColumn(startOffset, lineStarts);
|
|
10796
|
+
const isHighConfidenceKind = kind === "double-non-null" || kind === "redundant-non-null-on-literal" || kind === "redundant-double-assertion";
|
|
10797
|
+
results.push({
|
|
10798
|
+
path: filePath,
|
|
10799
|
+
kind,
|
|
10800
|
+
snippet: sliceSnippet(sourceText, startOffset, endOffset),
|
|
10801
|
+
line,
|
|
10802
|
+
column,
|
|
10803
|
+
confidence: isHighConfidenceKind ? "high" : "medium",
|
|
10804
|
+
reason,
|
|
10805
|
+
suggestion
|
|
10806
|
+
});
|
|
10807
|
+
};
|
|
10808
|
+
const visitForUnnecessaryAssertions = (node, filePath, sourceText, lineStarts, results) => {
|
|
10809
|
+
if (!isAstNode(node)) return;
|
|
10810
|
+
collectUnnecessaryAssertionsInNode(node, filePath, sourceText, lineStarts, results);
|
|
10811
|
+
for (const propertyKey of Object.keys(node)) {
|
|
10812
|
+
if (propertyKey === "type" || propertyKey === "start" || propertyKey === "end" || propertyKey === "loc" || propertyKey === "range") continue;
|
|
10813
|
+
const value = node[propertyKey];
|
|
10814
|
+
if (Array.isArray(value)) for (const item of value) visitForUnnecessaryAssertions(item, filePath, sourceText, lineStarts, results);
|
|
10815
|
+
else if (value !== null && typeof value === "object") visitForUnnecessaryAssertions(value, filePath, sourceText, lineStarts, results);
|
|
10816
|
+
}
|
|
10817
|
+
};
|
|
10818
|
+
const importExpressionSpecifier = (importExpression) => {
|
|
10819
|
+
if (!isAstNode(importExpression)) return void 0;
|
|
10820
|
+
if (importExpression.type !== "ImportExpression") return void 0;
|
|
10821
|
+
const sourceNode = importExpression.source;
|
|
10822
|
+
if (!isAstNode(sourceNode)) return void 0;
|
|
10823
|
+
if (sourceNode.type !== "Literal") return void 0;
|
|
10824
|
+
const literalValue = sourceNode.value;
|
|
10825
|
+
return typeof literalValue === "string" ? literalValue : void 0;
|
|
10826
|
+
};
|
|
10827
|
+
const findThenImportInExpressionStatement = (expressionNode) => {
|
|
10828
|
+
if (!isAstNode(expressionNode)) return void 0;
|
|
10829
|
+
if (expressionNode.type !== "CallExpression") return void 0;
|
|
10830
|
+
const callee = expressionNode.callee;
|
|
10831
|
+
if (!isAstNode(callee)) return void 0;
|
|
10832
|
+
if (callee.type !== "MemberExpression" && callee.type !== "StaticMemberExpression") return void 0;
|
|
10833
|
+
const propertyNode = callee.property;
|
|
10834
|
+
const propertyName = isAstNode(propertyNode) ? propertyNode.name : void 0;
|
|
10835
|
+
if (propertyName !== "then" && propertyName !== "catch" && propertyName !== "finally") return void 0;
|
|
10836
|
+
const objectNode = callee.object;
|
|
10837
|
+
const specifier = importExpressionSpecifier(objectNode);
|
|
10838
|
+
if (specifier === void 0) return void 0;
|
|
10839
|
+
return {
|
|
10840
|
+
importExpression: objectNode,
|
|
10841
|
+
specifier
|
|
10842
|
+
};
|
|
10843
|
+
};
|
|
10844
|
+
const findAwaitImportInExpression = (expressionNode) => {
|
|
10845
|
+
if (!isAstNode(expressionNode)) return void 0;
|
|
10846
|
+
if (expressionNode.type !== "AwaitExpression") return void 0;
|
|
10847
|
+
const argumentNode = expressionNode.argument;
|
|
10848
|
+
const specifier = importExpressionSpecifier(argumentNode);
|
|
10849
|
+
if (specifier === void 0) return void 0;
|
|
10850
|
+
return {
|
|
10851
|
+
importExpression: argumentNode,
|
|
10852
|
+
specifier
|
|
10853
|
+
};
|
|
10854
|
+
};
|
|
10855
|
+
const collectLazyImportsAtTopLevel = (programNode, filePath, lineStarts, results) => {
|
|
10856
|
+
if (!isAstNode(programNode)) return;
|
|
10857
|
+
const programBody = programNode.body;
|
|
10858
|
+
if (!Array.isArray(programBody)) return;
|
|
10859
|
+
for (const topLevelStatement of programBody) {
|
|
10860
|
+
if (!isAstNode(topLevelStatement)) continue;
|
|
10861
|
+
if (topLevelStatement.type === "VariableDeclaration") {
|
|
10862
|
+
const declarators = topLevelStatement.declarations;
|
|
10863
|
+
if (!Array.isArray(declarators)) continue;
|
|
10864
|
+
for (const declarator of declarators) {
|
|
10865
|
+
if (!isAstNode(declarator)) continue;
|
|
10866
|
+
const initializer = declarator.init;
|
|
10867
|
+
const awaitImport = findAwaitImportInExpression(initializer);
|
|
10868
|
+
if (awaitImport) recordLazyImport(awaitImport, "top-level-await-import", filePath, lineStarts, results);
|
|
10869
|
+
}
|
|
10870
|
+
continue;
|
|
10871
|
+
}
|
|
10872
|
+
if (topLevelStatement.type === "ExpressionStatement") {
|
|
10873
|
+
const innerExpression = topLevelStatement.expression;
|
|
10874
|
+
const awaitImport = findAwaitImportInExpression(innerExpression);
|
|
10875
|
+
if (awaitImport) {
|
|
10876
|
+
recordLazyImport(awaitImport, "top-level-await-import", filePath, lineStarts, results);
|
|
10877
|
+
continue;
|
|
10878
|
+
}
|
|
10879
|
+
const thenImport = findThenImportInExpressionStatement(innerExpression);
|
|
10880
|
+
if (thenImport) recordLazyImport(thenImport, "top-level-then-import", filePath, lineStarts, results);
|
|
10881
|
+
}
|
|
10882
|
+
}
|
|
10883
|
+
};
|
|
10884
|
+
const recordLazyImport = (match, kind, filePath, lineStarts, results) => {
|
|
10885
|
+
if (!isAstNode(match.importExpression)) return;
|
|
10886
|
+
const startOffset = match.importExpression.start;
|
|
10887
|
+
if (typeof startOffset !== "number") return;
|
|
10888
|
+
const { line, column } = offsetToLineColumn(startOffset, lineStarts);
|
|
10889
|
+
results.push({
|
|
10890
|
+
path: filePath,
|
|
10891
|
+
specifier: match.specifier,
|
|
10892
|
+
kind,
|
|
10893
|
+
line,
|
|
10894
|
+
column,
|
|
10895
|
+
confidence: kind === "top-level-await-import" ? "high" : "medium",
|
|
10896
|
+
reason: kind === "top-level-await-import" ? `top-level \`await import("${match.specifier}")\` runs synchronously before the module finishes loading anyway — there is no laziness benefit, prefer a static \`import\`` : `top-level \`import("${match.specifier}").then(...)\` runs at module evaluation — prefer a static \`import\` and a regular function call unless the dynamic-import contract is intentional`
|
|
10897
|
+
});
|
|
10898
|
+
};
|
|
10899
|
+
const buildPackageJsonTypeCache = () => {
|
|
10900
|
+
const directoryToType = /* @__PURE__ */ new Map();
|
|
10901
|
+
const resolveModuleType = (filePath) => {
|
|
10902
|
+
let currentDirectory = dirname(resolve(filePath));
|
|
10903
|
+
const visitedDirectories = [];
|
|
10904
|
+
while (true) {
|
|
10905
|
+
visitedDirectories.push(currentDirectory);
|
|
10906
|
+
const cached = directoryToType.get(currentDirectory);
|
|
10907
|
+
if (cached !== void 0) {
|
|
10908
|
+
for (const visitedDirectory of visitedDirectories) directoryToType.set(visitedDirectory, cached);
|
|
10909
|
+
return cached;
|
|
10910
|
+
}
|
|
10911
|
+
const packageJsonPath = join(currentDirectory, "package.json");
|
|
10912
|
+
if (existsSync(packageJsonPath)) try {
|
|
10913
|
+
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf-8"));
|
|
10914
|
+
const moduleType = packageJson.type === "module" ? "module" : packageJson.type === "commonjs" ? "commonjs" : void 0;
|
|
10915
|
+
for (const visitedDirectory of visitedDirectories) directoryToType.set(visitedDirectory, moduleType);
|
|
10916
|
+
return moduleType;
|
|
10917
|
+
} catch {
|
|
10918
|
+
for (const visitedDirectory of visitedDirectories) directoryToType.set(visitedDirectory, void 0);
|
|
10919
|
+
return;
|
|
10920
|
+
}
|
|
10921
|
+
const parentDirectory = dirname(currentDirectory);
|
|
10922
|
+
if (parentDirectory === currentDirectory) {
|
|
10923
|
+
for (const visitedDirectory of visitedDirectories) directoryToType.set(visitedDirectory, void 0);
|
|
10924
|
+
return;
|
|
10925
|
+
}
|
|
10926
|
+
currentDirectory = parentDirectory;
|
|
10927
|
+
}
|
|
10928
|
+
};
|
|
10929
|
+
return { resolveModuleType };
|
|
10930
|
+
};
|
|
10931
|
+
const isEsmFilePath = (filePath, typeCache) => {
|
|
10932
|
+
if (filePath.endsWith(".mts") || filePath.endsWith(".mjs")) return true;
|
|
10933
|
+
if (filePath.endsWith(".cts") || filePath.endsWith(".cjs")) return false;
|
|
10934
|
+
return typeCache.resolveModuleType(filePath) === "module";
|
|
10935
|
+
};
|
|
10936
|
+
const collectCommonjsInEsm = (programNode, filePath, sourceText, lineStarts, results) => {
|
|
10937
|
+
if (!isAstNode(programNode)) return;
|
|
10938
|
+
visitForCommonjs(programNode, filePath, sourceText, lineStarts, results);
|
|
10939
|
+
};
|
|
10940
|
+
const visitForCommonjs = (node, filePath, sourceText, lineStarts, results) => {
|
|
10941
|
+
if (!isAstNode(node)) return;
|
|
10942
|
+
if (node.type === "CallExpression") {
|
|
10943
|
+
const callee = node.callee;
|
|
10944
|
+
if (isAstNode(callee) && callee.type === "Identifier") {
|
|
10945
|
+
if (callee.name === "require") {
|
|
10946
|
+
const callArguments = node.arguments;
|
|
10947
|
+
if (Array.isArray(callArguments) && callArguments.length > 0) {
|
|
10948
|
+
const firstArgument = callArguments[0];
|
|
10949
|
+
if (isAstNode(firstArgument) && firstArgument.type === "Literal" && typeof firstArgument.value === "string") {
|
|
10950
|
+
const startOffset = node.start;
|
|
10951
|
+
const endOffset = node.end;
|
|
10952
|
+
if (typeof startOffset === "number" && typeof endOffset === "number") {
|
|
10953
|
+
const { line, column } = offsetToLineColumn(startOffset, lineStarts);
|
|
10954
|
+
results.push({
|
|
10955
|
+
path: filePath,
|
|
10956
|
+
kind: "require",
|
|
10957
|
+
line,
|
|
10958
|
+
column,
|
|
10959
|
+
confidence: "high",
|
|
10960
|
+
reason: "synchronous `require()` is unavailable in native ESM — use a static `import` or top-level `await import()`",
|
|
10961
|
+
snippet: sliceSnippet(sourceText, startOffset, endOffset)
|
|
10962
|
+
});
|
|
10963
|
+
}
|
|
10964
|
+
}
|
|
10965
|
+
}
|
|
10966
|
+
}
|
|
10967
|
+
}
|
|
10968
|
+
}
|
|
10969
|
+
if (node.type === "AssignmentExpression") {
|
|
10970
|
+
const leftSide = node.left;
|
|
10971
|
+
if (isAstNode(leftSide)) {
|
|
10972
|
+
if (leftSide.type === "MemberExpression" || leftSide.type === "StaticMemberExpression") {
|
|
10973
|
+
const objectNode = leftSide.object;
|
|
10974
|
+
const propertyNode = leftSide.property;
|
|
10975
|
+
const objectName = isAstNode(objectNode) ? objectNode.name : void 0;
|
|
10976
|
+
const propertyName = isAstNode(propertyNode) ? propertyNode.name : void 0;
|
|
10977
|
+
if (objectName === "module" && propertyName === "exports") {
|
|
10978
|
+
const startOffset = node.start;
|
|
10979
|
+
const endOffset = node.end;
|
|
10980
|
+
if (typeof startOffset === "number" && typeof endOffset === "number") {
|
|
10981
|
+
const { line, column } = offsetToLineColumn(startOffset, lineStarts);
|
|
10982
|
+
results.push({
|
|
10983
|
+
path: filePath,
|
|
10984
|
+
kind: "module-exports",
|
|
10985
|
+
line,
|
|
10986
|
+
column,
|
|
10987
|
+
confidence: "high",
|
|
10988
|
+
reason: "`module.exports = ...` is CommonJS — replace with `export default` or named `export` for ESM",
|
|
10989
|
+
snippet: sliceSnippet(sourceText, startOffset, endOffset)
|
|
10990
|
+
});
|
|
10991
|
+
}
|
|
10992
|
+
} else if (objectName === "exports") {
|
|
10993
|
+
const startOffset = node.start;
|
|
10994
|
+
const endOffset = node.end;
|
|
10995
|
+
if (typeof startOffset === "number" && typeof endOffset === "number") {
|
|
10996
|
+
const { line, column } = offsetToLineColumn(startOffset, lineStarts);
|
|
10997
|
+
results.push({
|
|
10998
|
+
path: filePath,
|
|
10999
|
+
kind: "exports-assignment",
|
|
11000
|
+
line,
|
|
11001
|
+
column,
|
|
11002
|
+
confidence: "high",
|
|
11003
|
+
reason: "`exports.x = ...` is CommonJS — replace with a named `export` for ESM",
|
|
11004
|
+
snippet: sliceSnippet(sourceText, startOffset, endOffset)
|
|
11005
|
+
});
|
|
11006
|
+
}
|
|
11007
|
+
}
|
|
11008
|
+
}
|
|
11009
|
+
}
|
|
11010
|
+
}
|
|
11011
|
+
for (const propertyKey of Object.keys(node)) {
|
|
11012
|
+
if (propertyKey === "type" || propertyKey === "start" || propertyKey === "end" || propertyKey === "loc" || propertyKey === "range") continue;
|
|
11013
|
+
const value = node[propertyKey];
|
|
11014
|
+
if (Array.isArray(value)) for (const item of value) visitForCommonjs(item, filePath, sourceText, lineStarts, results);
|
|
11015
|
+
else if (value !== null && typeof value === "object") visitForCommonjs(value, filePath, sourceText, lineStarts, results);
|
|
11016
|
+
}
|
|
11017
|
+
};
|
|
11018
|
+
const TS_IGNORE_LEADING = /^\s*@ts-ignore\b/;
|
|
11019
|
+
const TS_NOCHECK_LEADING = /^\s*@ts-nocheck\b/;
|
|
11020
|
+
const TS_EXPECT_ERROR_LEADING = /^\s*@ts-expect-error\b(.*)$/;
|
|
11021
|
+
const collectTypeScriptEscapeHatches = (comments, filePath, lineStarts, results) => {
|
|
11022
|
+
for (const comment of comments) {
|
|
11023
|
+
const commentBody = comment.type === "Block" ? comment.value.split("\n")[0] : comment.value;
|
|
11024
|
+
if (TS_IGNORE_LEADING.test(commentBody)) {
|
|
11025
|
+
pushEscapeHatch(comment.start, "ts-ignore", "`@ts-ignore` silently swallows the next line's type errors forever — use `@ts-expect-error` so the suppression breaks if the underlying error gets fixed", "rewrite as `@ts-expect-error <why this is okay>`", "high", filePath, lineStarts, results);
|
|
11026
|
+
continue;
|
|
11027
|
+
}
|
|
11028
|
+
if (TS_NOCHECK_LEADING.test(commentBody)) {
|
|
11029
|
+
pushEscapeHatch(comment.start, "ts-nocheck", "`@ts-nocheck` disables type checking for the entire file — fix the underlying types or scope the suppression to a specific line", "remove `@ts-nocheck` and address the underlying type errors, or use per-line `@ts-expect-error` with a justification", "medium", filePath, lineStarts, results);
|
|
11030
|
+
continue;
|
|
11031
|
+
}
|
|
11032
|
+
const expectErrorMatch = commentBody.match(TS_EXPECT_ERROR_LEADING);
|
|
11033
|
+
if (expectErrorMatch) {
|
|
11034
|
+
if ((expectErrorMatch[1] ?? "").trim().length === 0) pushEscapeHatch(comment.start, "ts-expect-error-without-explanation", "`@ts-expect-error` should be followed by a comment explaining why the next line legitimately produces a type error", "add a short justification: `// @ts-expect-error: <why this is okay>`", "low", filePath, lineStarts, results);
|
|
11035
|
+
}
|
|
11036
|
+
}
|
|
11037
|
+
};
|
|
11038
|
+
const pushEscapeHatch = (commentStartOffset, kind, reason, suggestion, confidence, filePath, lineStarts, results) => {
|
|
11039
|
+
const { line, column } = offsetToLineColumn(commentStartOffset, lineStarts);
|
|
11040
|
+
results.push({
|
|
11041
|
+
path: filePath,
|
|
11042
|
+
kind,
|
|
11043
|
+
line,
|
|
11044
|
+
column,
|
|
11045
|
+
confidence,
|
|
11046
|
+
reason,
|
|
11047
|
+
suggestion
|
|
11048
|
+
});
|
|
11049
|
+
};
|
|
11050
|
+
const isTypeScriptOrJsFile = (filePath) => filePath.endsWith(".ts") || filePath.endsWith(".tsx") || filePath.endsWith(".mts") || filePath.endsWith(".cts") || filePath.endsWith(".js") || filePath.endsWith(".jsx") || filePath.endsWith(".mjs") || filePath.endsWith(".cjs");
|
|
11051
|
+
const isTypeScriptFileExtension = (filePath) => filePath.endsWith(".ts") || filePath.endsWith(".tsx") || filePath.endsWith(".mts") || filePath.endsWith(".cts");
|
|
11052
|
+
const detectTypeScriptSmells = (graph) => {
|
|
11053
|
+
const unnecessaryAssertions = [];
|
|
11054
|
+
const lazyImportsAtTopLevel = [];
|
|
11055
|
+
const commonjsInEsm = [];
|
|
11056
|
+
const typeScriptEscapeHatches = [];
|
|
11057
|
+
const packageJsonTypeCache = buildPackageJsonTypeCache();
|
|
11058
|
+
for (const module of graph.modules) {
|
|
11059
|
+
if (module.isDeclarationFile) continue;
|
|
11060
|
+
const filePath = module.fileId.path;
|
|
11061
|
+
if (!isTypeScriptOrJsFile(filePath)) continue;
|
|
11062
|
+
const parsedSource = parseSource(filePath);
|
|
11063
|
+
if (!parsedSource) continue;
|
|
11064
|
+
if (isTypeScriptFileExtension(filePath)) {
|
|
11065
|
+
visitForUnnecessaryAssertions(parsedSource.programNode, filePath, parsedSource.sourceText, parsedSource.lineStarts, unnecessaryAssertions);
|
|
11066
|
+
collectTypeScriptEscapeHatches(parsedSource.comments, filePath, parsedSource.lineStarts, typeScriptEscapeHatches);
|
|
11067
|
+
}
|
|
11068
|
+
collectLazyImportsAtTopLevel(parsedSource.programNode, filePath, parsedSource.lineStarts, lazyImportsAtTopLevel);
|
|
11069
|
+
if (isEsmFilePath(filePath, packageJsonTypeCache)) collectCommonjsInEsm(parsedSource.programNode, filePath, parsedSource.sourceText, parsedSource.lineStarts, commonjsInEsm);
|
|
11070
|
+
}
|
|
11071
|
+
unnecessaryAssertions.sort((leftFinding, rightFinding) => {
|
|
11072
|
+
if (leftFinding.path !== rightFinding.path) return leftFinding.path.localeCompare(rightFinding.path);
|
|
11073
|
+
return leftFinding.line - rightFinding.line;
|
|
11074
|
+
});
|
|
11075
|
+
lazyImportsAtTopLevel.sort((leftFinding, rightFinding) => {
|
|
11076
|
+
if (leftFinding.path !== rightFinding.path) return leftFinding.path.localeCompare(rightFinding.path);
|
|
11077
|
+
return leftFinding.line - rightFinding.line;
|
|
11078
|
+
});
|
|
11079
|
+
commonjsInEsm.sort((leftFinding, rightFinding) => {
|
|
11080
|
+
if (leftFinding.path !== rightFinding.path) return leftFinding.path.localeCompare(rightFinding.path);
|
|
11081
|
+
return leftFinding.line - rightFinding.line;
|
|
11082
|
+
});
|
|
11083
|
+
typeScriptEscapeHatches.sort((leftFinding, rightFinding) => {
|
|
11084
|
+
if (leftFinding.path !== rightFinding.path) return leftFinding.path.localeCompare(rightFinding.path);
|
|
11085
|
+
return leftFinding.line - rightFinding.line;
|
|
11086
|
+
});
|
|
11087
|
+
return {
|
|
11088
|
+
unnecessaryAssertions,
|
|
11089
|
+
lazyImportsAtTopLevel,
|
|
11090
|
+
commonjsInEsm,
|
|
11091
|
+
typeScriptEscapeHatches
|
|
11092
|
+
};
|
|
11093
|
+
};
|
|
11094
|
+
|
|
11095
|
+
//#endregion
|
|
11096
|
+
//#region src/utils/run-safe-detector.ts
|
|
11097
|
+
const runSafeDetector = (input) => {
|
|
11098
|
+
try {
|
|
11099
|
+
return input.detector();
|
|
11100
|
+
} catch (caughtError) {
|
|
11101
|
+
input.errorSink.push(new DetectorError({
|
|
11102
|
+
module: input.module,
|
|
11103
|
+
message: `${input.detectorName} threw ${input.contextDescription}`,
|
|
11104
|
+
detail: describeUnknownError(caughtError)
|
|
11105
|
+
}));
|
|
11106
|
+
return input.fallback;
|
|
11107
|
+
}
|
|
11108
|
+
};
|
|
11109
|
+
|
|
11110
|
+
//#endregion
|
|
11111
|
+
//#region src/semantic/program.ts
|
|
11112
|
+
const failureFor = (reason, message, options = { rootDir: "" }) => {
|
|
11113
|
+
return {
|
|
11114
|
+
reason,
|
|
11115
|
+
message,
|
|
11116
|
+
error: new TypeScriptError({
|
|
11117
|
+
code: {
|
|
11118
|
+
"no-tsconfig": "tsconfig-not-found",
|
|
11119
|
+
"tsconfig-parse-error": "tsconfig-parse-failed",
|
|
11120
|
+
"program-creation-failed": "ts-program-creation-failed",
|
|
11121
|
+
"too-many-files": "ts-program-too-large",
|
|
11122
|
+
"typescript-load-failed": "ts-not-loadable"
|
|
11123
|
+
}[reason],
|
|
11124
|
+
severity: reason === "no-tsconfig" ? "info" : "warning",
|
|
11125
|
+
message,
|
|
11126
|
+
path: options.rootDir || void 0,
|
|
11127
|
+
detail: options.detail
|
|
11128
|
+
})
|
|
11129
|
+
};
|
|
11130
|
+
};
|
|
11131
|
+
const findNearestTsconfig = (rootDir, explicitPath) => {
|
|
11132
|
+
if (explicitPath) {
|
|
11133
|
+
const absoluteExplicit = resolve(rootDir, explicitPath);
|
|
11134
|
+
if (existsSync(absoluteExplicit)) return absoluteExplicit;
|
|
11135
|
+
return;
|
|
11136
|
+
}
|
|
11137
|
+
for (const candidateName of DEFAULT_SEMANTIC_TSCONFIG_NAMES) {
|
|
11138
|
+
const candidatePath = resolve(rootDir, candidateName);
|
|
11139
|
+
if (existsSync(candidatePath)) return candidatePath;
|
|
11140
|
+
}
|
|
11141
|
+
};
|
|
11142
|
+
const createSemanticContext = (rootDir, tsconfigPath) => {
|
|
11143
|
+
const resolvedTsconfigPath = findNearestTsconfig(rootDir, tsconfigPath);
|
|
11144
|
+
if (!resolvedTsconfigPath) return {
|
|
11145
|
+
ok: false,
|
|
11146
|
+
failure: failureFor("no-tsconfig", `No tsconfig found under ${rootDir}`, { rootDir })
|
|
11147
|
+
};
|
|
11148
|
+
let configFileContent;
|
|
11149
|
+
try {
|
|
11150
|
+
configFileContent = ts.readConfigFile(resolvedTsconfigPath, ts.sys.readFile);
|
|
11151
|
+
} catch (readError) {
|
|
11152
|
+
return {
|
|
11153
|
+
ok: false,
|
|
11154
|
+
failure: failureFor("tsconfig-parse-error", "ts.readConfigFile threw", {
|
|
11155
|
+
rootDir: resolvedTsconfigPath,
|
|
11156
|
+
detail: describeUnknownError(readError)
|
|
11157
|
+
})
|
|
11158
|
+
};
|
|
11159
|
+
}
|
|
11160
|
+
if (configFileContent.error) return {
|
|
11161
|
+
ok: false,
|
|
11162
|
+
failure: failureFor("tsconfig-parse-error", ts.flattenDiagnosticMessageText(configFileContent.error.messageText, "\n"), { rootDir: resolvedTsconfigPath })
|
|
11163
|
+
};
|
|
11164
|
+
let parsedCommandLine;
|
|
11165
|
+
try {
|
|
11166
|
+
parsedCommandLine = ts.parseJsonConfigFileContent(configFileContent.config, ts.sys, dirname(resolvedTsconfigPath), {
|
|
8519
11167
|
noEmit: true,
|
|
8520
11168
|
skipLibCheck: true,
|
|
8521
11169
|
allowJs: true,
|
|
@@ -8884,6 +11532,55 @@ const detectUnusedEnumMembers = (graph, config, context, referenceIndex) => {
|
|
|
8884
11532
|
return findings;
|
|
8885
11533
|
};
|
|
8886
11534
|
|
|
11535
|
+
//#endregion
|
|
11536
|
+
//#region src/utils/is-framework-lifecycle-method.ts
|
|
11537
|
+
/**
|
|
11538
|
+
* Methods invoked by-name by React / Angular runtimes. Static "no caller"
|
|
11539
|
+
* analysis can't see those call sites, so without this allowlist
|
|
11540
|
+
* `unusedClassMembers` would fire on every component.
|
|
11541
|
+
*/
|
|
11542
|
+
const FRAMEWORK_LIFECYCLE_METHODS = new Set([
|
|
11543
|
+
"render",
|
|
11544
|
+
"componentDidMount",
|
|
11545
|
+
"componentDidUpdate",
|
|
11546
|
+
"componentWillUnmount",
|
|
11547
|
+
"shouldComponentUpdate",
|
|
11548
|
+
"getSnapshotBeforeUpdate",
|
|
11549
|
+
"getDerivedStateFromProps",
|
|
11550
|
+
"getDerivedStateFromError",
|
|
11551
|
+
"componentDidCatch",
|
|
11552
|
+
"componentWillMount",
|
|
11553
|
+
"componentWillReceiveProps",
|
|
11554
|
+
"componentWillUpdate",
|
|
11555
|
+
"UNSAFE_componentWillMount",
|
|
11556
|
+
"UNSAFE_componentWillReceiveProps",
|
|
11557
|
+
"UNSAFE_componentWillUpdate",
|
|
11558
|
+
"getChildContext",
|
|
11559
|
+
"contextType",
|
|
11560
|
+
"ngOnInit",
|
|
11561
|
+
"ngOnDestroy",
|
|
11562
|
+
"ngOnChanges",
|
|
11563
|
+
"ngDoCheck",
|
|
11564
|
+
"ngAfterContentInit",
|
|
11565
|
+
"ngAfterContentChecked",
|
|
11566
|
+
"ngAfterViewInit",
|
|
11567
|
+
"ngAfterViewChecked",
|
|
11568
|
+
"ngAcceptInputType",
|
|
11569
|
+
"canActivate",
|
|
11570
|
+
"canDeactivate",
|
|
11571
|
+
"canActivateChild",
|
|
11572
|
+
"canMatch",
|
|
11573
|
+
"resolve",
|
|
11574
|
+
"intercept",
|
|
11575
|
+
"transform",
|
|
11576
|
+
"validate",
|
|
11577
|
+
"registerOnChange",
|
|
11578
|
+
"registerOnTouched",
|
|
11579
|
+
"writeValue",
|
|
11580
|
+
"setDisabledState"
|
|
11581
|
+
]);
|
|
11582
|
+
const isFrameworkLifecycleMethod = (name) => FRAMEWORK_LIFECYCLE_METHODS.has(name);
|
|
11583
|
+
|
|
8887
11584
|
//#endregion
|
|
8888
11585
|
//#region src/semantic/unused-class-members.ts
|
|
8889
11586
|
const isClassExported = (declaration) => {
|
|
@@ -9015,6 +11712,7 @@ const detectUnusedClassMembers = (graph, config, context, referenceIndex, decora
|
|
|
9015
11712
|
if (memberHasExternalReference(memberSymbol, referenceIndex)) continue;
|
|
9016
11713
|
const memberName = ts.isIdentifier(member.name) ? member.name.text : member.name.getText(sourceFile);
|
|
9017
11714
|
if (overriddenMemberNames.has(memberName)) continue;
|
|
11715
|
+
if (isFrameworkLifecycleMethod(memberName)) continue;
|
|
9018
11716
|
const { line: zeroIndexedLine, character: zeroIndexedColumn } = sourceFile.getLineAndCharacterOfPosition(member.getStart(sourceFile));
|
|
9019
11717
|
const line = zeroIndexedLine + 1;
|
|
9020
11718
|
const column = zeroIndexedColumn + 1;
|
|
@@ -9415,6 +12113,22 @@ const generateReport = (graph, config) => {
|
|
|
9415
12113
|
const simplifiableFunctions = config.reportRedundancy ? safeReportDetector("detectSimplifiableFunctions", () => detectSimplifiableFunctions(graph), [], errorSink) : [];
|
|
9416
12114
|
const simplifiableExpressions = config.reportRedundancy ? safeReportDetector("detectSimplifiableExpressions", () => detectSimplifiableExpressions(graph), [], errorSink) : [];
|
|
9417
12115
|
const duplicateConstants = config.reportRedundancy ? safeReportDetector("detectDuplicateConstants", () => detectDuplicateConstants(graph), [], errorSink) : [];
|
|
12116
|
+
const crossFileDuplicateExports = config.reportRedundancy ? safeReportDetector("detectCrossFileDuplicateExports", () => detectCrossFileDuplicateExports(graph), [], errorSink) : [];
|
|
12117
|
+
const duplicateBlockResult = safeReportDetector("detectDuplicateBlocks", () => detectDuplicateBlocks(graph, config.duplicateBlocks, config.rootDir), {
|
|
12118
|
+
duplicateBlocks: [],
|
|
12119
|
+
duplicateBlockClusters: [],
|
|
12120
|
+
shadowedDirectoryPairs: []
|
|
12121
|
+
}, errorSink);
|
|
12122
|
+
const reExportCycles = safeReportDetector("detectReExportCycles", () => detectReExportCycles(graph), [], errorSink);
|
|
12123
|
+
const featureFlags = safeReportDetector("detectFeatureFlags", () => detectFeatureFlags(graph, config.featureFlags), [], errorSink);
|
|
12124
|
+
const complexFunctions = safeReportDetector("detectComplexHotspots", () => detectComplexHotspots(graph, config.complexity), [], errorSink);
|
|
12125
|
+
const privateTypeLeaks = safeReportDetector("detectPrivateTypeLeaks", () => detectPrivateTypeLeaks(graph), [], errorSink);
|
|
12126
|
+
const typeScriptSmellsResult = safeReportDetector("detectTypeScriptSmells", () => detectTypeScriptSmells(graph), {
|
|
12127
|
+
unnecessaryAssertions: [],
|
|
12128
|
+
lazyImportsAtTopLevel: [],
|
|
12129
|
+
commonjsInEsm: [],
|
|
12130
|
+
typeScriptEscapeHatches: []
|
|
12131
|
+
}, errorSink);
|
|
9418
12132
|
let semanticResult;
|
|
9419
12133
|
try {
|
|
9420
12134
|
semanticResult = runSemanticAnalysis(graph, config);
|
|
@@ -9439,6 +12153,7 @@ const generateReport = (graph, config) => {
|
|
|
9439
12153
|
errorSink.push(semanticError);
|
|
9440
12154
|
}
|
|
9441
12155
|
const redundantAliases = config.reportRedundancy ? [...syntacticRedundantAliases, ...semanticResult.redundantAliases] : [];
|
|
12156
|
+
if (featureFlags.length > 0) correlateFlagsWithDeadCode(featureFlags, { unusedExports });
|
|
9442
12157
|
const totalExports = graph.modules.reduce((exportCount, module) => exportCount + module.exports.filter((exportInfo) => !(exportInfo.name === "*" && exportInfo.isNamespaceReExport)).length, 0);
|
|
9443
12158
|
return {
|
|
9444
12159
|
unusedFiles,
|
|
@@ -9459,6 +12174,18 @@ const generateReport = (graph, config) => {
|
|
|
9459
12174
|
simplifiableFunctions,
|
|
9460
12175
|
simplifiableExpressions,
|
|
9461
12176
|
duplicateConstants,
|
|
12177
|
+
crossFileDuplicateExports,
|
|
12178
|
+
duplicateBlocks: duplicateBlockResult.duplicateBlocks,
|
|
12179
|
+
duplicateBlockClusters: duplicateBlockResult.duplicateBlockClusters,
|
|
12180
|
+
shadowedDirectoryPairs: duplicateBlockResult.shadowedDirectoryPairs,
|
|
12181
|
+
reExportCycles,
|
|
12182
|
+
featureFlags,
|
|
12183
|
+
complexFunctions,
|
|
12184
|
+
privateTypeLeaks,
|
|
12185
|
+
unnecessaryAssertions: typeScriptSmellsResult.unnecessaryAssertions,
|
|
12186
|
+
lazyImportsAtTopLevel: typeScriptSmellsResult.lazyImportsAtTopLevel,
|
|
12187
|
+
commonjsInEsm: typeScriptSmellsResult.commonjsInEsm,
|
|
12188
|
+
typeScriptEscapeHatches: typeScriptSmellsResult.typeScriptEscapeHatches,
|
|
9462
12189
|
analysisErrors: errorSink,
|
|
9463
12190
|
totalFiles: graph.modules.length,
|
|
9464
12191
|
totalExports,
|
|
@@ -9546,18 +12273,53 @@ const detectReactNative = (rootDir, workspacePackages) => {
|
|
|
9546
12273
|
*
|
|
9547
12274
|
* - `reportRedundancy: true` — on because redundancy findings are mostly
|
|
9548
12275
|
* high-signal and the detectors carry their own confidence tiers.
|
|
12276
|
+
*
|
|
12277
|
+
* - `duplicateBlocks: undefined` — token-based copy-paste detection (suffix
|
|
12278
|
+
* array + LCP) is opt-in. It re-parses every source
|
|
12279
|
+
* file to emit a token stream and adds significant runtime to the scan.
|
|
12280
|
+
* Pass `duplicateBlocks: { enabled: true }` to turn it on.
|
|
9549
12281
|
*/
|
|
9550
12282
|
const fillSemanticConfig = (semanticOverrides) => {
|
|
9551
|
-
|
|
12283
|
+
const overrides = semanticOverrides ?? {};
|
|
12284
|
+
return {
|
|
12285
|
+
enabled: overrides.enabled ?? true,
|
|
12286
|
+
reportUnusedTypes: overrides.reportUnusedTypes ?? true,
|
|
12287
|
+
reportUnusedEnumMembers: overrides.reportUnusedEnumMembers ?? true,
|
|
12288
|
+
reportUnusedClassMembers: overrides.reportUnusedClassMembers ?? false,
|
|
12289
|
+
reportRedundantVariableAliases: overrides.reportRedundantVariableAliases ?? true,
|
|
12290
|
+
reportMisclassifiedDependencies: overrides.reportMisclassifiedDependencies ?? true,
|
|
12291
|
+
reportRoundTripAliases: overrides.reportRoundTripAliases ?? true,
|
|
12292
|
+
decoratorAllowlist: overrides.decoratorAllowlist ?? DEFAULT_SEMANTIC_DECORATOR_ALLOWLIST
|
|
12293
|
+
};
|
|
12294
|
+
};
|
|
12295
|
+
const fillDuplicateBlocksConfig = (duplicateBlocksOverrides) => {
|
|
12296
|
+
const overrides = duplicateBlocksOverrides ?? {};
|
|
12297
|
+
return {
|
|
12298
|
+
enabled: overrides.enabled ?? true,
|
|
12299
|
+
mode: overrides.mode ?? "semantic",
|
|
12300
|
+
minTokens: overrides.minTokens ?? 50,
|
|
12301
|
+
minLines: overrides.minLines ?? 5,
|
|
12302
|
+
minOccurrences: overrides.minOccurrences ?? 2,
|
|
12303
|
+
skipLocal: overrides.skipLocal ?? false
|
|
12304
|
+
};
|
|
12305
|
+
};
|
|
12306
|
+
const fillFeatureFlagsConfig = (flagsOverrides) => {
|
|
12307
|
+
const overrides = flagsOverrides ?? {};
|
|
12308
|
+
return {
|
|
12309
|
+
enabled: overrides.enabled ?? true,
|
|
12310
|
+
extraEnvPrefixes: overrides.extraEnvPrefixes ?? [],
|
|
12311
|
+
extraSdkFunctionNames: overrides.extraSdkFunctionNames ?? [],
|
|
12312
|
+
detectConfigObjects: overrides.detectConfigObjects ?? false
|
|
12313
|
+
};
|
|
12314
|
+
};
|
|
12315
|
+
const fillComplexityConfig = (complexityOverrides) => {
|
|
12316
|
+
const overrides = complexityOverrides ?? {};
|
|
9552
12317
|
return {
|
|
9553
|
-
enabled:
|
|
9554
|
-
|
|
9555
|
-
|
|
9556
|
-
|
|
9557
|
-
|
|
9558
|
-
reportMisclassifiedDependencies: semanticOverrides.reportMisclassifiedDependencies ?? true,
|
|
9559
|
-
reportRoundTripAliases: semanticOverrides.reportRoundTripAliases ?? true,
|
|
9560
|
-
decoratorAllowlist: semanticOverrides.decoratorAllowlist ?? DEFAULT_SEMANTIC_DECORATOR_ALLOWLIST
|
|
12318
|
+
enabled: overrides.enabled ?? true,
|
|
12319
|
+
cyclomaticThreshold: overrides.cyclomaticThreshold ?? 10,
|
|
12320
|
+
cognitiveThreshold: overrides.cognitiveThreshold ?? 15,
|
|
12321
|
+
paramCountThreshold: overrides.paramCountThreshold ?? 5,
|
|
12322
|
+
functionLineThreshold: overrides.functionLineThreshold ?? 80
|
|
9561
12323
|
};
|
|
9562
12324
|
};
|
|
9563
12325
|
const defineConfig = (options) => ({
|
|
@@ -9569,7 +12331,10 @@ const defineConfig = (options) => ({
|
|
|
9569
12331
|
reportTypes: options.reportTypes ?? false,
|
|
9570
12332
|
includeEntryExports: options.includeEntryExports ?? false,
|
|
9571
12333
|
reportRedundancy: options.reportRedundancy ?? true,
|
|
9572
|
-
semantic: fillSemanticConfig(options.semantic)
|
|
12334
|
+
semantic: fillSemanticConfig(options.semantic),
|
|
12335
|
+
duplicateBlocks: fillDuplicateBlocksConfig(options.duplicateBlocks),
|
|
12336
|
+
featureFlags: fillFeatureFlagsConfig(options.featureFlags),
|
|
12337
|
+
complexity: fillComplexityConfig(options.complexity)
|
|
9573
12338
|
});
|
|
9574
12339
|
const buildEmptyScanResult = (errors, elapsedMs) => ({
|
|
9575
12340
|
unusedFiles: [],
|
|
@@ -9590,6 +12355,18 @@ const buildEmptyScanResult = (errors, elapsedMs) => ({
|
|
|
9590
12355
|
simplifiableFunctions: [],
|
|
9591
12356
|
simplifiableExpressions: [],
|
|
9592
12357
|
duplicateConstants: [],
|
|
12358
|
+
crossFileDuplicateExports: [],
|
|
12359
|
+
duplicateBlocks: [],
|
|
12360
|
+
duplicateBlockClusters: [],
|
|
12361
|
+
shadowedDirectoryPairs: [],
|
|
12362
|
+
reExportCycles: [],
|
|
12363
|
+
featureFlags: [],
|
|
12364
|
+
complexFunctions: [],
|
|
12365
|
+
privateTypeLeaks: [],
|
|
12366
|
+
unnecessaryAssertions: [],
|
|
12367
|
+
lazyImportsAtTopLevel: [],
|
|
12368
|
+
commonjsInEsm: [],
|
|
12369
|
+
typeScriptEscapeHatches: [],
|
|
9593
12370
|
analysisErrors: errors,
|
|
9594
12371
|
totalFiles: 0,
|
|
9595
12372
|
totalExports: 0,
|