deslop-js 0.0.11 → 0.0.13
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 +2913 -201
- package/dist/index.d.cts +164 -1
- package/dist/index.d.mts +164 -1
- package/dist/index.mjs +2913 -201
- package/package.json +1 -1
package/dist/index.cjs
CHANGED
|
@@ -250,6 +250,7 @@ const BUILTIN_MODULES = new Set([
|
|
|
250
250
|
]);
|
|
251
251
|
const PLATFORM_SUFFIXES = [
|
|
252
252
|
".web",
|
|
253
|
+
".react-native",
|
|
253
254
|
".native",
|
|
254
255
|
".ios",
|
|
255
256
|
".android",
|
|
@@ -257,6 +258,7 @@ const PLATFORM_SUFFIXES = [
|
|
|
257
258
|
".windows",
|
|
258
259
|
".macos",
|
|
259
260
|
".any",
|
|
261
|
+
".react-server",
|
|
260
262
|
".server",
|
|
261
263
|
".client"
|
|
262
264
|
];
|
|
@@ -952,7 +954,7 @@ const visitFunctionParameters = (parameters, captures, functionName) => {
|
|
|
952
954
|
inspectTypeAnnotation(parameter.typeAnnotation, captures, "function-parameter", functionName ? `${functionName}(${parameterIdentifierName ?? "?"})` : parameterIdentifierName);
|
|
953
955
|
}
|
|
954
956
|
};
|
|
955
|
-
const visitFunctionLike = (functionNode, captures, functionName) => {
|
|
957
|
+
const visitFunctionLike$1 = (functionNode, captures, functionName) => {
|
|
956
958
|
const parameters = functionNode.params;
|
|
957
959
|
visitFunctionParameters(parameters, captures, functionName);
|
|
958
960
|
const returnTypeNode = functionNode.returnType;
|
|
@@ -967,7 +969,7 @@ const visitVariableDeclaration = (declarationNode, captures, enclosingName) => {
|
|
|
967
969
|
const declarationName = getIdentifierName(declarator.id);
|
|
968
970
|
inspectTypeAnnotation(declarator.typeAnnotation ?? (declarator.id && isOxcAstNode(declarator.id) ? declarator.id.typeAnnotation : void 0), captures, "variable-annotation", declarationName);
|
|
969
971
|
const initializerNode = declarator.init;
|
|
970
|
-
if (isOxcAstNode(initializerNode)) if (initializerNode.type === "ArrowFunctionExpression" || initializerNode.type === "FunctionExpression") visitFunctionLike(initializerNode, captures, declarationName ?? enclosingName);
|
|
972
|
+
if (isOxcAstNode(initializerNode)) if (initializerNode.type === "ArrowFunctionExpression" || initializerNode.type === "FunctionExpression") visitFunctionLike$1(initializerNode, captures, declarationName ?? enclosingName);
|
|
971
973
|
else walkExpressionForInlineTypes(initializerNode, captures, declarationName ?? enclosingName);
|
|
972
974
|
}
|
|
973
975
|
};
|
|
@@ -979,7 +981,7 @@ const walkBodyForInlineTypes = (bodyNode, captures, enclosingName, recursionDept
|
|
|
979
981
|
for (const statement of statements) {
|
|
980
982
|
if (!isOxcAstNode(statement)) continue;
|
|
981
983
|
if (statement.type === "VariableDeclaration") visitVariableDeclaration(statement, captures, enclosingName);
|
|
982
|
-
else if (statement.type === "FunctionDeclaration") visitFunctionLike(statement, captures, getIdentifierName(statement.id) ?? enclosingName);
|
|
984
|
+
else if (statement.type === "FunctionDeclaration") visitFunctionLike$1(statement, captures, getIdentifierName(statement.id) ?? enclosingName);
|
|
983
985
|
else if (statement.type === "TSTypeAliasDeclaration") {
|
|
984
986
|
const typeAliasName = getIdentifierName(statement.id);
|
|
985
987
|
captureIfTypeLiteral(statement.typeAnnotation, captures, "local-type-alias", typeAliasName);
|
|
@@ -992,7 +994,7 @@ const walkExpressionForInlineTypes = (expressionNode, captures, enclosingName, r
|
|
|
992
994
|
if (recursionDepth > 200) return;
|
|
993
995
|
if (!isOxcAstNode(expressionNode)) return;
|
|
994
996
|
if (expressionNode.type === "ArrowFunctionExpression" || expressionNode.type === "FunctionExpression") {
|
|
995
|
-
visitFunctionLike(expressionNode, captures, enclosingName);
|
|
997
|
+
visitFunctionLike$1(expressionNode, captures, enclosingName);
|
|
996
998
|
return;
|
|
997
999
|
}
|
|
998
1000
|
for (const value of Object.values(expressionNode)) if (Array.isArray(value)) for (const element of value) walkExpressionForInlineTypes(element, captures, enclosingName, recursionDepth + 1);
|
|
@@ -1003,7 +1005,7 @@ const visitTopLevelStatement = (statementNode, captures) => {
|
|
|
1003
1005
|
const innerNode = statementNode.type === "ExportNamedDeclaration" || statementNode.type === "ExportDefaultDeclaration" ? statementNode.declaration ?? statementNode : statementNode;
|
|
1004
1006
|
const targetNode = isOxcAstNode(innerNode) ? innerNode : statementNode;
|
|
1005
1007
|
if (targetNode.type === "FunctionDeclaration") {
|
|
1006
|
-
visitFunctionLike(targetNode, captures, getIdentifierName(targetNode.id));
|
|
1008
|
+
visitFunctionLike$1(targetNode, captures, getIdentifierName(targetNode.id));
|
|
1007
1009
|
return;
|
|
1008
1010
|
}
|
|
1009
1011
|
if (targetNode.type === "VariableDeclaration") {
|
|
@@ -1023,7 +1025,7 @@ const visitTopLevelStatement = (statementNode, captures) => {
|
|
|
1023
1025
|
}
|
|
1024
1026
|
if (memberCandidate.type === "MethodDefinition" || memberCandidate.type === "TSAbstractMethodDefinition") {
|
|
1025
1027
|
const methodValue = memberCandidate.value;
|
|
1026
|
-
if (isOxcAstNode(methodValue)) visitFunctionLike(methodValue, captures, qualifiedName);
|
|
1028
|
+
if (isOxcAstNode(methodValue)) visitFunctionLike$1(methodValue, captures, qualifiedName);
|
|
1027
1029
|
}
|
|
1028
1030
|
}
|
|
1029
1031
|
return;
|
|
@@ -1078,10 +1080,22 @@ const containsCallOrPromiseSurface = (node, recursionDepth = 0) => {
|
|
|
1078
1080
|
}
|
|
1079
1081
|
return false;
|
|
1080
1082
|
};
|
|
1083
|
+
const unwrapParenthesizedExpression = (node) => {
|
|
1084
|
+
let current = node;
|
|
1085
|
+
while (current.type === "ParenthesizedExpression") {
|
|
1086
|
+
const inner = current.expression;
|
|
1087
|
+
if (!inner || !isOxcAstNode(inner)) return current;
|
|
1088
|
+
current = inner;
|
|
1089
|
+
}
|
|
1090
|
+
return current;
|
|
1091
|
+
};
|
|
1081
1092
|
const isSimpleReturnArgument = (argumentNode) => {
|
|
1082
1093
|
if (!isOxcAstNode(argumentNode)) return false;
|
|
1083
|
-
|
|
1084
|
-
if (
|
|
1094
|
+
const unwrapped = unwrapParenthesizedExpression(argumentNode);
|
|
1095
|
+
if (unwrapped.type === "BlockStatement") return false;
|
|
1096
|
+
if (unwrapped.type === "ObjectExpression") return false;
|
|
1097
|
+
if (unwrapped.type === "JSXElement") return false;
|
|
1098
|
+
if (unwrapped.type === "JSXFragment") return false;
|
|
1085
1099
|
return true;
|
|
1086
1100
|
};
|
|
1087
1101
|
const detectBlockArrowSingleReturn = (functionNode) => {
|
|
@@ -1135,9 +1149,34 @@ const detectRedundantAwaitReturn = (functionNode) => {
|
|
|
1135
1149
|
};
|
|
1136
1150
|
};
|
|
1137
1151
|
const isAsyncFunction = (functionNode) => Boolean(functionNode.async);
|
|
1138
|
-
const
|
|
1152
|
+
const containsPromiseTypeReference = (node, recursionDepth = 0) => {
|
|
1153
|
+
if (recursionDepth > 30) return false;
|
|
1154
|
+
if (!isOxcAstNode(node)) return false;
|
|
1155
|
+
if (node.type === "TSTypeReference") {
|
|
1156
|
+
const typeName = node.typeName;
|
|
1157
|
+
if (typeName?.name === "Promise") return true;
|
|
1158
|
+
if (typeName?.right?.name === "Promise") return true;
|
|
1159
|
+
}
|
|
1160
|
+
for (const value of Object.values(node)) if (Array.isArray(value)) {
|
|
1161
|
+
for (const element of value) if (containsPromiseTypeReference(element, recursionDepth + 1)) return true;
|
|
1162
|
+
} else if (isOxcAstNode(value)) {
|
|
1163
|
+
if (containsPromiseTypeReference(value, recursionDepth + 1)) return true;
|
|
1164
|
+
}
|
|
1165
|
+
return false;
|
|
1166
|
+
};
|
|
1167
|
+
const hasExplicitPromiseReturnType = (functionNode) => {
|
|
1168
|
+
const returnType = functionNode.returnType;
|
|
1169
|
+
if (!returnType || !isOxcAstNode(returnType)) return false;
|
|
1170
|
+
const annotation = returnType.typeAnnotation;
|
|
1171
|
+
if (!annotation || !isOxcAstNode(annotation)) return false;
|
|
1172
|
+
return containsPromiseTypeReference(annotation);
|
|
1173
|
+
};
|
|
1174
|
+
const detectUselessAsync = (functionNode, context) => {
|
|
1139
1175
|
if (!isAsyncFunction(functionNode)) return void 0;
|
|
1140
1176
|
if (functionNode.type === "ClassDeclaration" || functionNode.type === "MethodDefinition") return;
|
|
1177
|
+
if (context.isMethodContext) return void 0;
|
|
1178
|
+
if (context.isInlineCallback) return void 0;
|
|
1179
|
+
if (hasExplicitPromiseReturnType(functionNode)) return void 0;
|
|
1141
1180
|
const bodyNode = functionNode.body;
|
|
1142
1181
|
if (!isOxcAstNode(bodyNode)) return void 0;
|
|
1143
1182
|
if (containsAwaitExpression(bodyNode)) return void 0;
|
|
@@ -1149,14 +1188,14 @@ const detectUselessAsync = (functionNode) => {
|
|
|
1149
1188
|
suggestion: "drop `async` (caller's existing `await` keeps the type identical) or add an explicit return type"
|
|
1150
1189
|
};
|
|
1151
1190
|
};
|
|
1152
|
-
const detectSimplifiableFunctionPatterns = (functionNode) => {
|
|
1191
|
+
const detectSimplifiableFunctionPatterns = (functionNode, context = {}) => {
|
|
1153
1192
|
if (!isOxcAstNode(functionNode)) return [];
|
|
1154
1193
|
const findings = [];
|
|
1155
1194
|
const blockArrow = detectBlockArrowSingleReturn(functionNode);
|
|
1156
1195
|
if (blockArrow) findings.push(blockArrow);
|
|
1157
1196
|
const awaitReturn = detectRedundantAwaitReturn(functionNode);
|
|
1158
1197
|
if (awaitReturn) findings.push(awaitReturn);
|
|
1159
|
-
const uselessAsync = detectUselessAsync(functionNode);
|
|
1198
|
+
const uselessAsync = detectUselessAsync(functionNode, context);
|
|
1160
1199
|
if (uselessAsync) findings.push(uselessAsync);
|
|
1161
1200
|
return findings;
|
|
1162
1201
|
};
|
|
@@ -1169,9 +1208,12 @@ const inferFunctionName = (functionNode, parentContext) => {
|
|
|
1169
1208
|
if (declaredId?.name) return declaredId.name;
|
|
1170
1209
|
return parentContext;
|
|
1171
1210
|
};
|
|
1172
|
-
const visitFunctionAndDescend = (functionNode, captures, contextName, recursionDepth) => {
|
|
1211
|
+
const visitFunctionAndDescend = (functionNode, captures, contextName, recursionDepth, isMethodContext, isInlineCallback) => {
|
|
1173
1212
|
const functionName = inferFunctionName(functionNode, contextName);
|
|
1174
|
-
const detections = detectSimplifiableFunctionPatterns(functionNode
|
|
1213
|
+
const detections = detectSimplifiableFunctionPatterns(functionNode, {
|
|
1214
|
+
isMethodContext,
|
|
1215
|
+
isInlineCallback
|
|
1216
|
+
});
|
|
1175
1217
|
for (const detection of detections) captures.push({
|
|
1176
1218
|
kind: detection.kind,
|
|
1177
1219
|
functionName,
|
|
@@ -1184,10 +1226,13 @@ const visitFunctionAndDescend = (functionNode, captures, contextName, recursionD
|
|
|
1184
1226
|
const parameters = functionNode.params ?? [];
|
|
1185
1227
|
for (const parameter of parameters) if (isOxcAstNode(parameter)) walkForFunctions(parameter, captures, functionName, recursionDepth + 1);
|
|
1186
1228
|
};
|
|
1229
|
+
const isObjectMethodShorthand = (node) => (node.type === "Property" || node.type === "ObjectProperty") && node.method === true;
|
|
1230
|
+
const isObjectPropertyAssignment = (node) => (node.type === "Property" || node.type === "ObjectProperty") && node.method !== true;
|
|
1231
|
+
const isCallOrNewExpression = (node) => node.type === "CallExpression" || node.type === "NewExpression";
|
|
1187
1232
|
const walkForFunctions = (node, captures, contextName, recursionDepth = 0) => {
|
|
1188
1233
|
if (recursionDepth > 200) return;
|
|
1189
1234
|
if (looksLikeFunction(node)) {
|
|
1190
|
-
visitFunctionAndDescend(node, captures, contextName, recursionDepth);
|
|
1235
|
+
visitFunctionAndDescend(node, captures, contextName, recursionDepth, false, false);
|
|
1191
1236
|
return;
|
|
1192
1237
|
}
|
|
1193
1238
|
let nextContext = contextName;
|
|
@@ -1203,6 +1248,35 @@ const walkForFunctions = (node, captures, contextName, recursionDepth = 0) => {
|
|
|
1203
1248
|
const className = getIdentifierName(node.id);
|
|
1204
1249
|
if (className) nextContext = className;
|
|
1205
1250
|
}
|
|
1251
|
+
if (node.type === "MethodDefinition" || isObjectMethodShorthand(node)) {
|
|
1252
|
+
const methodValue = node.value;
|
|
1253
|
+
if (methodValue && isOxcAstNode(methodValue) && looksLikeFunction(methodValue)) {
|
|
1254
|
+
visitFunctionAndDescend(methodValue, captures, getIdentifierName(node.key) ?? nextContext, recursionDepth + 1, true, false);
|
|
1255
|
+
const keyNode = node.key;
|
|
1256
|
+
if (keyNode && isOxcAstNode(keyNode) && node.computed) walkForFunctions(keyNode, captures, nextContext, recursionDepth + 1);
|
|
1257
|
+
return;
|
|
1258
|
+
}
|
|
1259
|
+
}
|
|
1260
|
+
if (isObjectPropertyAssignment(node)) {
|
|
1261
|
+
const propertyValue = node.value;
|
|
1262
|
+
if (propertyValue && isOxcAstNode(propertyValue) && looksLikeFunction(propertyValue)) {
|
|
1263
|
+
visitFunctionAndDescend(propertyValue, captures, getIdentifierName(node.key) ?? nextContext, recursionDepth + 1, false, true);
|
|
1264
|
+
const keyNode = node.key;
|
|
1265
|
+
if (keyNode && isOxcAstNode(keyNode) && node.computed) walkForFunctions(keyNode, captures, nextContext, recursionDepth + 1);
|
|
1266
|
+
return;
|
|
1267
|
+
}
|
|
1268
|
+
}
|
|
1269
|
+
if (isCallOrNewExpression(node)) {
|
|
1270
|
+
const callee = node.callee;
|
|
1271
|
+
if (callee && isOxcAstNode(callee)) walkForFunctions(callee, captures, nextContext, recursionDepth + 1);
|
|
1272
|
+
const callArguments = node.arguments ?? [];
|
|
1273
|
+
for (const argument of callArguments) {
|
|
1274
|
+
if (!isOxcAstNode(argument)) continue;
|
|
1275
|
+
if (looksLikeFunction(argument)) visitFunctionAndDescend(argument, captures, nextContext, recursionDepth + 1, false, true);
|
|
1276
|
+
else walkForFunctions(argument, captures, nextContext, recursionDepth + 1);
|
|
1277
|
+
}
|
|
1278
|
+
return;
|
|
1279
|
+
}
|
|
1206
1280
|
for (const value of Object.values(node)) if (Array.isArray(value)) {
|
|
1207
1281
|
for (const element of value) if (isOxcAstNode(element)) walkForFunctions(element, captures, nextContext, recursionDepth + 1);
|
|
1208
1282
|
} else if (isOxcAstNode(value)) walkForFunctions(value, captures, nextContext, recursionDepth + 1);
|
|
@@ -1535,10 +1609,15 @@ const CSS_EXTENSIONS = [
|
|
|
1535
1609
|
];
|
|
1536
1610
|
const CSS_IMPORT_PATTERN = /@import\s+(?:url\()?['"]([^'"]+)['"]\)?/g;
|
|
1537
1611
|
const SCSS_USE_FORWARD_PATTERN = /@(?:use|forward)\s+['"]([^'"]+)['"]/g;
|
|
1612
|
+
const TAILWIND_PLUGIN_REFERENCE_PATTERN = /@(?:plugin|reference|config)\s+['"]([^'"]+)['"]/g;
|
|
1538
1613
|
const parseCssImports = (filePath) => {
|
|
1539
1614
|
const sourceText = (0, node_fs.readFileSync)(filePath, "utf-8");
|
|
1540
1615
|
const imports = [];
|
|
1541
|
-
const patterns = [
|
|
1616
|
+
const patterns = [
|
|
1617
|
+
CSS_IMPORT_PATTERN,
|
|
1618
|
+
SCSS_USE_FORWARD_PATTERN,
|
|
1619
|
+
TAILWIND_PLUGIN_REFERENCE_PATTERN
|
|
1620
|
+
];
|
|
1542
1621
|
for (const pattern of patterns) {
|
|
1543
1622
|
let match;
|
|
1544
1623
|
pattern.lastIndex = 0;
|
|
@@ -1561,6 +1640,7 @@ const parseCssImports = (filePath) => {
|
|
|
1561
1640
|
memberAccesses: [],
|
|
1562
1641
|
wholeObjectUses: [],
|
|
1563
1642
|
localIdentifierReferences: [],
|
|
1643
|
+
referencedFilenames: [],
|
|
1564
1644
|
redundantTypePatterns: [],
|
|
1565
1645
|
identityWrappers: [],
|
|
1566
1646
|
typeDefinitionHashes: [],
|
|
@@ -1600,6 +1680,7 @@ const createEmptyParsedSource = () => ({
|
|
|
1600
1680
|
memberAccesses: [],
|
|
1601
1681
|
wholeObjectUses: [],
|
|
1602
1682
|
localIdentifierReferences: [],
|
|
1683
|
+
referencedFilenames: [],
|
|
1603
1684
|
redundantTypePatterns: [],
|
|
1604
1685
|
identityWrappers: [],
|
|
1605
1686
|
typeDefinitionHashes: [],
|
|
@@ -1743,6 +1824,7 @@ const parseSourceFile = (filePath) => {
|
|
|
1743
1824
|
...createEmptyParsedSource(),
|
|
1744
1825
|
imports,
|
|
1745
1826
|
exports,
|
|
1827
|
+
referencedFilenames: extractReferencedFilenames(sourceText),
|
|
1746
1828
|
errors: [...earlyErrors, new ParseError({
|
|
1747
1829
|
code: "parse-recovered",
|
|
1748
1830
|
severity: "info",
|
|
@@ -1755,6 +1837,7 @@ const parseSourceFile = (filePath) => {
|
|
|
1755
1837
|
...createEmptyParsedSource(),
|
|
1756
1838
|
imports,
|
|
1757
1839
|
exports,
|
|
1840
|
+
referencedFilenames: extractReferencedFilenames(sourceText),
|
|
1758
1841
|
errors: [...earlyErrors, new ParseError({
|
|
1759
1842
|
code: "parse-failed",
|
|
1760
1843
|
message: "oxc-parser returned no program body",
|
|
@@ -1807,50 +1890,63 @@ const parseSourceFile = (filePath) => {
|
|
|
1807
1890
|
safeWalk("collectDryPatterns", () => {
|
|
1808
1891
|
collectDryPatterns(program.body, sourceText, redundantTypePatterns, identityWrappers, typeDefinitionHashes);
|
|
1809
1892
|
}, void 0);
|
|
1893
|
+
const inlineTypeLiterals = safeWalk("collectInlineTypeLiterals", () => collectInlineTypeLiterals(program.body), []).map((capture) => ({
|
|
1894
|
+
structuralHash: capture.structuralHash,
|
|
1895
|
+
memberCount: capture.memberCount,
|
|
1896
|
+
preview: capture.preview,
|
|
1897
|
+
context: capture.context,
|
|
1898
|
+
nearestName: capture.nearestName,
|
|
1899
|
+
line: getLineFromOffset(sourceText, capture.startOffset),
|
|
1900
|
+
column: getColumnFromOffset(sourceText, capture.startOffset)
|
|
1901
|
+
}));
|
|
1902
|
+
const simplifiableFunctions = safeWalk("collectSimplifiableFunctions", () => collectSimplifiableFunctions(program.body), []).map((capture) => ({
|
|
1903
|
+
kind: capture.kind,
|
|
1904
|
+
functionName: capture.functionName,
|
|
1905
|
+
line: getLineFromOffset(sourceText, capture.startOffset),
|
|
1906
|
+
column: getColumnFromOffset(sourceText, capture.startOffset),
|
|
1907
|
+
reason: capture.reason,
|
|
1908
|
+
suggestion: capture.suggestion
|
|
1909
|
+
}));
|
|
1910
|
+
const simplifiableExpressions = safeWalk("collectSimplifiableExpressions", () => collectSimplifiableExpressions(program.body), []).map((capture) => ({
|
|
1911
|
+
kind: capture.kind,
|
|
1912
|
+
snippet: capture.snippet,
|
|
1913
|
+
line: getLineFromOffset(sourceText, capture.startOffset),
|
|
1914
|
+
column: getColumnFromOffset(sourceText, capture.startOffset),
|
|
1915
|
+
reason: capture.reason,
|
|
1916
|
+
suggestion: capture.suggestion
|
|
1917
|
+
}));
|
|
1918
|
+
const duplicateConstantCandidates = safeWalk("collectDuplicateConstantCandidates", () => collectDuplicateConstantCandidates(program.body), []).map((capture) => ({
|
|
1919
|
+
constantName: capture.constantName,
|
|
1920
|
+
literalHash: capture.literalHash,
|
|
1921
|
+
literalPreview: capture.literalPreview,
|
|
1922
|
+
line: getLineFromOffset(sourceText, capture.startOffset),
|
|
1923
|
+
column: getColumnFromOffset(sourceText, capture.startOffset)
|
|
1924
|
+
}));
|
|
1810
1925
|
return {
|
|
1811
1926
|
imports,
|
|
1812
1927
|
exports,
|
|
1813
1928
|
memberAccesses,
|
|
1814
1929
|
wholeObjectUses,
|
|
1815
1930
|
localIdentifierReferences,
|
|
1931
|
+
referencedFilenames: extractReferencedFilenames(sourceText),
|
|
1816
1932
|
redundantTypePatterns,
|
|
1817
1933
|
identityWrappers,
|
|
1818
1934
|
typeDefinitionHashes,
|
|
1819
|
-
inlineTypeLiterals
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
context: capture.context,
|
|
1824
|
-
nearestName: capture.nearestName,
|
|
1825
|
-
line: getLineFromOffset(sourceText, capture.startOffset),
|
|
1826
|
-
column: getColumnFromOffset(sourceText, capture.startOffset)
|
|
1827
|
-
})),
|
|
1828
|
-
simplifiableFunctions: safeWalk("collectSimplifiableFunctions", () => collectSimplifiableFunctions(program.body), []).map((capture) => ({
|
|
1829
|
-
kind: capture.kind,
|
|
1830
|
-
functionName: capture.functionName,
|
|
1831
|
-
line: getLineFromOffset(sourceText, capture.startOffset),
|
|
1832
|
-
column: getColumnFromOffset(sourceText, capture.startOffset),
|
|
1833
|
-
reason: capture.reason,
|
|
1834
|
-
suggestion: capture.suggestion
|
|
1835
|
-
})),
|
|
1836
|
-
simplifiableExpressions: safeWalk("collectSimplifiableExpressions", () => collectSimplifiableExpressions(program.body), []).map((capture) => ({
|
|
1837
|
-
kind: capture.kind,
|
|
1838
|
-
snippet: capture.snippet,
|
|
1839
|
-
line: getLineFromOffset(sourceText, capture.startOffset),
|
|
1840
|
-
column: getColumnFromOffset(sourceText, capture.startOffset),
|
|
1841
|
-
reason: capture.reason,
|
|
1842
|
-
suggestion: capture.suggestion
|
|
1843
|
-
})),
|
|
1844
|
-
duplicateConstantCandidates: safeWalk("collectDuplicateConstantCandidates", () => collectDuplicateConstantCandidates(program.body), []).map((capture) => ({
|
|
1845
|
-
constantName: capture.constantName,
|
|
1846
|
-
literalHash: capture.literalHash,
|
|
1847
|
-
literalPreview: capture.literalPreview,
|
|
1848
|
-
line: getLineFromOffset(sourceText, capture.startOffset),
|
|
1849
|
-
column: getColumnFromOffset(sourceText, capture.startOffset)
|
|
1850
|
-
})),
|
|
1935
|
+
inlineTypeLiterals,
|
|
1936
|
+
simplifiableFunctions,
|
|
1937
|
+
simplifiableExpressions,
|
|
1938
|
+
duplicateConstantCandidates,
|
|
1851
1939
|
errors: [...earlyErrors, ...detectorErrors]
|
|
1852
1940
|
};
|
|
1853
1941
|
};
|
|
1942
|
+
const REFERENCED_FILENAME_LITERAL_PATTERN = /(?<![./@\w-])(?:["'`])([a-z][\w-]*\.(?:ts|tsx|js|jsx|mts|mjs|cts|cjs))(?:["'`])/g;
|
|
1943
|
+
const extractReferencedFilenames = (sourceText) => {
|
|
1944
|
+
const captured = /* @__PURE__ */ new Set();
|
|
1945
|
+
REFERENCED_FILENAME_LITERAL_PATTERN.lastIndex = 0;
|
|
1946
|
+
let match;
|
|
1947
|
+
while ((match = REFERENCED_FILENAME_LITERAL_PATTERN.exec(sourceText)) !== null) captured.add(match[1]);
|
|
1948
|
+
return [...captured];
|
|
1949
|
+
};
|
|
1854
1950
|
const collectDryPatterns = (bodyNodes, sourceText, redundantTypePatterns, identityWrappers, typeDefinitionHashes) => {
|
|
1855
1951
|
for (const statement of bodyNodes) inspectStatement(statement, sourceText, redundantTypePatterns, identityWrappers, typeDefinitionHashes);
|
|
1856
1952
|
};
|
|
@@ -3110,6 +3206,8 @@ const resolveSourcePath = (distPath, directory) => {
|
|
|
3110
3206
|
const sourceCandidate = (0, node_path.resolve)(directory, withoutExtension + sourceExtension);
|
|
3111
3207
|
if ((0, node_fs.existsSync)(sourceCandidate)) return sourceCandidate;
|
|
3112
3208
|
}
|
|
3209
|
+
const indexPrefixedCandidate = resolveWithIndexPrefix(withoutExtension, directory);
|
|
3210
|
+
if (indexPrefixedCandidate) return indexPrefixedCandidate;
|
|
3113
3211
|
}
|
|
3114
3212
|
if (matchesOutputDirectory(relativeToDist)) for (const stem of SOURCE_INDEX_FALLBACK_STEMS) for (const sourceExtension of SOURCE_EXTENSIONS$1) {
|
|
3115
3213
|
const fallbackCandidate = (0, node_path.resolve)(directory, stem + sourceExtension);
|
|
@@ -3123,6 +3221,15 @@ const resolveSourcePath = (distPath, directory) => {
|
|
|
3123
3221
|
}
|
|
3124
3222
|
const indexCandidate = (0, node_path.resolve)(directory, withoutExtension, "index.ts");
|
|
3125
3223
|
if ((0, node_fs.existsSync)(indexCandidate)) return indexCandidate;
|
|
3224
|
+
const indexPrefixedCandidate = resolveWithIndexPrefix(withoutExtension, directory);
|
|
3225
|
+
if (indexPrefixedCandidate) return indexPrefixedCandidate;
|
|
3226
|
+
}
|
|
3227
|
+
};
|
|
3228
|
+
const resolveWithIndexPrefix = (stemPath, directory) => {
|
|
3229
|
+
const indexPrefixedStem = `${(0, node_path.dirname)(stemPath)}/index.${(0, node_path.basename)(stemPath)}`;
|
|
3230
|
+
for (const sourceExtension of SOURCE_EXTENSIONS$1) {
|
|
3231
|
+
const candidate = (0, node_path.resolve)(directory, indexPrefixedStem + sourceExtension);
|
|
3232
|
+
if ((0, node_fs.existsSync)(candidate)) return candidate;
|
|
3126
3233
|
}
|
|
3127
3234
|
};
|
|
3128
3235
|
|
|
@@ -3452,8 +3559,11 @@ const resolveEntries = async (config) => {
|
|
|
3452
3559
|
}
|
|
3453
3560
|
const frameworkEntries = detectFrameworkEntries(absoluteRoot);
|
|
3454
3561
|
const entryEligiblePackages = workspacePackages.filter(isEntryEligible);
|
|
3562
|
+
const monorepoRootForEntries = findMonorepoRoot(absoluteRoot);
|
|
3563
|
+
const ancestorPackageJsonRoots = monorepoRootForEntries && monorepoRootForEntries !== absoluteRoot ? [monorepoRootForEntries] : [];
|
|
3455
3564
|
const scriptEntries = extractScriptEntries(absoluteRoot);
|
|
3456
3565
|
for (const workspacePackage of entryEligiblePackages) scriptEntries.push(...extractScriptEntries(workspacePackage.directory));
|
|
3566
|
+
for (const ancestorRoot of ancestorPackageJsonRoots) for (const entryPath of extractScriptEntries(ancestorRoot)) if (entryPath.startsWith(`${absoluteRoot}/`)) scriptEntries.push(entryPath);
|
|
3457
3567
|
const webpackEntries = extractWebpackEntryPoints(absoluteRoot);
|
|
3458
3568
|
for (const workspacePackage of entryEligiblePackages) webpackEntries.push(...extractWebpackEntryPoints(workspacePackage.directory));
|
|
3459
3569
|
const viteEntries = extractViteEntryPoints(absoluteRoot);
|
|
@@ -3726,6 +3836,7 @@ const SCRIPT_MULTIPLEXERS = new Set([
|
|
|
3726
3836
|
"lerna",
|
|
3727
3837
|
"ultra"
|
|
3728
3838
|
]);
|
|
3839
|
+
const TSCONFIG_PROJECT_FLAGS = new Set(["--project", "-p"]);
|
|
3729
3840
|
const CONFIG_LIKE_FLAGS = new Set([
|
|
3730
3841
|
"--config",
|
|
3731
3842
|
"-c",
|
|
@@ -3871,7 +3982,8 @@ const extractScriptFileArguments = (scriptCommand, directory) => {
|
|
|
3871
3982
|
const configPath = tokens[tokenIndex + 1].replace(/^['"]|['"]$/g, "");
|
|
3872
3983
|
if (looksLikeFilePath(configPath)) {
|
|
3873
3984
|
const absoluteConfigPath = (0, node_path.resolve)(directory, configPath);
|
|
3874
|
-
if ((0, node_fs.existsSync)(absoluteConfigPath)) entries.push(absoluteConfigPath);
|
|
3985
|
+
if ((0, node_fs.existsSync)(absoluteConfigPath)) if (TSCONFIG_PROJECT_FLAGS.has(token) && TSCONFIG_PROJECT_PATTERN.test(absoluteConfigPath)) entries.push(...expandTsConfigProjectEntries(absoluteConfigPath));
|
|
3986
|
+
else entries.push(absoluteConfigPath);
|
|
3875
3987
|
}
|
|
3876
3988
|
tokenIndex++;
|
|
3877
3989
|
}
|
|
@@ -3880,9 +3992,11 @@ const extractScriptFileArguments = (scriptCommand, directory) => {
|
|
|
3880
3992
|
const equalsIndex = token.indexOf("=");
|
|
3881
3993
|
if (equalsIndex > 0 && CONFIG_LIKE_FLAGS.has(token.slice(0, equalsIndex))) {
|
|
3882
3994
|
const configValue = token.slice(equalsIndex + 1);
|
|
3995
|
+
const flagName = token.slice(0, equalsIndex);
|
|
3883
3996
|
if (configValue && looksLikeFilePath(configValue)) {
|
|
3884
3997
|
const absoluteConfigPath = (0, node_path.resolve)(directory, configValue);
|
|
3885
|
-
if ((0, node_fs.existsSync)(absoluteConfigPath)) entries.push(absoluteConfigPath);
|
|
3998
|
+
if ((0, node_fs.existsSync)(absoluteConfigPath)) if (TSCONFIG_PROJECT_FLAGS.has(flagName) && TSCONFIG_PROJECT_PATTERN.test(absoluteConfigPath)) entries.push(...expandTsConfigProjectEntries(absoluteConfigPath));
|
|
3999
|
+
else entries.push(absoluteConfigPath);
|
|
3886
4000
|
}
|
|
3887
4001
|
continue;
|
|
3888
4002
|
}
|
|
@@ -4185,6 +4299,7 @@ const extractScriptTagsFromHtmlFile = (htmlFilePath) => {
|
|
|
4185
4299
|
return entries;
|
|
4186
4300
|
};
|
|
4187
4301
|
const TSCONFIG_FILENAME_GLOBS = ["tsconfig.json", "tsconfig.*.json"];
|
|
4302
|
+
const TSCONFIG_PROJECT_PATTERN = /(?:^|[\\/])tsconfig(?:\.[^.]+)?\.json$/;
|
|
4188
4303
|
const stripJsoncCommentsLocal = (sourceText) => {
|
|
4189
4304
|
let result = "";
|
|
4190
4305
|
let insideString = false;
|
|
@@ -4256,6 +4371,34 @@ const extractTsConfigIncludeFilesEntries = (directory) => {
|
|
|
4256
4371
|
} catch {}
|
|
4257
4372
|
return entries;
|
|
4258
4373
|
};
|
|
4374
|
+
const expandTsConfigProjectEntries = (tsconfigAbsolutePath) => {
|
|
4375
|
+
const entries = [];
|
|
4376
|
+
try {
|
|
4377
|
+
const cleaned = stripJsoncCommentsLocal((0, node_fs.readFileSync)(tsconfigAbsolutePath, "utf-8"));
|
|
4378
|
+
const tsconfigJson = JSON.parse(cleaned);
|
|
4379
|
+
const tsconfigDir = (0, node_path.dirname)(tsconfigAbsolutePath);
|
|
4380
|
+
if (Array.isArray(tsconfigJson.files)) for (const fileItem of tsconfigJson.files) {
|
|
4381
|
+
if (typeof fileItem !== "string") continue;
|
|
4382
|
+
const candidatePath = (0, node_path.resolve)(tsconfigDir, fileItem);
|
|
4383
|
+
if ((0, node_fs.existsSync)(candidatePath)) entries.push(candidatePath);
|
|
4384
|
+
}
|
|
4385
|
+
if (Array.isArray(tsconfigJson.include)) for (const includePattern of tsconfigJson.include) {
|
|
4386
|
+
if (typeof includePattern !== "string") continue;
|
|
4387
|
+
const expandedFiles = fast_glob.default.sync(includePattern, {
|
|
4388
|
+
cwd: tsconfigDir,
|
|
4389
|
+
absolute: true,
|
|
4390
|
+
onlyFiles: true,
|
|
4391
|
+
ignore: [
|
|
4392
|
+
"**/node_modules/**",
|
|
4393
|
+
"**/dist/**",
|
|
4394
|
+
"**/build/**"
|
|
4395
|
+
]
|
|
4396
|
+
});
|
|
4397
|
+
entries.push(...expandedFiles);
|
|
4398
|
+
}
|
|
4399
|
+
} catch {}
|
|
4400
|
+
return entries;
|
|
4401
|
+
};
|
|
4259
4402
|
const WRANGLER_TOML_MAIN_PATTERN = /^\s*main\s*=\s*['"]([^'"\n]+)['"]/m;
|
|
4260
4403
|
const WRANGLER_JSON_MAIN_PATTERN = /"main"\s*:\s*"([^"]+)"/;
|
|
4261
4404
|
const WRANGLER_SERVICE_BINDINGS_PATTERN = /entry_point\s*=\s*['"]([^'"\n]+)['"]/g;
|
|
@@ -5686,6 +5829,40 @@ const discoverToolingEntryPoints = (rootDir, workspacePackages) => {
|
|
|
5686
5829
|
};
|
|
5687
5830
|
};
|
|
5688
5831
|
|
|
5832
|
+
//#endregion
|
|
5833
|
+
//#region src/utils/is-platform-builtin-or-virtual.ts
|
|
5834
|
+
const BUILTIN_SUBPATH_NODE_MODULES = new Set([
|
|
5835
|
+
"fs",
|
|
5836
|
+
"dns",
|
|
5837
|
+
"stream",
|
|
5838
|
+
"readline",
|
|
5839
|
+
"timers",
|
|
5840
|
+
"util",
|
|
5841
|
+
"test",
|
|
5842
|
+
"assert",
|
|
5843
|
+
"inspector",
|
|
5844
|
+
"path"
|
|
5845
|
+
]);
|
|
5846
|
+
/**
|
|
5847
|
+
* True for module specifiers that don't correspond to a real on-disk
|
|
5848
|
+
* package — Node / Bun / Cloudflare / Sass built-ins, the Deno `std`
|
|
5849
|
+
* bare specifier, and Vite `virtual:` modules — so they aren't mistakenly
|
|
5850
|
+
* surfaced as `unused-dependency` or `unresolved-import`.
|
|
5851
|
+
*/
|
|
5852
|
+
const isPlatformBuiltinOrVirtualSpecifier = (specifier) => {
|
|
5853
|
+
if (specifier.startsWith("virtual:")) return true;
|
|
5854
|
+
if (specifier === "bun" || specifier.startsWith("bun:")) return true;
|
|
5855
|
+
if (specifier.startsWith("cloudflare:")) return true;
|
|
5856
|
+
if (specifier.startsWith("sass:")) return true;
|
|
5857
|
+
if (specifier === "std" || specifier.startsWith("std/")) return true;
|
|
5858
|
+
const stripped = specifier.startsWith("node:") ? specifier.slice(5) : specifier;
|
|
5859
|
+
const slashIndex = stripped.indexOf("/");
|
|
5860
|
+
if (slashIndex === -1) return BUILTIN_MODULES.has(stripped);
|
|
5861
|
+
const baseName = stripped.slice(0, slashIndex);
|
|
5862
|
+
if (!BUILTIN_MODULES.has(baseName)) return false;
|
|
5863
|
+
return BUILTIN_SUBPATH_NODE_MODULES.has(baseName);
|
|
5864
|
+
};
|
|
5865
|
+
|
|
5689
5866
|
//#endregion
|
|
5690
5867
|
//#region src/resolver/resolve.ts
|
|
5691
5868
|
const fileExistsCache = /* @__PURE__ */ new Map();
|
|
@@ -6480,21 +6657,7 @@ const stripJsonComments = (content) => {
|
|
|
6480
6657
|
}
|
|
6481
6658
|
return result.replace(/,(\s*[}\]])/g, "$1");
|
|
6482
6659
|
};
|
|
6483
|
-
const
|
|
6484
|
-
"fs",
|
|
6485
|
-
"dns",
|
|
6486
|
-
"stream",
|
|
6487
|
-
"readline",
|
|
6488
|
-
"timers",
|
|
6489
|
-
"util"
|
|
6490
|
-
]);
|
|
6491
|
-
const isBuiltinModule = (specifier) => {
|
|
6492
|
-
if (specifier.startsWith("node:")) return true;
|
|
6493
|
-
const baseName = specifier.split("/")[0];
|
|
6494
|
-
if (!BUILTIN_MODULES.has(baseName)) return false;
|
|
6495
|
-
if (!specifier.includes("/")) return true;
|
|
6496
|
-
return BUILTIN_SUBPATH_MODULES.has(baseName);
|
|
6497
|
-
};
|
|
6660
|
+
const isBuiltinModule = (specifier) => isPlatformBuiltinOrVirtualSpecifier(specifier);
|
|
6498
6661
|
const isBareSpecifier = (specifier) => !specifier.startsWith(".") && !specifier.startsWith("/");
|
|
6499
6662
|
const extractPackageNameFromSpecifier = (specifier) => {
|
|
6500
6663
|
if (specifier.startsWith("node:")) return specifier.slice(5).split("/")[0];
|
|
@@ -6527,6 +6690,7 @@ const buildDependencyGraph = (inputs) => {
|
|
|
6527
6690
|
memberAccesses: input.parsed.memberAccesses,
|
|
6528
6691
|
wholeObjectUses: input.parsed.wholeObjectUses,
|
|
6529
6692
|
localIdentifierReferences: input.parsed.localIdentifierReferences,
|
|
6693
|
+
referencedFilenames: input.parsed.referencedFilenames,
|
|
6530
6694
|
redundantTypePatterns: input.parsed.redundantTypePatterns,
|
|
6531
6695
|
identityWrappers: input.parsed.identityWrappers,
|
|
6532
6696
|
typeDefinitionHashes: input.parsed.typeDefinitionHashes,
|
|
@@ -6854,6 +7018,21 @@ const hasExcludedExtension = (filePath) => {
|
|
|
6854
7018
|
return EXCLUDED_EXTENSIONS.has(filePath.slice(lastDot));
|
|
6855
7019
|
};
|
|
6856
7020
|
const isExcludedByPattern = (filePath) => TEST_FILE_PATTERN.test(filePath) || EXCLUDED_DIRECTORY_PATTERN.test(filePath) || CONFIG_FILE_PATTERN.test(filePath);
|
|
7021
|
+
/**
|
|
7022
|
+
* Files the parser couldn't analyze (minified bundles, oversized files, binaries)
|
|
7023
|
+
* have no detectable imports — they're effectively opaque. Flagging them as
|
|
7024
|
+
* "unused" is a false positive because we can't see who imports them, and they
|
|
7025
|
+
* may be static assets, generated bundles, or build artifacts that get loaded
|
|
7026
|
+
* outside the JS module graph (HTML `<script src>`, `vite-plugin-string`, etc.).
|
|
7027
|
+
* The parser already records a `file-minified`/`file-too-large`/`file-binary`
|
|
7028
|
+
* info-level entry in `analysisErrors`, which is the actionable signal.
|
|
7029
|
+
*/
|
|
7030
|
+
const PARSE_OPAQUE_ERROR_CODES = new Set([
|
|
7031
|
+
"file-minified",
|
|
7032
|
+
"file-too-large",
|
|
7033
|
+
"file-binary"
|
|
7034
|
+
]);
|
|
7035
|
+
const isOpaqueToAnalysis = (module) => module.parseErrors.some((parseError) => parseError.code && PARSE_OPAQUE_ERROR_CODES.has(parseError.code));
|
|
6857
7036
|
const detectOrphanFiles = (graph) => {
|
|
6858
7037
|
const unusedFiles = [];
|
|
6859
7038
|
for (const module of graph.modules) {
|
|
@@ -6863,6 +7042,7 @@ const detectOrphanFiles = (graph) => {
|
|
|
6863
7042
|
if (module.isConfigFile) continue;
|
|
6864
7043
|
if (hasExcludedExtension(module.fileId.path)) continue;
|
|
6865
7044
|
if (isExcludedByPattern(module.fileId.path)) continue;
|
|
7045
|
+
if (isOpaqueToAnalysis(module)) continue;
|
|
6866
7046
|
if (isBarrelWithReachableSources(module, graph)) continue;
|
|
6867
7047
|
if (hasReachableDirectImporter(module.fileId.index, graph)) continue;
|
|
6868
7048
|
unusedFiles.push({ path: module.fileId.path });
|
|
@@ -7193,13 +7373,13 @@ const detectStalePackages = (graph, config) => {
|
|
|
7193
7373
|
const declaredNames = new Set(declaredDependencies.keys());
|
|
7194
7374
|
const usedPackageNames = collectUsedPackages(graph);
|
|
7195
7375
|
const monorepoRoot = findMonorepoRoot(config.rootDir);
|
|
7196
|
-
const
|
|
7376
|
+
const nodeModulesSearchRoots = monorepoRoot && monorepoRoot !== config.rootDir ? [config.rootDir, monorepoRoot] : [config.rootDir];
|
|
7197
7377
|
const allPackageJsonPaths = discoverAllPackageJsonPaths(config.rootDir);
|
|
7198
7378
|
if (monorepoRoot) {
|
|
7199
7379
|
const monorepoPackageJson = (0, node_path.join)(monorepoRoot, "package.json");
|
|
7200
7380
|
if (!allPackageJsonPaths.includes(monorepoPackageJson) && (0, node_fs.existsSync)(monorepoPackageJson)) allPackageJsonPaths.push(monorepoPackageJson);
|
|
7201
7381
|
}
|
|
7202
|
-
const binToPackage = buildBinToPackageMap(
|
|
7382
|
+
const binToPackage = buildBinToPackageMap(nodeModulesSearchRoots, declaredNames);
|
|
7203
7383
|
for (const workspacePackageJsonPath of allPackageJsonPaths) {
|
|
7204
7384
|
const scriptReferenced = collectScriptReferencedPackages(workspacePackageJsonPath, declaredNames, binToPackage);
|
|
7205
7385
|
for (const packageName of scriptReferenced) usedPackageNames.add(packageName);
|
|
@@ -7245,7 +7425,7 @@ const detectStalePackages = (graph, config) => {
|
|
|
7245
7425
|
if ("react-dom" in peerDeps && declaredDependencies.get("react-dom") === true) usedPackageNames.add("react-dom");
|
|
7246
7426
|
} catch {}
|
|
7247
7427
|
}
|
|
7248
|
-
const peerSatisfied = collectPeerSatisfiedPackages(
|
|
7428
|
+
const peerSatisfied = collectPeerSatisfiedPackages(nodeModulesSearchRoots, declaredNames, usedPackageNames);
|
|
7249
7429
|
for (const packageName of peerSatisfied) usedPackageNames.add(packageName);
|
|
7250
7430
|
const staticPeerSatisfied = collectStaticPeerSatisfiedPackages(declaredNames, usedPackageNames);
|
|
7251
7431
|
for (const packageName of staticPeerSatisfied) usedPackageNames.add(packageName);
|
|
@@ -7291,14 +7471,14 @@ const hasJsxFiles = (graph) => graph.modules.some((module) => {
|
|
|
7291
7471
|
const filePath = module.fileId.path;
|
|
7292
7472
|
return filePath.endsWith(".tsx") || filePath.endsWith(".jsx");
|
|
7293
7473
|
});
|
|
7294
|
-
const collectPeerSatisfiedPackages = (
|
|
7474
|
+
const collectPeerSatisfiedPackages = (nodeModulesSearchRoots, declaredNames, confirmedUsedNames) => {
|
|
7295
7475
|
const peerSatisfied = /* @__PURE__ */ new Set();
|
|
7296
|
-
const nodeModulesDir = (0, node_path.join)(rootDir, "node_modules");
|
|
7297
7476
|
for (const installedName of declaredNames) {
|
|
7298
7477
|
if (!confirmedUsedNames.has(installedName)) continue;
|
|
7299
|
-
const
|
|
7478
|
+
const installedPackageJsonPath = findInstalledPackageJsonPath(installedName, nodeModulesSearchRoots);
|
|
7479
|
+
if (!installedPackageJsonPath) continue;
|
|
7300
7480
|
try {
|
|
7301
|
-
const content = (0, node_fs.readFileSync)(
|
|
7481
|
+
const content = (0, node_fs.readFileSync)(installedPackageJsonPath, "utf-8");
|
|
7302
7482
|
const peerDeps = JSON.parse(content).peerDependencies;
|
|
7303
7483
|
if (peerDeps && typeof peerDeps === "object") {
|
|
7304
7484
|
for (const peerName of Object.keys(peerDeps)) if (declaredNames.has(peerName)) peerSatisfied.add(peerName);
|
|
@@ -7309,6 +7489,12 @@ const collectPeerSatisfiedPackages = (rootDir, declaredNames, confirmedUsedNames
|
|
|
7309
7489
|
}
|
|
7310
7490
|
return peerSatisfied;
|
|
7311
7491
|
};
|
|
7492
|
+
const findInstalledPackageJsonPath = (packageName, nodeModulesSearchRoots) => {
|
|
7493
|
+
for (const searchRoot of nodeModulesSearchRoots) {
|
|
7494
|
+
const candidatePath = packageName.startsWith("@") ? (0, node_path.join)(searchRoot, "node_modules", ...packageName.split("/"), "package.json") : (0, node_path.join)(searchRoot, "node_modules", packageName, "package.json");
|
|
7495
|
+
if ((0, node_fs.existsSync)(candidatePath)) return candidatePath;
|
|
7496
|
+
}
|
|
7497
|
+
};
|
|
7312
7498
|
const STATIC_PEER_DEPENDENCY_MAP = {
|
|
7313
7499
|
"@apollo/client": ["graphql"],
|
|
7314
7500
|
"@docusaurus/core": ["@mdx-js/react"],
|
|
@@ -7413,11 +7599,12 @@ const ENV_WRAPPER_BINARY_SET = new Set([
|
|
|
7413
7599
|
"env-cmd"
|
|
7414
7600
|
]);
|
|
7415
7601
|
const INLINE_ENV_VAR_PATTERN = /^[A-Z_][A-Z0-9_]*=/;
|
|
7416
|
-
const buildBinToPackageMap = (
|
|
7602
|
+
const buildBinToPackageMap = (nodeModulesSearchRoots, declaredNames) => {
|
|
7417
7603
|
const binToPackage = /* @__PURE__ */ new Map();
|
|
7418
7604
|
for (const [binary, packageName] of Object.entries(CLI_BINARY_TO_PACKAGE)) binToPackage.set(binary, packageName);
|
|
7419
7605
|
for (const packageName of declaredNames) {
|
|
7420
|
-
const packageBinJsonPath =
|
|
7606
|
+
const packageBinJsonPath = findInstalledPackageJsonPath(packageName, nodeModulesSearchRoots);
|
|
7607
|
+
if (!packageBinJsonPath) continue;
|
|
7421
7608
|
try {
|
|
7422
7609
|
const binContent = (0, node_fs.readFileSync)(packageBinJsonPath, "utf-8");
|
|
7423
7610
|
const binPackageJson = JSON.parse(binContent);
|
|
@@ -8127,7 +8314,7 @@ const detectDuplicateImports = (graph) => {
|
|
|
8127
8314
|
const findings = [];
|
|
8128
8315
|
for (const module of graph.modules) {
|
|
8129
8316
|
if (module.isDeclarationFile) continue;
|
|
8130
|
-
const
|
|
8317
|
+
const groupedByKindAndSpecifier = /* @__PURE__ */ new Map();
|
|
8131
8318
|
for (const importInfo of module.imports) {
|
|
8132
8319
|
if (importInfo.isSideEffect) continue;
|
|
8133
8320
|
if (importInfo.isDynamic) continue;
|
|
@@ -8138,18 +8325,21 @@ const detectDuplicateImports = (graph) => {
|
|
|
8138
8325
|
importedNames: importInfo.importedNames.map((binding) => binding.isNamespace ? `* as ${binding.alias ?? ""}` : binding.alias ?? binding.name),
|
|
8139
8326
|
isTypeOnly: importInfo.isTypeOnly
|
|
8140
8327
|
};
|
|
8141
|
-
const
|
|
8328
|
+
const groupKey = `${importInfo.isTypeOnly ? "type" : "value"}:${importInfo.specifier}`;
|
|
8329
|
+
const existing = groupedByKindAndSpecifier.get(groupKey);
|
|
8142
8330
|
if (existing) existing.push(occurrence);
|
|
8143
|
-
else
|
|
8331
|
+
else groupedByKindAndSpecifier.set(groupKey, [occurrence]);
|
|
8144
8332
|
}
|
|
8145
|
-
for (const [
|
|
8333
|
+
for (const [groupKey, occurrences] of groupedByKindAndSpecifier) {
|
|
8146
8334
|
if (occurrences.length < 2) continue;
|
|
8335
|
+
const specifier = groupKey.slice(groupKey.indexOf(":") + 1);
|
|
8336
|
+
const kindLabel = groupKey.startsWith("type:") ? "type-only " : "";
|
|
8147
8337
|
findings.push({
|
|
8148
8338
|
path: module.fileId.path,
|
|
8149
8339
|
specifier,
|
|
8150
8340
|
occurrences,
|
|
8151
8341
|
confidence: "high",
|
|
8152
|
-
reason: `"${specifier}" is imported ${occurrences.length} times in this file — merge into a single statement`
|
|
8342
|
+
reason: `"${specifier}" is imported ${occurrences.length} times in this file as ${kindLabel}imports — merge into a single statement`
|
|
8153
8343
|
});
|
|
8154
8344
|
}
|
|
8155
8345
|
}
|
|
@@ -8244,6 +8434,7 @@ const detectDuplicateConstants = (graph) => {
|
|
|
8244
8434
|
const uniqueFilePaths = new Set(bucket.occurrences.map((occurrence) => occurrence.path));
|
|
8245
8435
|
if (uniqueFilePaths.size < 3) continue;
|
|
8246
8436
|
const uniqueNames = new Set(bucket.occurrences.map((occurrence) => occurrence.constantName));
|
|
8437
|
+
if (uniqueNames.size > 1 && hasDistinctUnitSuffixes([...uniqueNames])) continue;
|
|
8247
8438
|
findings.push({
|
|
8248
8439
|
literalHash,
|
|
8249
8440
|
literalPreview: bucket.literalPreview,
|
|
@@ -8254,6 +8445,29 @@ const detectDuplicateConstants = (graph) => {
|
|
|
8254
8445
|
}
|
|
8255
8446
|
return findings;
|
|
8256
8447
|
};
|
|
8448
|
+
const TRAILING_NAME_TOKEN_PATTERN = /_([A-Z][A-Z0-9]*)$/;
|
|
8449
|
+
const extractTrailingNameToken = (constantName) => {
|
|
8450
|
+
const match = constantName.match(TRAILING_NAME_TOKEN_PATTERN);
|
|
8451
|
+
return match ? match[1] : void 0;
|
|
8452
|
+
};
|
|
8453
|
+
/**
|
|
8454
|
+
* AGENTS.md requires magic numbers to use trailing unit suffixes (`_MS`, `_PX`,
|
|
8455
|
+
* `_TOKENS`, `_WIDTH`, …). When same-value constants carry DIFFERENT trailing
|
|
8456
|
+
* tokens (e.g. `STEP_DELAY_MS = 1000` vs `MINIMUM_TOKENS = 1000`), they
|
|
8457
|
+
* represent semantically distinct quantities that cannot be consolidated —
|
|
8458
|
+
* flagging them as duplicates is misleading. Constants sharing the same
|
|
8459
|
+
* trailing token (e.g. `CACHE_INTERVAL_MS` + `RECONNECT_DELAY_MS`, both `_MS`)
|
|
8460
|
+
* stay flagged because they are at least same-unit and might be extractable.
|
|
8461
|
+
*/
|
|
8462
|
+
const hasDistinctUnitSuffixes = (constantNames) => {
|
|
8463
|
+
const trailingTokens = /* @__PURE__ */ new Set();
|
|
8464
|
+
for (const name of constantNames) {
|
|
8465
|
+
const token = extractTrailingNameToken(name);
|
|
8466
|
+
if (!token) return false;
|
|
8467
|
+
trailingTokens.add(token);
|
|
8468
|
+
}
|
|
8469
|
+
return trailingTokens.size > 1;
|
|
8470
|
+
};
|
|
8257
8471
|
const detectSimplifiableExpressions = (graph) => {
|
|
8258
8472
|
const findings = [];
|
|
8259
8473
|
for (const module of graph.modules) {
|
|
@@ -8328,134 +8542,2473 @@ const detectDuplicateInlineTypes = (graph) => {
|
|
|
8328
8542
|
};
|
|
8329
8543
|
|
|
8330
8544
|
//#endregion
|
|
8331
|
-
//#region src/
|
|
8332
|
-
const
|
|
8333
|
-
|
|
8334
|
-
|
|
8335
|
-
|
|
8336
|
-
|
|
8337
|
-
|
|
8338
|
-
|
|
8339
|
-
|
|
8545
|
+
//#region src/report/cross-file-duplicate-exports.ts
|
|
8546
|
+
const buildReExportSourceSets = (graph) => {
|
|
8547
|
+
const reExportSources = /* @__PURE__ */ new Map();
|
|
8548
|
+
for (const edge of graph.edges) {
|
|
8549
|
+
if (!edge.isReExportEdge) continue;
|
|
8550
|
+
const existing = reExportSources.get(edge.source);
|
|
8551
|
+
if (existing) existing.add(edge.target);
|
|
8552
|
+
else reExportSources.set(edge.source, new Set([edge.target]));
|
|
8553
|
+
}
|
|
8554
|
+
return reExportSources;
|
|
8555
|
+
};
|
|
8556
|
+
/**
|
|
8557
|
+
* Two duplicate-export files "share a common importer" when there exists a
|
|
8558
|
+
* third file that imports from both, OR one duplicate file imports another.
|
|
8559
|
+
* This filters out coincidental duplicates among unrelated leaf modules
|
|
8560
|
+
* (SvelteKit/Next.js route files, scripts in different parts of a monorepo,
|
|
8561
|
+
* etc.) that happen to export the same name but can never be confused at any
|
|
8562
|
+
* import site.
|
|
8563
|
+
*/
|
|
8564
|
+
const hasCommonImporter = (moduleIndices, graph) => {
|
|
8565
|
+
if (moduleIndices.length <= 1) return false;
|
|
8566
|
+
const duplicateModuleSet = new Set(moduleIndices);
|
|
8567
|
+
const importerOwner = /* @__PURE__ */ new Map();
|
|
8568
|
+
for (const moduleIndex of moduleIndices) {
|
|
8569
|
+
const importers = graph.reverseEdges.get(moduleIndex) ?? [];
|
|
8570
|
+
for (const importerIndex of importers) {
|
|
8571
|
+
if (duplicateModuleSet.has(importerIndex)) return true;
|
|
8572
|
+
const previousOwner = importerOwner.get(importerIndex);
|
|
8573
|
+
if (previousOwner === void 0) importerOwner.set(importerIndex, moduleIndex);
|
|
8574
|
+
else if (previousOwner !== moduleIndex) return true;
|
|
8575
|
+
}
|
|
8576
|
+
}
|
|
8577
|
+
return false;
|
|
8578
|
+
};
|
|
8579
|
+
/**
|
|
8580
|
+
* Cross-file duplicate exports: the same exported name lives in 2+ files.
|
|
8581
|
+
*
|
|
8582
|
+
* Filters applied (to keep the rule actionable):
|
|
8583
|
+
* - default exports are skipped (every module gets one and it's not actionable)
|
|
8584
|
+
* - re-export chains are pruned: if module A re-exports `Foo` from module B,
|
|
8585
|
+
* the (A, B) pair is one chain, not two real declarations
|
|
8586
|
+
* - TypeScript value/type namespace split: `export const X` and `export type X`
|
|
8587
|
+
* in the same file are distinct in TS's value/type namespaces; same name in a
|
|
8588
|
+
* value file and a type file is not a true duplicate either
|
|
8589
|
+
* - common-importer filter: only report duplicates where two of the duplicate
|
|
8590
|
+
* files share an importer or one imports another, so unrelated route files in
|
|
8591
|
+
* different parts of a repo don't get flagged
|
|
8592
|
+
*/
|
|
8593
|
+
const detectCrossFileDuplicateExports = (graph) => {
|
|
8594
|
+
const reExportSources = buildReExportSourceSets(graph);
|
|
8595
|
+
const exportEntriesByName = /* @__PURE__ */ new Map();
|
|
8596
|
+
for (const module of graph.modules) {
|
|
8597
|
+
if (!module.isReachable) continue;
|
|
8598
|
+
if (module.isDeclarationFile) continue;
|
|
8599
|
+
if (module.isEntryPoint) continue;
|
|
8600
|
+
for (const exportInfo of module.exports) {
|
|
8601
|
+
if (exportInfo.isDefault) continue;
|
|
8602
|
+
if (exportInfo.isSynthetic) continue;
|
|
8603
|
+
if (exportInfo.name === "*") continue;
|
|
8604
|
+
if (exportInfo.isReExport) continue;
|
|
8605
|
+
const entry = {
|
|
8606
|
+
moduleIndex: module.fileId.index,
|
|
8607
|
+
path: module.fileId.path,
|
|
8608
|
+
line: exportInfo.line,
|
|
8609
|
+
column: exportInfo.column,
|
|
8610
|
+
isTypeOnly: exportInfo.isTypeOnly
|
|
8611
|
+
};
|
|
8612
|
+
const existing = exportEntriesByName.get(exportInfo.name);
|
|
8613
|
+
if (existing) existing.push(entry);
|
|
8614
|
+
else exportEntriesByName.set(exportInfo.name, [entry]);
|
|
8615
|
+
}
|
|
8616
|
+
}
|
|
8617
|
+
const findings = [];
|
|
8618
|
+
const sortedEntries = [...exportEntriesByName.entries()].sort(([nameA], [nameB]) => nameA.localeCompare(nameB));
|
|
8619
|
+
for (const [name, entries] of sortedEntries) {
|
|
8620
|
+
if (entries.length <= 1) continue;
|
|
8621
|
+
const hasValueExport = entries.some((entry) => !entry.isTypeOnly);
|
|
8622
|
+
const hasTypeExport = entries.some((entry) => entry.isTypeOnly);
|
|
8623
|
+
if (hasValueExport && hasTypeExport) {
|
|
8624
|
+
const valueModuleIndices = new Set(entries.filter((entry) => !entry.isTypeOnly).map((entry) => entry.moduleIndex));
|
|
8625
|
+
const typeModuleIndices = new Set(entries.filter((entry) => entry.isTypeOnly).map((entry) => entry.moduleIndex));
|
|
8626
|
+
if (valueModuleIndices.size <= 1 && typeModuleIndices.size <= 1) continue;
|
|
8627
|
+
}
|
|
8628
|
+
const moduleIndexSet = new Set(entries.map((entry) => entry.moduleIndex));
|
|
8629
|
+
const independentEntries = entries.filter((entry) => {
|
|
8630
|
+
const sources = reExportSources.get(entry.moduleIndex);
|
|
8631
|
+
if (!sources) return true;
|
|
8632
|
+
for (const sourceIndex of sources) if (moduleIndexSet.has(sourceIndex)) return false;
|
|
8633
|
+
return true;
|
|
8634
|
+
});
|
|
8635
|
+
if (independentEntries.length <= 1) continue;
|
|
8636
|
+
if (!hasCommonImporter(independentEntries.map((entry) => entry.moduleIndex), graph)) continue;
|
|
8637
|
+
const locations = independentEntries.map((entry) => ({
|
|
8638
|
+
path: entry.path,
|
|
8639
|
+
line: entry.line,
|
|
8640
|
+
column: entry.column,
|
|
8641
|
+
isTypeOnly: entry.isTypeOnly
|
|
8340
8642
|
}));
|
|
8341
|
-
|
|
8643
|
+
findings.push({
|
|
8644
|
+
name,
|
|
8645
|
+
locations,
|
|
8646
|
+
confidence: "medium",
|
|
8647
|
+
reason: `"${name}" is exported from ${locations.length} files that share a common importer — consumers may import the wrong one`
|
|
8648
|
+
});
|
|
8342
8649
|
}
|
|
8650
|
+
return findings;
|
|
8343
8651
|
};
|
|
8344
8652
|
|
|
8345
8653
|
//#endregion
|
|
8346
|
-
//#region src/
|
|
8347
|
-
const
|
|
8654
|
+
//#region src/utils/compute-line-starts.ts
|
|
8655
|
+
const LINE_FEED_CHAR_CODE = 10;
|
|
8656
|
+
const computeLineStarts = (sourceText) => {
|
|
8657
|
+
const lineStarts = [0];
|
|
8658
|
+
for (let charIndex = 0; charIndex < sourceText.length; charIndex++) if (sourceText.charCodeAt(charIndex) === LINE_FEED_CHAR_CODE) lineStarts.push(charIndex + 1);
|
|
8659
|
+
return lineStarts;
|
|
8660
|
+
};
|
|
8661
|
+
|
|
8662
|
+
//#endregion
|
|
8663
|
+
//#region src/utils/offset-to-line-column.ts
|
|
8664
|
+
const offsetToLineColumn = (byteOffset, lineStarts) => {
|
|
8665
|
+
let lowIndex = 0;
|
|
8666
|
+
let highIndex = lineStarts.length - 1;
|
|
8667
|
+
while (lowIndex < highIndex) {
|
|
8668
|
+
const middleIndex = lowIndex + highIndex + 1 >>> 1;
|
|
8669
|
+
if (lineStarts[middleIndex] <= byteOffset) lowIndex = middleIndex;
|
|
8670
|
+
else highIndex = middleIndex - 1;
|
|
8671
|
+
}
|
|
8348
8672
|
return {
|
|
8349
|
-
|
|
8350
|
-
|
|
8351
|
-
error: new TypeScriptError({
|
|
8352
|
-
code: {
|
|
8353
|
-
"no-tsconfig": "tsconfig-not-found",
|
|
8354
|
-
"tsconfig-parse-error": "tsconfig-parse-failed",
|
|
8355
|
-
"program-creation-failed": "ts-program-creation-failed",
|
|
8356
|
-
"too-many-files": "ts-program-too-large",
|
|
8357
|
-
"typescript-load-failed": "ts-not-loadable"
|
|
8358
|
-
}[reason],
|
|
8359
|
-
severity: reason === "no-tsconfig" ? "info" : "warning",
|
|
8360
|
-
message,
|
|
8361
|
-
path: options.rootDir || void 0,
|
|
8362
|
-
detail: options.detail
|
|
8363
|
-
})
|
|
8673
|
+
line: lowIndex + 1,
|
|
8674
|
+
column: byteOffset - lineStarts[lowIndex]
|
|
8364
8675
|
};
|
|
8365
8676
|
};
|
|
8366
|
-
|
|
8367
|
-
|
|
8368
|
-
|
|
8369
|
-
|
|
8370
|
-
|
|
8371
|
-
|
|
8372
|
-
|
|
8373
|
-
|
|
8374
|
-
|
|
8677
|
+
|
|
8678
|
+
//#endregion
|
|
8679
|
+
//#region src/duplicate-blocks/concatenate.ts
|
|
8680
|
+
const SENTINEL_FILE_INDEX = Number.MAX_SAFE_INTEGER;
|
|
8681
|
+
/**
|
|
8682
|
+
* Rank-reduce token hashes to dense 0..K-1 integers and concatenate every
|
|
8683
|
+
* file's reduced sequence with a unique negative sentinel between files. Dense
|
|
8684
|
+
* ranks shrink the suffix-array's bucket counters from ~4 billion to a few
|
|
8685
|
+
* thousand (the standard prefix-doubling speedup), and negative sentinels
|
|
8686
|
+
* guarantee no real-token suffix can match across a file boundary.
|
|
8687
|
+
*/
|
|
8688
|
+
const rankReduceAndConcatenate = (filesHashedTokens) => {
|
|
8689
|
+
const uniqueHashes = /* @__PURE__ */ new Set();
|
|
8690
|
+
for (const fileTokens of filesHashedTokens) for (const hashedToken of fileTokens) uniqueHashes.add(hashedToken.hash);
|
|
8691
|
+
const sortedUniqueHashes = [...uniqueHashes].sort((leftHash, rightHash) => leftHash - rightHash);
|
|
8692
|
+
const hashToRank = /* @__PURE__ */ new Map();
|
|
8693
|
+
for (let rankIndex = 0; rankIndex < sortedUniqueHashes.length; rankIndex++) hashToRank.set(sortedUniqueHashes[rankIndex], rankIndex + 1);
|
|
8694
|
+
const sequenceLength = filesHashedTokens.reduce((runningSum, fileTokens) => runningSum + fileTokens.length, 0) + Math.max(0, filesHashedTokens.length - 1);
|
|
8695
|
+
const tokenSequence = new Array(sequenceLength);
|
|
8696
|
+
const fileOf = new Array(sequenceLength);
|
|
8697
|
+
const fileOffsets = new Array(filesHashedTokens.length);
|
|
8698
|
+
let writeCursor = 0;
|
|
8699
|
+
let nextSentinelValue = -1;
|
|
8700
|
+
for (let fileIndex = 0; fileIndex < filesHashedTokens.length; fileIndex++) {
|
|
8701
|
+
fileOffsets[fileIndex] = writeCursor;
|
|
8702
|
+
const fileTokens = filesHashedTokens[fileIndex];
|
|
8703
|
+
for (const hashedToken of fileTokens) {
|
|
8704
|
+
tokenSequence[writeCursor] = hashToRank.get(hashedToken.hash) ?? 0;
|
|
8705
|
+
fileOf[writeCursor] = fileIndex;
|
|
8706
|
+
writeCursor++;
|
|
8707
|
+
}
|
|
8708
|
+
if (fileIndex < filesHashedTokens.length - 1) {
|
|
8709
|
+
tokenSequence[writeCursor] = nextSentinelValue;
|
|
8710
|
+
fileOf[writeCursor] = SENTINEL_FILE_INDEX;
|
|
8711
|
+
writeCursor++;
|
|
8712
|
+
nextSentinelValue--;
|
|
8713
|
+
}
|
|
8375
8714
|
}
|
|
8376
|
-
|
|
8377
|
-
|
|
8378
|
-
|
|
8379
|
-
|
|
8380
|
-
ok: false,
|
|
8381
|
-
failure: failureFor("no-tsconfig", `No tsconfig found under ${rootDir}`, { rootDir })
|
|
8715
|
+
return {
|
|
8716
|
+
tokenSequence,
|
|
8717
|
+
fileOf,
|
|
8718
|
+
fileOffsets
|
|
8382
8719
|
};
|
|
8383
|
-
|
|
8384
|
-
|
|
8385
|
-
|
|
8386
|
-
|
|
8387
|
-
|
|
8388
|
-
|
|
8389
|
-
|
|
8390
|
-
|
|
8391
|
-
|
|
8392
|
-
|
|
8393
|
-
|
|
8720
|
+
};
|
|
8721
|
+
const SENTINEL_FILE_MARKER = SENTINEL_FILE_INDEX;
|
|
8722
|
+
|
|
8723
|
+
//#endregion
|
|
8724
|
+
//#region src/duplicate-blocks/extract.ts
|
|
8725
|
+
const buildRawBlock = (suffixArray, fileOf, fileOffsets, filesTokenCounts, intervalBegin, intervalEnd, tokenLength) => {
|
|
8726
|
+
const candidateInstances = [];
|
|
8727
|
+
for (let suffixIndex = intervalBegin; suffixIndex < intervalEnd; suffixIndex++) {
|
|
8728
|
+
const startPosition = suffixArray[suffixIndex];
|
|
8729
|
+
const fileIndex = fileOf[startPosition];
|
|
8730
|
+
if (fileIndex === SENTINEL_FILE_MARKER) continue;
|
|
8731
|
+
const tokenOffsetWithinFile = startPosition - fileOffsets[fileIndex];
|
|
8732
|
+
if (tokenOffsetWithinFile + tokenLength > filesTokenCounts[fileIndex]) continue;
|
|
8733
|
+
candidateInstances.push({
|
|
8734
|
+
fileIndex,
|
|
8735
|
+
tokenOffsetWithinFile
|
|
8736
|
+
});
|
|
8394
8737
|
}
|
|
8395
|
-
if (
|
|
8396
|
-
|
|
8397
|
-
|
|
8738
|
+
if (candidateInstances.length < 2) return void 0;
|
|
8739
|
+
candidateInstances.sort((leftInstance, rightInstance) => {
|
|
8740
|
+
if (leftInstance.fileIndex !== rightInstance.fileIndex) return leftInstance.fileIndex - rightInstance.fileIndex;
|
|
8741
|
+
return leftInstance.tokenOffsetWithinFile - rightInstance.tokenOffsetWithinFile;
|
|
8742
|
+
});
|
|
8743
|
+
const dedupedInstances = [];
|
|
8744
|
+
for (const instance of candidateInstances) {
|
|
8745
|
+
const lastInstance = dedupedInstances[dedupedInstances.length - 1];
|
|
8746
|
+
if (lastInstance !== void 0 && lastInstance.fileIndex === instance.fileIndex && instance.tokenOffsetWithinFile < lastInstance.tokenOffsetWithinFile + tokenLength) continue;
|
|
8747
|
+
dedupedInstances.push(instance);
|
|
8748
|
+
}
|
|
8749
|
+
if (dedupedInstances.length < 2) return void 0;
|
|
8750
|
+
return {
|
|
8751
|
+
instances: dedupedInstances,
|
|
8752
|
+
tokenLength
|
|
8398
8753
|
};
|
|
8399
|
-
|
|
8400
|
-
|
|
8401
|
-
|
|
8402
|
-
|
|
8403
|
-
|
|
8404
|
-
|
|
8405
|
-
|
|
8406
|
-
|
|
8407
|
-
|
|
8408
|
-
|
|
8409
|
-
|
|
8410
|
-
|
|
8411
|
-
|
|
8412
|
-
|
|
8413
|
-
|
|
8414
|
-
|
|
8754
|
+
};
|
|
8755
|
+
/**
|
|
8756
|
+
* Walks `lcpArray` with a monotone stack to materialize every maximal
|
|
8757
|
+
* interval `[i, j]` whose minimum LCP is >= `minTokens`. Within-file
|
|
8758
|
+
* overlapping occurrences are dropped (keep the earliest non-overlapping
|
|
8759
|
+
* prefix), and any block left with fewer than two occurrences is discarded.
|
|
8760
|
+
*/
|
|
8761
|
+
const extractRawDuplicateBlocks = (suffixArray, lcpArray, fileOf, fileOffsets, filesTokenCounts, minTokens) => {
|
|
8762
|
+
const sequenceLength = suffixArray.length;
|
|
8763
|
+
if (sequenceLength < 2) return [];
|
|
8764
|
+
const rawBlocks = [];
|
|
8765
|
+
const monotoneStack = [];
|
|
8766
|
+
for (let scanIndex = 1; scanIndex <= sequenceLength; scanIndex++) {
|
|
8767
|
+
const currentLcp = scanIndex < sequenceLength ? lcpArray[scanIndex] : 0;
|
|
8768
|
+
let intervalStart = scanIndex;
|
|
8769
|
+
while (monotoneStack.length > 0 && monotoneStack[monotoneStack.length - 1].lcpValue > currentLcp) {
|
|
8770
|
+
const popped = monotoneStack.pop();
|
|
8771
|
+
intervalStart = popped.startIndex;
|
|
8772
|
+
if (popped.lcpValue >= minTokens) {
|
|
8773
|
+
const candidate = buildRawBlock(suffixArray, fileOf, fileOffsets, filesTokenCounts, intervalStart - 1, scanIndex, popped.lcpValue);
|
|
8774
|
+
if (candidate) rawBlocks.push(candidate);
|
|
8775
|
+
}
|
|
8776
|
+
}
|
|
8777
|
+
if (scanIndex < sequenceLength) monotoneStack.push({
|
|
8778
|
+
lcpValue: currentLcp,
|
|
8779
|
+
startIndex: intervalStart
|
|
8780
|
+
});
|
|
8415
8781
|
}
|
|
8416
|
-
|
|
8417
|
-
|
|
8418
|
-
|
|
8419
|
-
|
|
8420
|
-
|
|
8421
|
-
|
|
8782
|
+
return rawBlocks;
|
|
8783
|
+
};
|
|
8784
|
+
|
|
8785
|
+
//#endregion
|
|
8786
|
+
//#region src/duplicate-blocks/clusters.ts
|
|
8787
|
+
const baseName = (filePath) => {
|
|
8788
|
+
const trailingSlashIndex = filePath.lastIndexOf("/");
|
|
8789
|
+
return trailingSlashIndex === -1 ? filePath : filePath.slice(trailingSlashIndex + 1);
|
|
8790
|
+
};
|
|
8791
|
+
const buildSuggestions = (files, blocks, totalDuplicatedLines) => {
|
|
8792
|
+
const fileBaseNames = files.map((filePath) => baseName(filePath));
|
|
8793
|
+
if (files.length >= 2 && totalDuplicatedLines >= 50) {
|
|
8794
|
+
const estimatedSavings = blocks.reduce((runningSum, block) => runningSum + block.lineCount * Math.max(0, block.instances.length - 1), 0);
|
|
8795
|
+
return [{
|
|
8796
|
+
kind: "extract-module",
|
|
8797
|
+
description: `Extract ${blocks.length} shared duplicate block${blocks.length === 1 ? "" : "s"} (${totalDuplicatedLines} lines) from ${fileBaseNames.join(", ")} into a shared module`,
|
|
8798
|
+
estimatedSavings
|
|
8799
|
+
}];
|
|
8800
|
+
}
|
|
8801
|
+
return blocks.map((block) => ({
|
|
8802
|
+
kind: "extract-function",
|
|
8803
|
+
description: `Extract shared function (${block.lineCount} lines) from ${fileBaseNames.join(", ")}`,
|
|
8804
|
+
estimatedSavings: block.lineCount * Math.max(0, block.instances.length - 1)
|
|
8805
|
+
}));
|
|
8806
|
+
};
|
|
8807
|
+
const groupDuplicateBlocksIntoClusters = (duplicateBlocks) => {
|
|
8808
|
+
if (duplicateBlocks.length === 0) return [];
|
|
8809
|
+
const fileSetKeyToBucket = /* @__PURE__ */ new Map();
|
|
8810
|
+
for (const block of duplicateBlocks) {
|
|
8811
|
+
const sortedFiles = [...new Set(block.instances.map((instance) => instance.path))].sort();
|
|
8812
|
+
const fileSetKey = sortedFiles.join("|");
|
|
8813
|
+
const existing = fileSetKeyToBucket.get(fileSetKey);
|
|
8814
|
+
if (existing) existing.blocks.push(block);
|
|
8815
|
+
else fileSetKeyToBucket.set(fileSetKey, {
|
|
8816
|
+
files: sortedFiles,
|
|
8817
|
+
blocks: [block]
|
|
8818
|
+
});
|
|
8422
8819
|
}
|
|
8423
|
-
|
|
8424
|
-
|
|
8425
|
-
|
|
8426
|
-
|
|
8427
|
-
|
|
8428
|
-
|
|
8429
|
-
|
|
8430
|
-
|
|
8431
|
-
|
|
8820
|
+
const clusters = [];
|
|
8821
|
+
for (const bucket of fileSetKeyToBucket.values()) {
|
|
8822
|
+
const totalDuplicatedLines = bucket.blocks.reduce((runningSum, block) => runningSum + block.lineCount, 0);
|
|
8823
|
+
const totalDuplicatedTokens = bucket.blocks.reduce((runningSum, block) => runningSum + block.tokenCount, 0);
|
|
8824
|
+
clusters.push({
|
|
8825
|
+
files: bucket.files,
|
|
8826
|
+
groups: bucket.blocks,
|
|
8827
|
+
totalDuplicatedLines,
|
|
8828
|
+
totalDuplicatedTokens,
|
|
8829
|
+
suggestions: buildSuggestions(bucket.files, bucket.blocks, totalDuplicatedLines)
|
|
8432
8830
|
});
|
|
8433
|
-
return {
|
|
8434
|
-
ok: true,
|
|
8435
|
-
context: {
|
|
8436
|
-
program,
|
|
8437
|
-
checker: program.getTypeChecker(),
|
|
8438
|
-
rootSourceFiles: program.getSourceFiles().filter((sourceFile) => !sourceFile.isDeclarationFile || sourceFile.fileName.endsWith(".d.ts")),
|
|
8439
|
-
tsconfigPath: resolvedTsconfigPath
|
|
8440
|
-
}
|
|
8441
|
-
};
|
|
8442
|
-
} catch (programError) {
|
|
8443
|
-
return {
|
|
8444
|
-
ok: false,
|
|
8445
|
-
failure: failureFor("program-creation-failed", "ts.createProgram threw", {
|
|
8446
|
-
rootDir: resolvedTsconfigPath,
|
|
8447
|
-
detail: describeUnknownError(programError)
|
|
8448
|
-
})
|
|
8449
|
-
};
|
|
8450
8831
|
}
|
|
8832
|
+
clusters.sort((leftCluster, rightCluster) => {
|
|
8833
|
+
if (leftCluster.totalDuplicatedLines !== rightCluster.totalDuplicatedLines) return rightCluster.totalDuplicatedLines - leftCluster.totalDuplicatedLines;
|
|
8834
|
+
return rightCluster.groups.length - leftCluster.groups.length;
|
|
8835
|
+
});
|
|
8836
|
+
return clusters;
|
|
8451
8837
|
};
|
|
8452
8838
|
|
|
8453
8839
|
//#endregion
|
|
8454
|
-
//#region src/
|
|
8455
|
-
const
|
|
8456
|
-
|
|
8840
|
+
//#region src/duplicate-blocks/shadowed-directory-pairs.ts
|
|
8841
|
+
const splitDirectoryAndFile = (filePath) => {
|
|
8842
|
+
const trailingSlashIndex = filePath.lastIndexOf("/");
|
|
8843
|
+
if (trailingSlashIndex === -1) return {
|
|
8844
|
+
directory: "",
|
|
8845
|
+
baseName: filePath
|
|
8846
|
+
};
|
|
8847
|
+
return {
|
|
8848
|
+
directory: filePath.slice(0, trailingSlashIndex + 1),
|
|
8849
|
+
baseName: filePath.slice(trailingSlashIndex + 1)
|
|
8850
|
+
};
|
|
8457
8851
|
};
|
|
8458
|
-
const
|
|
8852
|
+
const toRelative = (filePath, rootDir) => {
|
|
8853
|
+
if (filePath.startsWith(rootDir + "/")) return filePath.slice(rootDir.length + 1);
|
|
8854
|
+
if (filePath === rootDir) return "";
|
|
8855
|
+
return filePath;
|
|
8856
|
+
};
|
|
8857
|
+
/**
|
|
8858
|
+
* Collapse N two-file duplicate-block clusters that share the same
|
|
8859
|
+
* `(directoryA, directoryB)` and matching basenames into a single
|
|
8860
|
+
* `ShadowedDirectoryPair` finding — the directories themselves drifted
|
|
8861
|
+
* (e.g. `src/` vs `deno/lib/`, a fork, a copy-paste of a route tree).
|
|
8862
|
+
*/
|
|
8863
|
+
const detectShadowedDirectoryPairs = (duplicateBlockClusters, rootDir) => {
|
|
8864
|
+
const directoryPairBuckets = /* @__PURE__ */ new Map();
|
|
8865
|
+
for (const cluster of duplicateBlockClusters) {
|
|
8866
|
+
if (cluster.files.length !== 2) continue;
|
|
8867
|
+
const [firstFile, secondFile] = cluster.files;
|
|
8868
|
+
const firstSplit = splitDirectoryAndFile(toRelative(firstFile, rootDir));
|
|
8869
|
+
const secondSplit = splitDirectoryAndFile(toRelative(secondFile, rootDir));
|
|
8870
|
+
if (firstSplit.baseName !== secondSplit.baseName) continue;
|
|
8871
|
+
const [smallerDirectory, largerDirectory] = firstSplit.directory <= secondSplit.directory ? [firstSplit.directory, secondSplit.directory] : [secondSplit.directory, firstSplit.directory];
|
|
8872
|
+
const pairKey = `${smallerDirectory}::${largerDirectory}`;
|
|
8873
|
+
const entry = {
|
|
8874
|
+
baseName: firstSplit.baseName,
|
|
8875
|
+
duplicatedLines: cluster.totalDuplicatedLines
|
|
8876
|
+
};
|
|
8877
|
+
const existing = directoryPairBuckets.get(pairKey);
|
|
8878
|
+
if (existing) existing.push(entry);
|
|
8879
|
+
else directoryPairBuckets.set(pairKey, [entry]);
|
|
8880
|
+
}
|
|
8881
|
+
const shadowedDirectoryPairs = [];
|
|
8882
|
+
for (const [pairKey, entries] of directoryPairBuckets) {
|
|
8883
|
+
if (entries.length < 3) continue;
|
|
8884
|
+
const [directoryA, directoryB] = pairKey.split("::");
|
|
8885
|
+
const sharedBaseNames = [...new Set(entries.map((entry) => entry.baseName))].sort();
|
|
8886
|
+
const totalDuplicatedLines = entries.reduce((runningSum, entry) => runningSum + entry.duplicatedLines, 0);
|
|
8887
|
+
shadowedDirectoryPairs.push({
|
|
8888
|
+
directoryA,
|
|
8889
|
+
directoryB,
|
|
8890
|
+
sharedFiles: sharedBaseNames,
|
|
8891
|
+
totalDuplicatedLines
|
|
8892
|
+
});
|
|
8893
|
+
}
|
|
8894
|
+
shadowedDirectoryPairs.sort((leftPair, rightPair) => rightPair.totalDuplicatedLines - leftPair.totalDuplicatedLines);
|
|
8895
|
+
return shadowedDirectoryPairs;
|
|
8896
|
+
};
|
|
8897
|
+
|
|
8898
|
+
//#endregion
|
|
8899
|
+
//#region src/duplicate-blocks/normalize.ts
|
|
8900
|
+
/**
|
|
8901
|
+
* 32-bit FNV-1a. Collisions are tolerable: ties are broken back to the
|
|
8902
|
+
* original (path, offset) tuples downstream, so a rare collision inflates a
|
|
8903
|
+
* duplicate block with one extra spurious instance at worst.
|
|
8904
|
+
*/
|
|
8905
|
+
const FNV_OFFSET_BASIS = 2166136261;
|
|
8906
|
+
const FNV_PRIME = 16777619;
|
|
8907
|
+
const hashString = (input) => {
|
|
8908
|
+
let hash = FNV_OFFSET_BASIS;
|
|
8909
|
+
for (let charIndex = 0; charIndex < input.length; charIndex++) {
|
|
8910
|
+
hash ^= input.charCodeAt(charIndex);
|
|
8911
|
+
hash = Math.imul(hash, FNV_PRIME);
|
|
8912
|
+
}
|
|
8913
|
+
return hash >>> 0;
|
|
8914
|
+
};
|
|
8915
|
+
const resolveNormalization = (mode) => {
|
|
8916
|
+
if (mode === "strict") return {
|
|
8917
|
+
ignoreIdentifiers: false,
|
|
8918
|
+
ignoreStringValues: false,
|
|
8919
|
+
ignoreNumericValues: false
|
|
8920
|
+
};
|
|
8921
|
+
return {
|
|
8922
|
+
ignoreIdentifiers: true,
|
|
8923
|
+
ignoreStringValues: true,
|
|
8924
|
+
ignoreNumericValues: true
|
|
8925
|
+
};
|
|
8926
|
+
};
|
|
8927
|
+
const hashSourceToken = (sourceToken, normalization) => {
|
|
8928
|
+
switch (sourceToken.kind) {
|
|
8929
|
+
case "node-enter": return hashString(`n:${sourceToken.payload}`);
|
|
8930
|
+
case "identifier": return normalization.ignoreIdentifiers ? hashString("id:*") : hashString(`id:${sourceToken.payload}`);
|
|
8931
|
+
case "string-literal": return normalization.ignoreStringValues ? hashString("s:*") : hashString(`s:${sourceToken.payload}`);
|
|
8932
|
+
case "numeric-literal": return normalization.ignoreNumericValues ? hashString("num:*") : hashString(`num:${sourceToken.payload}`);
|
|
8933
|
+
case "boolean-literal": return hashString(`b:${sourceToken.payload}`);
|
|
8934
|
+
case "null-literal": return hashString("null");
|
|
8935
|
+
case "template-literal": return hashString("tpl");
|
|
8936
|
+
case "regexp-literal": return hashString("re");
|
|
8937
|
+
default: return hashString("?");
|
|
8938
|
+
}
|
|
8939
|
+
};
|
|
8940
|
+
const normalizeAndHashTokens = (tokens, mode) => {
|
|
8941
|
+
const normalization = resolveNormalization(mode);
|
|
8942
|
+
const hashedTokens = new Array(tokens.length);
|
|
8943
|
+
for (let tokenIndex = 0; tokenIndex < tokens.length; tokenIndex++) hashedTokens[tokenIndex] = {
|
|
8944
|
+
hash: hashSourceToken(tokens[tokenIndex], normalization),
|
|
8945
|
+
originalIndex: tokenIndex
|
|
8946
|
+
};
|
|
8947
|
+
return hashedTokens;
|
|
8948
|
+
};
|
|
8949
|
+
|
|
8950
|
+
//#endregion
|
|
8951
|
+
//#region src/duplicate-blocks/suffix-array.ts
|
|
8952
|
+
/**
|
|
8953
|
+
* Prefix-doubling suffix array with two-pass radix sort, O(N log N).
|
|
8954
|
+
*
|
|
8955
|
+
* Negative values in `tokenSequence` (file-separator sentinels emitted by
|
|
8956
|
+
* `rankReduceAndConcatenate`) are shifted up so all ranks are >= 0. The
|
|
8957
|
+
* shift preserves the property that sentinels sort before all real ranks,
|
|
8958
|
+
* which is what stops cross-file suffix matches.
|
|
8959
|
+
*/
|
|
8960
|
+
const buildSuffixArray = (tokenSequence) => {
|
|
8961
|
+
const sequenceLength = tokenSequence.length;
|
|
8962
|
+
if (sequenceLength === 0) return [];
|
|
8963
|
+
let minimumValue = 0;
|
|
8964
|
+
for (let scanIndex = 0; scanIndex < sequenceLength; scanIndex++) if (tokenSequence[scanIndex] < minimumValue) minimumValue = tokenSequence[scanIndex];
|
|
8965
|
+
let currentRanks = new Array(sequenceLength);
|
|
8966
|
+
for (let scanIndex = 0; scanIndex < sequenceLength; scanIndex++) currentRanks[scanIndex] = tokenSequence[scanIndex] - minimumValue;
|
|
8967
|
+
let suffixArray = new Array(sequenceLength);
|
|
8968
|
+
for (let positionIndex = 0; positionIndex < sequenceLength; positionIndex++) suffixArray[positionIndex] = positionIndex;
|
|
8969
|
+
let nextRanks = new Array(sequenceLength);
|
|
8970
|
+
let scratchSuffixArray = new Array(sequenceLength);
|
|
8971
|
+
let maximumRank = 0;
|
|
8972
|
+
for (let scanIndex = 0; scanIndex < sequenceLength; scanIndex++) if (currentRanks[scanIndex] > maximumRank) maximumRank = currentRanks[scanIndex];
|
|
8973
|
+
let stride = 1;
|
|
8974
|
+
while (stride < sequenceLength) {
|
|
8975
|
+
const bucketCount = maximumRank + 2;
|
|
8976
|
+
const buckets = new Array(bucketCount + 1).fill(0);
|
|
8977
|
+
for (let suffixIndex = 0; suffixIndex < sequenceLength; suffixIndex++) {
|
|
8978
|
+
const startPosition = suffixArray[suffixIndex];
|
|
8979
|
+
const secondaryKey = startPosition + stride < sequenceLength ? currentRanks[startPosition + stride] + 1 : 0;
|
|
8980
|
+
buckets[secondaryKey]++;
|
|
8981
|
+
}
|
|
8982
|
+
let prefixSum = 0;
|
|
8983
|
+
for (let bucketIndex = 0; bucketIndex < buckets.length; bucketIndex++) {
|
|
8984
|
+
const bucketCountValue = buckets[bucketIndex];
|
|
8985
|
+
buckets[bucketIndex] = prefixSum;
|
|
8986
|
+
prefixSum += bucketCountValue;
|
|
8987
|
+
}
|
|
8988
|
+
for (let suffixIndex = 0; suffixIndex < sequenceLength; suffixIndex++) {
|
|
8989
|
+
const startPosition = suffixArray[suffixIndex];
|
|
8990
|
+
const secondaryKey = startPosition + stride < sequenceLength ? currentRanks[startPosition + stride] + 1 : 0;
|
|
8991
|
+
scratchSuffixArray[buckets[secondaryKey]] = startPosition;
|
|
8992
|
+
buckets[secondaryKey]++;
|
|
8993
|
+
}
|
|
8994
|
+
for (let bucketIndex = 0; bucketIndex < buckets.length; bucketIndex++) buckets[bucketIndex] = 0;
|
|
8995
|
+
for (let suffixIndex = 0; suffixIndex < sequenceLength; suffixIndex++) {
|
|
8996
|
+
const startPosition = scratchSuffixArray[suffixIndex];
|
|
8997
|
+
buckets[currentRanks[startPosition]]++;
|
|
8998
|
+
}
|
|
8999
|
+
prefixSum = 0;
|
|
9000
|
+
for (let bucketIndex = 0; bucketIndex < buckets.length; bucketIndex++) {
|
|
9001
|
+
const bucketCountValue = buckets[bucketIndex];
|
|
9002
|
+
buckets[bucketIndex] = prefixSum;
|
|
9003
|
+
prefixSum += bucketCountValue;
|
|
9004
|
+
}
|
|
9005
|
+
for (let suffixIndex = 0; suffixIndex < sequenceLength; suffixIndex++) {
|
|
9006
|
+
const startPosition = scratchSuffixArray[suffixIndex];
|
|
9007
|
+
suffixArray[buckets[currentRanks[startPosition]]] = startPosition;
|
|
9008
|
+
buckets[currentRanks[startPosition]]++;
|
|
9009
|
+
}
|
|
9010
|
+
nextRanks[suffixArray[0]] = 0;
|
|
9011
|
+
for (let suffixIndex = 1; suffixIndex < sequenceLength; suffixIndex++) {
|
|
9012
|
+
const previousStart = suffixArray[suffixIndex - 1];
|
|
9013
|
+
const currentStart = suffixArray[suffixIndex];
|
|
9014
|
+
const previousSecondary = previousStart + stride < sequenceLength ? currentRanks[previousStart + stride] : -1;
|
|
9015
|
+
const currentSecondary = currentStart + stride < sequenceLength ? currentRanks[currentStart + stride] : -1;
|
|
9016
|
+
const isSameBucket = currentRanks[previousStart] === currentRanks[currentStart] && previousSecondary === currentSecondary;
|
|
9017
|
+
nextRanks[currentStart] = nextRanks[previousStart] + (isSameBucket ? 0 : 1);
|
|
9018
|
+
}
|
|
9019
|
+
const newMaximumRank = nextRanks[suffixArray[sequenceLength - 1]];
|
|
9020
|
+
[currentRanks, nextRanks] = [nextRanks, currentRanks];
|
|
9021
|
+
if (newMaximumRank === sequenceLength - 1) break;
|
|
9022
|
+
maximumRank = newMaximumRank;
|
|
9023
|
+
stride *= 2;
|
|
9024
|
+
}
|
|
9025
|
+
return suffixArray;
|
|
9026
|
+
};
|
|
9027
|
+
/**
|
|
9028
|
+
* Kasai's O(N) longest-common-prefix array. The `>= 0` check inside the inner
|
|
9029
|
+
* loop is the only non-textbook bit: it prevents a real-token LCP from
|
|
9030
|
+
* accidentally crossing a sentinel boundary (sentinels are negative).
|
|
9031
|
+
*/
|
|
9032
|
+
const buildLcpArray = (tokenSequence, suffixArray) => {
|
|
9033
|
+
const sequenceLength = tokenSequence.length;
|
|
9034
|
+
const inverseSuffixArray = new Array(sequenceLength);
|
|
9035
|
+
for (let arrayIndex = 0; arrayIndex < sequenceLength; arrayIndex++) inverseSuffixArray[suffixArray[arrayIndex]] = arrayIndex;
|
|
9036
|
+
const lcpArray = new Array(sequenceLength).fill(0);
|
|
9037
|
+
let runningLcp = 0;
|
|
9038
|
+
for (let positionIndex = 0; positionIndex < sequenceLength; positionIndex++) {
|
|
9039
|
+
if (inverseSuffixArray[positionIndex] === 0) {
|
|
9040
|
+
runningLcp = 0;
|
|
9041
|
+
continue;
|
|
9042
|
+
}
|
|
9043
|
+
const previousStart = suffixArray[inverseSuffixArray[positionIndex] - 1];
|
|
9044
|
+
while (positionIndex + runningLcp < sequenceLength && previousStart + runningLcp < sequenceLength && tokenSequence[positionIndex + runningLcp] === tokenSequence[previousStart + runningLcp] && tokenSequence[positionIndex + runningLcp] >= 0) runningLcp++;
|
|
9045
|
+
lcpArray[inverseSuffixArray[positionIndex]] = runningLcp;
|
|
9046
|
+
if (runningLcp > 0) runningLcp--;
|
|
9047
|
+
}
|
|
9048
|
+
return lcpArray;
|
|
9049
|
+
};
|
|
9050
|
+
|
|
9051
|
+
//#endregion
|
|
9052
|
+
//#region src/utils/is-ast-node.ts
|
|
9053
|
+
const isAstNode = (candidate) => typeof candidate === "object" && candidate !== null && "type" in candidate;
|
|
9054
|
+
|
|
9055
|
+
//#endregion
|
|
9056
|
+
//#region src/duplicate-blocks/token-visitor.ts
|
|
9057
|
+
const NODES_DROPPED_FROM_TOKEN_STREAM = new Set([
|
|
9058
|
+
"ImportDeclaration",
|
|
9059
|
+
"ExportAllDeclaration",
|
|
9060
|
+
"TSTypeAnnotation",
|
|
9061
|
+
"TSTypeAliasDeclaration",
|
|
9062
|
+
"TSInterfaceDeclaration",
|
|
9063
|
+
"TSTypeParameterDeclaration",
|
|
9064
|
+
"TSTypeParameterInstantiation",
|
|
9065
|
+
"TSTypeReference",
|
|
9066
|
+
"TSAnyKeyword",
|
|
9067
|
+
"TSUnknownKeyword",
|
|
9068
|
+
"TSStringKeyword",
|
|
9069
|
+
"TSNumberKeyword",
|
|
9070
|
+
"TSBooleanKeyword",
|
|
9071
|
+
"TSVoidKeyword",
|
|
9072
|
+
"TSUndefinedKeyword",
|
|
9073
|
+
"TSNullKeyword",
|
|
9074
|
+
"TSNeverKeyword",
|
|
9075
|
+
"TSUnionType",
|
|
9076
|
+
"TSIntersectionType",
|
|
9077
|
+
"TSLiteralType",
|
|
9078
|
+
"TSArrayType",
|
|
9079
|
+
"TSTupleType",
|
|
9080
|
+
"TSTypeLiteral",
|
|
9081
|
+
"TSPropertySignature",
|
|
9082
|
+
"TSMethodSignature",
|
|
9083
|
+
"TSCallSignatureDeclaration",
|
|
9084
|
+
"TSConstructSignatureDeclaration",
|
|
9085
|
+
"TSIndexSignature",
|
|
9086
|
+
"TSConditionalType",
|
|
9087
|
+
"TSMappedType",
|
|
9088
|
+
"TSInferType",
|
|
9089
|
+
"TSImportType",
|
|
9090
|
+
"TSQualifiedName",
|
|
9091
|
+
"TSTypeOperator",
|
|
9092
|
+
"TSTypePredicate",
|
|
9093
|
+
"TSFunctionType",
|
|
9094
|
+
"TSConstructorType"
|
|
9095
|
+
]);
|
|
9096
|
+
const visitChildrenRaw = (node, visit) => {
|
|
9097
|
+
if (!isAstNode(node)) return;
|
|
9098
|
+
for (const key of Object.keys(node)) {
|
|
9099
|
+
if (key === "type" || key === "start" || key === "end" || key === "loc" || key === "range") continue;
|
|
9100
|
+
const value = node[key];
|
|
9101
|
+
if (Array.isArray(value)) for (const item of value) visit(item);
|
|
9102
|
+
else if (value !== null && typeof value === "object") visit(value);
|
|
9103
|
+
}
|
|
9104
|
+
};
|
|
9105
|
+
const safeNumberOrZero = (candidate) => typeof candidate === "number" ? candidate : 0;
|
|
9106
|
+
/**
|
|
9107
|
+
* Walk an oxc AST and emit a flat token stream suitable for suffix-array-based
|
|
9108
|
+
* duplicate-block detection. Two structurally-identical regions of code produce the same
|
|
9109
|
+
* token sequence (modulo identifier/literal-value normalization, applied later
|
|
9110
|
+
* in `normalize.ts`).
|
|
9111
|
+
*
|
|
9112
|
+
* Implementation note: rather than a hand-written keyword/operator lexer-style
|
|
9113
|
+
* visitor, we walk the AST generically and emit one `node-enter` token per
|
|
9114
|
+
* visited node. This trades a slightly different token-density profile for
|
|
9115
|
+
* less code. AST-shape tokens still distinguish
|
|
9116
|
+
* `function add(a, b) { return a + b }` from `const add = (a, b) => a + b`.
|
|
9117
|
+
* Identifiers and value literals get dedicated tokens so semantic-mode
|
|
9118
|
+
* normalization can blind them.
|
|
9119
|
+
*
|
|
9120
|
+
* Imports and type-only constructs are dropped to keep import-block boilerplate
|
|
9121
|
+
* and ambient type declarations from inflating the noise floor.
|
|
9122
|
+
*/
|
|
9123
|
+
const tokenizeAst = (program) => {
|
|
9124
|
+
const tokens = [];
|
|
9125
|
+
const visit = (node) => {
|
|
9126
|
+
if (!isAstNode(node)) return;
|
|
9127
|
+
const nodeType = node.type;
|
|
9128
|
+
if (NODES_DROPPED_FROM_TOKEN_STREAM.has(nodeType)) return;
|
|
9129
|
+
const start = safeNumberOrZero(node.start);
|
|
9130
|
+
const end = safeNumberOrZero(node.end);
|
|
9131
|
+
if (nodeType === "Identifier" || nodeType === "PrivateIdentifier") {
|
|
9132
|
+
const identifierName = node.name;
|
|
9133
|
+
tokens.push({
|
|
9134
|
+
kind: "identifier",
|
|
9135
|
+
payload: typeof identifierName === "string" ? identifierName : "",
|
|
9136
|
+
start,
|
|
9137
|
+
end
|
|
9138
|
+
});
|
|
9139
|
+
return;
|
|
9140
|
+
}
|
|
9141
|
+
if (nodeType === "Literal") {
|
|
9142
|
+
const literalValue = node.value;
|
|
9143
|
+
if (typeof literalValue === "string") tokens.push({
|
|
9144
|
+
kind: "string-literal",
|
|
9145
|
+
payload: literalValue,
|
|
9146
|
+
start,
|
|
9147
|
+
end
|
|
9148
|
+
});
|
|
9149
|
+
else if (typeof literalValue === "number") tokens.push({
|
|
9150
|
+
kind: "numeric-literal",
|
|
9151
|
+
payload: String(literalValue),
|
|
9152
|
+
start,
|
|
9153
|
+
end
|
|
9154
|
+
});
|
|
9155
|
+
else if (typeof literalValue === "boolean") tokens.push({
|
|
9156
|
+
kind: "boolean-literal",
|
|
9157
|
+
payload: literalValue ? "true" : "false",
|
|
9158
|
+
start,
|
|
9159
|
+
end
|
|
9160
|
+
});
|
|
9161
|
+
else if (literalValue === null) tokens.push({
|
|
9162
|
+
kind: "null-literal",
|
|
9163
|
+
payload: "null",
|
|
9164
|
+
start,
|
|
9165
|
+
end
|
|
9166
|
+
});
|
|
9167
|
+
else if (node.regex) tokens.push({
|
|
9168
|
+
kind: "regexp-literal",
|
|
9169
|
+
payload: "regex",
|
|
9170
|
+
start,
|
|
9171
|
+
end
|
|
9172
|
+
});
|
|
9173
|
+
else tokens.push({
|
|
9174
|
+
kind: "node-enter",
|
|
9175
|
+
payload: nodeType,
|
|
9176
|
+
start,
|
|
9177
|
+
end
|
|
9178
|
+
});
|
|
9179
|
+
return;
|
|
9180
|
+
}
|
|
9181
|
+
if (nodeType === "TemplateLiteral") {
|
|
9182
|
+
tokens.push({
|
|
9183
|
+
kind: "template-literal",
|
|
9184
|
+
payload: "tpl",
|
|
9185
|
+
start,
|
|
9186
|
+
end
|
|
9187
|
+
});
|
|
9188
|
+
visitChildrenRaw(node, visit);
|
|
9189
|
+
return;
|
|
9190
|
+
}
|
|
9191
|
+
tokens.push({
|
|
9192
|
+
kind: "node-enter",
|
|
9193
|
+
payload: nodeType,
|
|
9194
|
+
start,
|
|
9195
|
+
end
|
|
9196
|
+
});
|
|
9197
|
+
visitChildrenRaw(node, visit);
|
|
9198
|
+
};
|
|
9199
|
+
visit(program);
|
|
9200
|
+
return tokens;
|
|
9201
|
+
};
|
|
9202
|
+
|
|
9203
|
+
//#endregion
|
|
9204
|
+
//#region src/duplicate-blocks/index.ts
|
|
9205
|
+
const isBinaryFile = (sourceText) => {
|
|
9206
|
+
const sampleEnd = Math.min(sourceText.length, BINARY_DETECTION_SAMPLE_BYTES);
|
|
9207
|
+
let nullByteCount = 0;
|
|
9208
|
+
for (let charIndex = 0; charIndex < sampleEnd; charIndex++) if (sourceText.charCodeAt(charIndex) === 0) {
|
|
9209
|
+
nullByteCount++;
|
|
9210
|
+
if (nullByteCount >= 4) return true;
|
|
9211
|
+
}
|
|
9212
|
+
return false;
|
|
9213
|
+
};
|
|
9214
|
+
const isMinifiedSource = (sourceText) => {
|
|
9215
|
+
if (sourceText.length < 5e3) return false;
|
|
9216
|
+
const lineCount = (sourceText.match(/\n/g)?.length ?? 0) + 1;
|
|
9217
|
+
return sourceText.length / lineCount > 500;
|
|
9218
|
+
};
|
|
9219
|
+
const tokenizeFile = (filePath) => {
|
|
9220
|
+
let sourceStat;
|
|
9221
|
+
try {
|
|
9222
|
+
sourceStat = (0, node_fs.statSync)(filePath);
|
|
9223
|
+
} catch {
|
|
9224
|
+
return;
|
|
9225
|
+
}
|
|
9226
|
+
if (sourceStat.size > 2e6) return void 0;
|
|
9227
|
+
let sourceText;
|
|
9228
|
+
try {
|
|
9229
|
+
sourceText = (0, node_fs.readFileSync)(filePath, "utf-8");
|
|
9230
|
+
} catch {
|
|
9231
|
+
return;
|
|
9232
|
+
}
|
|
9233
|
+
if (sourceText.length === 0) return void 0;
|
|
9234
|
+
if (isBinaryFile(sourceText)) return void 0;
|
|
9235
|
+
if (isMinifiedSource(sourceText)) return void 0;
|
|
9236
|
+
let parseResult;
|
|
9237
|
+
try {
|
|
9238
|
+
parseResult = (0, oxc_parser.parseSync)(filePath, sourceText);
|
|
9239
|
+
} catch {
|
|
9240
|
+
return;
|
|
9241
|
+
}
|
|
9242
|
+
const sourceTokens = tokenizeAst(parseResult.program);
|
|
9243
|
+
if (sourceTokens.length === 0) return void 0;
|
|
9244
|
+
const lineStarts = computeLineStarts(sourceText);
|
|
9245
|
+
return {
|
|
9246
|
+
path: filePath,
|
|
9247
|
+
sourceTokens,
|
|
9248
|
+
lineStarts,
|
|
9249
|
+
lineCount: lineStarts.length
|
|
9250
|
+
};
|
|
9251
|
+
};
|
|
9252
|
+
const buildCloneInstance = (rawInstance, tokenLength, tokenizedFiles) => {
|
|
9253
|
+
const file = tokenizedFiles[rawInstance.fileIndex];
|
|
9254
|
+
const firstToken = file.sourceTokens[rawInstance.tokenOffsetWithinFile];
|
|
9255
|
+
const lastToken = file.sourceTokens[rawInstance.tokenOffsetWithinFile + tokenLength - 1];
|
|
9256
|
+
const startSpan = offsetToLineColumn(firstToken.start, file.lineStarts);
|
|
9257
|
+
const endSpan = offsetToLineColumn(lastToken.end, file.lineStarts);
|
|
9258
|
+
return {
|
|
9259
|
+
path: file.path,
|
|
9260
|
+
startLine: startSpan.line,
|
|
9261
|
+
endLine: endSpan.line,
|
|
9262
|
+
startColumn: startSpan.column,
|
|
9263
|
+
endColumn: endSpan.column
|
|
9264
|
+
};
|
|
9265
|
+
};
|
|
9266
|
+
const directoryOf = (filePath) => (0, node_path.dirname)(filePath);
|
|
9267
|
+
const filterRawBlocksToReportableDuplicates = (rawBlocks, tokenizedFiles, config) => {
|
|
9268
|
+
const duplicateBlocks = [];
|
|
9269
|
+
for (const rawBlock of rawBlocks) {
|
|
9270
|
+
const instances = rawBlock.instances.map((rawInstance) => buildCloneInstance(rawInstance, rawBlock.tokenLength, tokenizedFiles));
|
|
9271
|
+
let lineCount = 0;
|
|
9272
|
+
for (const instance of instances) {
|
|
9273
|
+
const instanceLineCount = instance.endLine - instance.startLine + 1;
|
|
9274
|
+
if (instanceLineCount > lineCount) lineCount = instanceLineCount;
|
|
9275
|
+
}
|
|
9276
|
+
if (lineCount < config.minLines) continue;
|
|
9277
|
+
if (instances.length < config.minOccurrences) continue;
|
|
9278
|
+
if (config.skipLocal) {
|
|
9279
|
+
if (new Set(instances.map((instance) => directoryOf(instance.path))).size < 2) continue;
|
|
9280
|
+
}
|
|
9281
|
+
const distinctFiles = new Set(instances.map((instance) => instance.path));
|
|
9282
|
+
const confidence = distinctFiles.size >= 2 ? "high" : "medium";
|
|
9283
|
+
duplicateBlocks.push({
|
|
9284
|
+
instances,
|
|
9285
|
+
tokenCount: rawBlock.tokenLength,
|
|
9286
|
+
lineCount,
|
|
9287
|
+
confidence,
|
|
9288
|
+
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)`
|
|
9289
|
+
});
|
|
9290
|
+
}
|
|
9291
|
+
const maximalBlocks = dropBlocksSubsumedByLongerSibling(duplicateBlocks);
|
|
9292
|
+
maximalBlocks.sort((firstClone, secondClone) => {
|
|
9293
|
+
if (firstClone.lineCount !== secondClone.lineCount) return secondClone.lineCount - firstClone.lineCount;
|
|
9294
|
+
return secondClone.tokenCount - firstClone.tokenCount;
|
|
9295
|
+
});
|
|
9296
|
+
return maximalBlocks;
|
|
9297
|
+
};
|
|
9298
|
+
/**
|
|
9299
|
+
* The suffix-array + LCP-interval scan emits one block per LCP interval, but
|
|
9300
|
+
* nested intervals routinely yield the same set of source spans at multiple
|
|
9301
|
+
* lengths (the same maximal repeat reported at L, L-1, L-2, …). Drop any
|
|
9302
|
+
* block whose every instance is spatially contained inside some other block's
|
|
9303
|
+
* matching instance — that other block is strictly more informative.
|
|
9304
|
+
*
|
|
9305
|
+
* O(N²) worst-case, but N here is post-filter blocks (typically <1000 even on
|
|
9306
|
+
* large monorepos), and the early-exit on instance-count mismatch keeps it
|
|
9307
|
+
* tight in practice.
|
|
9308
|
+
*/
|
|
9309
|
+
const dropBlocksSubsumedByLongerSibling = (blocks) => {
|
|
9310
|
+
const sorted = [...blocks].sort((firstBlock, secondBlock) => {
|
|
9311
|
+
if (firstBlock.tokenCount !== secondBlock.tokenCount) return secondBlock.tokenCount - firstBlock.tokenCount;
|
|
9312
|
+
return secondBlock.lineCount - firstBlock.lineCount;
|
|
9313
|
+
});
|
|
9314
|
+
const survivors = [];
|
|
9315
|
+
for (const candidate of sorted) {
|
|
9316
|
+
let subsumed = false;
|
|
9317
|
+
for (const survivor of survivors) {
|
|
9318
|
+
if (survivor.instances.length !== candidate.instances.length) continue;
|
|
9319
|
+
if (allInstancesContainedIn(candidate, survivor)) {
|
|
9320
|
+
subsumed = true;
|
|
9321
|
+
break;
|
|
9322
|
+
}
|
|
9323
|
+
}
|
|
9324
|
+
if (!subsumed) survivors.push(candidate);
|
|
9325
|
+
}
|
|
9326
|
+
return survivors;
|
|
9327
|
+
};
|
|
9328
|
+
const allInstancesContainedIn = (candidate, longer) => {
|
|
9329
|
+
for (const candidateInstance of candidate.instances) {
|
|
9330
|
+
let matched = false;
|
|
9331
|
+
for (const longerInstance of longer.instances) if (candidateInstance.path === longerInstance.path && isSpanContained(candidateInstance, longerInstance)) {
|
|
9332
|
+
matched = true;
|
|
9333
|
+
break;
|
|
9334
|
+
}
|
|
9335
|
+
if (!matched) return false;
|
|
9336
|
+
}
|
|
9337
|
+
return true;
|
|
9338
|
+
};
|
|
9339
|
+
const isSpanContained = (inner, outer) => {
|
|
9340
|
+
const innerStartsAfterOuter = inner.startLine > outer.startLine || inner.startLine === outer.startLine && inner.startColumn >= outer.startColumn;
|
|
9341
|
+
const innerEndsBeforeOuter = inner.endLine < outer.endLine || inner.endLine === outer.endLine && inner.endColumn <= outer.endColumn;
|
|
9342
|
+
return innerStartsAfterOuter && innerEndsBeforeOuter;
|
|
9343
|
+
};
|
|
9344
|
+
/**
|
|
9345
|
+
* Token-based duplicate block detector.
|
|
9346
|
+
*
|
|
9347
|
+
* Pipeline:
|
|
9348
|
+
* 1. Tokenize each file with the AST visitor in `token-visitor.ts`
|
|
9349
|
+
* 2. Hash + normalize tokens with the chosen detection mode
|
|
9350
|
+
* 3. Concatenate every file's hashed tokens with unique negative sentinels
|
|
9351
|
+
* 4. Build a suffix array (prefix doubling + radix sort) and LCP array
|
|
9352
|
+
* 5. Stack-based LCP-interval scan extracts maximal duplicate blocks
|
|
9353
|
+
* 6. Filter on min-tokens / min-lines / min-occurrences / skip-local
|
|
9354
|
+
* 7. Group clones into families; collapse N two-file families with matching
|
|
9355
|
+
* basenames into a `ShadowedDirectoryPair` finding
|
|
9356
|
+
*
|
|
9357
|
+
* Returns empty arrays when `config.enabled` is false.
|
|
9358
|
+
*/
|
|
9359
|
+
const detectDuplicateBlocks = (graph, config, rootDir) => {
|
|
9360
|
+
if (!config || !config.enabled) return {
|
|
9361
|
+
duplicateBlocks: [],
|
|
9362
|
+
duplicateBlockClusters: [],
|
|
9363
|
+
shadowedDirectoryPairs: []
|
|
9364
|
+
};
|
|
9365
|
+
const tokenizedFiles = [];
|
|
9366
|
+
for (const module of graph.modules) {
|
|
9367
|
+
if (module.isDeclarationFile) continue;
|
|
9368
|
+
if (module.isConfigFile) continue;
|
|
9369
|
+
const tokenizedFile = tokenizeFile(module.fileId.path);
|
|
9370
|
+
if (!tokenizedFile) continue;
|
|
9371
|
+
tokenizedFiles.push(tokenizedFile);
|
|
9372
|
+
}
|
|
9373
|
+
if (tokenizedFiles.length === 0) return {
|
|
9374
|
+
duplicateBlocks: [],
|
|
9375
|
+
duplicateBlockClusters: [],
|
|
9376
|
+
shadowedDirectoryPairs: []
|
|
9377
|
+
};
|
|
9378
|
+
const filesHashedTokens = tokenizedFiles.map((file) => normalizeAndHashTokens(file.sourceTokens, config.mode));
|
|
9379
|
+
const filesTokenCounts = filesHashedTokens.map((fileTokens) => fileTokens.length);
|
|
9380
|
+
if (!filesTokenCounts.some((count) => count >= config.minTokens)) return {
|
|
9381
|
+
duplicateBlocks: [],
|
|
9382
|
+
duplicateBlockClusters: [],
|
|
9383
|
+
shadowedDirectoryPairs: []
|
|
9384
|
+
};
|
|
9385
|
+
const concatenation = rankReduceAndConcatenate(filesHashedTokens);
|
|
9386
|
+
if (concatenation.tokenSequence.length === 0) return {
|
|
9387
|
+
duplicateBlocks: [],
|
|
9388
|
+
duplicateBlockClusters: [],
|
|
9389
|
+
shadowedDirectoryPairs: []
|
|
9390
|
+
};
|
|
9391
|
+
const suffixArray = buildSuffixArray(concatenation.tokenSequence);
|
|
9392
|
+
const duplicateBlocks = filterRawBlocksToReportableDuplicates(extractRawDuplicateBlocks(suffixArray, buildLcpArray(concatenation.tokenSequence, suffixArray), concatenation.fileOf, concatenation.fileOffsets, filesTokenCounts, config.minTokens), tokenizedFiles, config);
|
|
9393
|
+
const duplicateBlockClusters = groupDuplicateBlocksIntoClusters(duplicateBlocks);
|
|
9394
|
+
return {
|
|
9395
|
+
duplicateBlocks,
|
|
9396
|
+
duplicateBlockClusters,
|
|
9397
|
+
shadowedDirectoryPairs: detectShadowedDirectoryPairs(duplicateBlockClusters, rootDir)
|
|
9398
|
+
};
|
|
9399
|
+
};
|
|
9400
|
+
|
|
9401
|
+
//#endregion
|
|
9402
|
+
//#region src/report/re-export-cycles.ts
|
|
9403
|
+
/**
|
|
9404
|
+
* Reports cycles in the subgraph of `isReExportEdge` edges only. These are
|
|
9405
|
+
* a strict subset of `circularDependencies` but worth separating: every
|
|
9406
|
+
* general cycle can have a legitimate bidirectional-collaboration reason,
|
|
9407
|
+
* but a re-export cycle has none — it always tanks tree-shaking and risks
|
|
9408
|
+
* the "Cannot access X before initialization" TDZ runtime error.
|
|
9409
|
+
*/
|
|
9410
|
+
const detectReExportCycles = (graph) => {
|
|
9411
|
+
const adjacency = Array.from({ length: graph.modules.length }, () => []);
|
|
9412
|
+
const reExportTargetSets = Array.from({ length: graph.modules.length }, () => /* @__PURE__ */ new Set());
|
|
9413
|
+
for (const edge of graph.edges) {
|
|
9414
|
+
if (!edge.isReExportEdge) continue;
|
|
9415
|
+
if (edge.target >= graph.modules.length) continue;
|
|
9416
|
+
if (reExportTargetSets[edge.source].has(edge.target)) continue;
|
|
9417
|
+
reExportTargetSets[edge.source].add(edge.target);
|
|
9418
|
+
adjacency[edge.source].push(edge.target);
|
|
9419
|
+
}
|
|
9420
|
+
const sccComponents = computeStronglyConnectedComponents(adjacency);
|
|
9421
|
+
const findings = [];
|
|
9422
|
+
for (const component of sccComponents) {
|
|
9423
|
+
if (component.length === 1) {
|
|
9424
|
+
const onlyNode = component[0];
|
|
9425
|
+
if (!adjacency[onlyNode].includes(onlyNode)) continue;
|
|
9426
|
+
const filePath = graph.modules[onlyNode].fileId.path;
|
|
9427
|
+
findings.push({
|
|
9428
|
+
files: [filePath],
|
|
9429
|
+
kind: "self-loop",
|
|
9430
|
+
confidence: "high",
|
|
9431
|
+
reason: `${filePath} re-exports from itself — the barrel imports its own root, which breaks bundler tree-shaking and risks TDZ runtime errors`
|
|
9432
|
+
});
|
|
9433
|
+
continue;
|
|
9434
|
+
}
|
|
9435
|
+
const sortedFiles = component.map((moduleIndex) => graph.modules[moduleIndex].fileId.path).sort();
|
|
9436
|
+
findings.push({
|
|
9437
|
+
files: sortedFiles,
|
|
9438
|
+
kind: "multi-node",
|
|
9439
|
+
confidence: "high",
|
|
9440
|
+
reason: `${sortedFiles.length} modules form a re-export cycle — refactor consumers to import from the leaf module instead of the barrel`
|
|
9441
|
+
});
|
|
9442
|
+
}
|
|
9443
|
+
findings.sort((firstFinding, secondFinding) => firstFinding.files[0].localeCompare(secondFinding.files[0]));
|
|
9444
|
+
return findings;
|
|
9445
|
+
};
|
|
9446
|
+
/**
|
|
9447
|
+
* Iterative Tarjan's SCC. Singleton components are returned too so the
|
|
9448
|
+
* caller can distinguish a real self-loop from a node with no edges.
|
|
9449
|
+
*/
|
|
9450
|
+
const computeStronglyConnectedComponents = (adjacency) => {
|
|
9451
|
+
const nodeCount = adjacency.length;
|
|
9452
|
+
if (nodeCount === 0) return [];
|
|
9453
|
+
const indices = new Array(nodeCount).fill(-1);
|
|
9454
|
+
const lowLinks = new Array(nodeCount).fill(0);
|
|
9455
|
+
const onStack = new Array(nodeCount).fill(false);
|
|
9456
|
+
const tarjanStack = [];
|
|
9457
|
+
const components = [];
|
|
9458
|
+
let nextIndex = 0;
|
|
9459
|
+
for (let startNode = 0; startNode < nodeCount; startNode++) {
|
|
9460
|
+
if (indices[startNode] !== -1) continue;
|
|
9461
|
+
const dfsStack = [{
|
|
9462
|
+
node: startNode,
|
|
9463
|
+
successorPosition: 0
|
|
9464
|
+
}];
|
|
9465
|
+
indices[startNode] = nextIndex;
|
|
9466
|
+
lowLinks[startNode] = nextIndex;
|
|
9467
|
+
nextIndex++;
|
|
9468
|
+
onStack[startNode] = true;
|
|
9469
|
+
tarjanStack.push(startNode);
|
|
9470
|
+
while (dfsStack.length > 0) {
|
|
9471
|
+
const frame = dfsStack[dfsStack.length - 1];
|
|
9472
|
+
const successors = adjacency[frame.node];
|
|
9473
|
+
if (frame.successorPosition < successors.length) {
|
|
9474
|
+
const successorNode = successors[frame.successorPosition];
|
|
9475
|
+
frame.successorPosition++;
|
|
9476
|
+
if (indices[successorNode] === -1) {
|
|
9477
|
+
indices[successorNode] = nextIndex;
|
|
9478
|
+
lowLinks[successorNode] = nextIndex;
|
|
9479
|
+
nextIndex++;
|
|
9480
|
+
onStack[successorNode] = true;
|
|
9481
|
+
tarjanStack.push(successorNode);
|
|
9482
|
+
dfsStack.push({
|
|
9483
|
+
node: successorNode,
|
|
9484
|
+
successorPosition: 0
|
|
9485
|
+
});
|
|
9486
|
+
} else if (onStack[successorNode]) {
|
|
9487
|
+
if (indices[successorNode] < lowLinks[frame.node]) lowLinks[frame.node] = indices[successorNode];
|
|
9488
|
+
}
|
|
9489
|
+
} else {
|
|
9490
|
+
if (lowLinks[frame.node] === indices[frame.node]) {
|
|
9491
|
+
const component = [];
|
|
9492
|
+
let popped;
|
|
9493
|
+
do {
|
|
9494
|
+
popped = tarjanStack.pop();
|
|
9495
|
+
onStack[popped] = false;
|
|
9496
|
+
component.push(popped);
|
|
9497
|
+
} while (popped !== frame.node);
|
|
9498
|
+
components.push(component);
|
|
9499
|
+
}
|
|
9500
|
+
dfsStack.pop();
|
|
9501
|
+
if (dfsStack.length > 0) {
|
|
9502
|
+
const parent = dfsStack[dfsStack.length - 1];
|
|
9503
|
+
if (lowLinks[frame.node] < lowLinks[parent.node]) lowLinks[parent.node] = lowLinks[frame.node];
|
|
9504
|
+
}
|
|
9505
|
+
}
|
|
9506
|
+
}
|
|
9507
|
+
}
|
|
9508
|
+
return components;
|
|
9509
|
+
};
|
|
9510
|
+
|
|
9511
|
+
//#endregion
|
|
9512
|
+
//#region src/report/feature-flags.ts
|
|
9513
|
+
const BUILTIN_SDK_PATTERNS = [
|
|
9514
|
+
{
|
|
9515
|
+
functionName: "useFlag",
|
|
9516
|
+
nameArgIndex: 0,
|
|
9517
|
+
provider: "LaunchDarkly"
|
|
9518
|
+
},
|
|
9519
|
+
{
|
|
9520
|
+
functionName: "useLDFlag",
|
|
9521
|
+
nameArgIndex: 0,
|
|
9522
|
+
provider: "LaunchDarkly"
|
|
9523
|
+
},
|
|
9524
|
+
{
|
|
9525
|
+
functionName: "useFeatureFlag",
|
|
9526
|
+
nameArgIndex: 0,
|
|
9527
|
+
provider: "LaunchDarkly"
|
|
9528
|
+
},
|
|
9529
|
+
{
|
|
9530
|
+
functionName: "variation",
|
|
9531
|
+
nameArgIndex: 0,
|
|
9532
|
+
provider: "LaunchDarkly"
|
|
9533
|
+
},
|
|
9534
|
+
{
|
|
9535
|
+
functionName: "boolVariation",
|
|
9536
|
+
nameArgIndex: 0,
|
|
9537
|
+
provider: "LaunchDarkly"
|
|
9538
|
+
},
|
|
9539
|
+
{
|
|
9540
|
+
functionName: "stringVariation",
|
|
9541
|
+
nameArgIndex: 0,
|
|
9542
|
+
provider: "LaunchDarkly"
|
|
9543
|
+
},
|
|
9544
|
+
{
|
|
9545
|
+
functionName: "numberVariation",
|
|
9546
|
+
nameArgIndex: 0,
|
|
9547
|
+
provider: "LaunchDarkly"
|
|
9548
|
+
},
|
|
9549
|
+
{
|
|
9550
|
+
functionName: "jsonVariation",
|
|
9551
|
+
nameArgIndex: 0,
|
|
9552
|
+
provider: "LaunchDarkly"
|
|
9553
|
+
},
|
|
9554
|
+
{
|
|
9555
|
+
functionName: "useGate",
|
|
9556
|
+
nameArgIndex: 0,
|
|
9557
|
+
provider: "Statsig"
|
|
9558
|
+
},
|
|
9559
|
+
{
|
|
9560
|
+
functionName: "checkGate",
|
|
9561
|
+
nameArgIndex: 0,
|
|
9562
|
+
provider: "Statsig"
|
|
9563
|
+
},
|
|
9564
|
+
{
|
|
9565
|
+
functionName: "useExperiment",
|
|
9566
|
+
nameArgIndex: 0,
|
|
9567
|
+
provider: "Statsig"
|
|
9568
|
+
},
|
|
9569
|
+
{
|
|
9570
|
+
functionName: "useConfig",
|
|
9571
|
+
nameArgIndex: 0,
|
|
9572
|
+
provider: "Statsig"
|
|
9573
|
+
},
|
|
9574
|
+
{
|
|
9575
|
+
functionName: "isEnabled",
|
|
9576
|
+
nameArgIndex: 0,
|
|
9577
|
+
provider: "Unleash"
|
|
9578
|
+
},
|
|
9579
|
+
{
|
|
9580
|
+
functionName: "getVariant",
|
|
9581
|
+
nameArgIndex: 0,
|
|
9582
|
+
provider: "Unleash"
|
|
9583
|
+
},
|
|
9584
|
+
{
|
|
9585
|
+
functionName: "isOn",
|
|
9586
|
+
nameArgIndex: 0,
|
|
9587
|
+
provider: "GrowthBook"
|
|
9588
|
+
},
|
|
9589
|
+
{
|
|
9590
|
+
functionName: "isOff",
|
|
9591
|
+
nameArgIndex: 0,
|
|
9592
|
+
provider: "GrowthBook"
|
|
9593
|
+
},
|
|
9594
|
+
{
|
|
9595
|
+
functionName: "getFeatureValue",
|
|
9596
|
+
nameArgIndex: 0,
|
|
9597
|
+
provider: "GrowthBook"
|
|
9598
|
+
},
|
|
9599
|
+
{
|
|
9600
|
+
functionName: "getTreatment",
|
|
9601
|
+
nameArgIndex: 0,
|
|
9602
|
+
provider: "Split"
|
|
9603
|
+
},
|
|
9604
|
+
{
|
|
9605
|
+
functionName: "useFeatureFlagEnabled",
|
|
9606
|
+
nameArgIndex: 0,
|
|
9607
|
+
provider: "PostHog"
|
|
9608
|
+
},
|
|
9609
|
+
{
|
|
9610
|
+
functionName: "useFeatureFlagPayload",
|
|
9611
|
+
nameArgIndex: 0,
|
|
9612
|
+
provider: "PostHog"
|
|
9613
|
+
},
|
|
9614
|
+
{
|
|
9615
|
+
functionName: "useFeatureFlagVariantKey",
|
|
9616
|
+
nameArgIndex: 0,
|
|
9617
|
+
provider: "PostHog"
|
|
9618
|
+
},
|
|
9619
|
+
{
|
|
9620
|
+
functionName: "getFeatureFlagPayload",
|
|
9621
|
+
nameArgIndex: 0,
|
|
9622
|
+
provider: "PostHog"
|
|
9623
|
+
},
|
|
9624
|
+
{
|
|
9625
|
+
functionName: "getValueAsync",
|
|
9626
|
+
nameArgIndex: 0,
|
|
9627
|
+
provider: "ConfigCat"
|
|
9628
|
+
},
|
|
9629
|
+
{
|
|
9630
|
+
functionName: "getValueDetailsAsync",
|
|
9631
|
+
nameArgIndex: 0,
|
|
9632
|
+
provider: "ConfigCat"
|
|
9633
|
+
},
|
|
9634
|
+
{
|
|
9635
|
+
functionName: "hasFeature",
|
|
9636
|
+
nameArgIndex: 0,
|
|
9637
|
+
provider: "Flagsmith"
|
|
9638
|
+
},
|
|
9639
|
+
{
|
|
9640
|
+
functionName: "useDecision",
|
|
9641
|
+
nameArgIndex: 0,
|
|
9642
|
+
provider: "Optimizely"
|
|
9643
|
+
},
|
|
9644
|
+
{
|
|
9645
|
+
functionName: "getFeatureVariable",
|
|
9646
|
+
nameArgIndex: 0,
|
|
9647
|
+
provider: "Optimizely"
|
|
9648
|
+
},
|
|
9649
|
+
{
|
|
9650
|
+
functionName: "getFeatureVariableBoolean",
|
|
9651
|
+
nameArgIndex: 0,
|
|
9652
|
+
provider: "Optimizely"
|
|
9653
|
+
},
|
|
9654
|
+
{
|
|
9655
|
+
functionName: "getFeatureVariableString",
|
|
9656
|
+
nameArgIndex: 0,
|
|
9657
|
+
provider: "Optimizely"
|
|
9658
|
+
},
|
|
9659
|
+
{
|
|
9660
|
+
functionName: "getFeatureVariableInteger",
|
|
9661
|
+
nameArgIndex: 0,
|
|
9662
|
+
provider: "Optimizely"
|
|
9663
|
+
},
|
|
9664
|
+
{
|
|
9665
|
+
functionName: "getFeatureVariableDouble",
|
|
9666
|
+
nameArgIndex: 0,
|
|
9667
|
+
provider: "Optimizely"
|
|
9668
|
+
},
|
|
9669
|
+
{
|
|
9670
|
+
functionName: "getFeatureVariableJson",
|
|
9671
|
+
nameArgIndex: 0,
|
|
9672
|
+
provider: "Optimizely"
|
|
9673
|
+
},
|
|
9674
|
+
{
|
|
9675
|
+
functionName: "getFeatureVariableJSON",
|
|
9676
|
+
nameArgIndex: 0,
|
|
9677
|
+
provider: "Optimizely"
|
|
9678
|
+
},
|
|
9679
|
+
{
|
|
9680
|
+
functionName: "getStringAssignment",
|
|
9681
|
+
nameArgIndex: 0,
|
|
9682
|
+
provider: "Eppo"
|
|
9683
|
+
},
|
|
9684
|
+
{
|
|
9685
|
+
functionName: "getBooleanAssignment",
|
|
9686
|
+
nameArgIndex: 0,
|
|
9687
|
+
provider: "Eppo"
|
|
9688
|
+
},
|
|
9689
|
+
{
|
|
9690
|
+
functionName: "getNumericAssignment",
|
|
9691
|
+
nameArgIndex: 0,
|
|
9692
|
+
provider: "Eppo"
|
|
9693
|
+
},
|
|
9694
|
+
{
|
|
9695
|
+
functionName: "getIntegerAssignment",
|
|
9696
|
+
nameArgIndex: 0,
|
|
9697
|
+
provider: "Eppo"
|
|
9698
|
+
},
|
|
9699
|
+
{
|
|
9700
|
+
functionName: "getJSONAssignment",
|
|
9701
|
+
nameArgIndex: 0,
|
|
9702
|
+
provider: "Eppo"
|
|
9703
|
+
}
|
|
9704
|
+
];
|
|
9705
|
+
const VERCEL_FLAGS_FUNCTION_NAMES = new Set(["flag", "evaluate"]);
|
|
9706
|
+
const BUILTIN_ENV_PREFIXES = [
|
|
9707
|
+
"FEATURE_",
|
|
9708
|
+
"NEXT_PUBLIC_FEATURE_",
|
|
9709
|
+
"NEXT_PUBLIC_ENABLE_",
|
|
9710
|
+
"REACT_APP_FEATURE_",
|
|
9711
|
+
"REACT_APP_ENABLE_",
|
|
9712
|
+
"VITE_FEATURE_",
|
|
9713
|
+
"VITE_ENABLE_",
|
|
9714
|
+
"NUXT_PUBLIC_FEATURE_",
|
|
9715
|
+
"ENABLE_",
|
|
9716
|
+
"FF_",
|
|
9717
|
+
"FLAG_",
|
|
9718
|
+
"TOGGLE_"
|
|
9719
|
+
];
|
|
9720
|
+
const CONFIG_OBJECT_KEYWORDS = new Set([
|
|
9721
|
+
"feature",
|
|
9722
|
+
"features",
|
|
9723
|
+
"featureflags",
|
|
9724
|
+
"featureflag",
|
|
9725
|
+
"flag",
|
|
9726
|
+
"flags",
|
|
9727
|
+
"toggle",
|
|
9728
|
+
"toggles"
|
|
9729
|
+
]);
|
|
9730
|
+
const getStaticName = (node) => {
|
|
9731
|
+
if (!isAstNode(node)) return void 0;
|
|
9732
|
+
if (node.type === "Identifier" || node.type === "PrivateIdentifier") {
|
|
9733
|
+
const identifierName = node.name;
|
|
9734
|
+
return typeof identifierName === "string" ? identifierName : void 0;
|
|
9735
|
+
}
|
|
9736
|
+
if (node.type === "Literal") {
|
|
9737
|
+
const literalValue = node.value;
|
|
9738
|
+
return typeof literalValue === "string" ? literalValue : void 0;
|
|
9739
|
+
}
|
|
9740
|
+
};
|
|
9741
|
+
const extractStringArgument = (callArguments, argumentIndex) => {
|
|
9742
|
+
if (!Array.isArray(callArguments)) return void 0;
|
|
9743
|
+
const argumentNode = callArguments[argumentIndex];
|
|
9744
|
+
if (!isAstNode(argumentNode)) return void 0;
|
|
9745
|
+
if (argumentNode.type === "Literal") {
|
|
9746
|
+
const literalValue = argumentNode.value;
|
|
9747
|
+
return typeof literalValue === "string" ? literalValue : void 0;
|
|
9748
|
+
}
|
|
9749
|
+
if (argumentNode.type === "ObjectExpression") {
|
|
9750
|
+
const properties = argumentNode.properties;
|
|
9751
|
+
if (!Array.isArray(properties)) return void 0;
|
|
9752
|
+
for (const property of properties) {
|
|
9753
|
+
if (!isAstNode(property)) continue;
|
|
9754
|
+
if (property.type !== "Property") continue;
|
|
9755
|
+
const propertyKey = getStaticName(property.key);
|
|
9756
|
+
if (propertyKey !== "key" && propertyKey !== "name") continue;
|
|
9757
|
+
const propertyValueName = getStaticName(property.value);
|
|
9758
|
+
if (propertyValueName !== void 0) return propertyValueName;
|
|
9759
|
+
}
|
|
9760
|
+
}
|
|
9761
|
+
};
|
|
9762
|
+
const extractProcessEnvName = (memberExpression) => {
|
|
9763
|
+
if (!isAstNode(memberExpression)) return void 0;
|
|
9764
|
+
if (memberExpression.type !== "MemberExpression" && memberExpression.type !== "StaticMemberExpression") return;
|
|
9765
|
+
const propertyName = getStaticName(memberExpression.property);
|
|
9766
|
+
if (propertyName === void 0) return void 0;
|
|
9767
|
+
const objectNode = memberExpression.object;
|
|
9768
|
+
if (!isAstNode(objectNode)) return void 0;
|
|
9769
|
+
if (objectNode.type !== "MemberExpression" && objectNode.type !== "StaticMemberExpression") return;
|
|
9770
|
+
const innerObjectName = getStaticName(objectNode.object);
|
|
9771
|
+
const innerPropertyName = getStaticName(objectNode.property);
|
|
9772
|
+
if (innerObjectName === "process" && innerPropertyName === "env") return propertyName;
|
|
9773
|
+
};
|
|
9774
|
+
const isFlagEnvName = (envName, extraEnvPrefixes) => {
|
|
9775
|
+
for (const prefix of BUILTIN_ENV_PREFIXES) if (envName.startsWith(prefix)) return true;
|
|
9776
|
+
for (const prefix of extraEnvPrefixes) if (envName.startsWith(prefix)) return true;
|
|
9777
|
+
return false;
|
|
9778
|
+
};
|
|
9779
|
+
const collectVercelFlagsImports = (programNode) => {
|
|
9780
|
+
const localNames = /* @__PURE__ */ new Set();
|
|
9781
|
+
if (!isAstNode(programNode)) return localNames;
|
|
9782
|
+
const body = programNode.body;
|
|
9783
|
+
if (!Array.isArray(body)) return localNames;
|
|
9784
|
+
for (const statement of body) {
|
|
9785
|
+
if (!isAstNode(statement)) continue;
|
|
9786
|
+
if (statement.type !== "ImportDeclaration") continue;
|
|
9787
|
+
const sourceLiteral = statement.source;
|
|
9788
|
+
const sourceValue = isAstNode(sourceLiteral) ? sourceLiteral.value : void 0;
|
|
9789
|
+
if (typeof sourceValue !== "string") continue;
|
|
9790
|
+
if (!(sourceValue === "flags" || sourceValue.startsWith("flags/") || sourceValue === "@vercel/flags" || sourceValue.startsWith("@vercel/flags/"))) continue;
|
|
9791
|
+
const specifiers = statement.specifiers;
|
|
9792
|
+
if (!Array.isArray(specifiers)) continue;
|
|
9793
|
+
for (const specifier of specifiers) {
|
|
9794
|
+
if (!isAstNode(specifier)) continue;
|
|
9795
|
+
if (specifier.type === "ImportSpecifier") {
|
|
9796
|
+
const imported = specifier.imported;
|
|
9797
|
+
const local = specifier.local;
|
|
9798
|
+
const importedName = getStaticName(imported);
|
|
9799
|
+
const localName = getStaticName(local);
|
|
9800
|
+
if (importedName && VERCEL_FLAGS_FUNCTION_NAMES.has(importedName) && localName) localNames.add(localName);
|
|
9801
|
+
}
|
|
9802
|
+
}
|
|
9803
|
+
}
|
|
9804
|
+
return localNames;
|
|
9805
|
+
};
|
|
9806
|
+
const visitChildrenWithGuard = (node, visitor) => {
|
|
9807
|
+
if (!isAstNode(node)) return;
|
|
9808
|
+
for (const key of Object.keys(node)) {
|
|
9809
|
+
if (key === "type" || key === "start" || key === "end" || key === "loc" || key === "range") continue;
|
|
9810
|
+
const value = node[key];
|
|
9811
|
+
if (Array.isArray(value)) for (const item of value) visitor(item);
|
|
9812
|
+
else if (value !== null && typeof value === "object") visitor(value);
|
|
9813
|
+
}
|
|
9814
|
+
};
|
|
9815
|
+
const recordFlag = (context, flagName, kind, byteOffset, sdkProvider) => {
|
|
9816
|
+
const { line, column } = offsetToLineColumn(byteOffset, context.lineStarts);
|
|
9817
|
+
context.results.push({
|
|
9818
|
+
path: context.filePath,
|
|
9819
|
+
name: flagName,
|
|
9820
|
+
kind,
|
|
9821
|
+
line,
|
|
9822
|
+
column,
|
|
9823
|
+
sdkProvider,
|
|
9824
|
+
guardLineStart: context.guard?.startLine,
|
|
9825
|
+
guardLineEnd: context.guard?.endLine,
|
|
9826
|
+
guardsDeadCode: false
|
|
9827
|
+
});
|
|
9828
|
+
};
|
|
9829
|
+
const visitNode$1 = (node, context) => {
|
|
9830
|
+
if (!isAstNode(node)) return;
|
|
9831
|
+
if (node.type === "IfStatement") {
|
|
9832
|
+
const start = node.start;
|
|
9833
|
+
const end = node.end;
|
|
9834
|
+
const guard = typeof start === "number" && typeof end === "number" ? {
|
|
9835
|
+
startLine: offsetToLineColumn(start, context.lineStarts).line,
|
|
9836
|
+
endLine: offsetToLineColumn(end, context.lineStarts).line
|
|
9837
|
+
} : void 0;
|
|
9838
|
+
const previousGuard = context.guard;
|
|
9839
|
+
context.guard = guard;
|
|
9840
|
+
visitNode$1(node.test, context);
|
|
9841
|
+
context.guard = previousGuard;
|
|
9842
|
+
visitNode$1(node.consequent, context);
|
|
9843
|
+
visitNode$1(node.alternate, context);
|
|
9844
|
+
return;
|
|
9845
|
+
}
|
|
9846
|
+
if (node.type === "ConditionalExpression") {
|
|
9847
|
+
const start = node.start;
|
|
9848
|
+
const end = node.end;
|
|
9849
|
+
const guard = typeof start === "number" && typeof end === "number" ? {
|
|
9850
|
+
startLine: offsetToLineColumn(start, context.lineStarts).line,
|
|
9851
|
+
endLine: offsetToLineColumn(end, context.lineStarts).line
|
|
9852
|
+
} : void 0;
|
|
9853
|
+
const previousGuard = context.guard;
|
|
9854
|
+
context.guard = guard;
|
|
9855
|
+
visitNode$1(node.test, context);
|
|
9856
|
+
context.guard = previousGuard;
|
|
9857
|
+
visitNode$1(node.consequent, context);
|
|
9858
|
+
visitNode$1(node.alternate, context);
|
|
9859
|
+
return;
|
|
9860
|
+
}
|
|
9861
|
+
visitFlagPatternsInExpression(node, context);
|
|
9862
|
+
visitChildrenWithGuard(node, (child) => visitNode$1(child, context));
|
|
9863
|
+
};
|
|
9864
|
+
const visitFlagPatternsInExpression = (node, context) => {
|
|
9865
|
+
if (!isAstNode(node)) return;
|
|
9866
|
+
if (node.type === "MemberExpression" || node.type === "StaticMemberExpression") {
|
|
9867
|
+
const envName = extractProcessEnvName(node);
|
|
9868
|
+
if (envName !== void 0 && isFlagEnvName(envName, context.envPrefixes)) {
|
|
9869
|
+
const start = node.start;
|
|
9870
|
+
if (typeof start === "number") recordFlag(context, envName, "env-var", start, void 0);
|
|
9871
|
+
} else if (context.detectConfigObjects) {
|
|
9872
|
+
const objectName = getStaticName(node.object);
|
|
9873
|
+
const propertyName = getStaticName(node.property);
|
|
9874
|
+
if (objectName && propertyName) {
|
|
9875
|
+
if (CONFIG_OBJECT_KEYWORDS.has(objectName.toLowerCase()) || CONFIG_OBJECT_KEYWORDS.has(propertyName.toLowerCase())) {
|
|
9876
|
+
const start = node.start;
|
|
9877
|
+
if (typeof start === "number") recordFlag(context, `${objectName}.${propertyName}`, "config-object", start, void 0);
|
|
9878
|
+
}
|
|
9879
|
+
}
|
|
9880
|
+
}
|
|
9881
|
+
}
|
|
9882
|
+
if (node.type === "CallExpression") {
|
|
9883
|
+
const callee = node.callee;
|
|
9884
|
+
let functionName;
|
|
9885
|
+
if (isAstNode(callee)) {
|
|
9886
|
+
if (callee.type === "Identifier") functionName = getStaticName(callee);
|
|
9887
|
+
else if (callee.type === "MemberExpression" || callee.type === "StaticMemberExpression") functionName = getStaticName(callee.property);
|
|
9888
|
+
}
|
|
9889
|
+
if (functionName !== void 0) {
|
|
9890
|
+
if (context.vercelFlagsLocalNames.has(functionName) || VERCEL_FLAGS_FUNCTION_NAMES.has(functionName)) {
|
|
9891
|
+
const callArguments = node.arguments;
|
|
9892
|
+
const flagName = extractStringArgument(callArguments, 0);
|
|
9893
|
+
if (flagName !== void 0) {
|
|
9894
|
+
const start = node.start;
|
|
9895
|
+
if (typeof start === "number") recordFlag(context, flagName, "sdk-call", start, "Vercel Flags");
|
|
9896
|
+
}
|
|
9897
|
+
return;
|
|
9898
|
+
}
|
|
9899
|
+
for (const sdkPattern of context.sdkPatterns) {
|
|
9900
|
+
if (sdkPattern.functionName !== functionName) continue;
|
|
9901
|
+
const callArguments = node.arguments;
|
|
9902
|
+
const flagName = extractStringArgument(callArguments, sdkPattern.nameArgIndex);
|
|
9903
|
+
if (flagName === void 0) continue;
|
|
9904
|
+
const start = node.start;
|
|
9905
|
+
if (typeof start === "number") recordFlag(context, flagName, "sdk-call", start, sdkPattern.provider === "" ? void 0 : sdkPattern.provider);
|
|
9906
|
+
break;
|
|
9907
|
+
}
|
|
9908
|
+
}
|
|
9909
|
+
}
|
|
9910
|
+
};
|
|
9911
|
+
const buildSdkPatterns = (extraSdkFunctionNames) => {
|
|
9912
|
+
const merged = [...BUILTIN_SDK_PATTERNS];
|
|
9913
|
+
for (const extraName of extraSdkFunctionNames) merged.push({
|
|
9914
|
+
functionName: extraName,
|
|
9915
|
+
nameArgIndex: 0,
|
|
9916
|
+
provider: ""
|
|
9917
|
+
});
|
|
9918
|
+
return merged;
|
|
9919
|
+
};
|
|
9920
|
+
const detectFeatureFlags = (graph, config) => {
|
|
9921
|
+
if (!config?.enabled) return [];
|
|
9922
|
+
const sdkPatterns = buildSdkPatterns(config.extraSdkFunctionNames);
|
|
9923
|
+
const collectedFlags = [];
|
|
9924
|
+
for (const module of graph.modules) {
|
|
9925
|
+
if (module.isDeclarationFile) continue;
|
|
9926
|
+
if (module.isConfigFile) continue;
|
|
9927
|
+
let sourceText;
|
|
9928
|
+
try {
|
|
9929
|
+
sourceText = (0, node_fs.readFileSync)(module.fileId.path, "utf-8");
|
|
9930
|
+
} catch {
|
|
9931
|
+
continue;
|
|
9932
|
+
}
|
|
9933
|
+
let parseResult;
|
|
9934
|
+
try {
|
|
9935
|
+
parseResult = (0, oxc_parser.parseSync)(module.fileId.path, sourceText);
|
|
9936
|
+
} catch {
|
|
9937
|
+
continue;
|
|
9938
|
+
}
|
|
9939
|
+
const lineStarts = computeLineStarts(sourceText);
|
|
9940
|
+
const vercelFlagsLocalNames = collectVercelFlagsImports(parseResult.program);
|
|
9941
|
+
const visitContext = {
|
|
9942
|
+
filePath: module.fileId.path,
|
|
9943
|
+
lineStarts,
|
|
9944
|
+
results: [],
|
|
9945
|
+
envPrefixes: config.extraEnvPrefixes,
|
|
9946
|
+
sdkPatterns,
|
|
9947
|
+
detectConfigObjects: config.detectConfigObjects,
|
|
9948
|
+
vercelFlagsLocalNames,
|
|
9949
|
+
guard: void 0
|
|
9950
|
+
};
|
|
9951
|
+
visitNode$1(parseResult.program, visitContext);
|
|
9952
|
+
collectedFlags.push(...visitContext.results);
|
|
9953
|
+
}
|
|
9954
|
+
collectedFlags.sort((leftFlag, rightFlag) => {
|
|
9955
|
+
if (leftFlag.path !== rightFlag.path) return leftFlag.path.localeCompare(rightFlag.path);
|
|
9956
|
+
if (leftFlag.line !== rightFlag.line) return leftFlag.line - rightFlag.line;
|
|
9957
|
+
return leftFlag.column - rightFlag.column;
|
|
9958
|
+
});
|
|
9959
|
+
return collectedFlags;
|
|
9960
|
+
};
|
|
9961
|
+
/**
|
|
9962
|
+
* Mark each flag whose guard span overlaps an unused export as
|
|
9963
|
+
* `guardsDeadCode: true`.
|
|
9964
|
+
*/
|
|
9965
|
+
const correlateFlagsWithDeadCode = (flags, scanResult) => {
|
|
9966
|
+
if (flags.length === 0 || scanResult.unusedExports.length === 0) return;
|
|
9967
|
+
const unusedByFile = /* @__PURE__ */ new Map();
|
|
9968
|
+
for (const unusedExport of scanResult.unusedExports) {
|
|
9969
|
+
const existing = unusedByFile.get(unusedExport.path);
|
|
9970
|
+
if (existing) existing.push(unusedExport.line);
|
|
9971
|
+
else unusedByFile.set(unusedExport.path, [unusedExport.line]);
|
|
9972
|
+
}
|
|
9973
|
+
for (const flag of flags) {
|
|
9974
|
+
if (flag.guardLineStart === void 0 || flag.guardLineEnd === void 0) continue;
|
|
9975
|
+
const linesInFile = unusedByFile.get(flag.path);
|
|
9976
|
+
if (!linesInFile) continue;
|
|
9977
|
+
const guardStart = flag.guardLineStart;
|
|
9978
|
+
const guardEnd = flag.guardLineEnd;
|
|
9979
|
+
for (const unusedLine of linesInFile) if (unusedLine >= guardStart && unusedLine <= guardEnd) {
|
|
9980
|
+
flag.guardsDeadCode = true;
|
|
9981
|
+
break;
|
|
9982
|
+
}
|
|
9983
|
+
}
|
|
9984
|
+
};
|
|
9985
|
+
|
|
9986
|
+
//#endregion
|
|
9987
|
+
//#region src/report/complexity.ts
|
|
9988
|
+
const incrementCyclomatic = (state) => {
|
|
9989
|
+
const topFrame = state.frameStack[state.frameStack.length - 1];
|
|
9990
|
+
if (topFrame) topFrame.cyclomaticComplexity++;
|
|
9991
|
+
};
|
|
9992
|
+
const incrementCognitiveWithNesting = (state) => {
|
|
9993
|
+
const topFrame = state.frameStack[state.frameStack.length - 1];
|
|
9994
|
+
if (topFrame) topFrame.cognitiveComplexity += 1 + topFrame.nestingLevel;
|
|
9995
|
+
};
|
|
9996
|
+
const incrementCognitiveFlat = (state) => {
|
|
9997
|
+
const topFrame = state.frameStack[state.frameStack.length - 1];
|
|
9998
|
+
if (topFrame) topFrame.cognitiveComplexity++;
|
|
9999
|
+
};
|
|
10000
|
+
const handleLogicalOperator = (operator, state) => {
|
|
10001
|
+
const topFrame = state.frameStack[state.frameStack.length - 1];
|
|
10002
|
+
if (!topFrame) return;
|
|
10003
|
+
if (topFrame.lastLogicalOperator === void 0) {
|
|
10004
|
+
topFrame.cognitiveComplexity++;
|
|
10005
|
+
topFrame.lastLogicalOperator = operator;
|
|
10006
|
+
return;
|
|
10007
|
+
}
|
|
10008
|
+
if (topFrame.lastLogicalOperator === operator) return;
|
|
10009
|
+
topFrame.cognitiveComplexity++;
|
|
10010
|
+
topFrame.lastLogicalOperator = operator;
|
|
10011
|
+
};
|
|
10012
|
+
const resetLogicalOperator = (state) => {
|
|
10013
|
+
const topFrame = state.frameStack[state.frameStack.length - 1];
|
|
10014
|
+
if (topFrame) topFrame.lastLogicalOperator = void 0;
|
|
10015
|
+
};
|
|
10016
|
+
const incrementNesting = (state) => {
|
|
10017
|
+
const topFrame = state.frameStack[state.frameStack.length - 1];
|
|
10018
|
+
if (topFrame) topFrame.nestingLevel++;
|
|
10019
|
+
};
|
|
10020
|
+
const decrementNesting = (state) => {
|
|
10021
|
+
const topFrame = state.frameStack[state.frameStack.length - 1];
|
|
10022
|
+
if (topFrame && topFrame.nestingLevel > 0) topFrame.nestingLevel--;
|
|
10023
|
+
};
|
|
10024
|
+
const countParameters = (parametersNode) => {
|
|
10025
|
+
if (!isAstNode(parametersNode)) return 0;
|
|
10026
|
+
const params = parametersNode;
|
|
10027
|
+
if (Array.isArray(params.params)) return params.params.length;
|
|
10028
|
+
if (Array.isArray(params.items)) return params.items.length;
|
|
10029
|
+
return 0;
|
|
10030
|
+
};
|
|
10031
|
+
const visitChildrenGeneric = (node, visitor) => {
|
|
10032
|
+
if (!isAstNode(node)) return;
|
|
10033
|
+
for (const key of Object.keys(node)) {
|
|
10034
|
+
if (key === "type" || key === "start" || key === "end" || key === "loc" || key === "range") continue;
|
|
10035
|
+
const value = node[key];
|
|
10036
|
+
if (Array.isArray(value)) for (const item of value) visitor(item);
|
|
10037
|
+
else if (value !== null && typeof value === "object") visitor(value);
|
|
10038
|
+
}
|
|
10039
|
+
};
|
|
10040
|
+
const pushFunctionFrame = (functionName, startOffset, endOffset, parameterCount, state) => {
|
|
10041
|
+
state.frameStack.push({
|
|
10042
|
+
functionName,
|
|
10043
|
+
startOffset,
|
|
10044
|
+
endOffset,
|
|
10045
|
+
cyclomaticComplexity: 1,
|
|
10046
|
+
cognitiveComplexity: 0,
|
|
10047
|
+
nestingLevel: 0,
|
|
10048
|
+
lastLogicalOperator: void 0,
|
|
10049
|
+
parameterCount
|
|
10050
|
+
});
|
|
10051
|
+
};
|
|
10052
|
+
const popFunctionFrame = (state) => {
|
|
10053
|
+
const completedFrame = state.frameStack.pop();
|
|
10054
|
+
if (!completedFrame) return;
|
|
10055
|
+
const { line, column } = offsetToLineColumn(completedFrame.startOffset, state.lineStarts);
|
|
10056
|
+
const endLine = offsetToLineColumn(completedFrame.endOffset, state.lineStarts).line;
|
|
10057
|
+
state.results.push({
|
|
10058
|
+
path: state.filePath,
|
|
10059
|
+
functionName: completedFrame.functionName,
|
|
10060
|
+
line,
|
|
10061
|
+
column,
|
|
10062
|
+
cyclomatic: completedFrame.cyclomaticComplexity,
|
|
10063
|
+
cognitive: completedFrame.cognitiveComplexity,
|
|
10064
|
+
lineCount: Math.max(1, endLine - line + 1),
|
|
10065
|
+
paramCount: completedFrame.parameterCount,
|
|
10066
|
+
confidence: "medium",
|
|
10067
|
+
reason: ""
|
|
10068
|
+
});
|
|
10069
|
+
};
|
|
10070
|
+
const visitFunctionLike = (node, kind, state) => {
|
|
10071
|
+
if (!isAstNode(node)) return;
|
|
10072
|
+
const functionName = state.pendingFunctionName ?? (() => {
|
|
10073
|
+
const idNode = node.id;
|
|
10074
|
+
const idName = isAstNode(idNode) ? idNode.name : void 0;
|
|
10075
|
+
return typeof idName === "string" ? idName : kind === "arrow" ? "<arrow>" : "<anonymous>";
|
|
10076
|
+
})();
|
|
10077
|
+
state.pendingFunctionName = void 0;
|
|
10078
|
+
const isNested = state.frameStack.length > 0;
|
|
10079
|
+
if (isNested) incrementNesting(state);
|
|
10080
|
+
const startOffset = node.start;
|
|
10081
|
+
const endOffset = node.end;
|
|
10082
|
+
const parameterCount = countParameters(node.params);
|
|
10083
|
+
pushFunctionFrame(functionName, typeof startOffset === "number" ? startOffset : 0, typeof endOffset === "number" ? endOffset : 0, parameterCount, state);
|
|
10084
|
+
visitChildrenGeneric(node, (child) => visitNode(child, state));
|
|
10085
|
+
popFunctionFrame(state);
|
|
10086
|
+
if (isNested) decrementNesting(state);
|
|
10087
|
+
};
|
|
10088
|
+
const visitNode = (node, state) => {
|
|
10089
|
+
if (!isAstNode(node)) return;
|
|
10090
|
+
switch (node.type) {
|
|
10091
|
+
case "FunctionDeclaration":
|
|
10092
|
+
case "FunctionExpression":
|
|
10093
|
+
case "MethodDefinition":
|
|
10094
|
+
if (node.type === "MethodDefinition") {
|
|
10095
|
+
const keyNode = node.key;
|
|
10096
|
+
const keyName = isAstNode(keyNode) ? keyNode.name ?? keyNode.value : void 0;
|
|
10097
|
+
if (typeof keyName === "string") state.pendingFunctionName = keyName;
|
|
10098
|
+
visitChildrenGeneric(node, (child) => visitNode(child, state));
|
|
10099
|
+
state.pendingFunctionName = void 0;
|
|
10100
|
+
return;
|
|
10101
|
+
}
|
|
10102
|
+
visitFunctionLike(node, "function", state);
|
|
10103
|
+
return;
|
|
10104
|
+
case "ArrowFunctionExpression":
|
|
10105
|
+
visitFunctionLike(node, "arrow", state);
|
|
10106
|
+
return;
|
|
10107
|
+
case "VariableDeclarator": {
|
|
10108
|
+
const declaratorId = node.id;
|
|
10109
|
+
const declaratorIdName = isAstNode(declaratorId) ? declaratorId.name : void 0;
|
|
10110
|
+
if (typeof declaratorIdName === "string") state.pendingFunctionName = declaratorIdName;
|
|
10111
|
+
visitChildrenGeneric(node, (child) => visitNode(child, state));
|
|
10112
|
+
state.pendingFunctionName = void 0;
|
|
10113
|
+
return;
|
|
10114
|
+
}
|
|
10115
|
+
case "PropertyDefinition": {
|
|
10116
|
+
const keyNode = node.key;
|
|
10117
|
+
const keyName = isAstNode(keyNode) ? keyNode.name : void 0;
|
|
10118
|
+
if (typeof keyName === "string") state.pendingFunctionName = keyName;
|
|
10119
|
+
visitChildrenGeneric(node, (child) => visitNode(child, state));
|
|
10120
|
+
state.pendingFunctionName = void 0;
|
|
10121
|
+
return;
|
|
10122
|
+
}
|
|
10123
|
+
case "IfStatement":
|
|
10124
|
+
incrementCyclomatic(state);
|
|
10125
|
+
incrementCognitiveWithNesting(state);
|
|
10126
|
+
incrementNesting(state);
|
|
10127
|
+
visitChildrenGeneric(node, (child) => visitNode(child, state));
|
|
10128
|
+
decrementNesting(state);
|
|
10129
|
+
resetLogicalOperator(state);
|
|
10130
|
+
return;
|
|
10131
|
+
case "ForStatement":
|
|
10132
|
+
case "ForInStatement":
|
|
10133
|
+
case "ForOfStatement":
|
|
10134
|
+
case "WhileStatement":
|
|
10135
|
+
case "DoWhileStatement":
|
|
10136
|
+
incrementCyclomatic(state);
|
|
10137
|
+
incrementCognitiveWithNesting(state);
|
|
10138
|
+
incrementNesting(state);
|
|
10139
|
+
visitChildrenGeneric(node, (child) => visitNode(child, state));
|
|
10140
|
+
decrementNesting(state);
|
|
10141
|
+
return;
|
|
10142
|
+
case "SwitchCase": {
|
|
10143
|
+
const testNode = node.test;
|
|
10144
|
+
if (testNode !== null && testNode !== void 0) {
|
|
10145
|
+
incrementCyclomatic(state);
|
|
10146
|
+
incrementCognitiveFlat(state);
|
|
10147
|
+
}
|
|
10148
|
+
visitChildrenGeneric(node, (child) => visitNode(child, state));
|
|
10149
|
+
return;
|
|
10150
|
+
}
|
|
10151
|
+
case "CatchClause":
|
|
10152
|
+
incrementCyclomatic(state);
|
|
10153
|
+
incrementCognitiveWithNesting(state);
|
|
10154
|
+
incrementNesting(state);
|
|
10155
|
+
visitChildrenGeneric(node, (child) => visitNode(child, state));
|
|
10156
|
+
decrementNesting(state);
|
|
10157
|
+
return;
|
|
10158
|
+
case "ConditionalExpression":
|
|
10159
|
+
incrementCyclomatic(state);
|
|
10160
|
+
incrementCognitiveWithNesting(state);
|
|
10161
|
+
visitChildrenGeneric(node, (child) => visitNode(child, state));
|
|
10162
|
+
return;
|
|
10163
|
+
case "LogicalExpression": {
|
|
10164
|
+
const operator = node.operator;
|
|
10165
|
+
if (operator === "&&" || operator === "||" || operator === "??") {
|
|
10166
|
+
incrementCyclomatic(state);
|
|
10167
|
+
handleLogicalOperator(operator, state);
|
|
10168
|
+
}
|
|
10169
|
+
visitChildrenGeneric(node, (child) => visitNode(child, state));
|
|
10170
|
+
return;
|
|
10171
|
+
}
|
|
10172
|
+
case "AssignmentExpression": {
|
|
10173
|
+
const operator = node.operator;
|
|
10174
|
+
if (operator === "&&=" || operator === "||=" || operator === "??=") incrementCyclomatic(state);
|
|
10175
|
+
visitChildrenGeneric(node, (child) => visitNode(child, state));
|
|
10176
|
+
return;
|
|
10177
|
+
}
|
|
10178
|
+
case "ChainExpression":
|
|
10179
|
+
incrementCyclomatic(state);
|
|
10180
|
+
visitChildrenGeneric(node, (child) => visitNode(child, state));
|
|
10181
|
+
return;
|
|
10182
|
+
default: visitChildrenGeneric(node, (child) => visitNode(child, state));
|
|
10183
|
+
}
|
|
10184
|
+
};
|
|
10185
|
+
const annotateConfidence = (finding, config) => {
|
|
10186
|
+
const breaches = [];
|
|
10187
|
+
if (finding.cyclomatic >= config.cyclomaticThreshold) breaches.push(`cyclomatic ${finding.cyclomatic} ≥ ${config.cyclomaticThreshold}`);
|
|
10188
|
+
if (finding.cognitive >= config.cognitiveThreshold) breaches.push(`cognitive ${finding.cognitive} ≥ ${config.cognitiveThreshold}`);
|
|
10189
|
+
if (finding.paramCount >= config.paramCountThreshold) breaches.push(`paramCount ${finding.paramCount} ≥ ${config.paramCountThreshold}`);
|
|
10190
|
+
if (finding.lineCount >= config.functionLineThreshold) breaches.push(`lineCount ${finding.lineCount} ≥ ${config.functionLineThreshold}`);
|
|
10191
|
+
return {
|
|
10192
|
+
confidence: breaches.length >= 2 ? "high" : "medium",
|
|
10193
|
+
reason: `${finding.functionName} breaches ${breaches.length} threshold${breaches.length === 1 ? "" : "s"}: ${breaches.join(", ")}`
|
|
10194
|
+
};
|
|
10195
|
+
};
|
|
10196
|
+
/**
|
|
10197
|
+
* Per-function cyclomatic + cognitive complexity.
|
|
10198
|
+
*
|
|
10199
|
+
* Cyclomatic (McCabe): 1 + decision points. Counts if/for/while/do/case/catch,
|
|
10200
|
+
* the ?: ternary, &&, ||, ??, &&=/||=/??=, and ?. (optional chaining).
|
|
10201
|
+
*
|
|
10202
|
+
* Cognitive (SonarSource): structural increments with nesting penalty.
|
|
10203
|
+
* Operator-sequence rule: a run of the same logical operator is +1 total;
|
|
10204
|
+
* each operator change adds another +1.
|
|
10205
|
+
*
|
|
10206
|
+
* Returns only functions whose metrics breach at least one threshold from
|
|
10207
|
+
* `config`. Threshold breach count tunes the `confidence` field.
|
|
10208
|
+
*/
|
|
10209
|
+
const detectComplexHotspots = (graph, config) => {
|
|
10210
|
+
if (!config?.enabled) return [];
|
|
10211
|
+
const hotspotFindings = [];
|
|
10212
|
+
for (const module of graph.modules) {
|
|
10213
|
+
if (module.isDeclarationFile) continue;
|
|
10214
|
+
if (module.isConfigFile) continue;
|
|
10215
|
+
let sourceText;
|
|
10216
|
+
try {
|
|
10217
|
+
sourceText = (0, node_fs.readFileSync)(module.fileId.path, "utf-8");
|
|
10218
|
+
} catch {
|
|
10219
|
+
continue;
|
|
10220
|
+
}
|
|
10221
|
+
let parseResult;
|
|
10222
|
+
try {
|
|
10223
|
+
parseResult = (0, oxc_parser.parseSync)(module.fileId.path, sourceText);
|
|
10224
|
+
} catch {
|
|
10225
|
+
continue;
|
|
10226
|
+
}
|
|
10227
|
+
const visitState = {
|
|
10228
|
+
filePath: module.fileId.path,
|
|
10229
|
+
lineStarts: computeLineStarts(sourceText),
|
|
10230
|
+
results: [],
|
|
10231
|
+
frameStack: [],
|
|
10232
|
+
pendingFunctionName: void 0
|
|
10233
|
+
};
|
|
10234
|
+
visitNode(parseResult.program, visitState);
|
|
10235
|
+
for (const result of visitState.results) {
|
|
10236
|
+
if (!(result.cyclomatic >= config.cyclomaticThreshold || result.cognitive >= config.cognitiveThreshold || result.paramCount >= config.paramCountThreshold || result.lineCount >= config.functionLineThreshold)) continue;
|
|
10237
|
+
const annotated = annotateConfidence(result, config);
|
|
10238
|
+
hotspotFindings.push({
|
|
10239
|
+
...result,
|
|
10240
|
+
confidence: annotated.confidence,
|
|
10241
|
+
reason: annotated.reason
|
|
10242
|
+
});
|
|
10243
|
+
}
|
|
10244
|
+
}
|
|
10245
|
+
hotspotFindings.sort((leftFinding, rightFinding) => {
|
|
10246
|
+
const leftScore = leftFinding.cyclomatic + leftFinding.cognitive;
|
|
10247
|
+
const rightScore = rightFinding.cyclomatic + rightFinding.cognitive;
|
|
10248
|
+
if (leftScore !== rightScore) return rightScore - leftScore;
|
|
10249
|
+
if (leftFinding.path !== rightFinding.path) return leftFinding.path.localeCompare(rightFinding.path);
|
|
10250
|
+
return leftFinding.line - rightFinding.line;
|
|
10251
|
+
});
|
|
10252
|
+
return hotspotFindings;
|
|
10253
|
+
};
|
|
10254
|
+
|
|
10255
|
+
//#endregion
|
|
10256
|
+
//#region src/report/private-type-leaks.ts
|
|
10257
|
+
const extractIdentifierName = (node) => {
|
|
10258
|
+
if (!isAstNode(node)) return void 0;
|
|
10259
|
+
if (node.type === "Identifier") {
|
|
10260
|
+
const identifierName = node.name;
|
|
10261
|
+
return typeof identifierName === "string" ? identifierName : void 0;
|
|
10262
|
+
}
|
|
10263
|
+
};
|
|
10264
|
+
const collectTypeReferenceNamesFromTypeNode = (typeNode, into) => {
|
|
10265
|
+
if (!isAstNode(typeNode)) return;
|
|
10266
|
+
if (typeNode.type === "TSTypeReference") {
|
|
10267
|
+
const referencedTypeName = typeNode.typeName;
|
|
10268
|
+
if (isAstNode(referencedTypeName) && referencedTypeName.type === "Identifier") {
|
|
10269
|
+
const name = referencedTypeName.name;
|
|
10270
|
+
if (typeof name === "string") into.add(name);
|
|
10271
|
+
}
|
|
10272
|
+
}
|
|
10273
|
+
for (const key of Object.keys(typeNode)) {
|
|
10274
|
+
if (key === "type" || key === "start" || key === "end") continue;
|
|
10275
|
+
const value = typeNode[key];
|
|
10276
|
+
if (Array.isArray(value)) for (const item of value) collectTypeReferenceNamesFromTypeNode(item, into);
|
|
10277
|
+
else if (value !== null && typeof value === "object") collectTypeReferenceNamesFromTypeNode(value, into);
|
|
10278
|
+
}
|
|
10279
|
+
};
|
|
10280
|
+
const isExportedDeclaration = (statement) => {
|
|
10281
|
+
if (!isAstNode(statement)) return false;
|
|
10282
|
+
return statement.type === "ExportNamedDeclaration" || statement.type === "ExportDefaultDeclaration";
|
|
10283
|
+
};
|
|
10284
|
+
const declarationOf = (statement) => {
|
|
10285
|
+
if (!isAstNode(statement)) return void 0;
|
|
10286
|
+
return statement.declaration;
|
|
10287
|
+
};
|
|
10288
|
+
const exportedNameOfDeclaration = (declarationNode) => {
|
|
10289
|
+
if (!isAstNode(declarationNode)) return void 0;
|
|
10290
|
+
if (declarationNode.type === "FunctionDeclaration" || declarationNode.type === "ClassDeclaration") return extractIdentifierName(declarationNode.id);
|
|
10291
|
+
if (declarationNode.type === "VariableDeclaration") {
|
|
10292
|
+
const declarators = declarationNode.declarations;
|
|
10293
|
+
if (Array.isArray(declarators) && declarators.length > 0) {
|
|
10294
|
+
const firstDeclarator = declarators[0];
|
|
10295
|
+
if (isAstNode(firstDeclarator)) return extractIdentifierName(firstDeclarator.id);
|
|
10296
|
+
}
|
|
10297
|
+
}
|
|
10298
|
+
if (declarationNode.type === "TSInterfaceDeclaration" || declarationNode.type === "TSTypeAliasDeclaration") return extractIdentifierName(declarationNode.id);
|
|
10299
|
+
};
|
|
10300
|
+
const collectFromFunctionLikeSignature = (functionLikeNode, exportName, collected) => {
|
|
10301
|
+
if (!isAstNode(functionLikeNode)) return;
|
|
10302
|
+
const params = functionLikeNode.params;
|
|
10303
|
+
if (Array.isArray(params)) for (const param of params) collectFromParameter(param, exportName, collected);
|
|
10304
|
+
const returnTypeAnnotation = functionLikeNode.returnType;
|
|
10305
|
+
if (isAstNode(returnTypeAnnotation)) {
|
|
10306
|
+
const annotation = returnTypeAnnotation.typeAnnotation;
|
|
10307
|
+
pushTypeReferences(annotation, exportName, collected, returnTypeAnnotation);
|
|
10308
|
+
}
|
|
10309
|
+
};
|
|
10310
|
+
const collectFromParameter = (parameterNode, exportName, collected) => {
|
|
10311
|
+
if (!isAstNode(parameterNode)) return;
|
|
10312
|
+
const annotation = parameterNode.typeAnnotation;
|
|
10313
|
+
if (isAstNode(annotation)) {
|
|
10314
|
+
const innerTypeNode = annotation.typeAnnotation;
|
|
10315
|
+
pushTypeReferences(innerTypeNode, exportName, collected, annotation);
|
|
10316
|
+
}
|
|
10317
|
+
};
|
|
10318
|
+
const pushTypeReferences = (typeNode, exportName, collected, spanFallbackNode) => {
|
|
10319
|
+
if (!isAstNode(typeNode)) return;
|
|
10320
|
+
const referencedTypeNames = /* @__PURE__ */ new Set();
|
|
10321
|
+
collectTypeReferenceNamesFromTypeNode(typeNode, referencedTypeNames);
|
|
10322
|
+
for (const referencedName of referencedTypeNames) {
|
|
10323
|
+
const offset = typeNode.start;
|
|
10324
|
+
const fallbackOffset = isAstNode(spanFallbackNode) && typeof spanFallbackNode.start === "number" ? spanFallbackNode.start : 0;
|
|
10325
|
+
collected.push({
|
|
10326
|
+
exportName,
|
|
10327
|
+
typeName: referencedName,
|
|
10328
|
+
byteOffset: typeof offset === "number" ? offset : fallbackOffset
|
|
10329
|
+
});
|
|
10330
|
+
}
|
|
10331
|
+
};
|
|
10332
|
+
const collectPublicSignatureReferences = (programNode) => {
|
|
10333
|
+
const collected = [];
|
|
10334
|
+
if (!isAstNode(programNode)) return collected;
|
|
10335
|
+
const programBody = programNode.body;
|
|
10336
|
+
if (!Array.isArray(programBody)) return collected;
|
|
10337
|
+
for (const statement of programBody) {
|
|
10338
|
+
if (!isExportedDeclaration(statement)) continue;
|
|
10339
|
+
const declarationNode = declarationOf(statement);
|
|
10340
|
+
if (declarationNode === void 0 || declarationNode === null) continue;
|
|
10341
|
+
const exportedName = exportedNameOfDeclaration(declarationNode);
|
|
10342
|
+
if (!exportedName) continue;
|
|
10343
|
+
if (isAstNode(declarationNode)) {
|
|
10344
|
+
if (declarationNode.type === "FunctionDeclaration" || declarationNode.type === "ArrowFunctionExpression" || declarationNode.type === "FunctionExpression") {
|
|
10345
|
+
collectFromFunctionLikeSignature(declarationNode, exportedName, collected);
|
|
10346
|
+
continue;
|
|
10347
|
+
}
|
|
10348
|
+
if (declarationNode.type === "VariableDeclaration") {
|
|
10349
|
+
const declarators = declarationNode.declarations;
|
|
10350
|
+
if (Array.isArray(declarators)) for (const declarator of declarators) {
|
|
10351
|
+
if (!isAstNode(declarator)) continue;
|
|
10352
|
+
const id = declarator.id;
|
|
10353
|
+
if (isAstNode(id)) {
|
|
10354
|
+
const annotation = id.typeAnnotation;
|
|
10355
|
+
if (isAstNode(annotation)) {
|
|
10356
|
+
const inner = annotation.typeAnnotation;
|
|
10357
|
+
pushTypeReferences(inner, exportedName, collected, annotation);
|
|
10358
|
+
}
|
|
10359
|
+
}
|
|
10360
|
+
const init = declarator.init;
|
|
10361
|
+
if (isAstNode(init) && (init.type === "ArrowFunctionExpression" || init.type === "FunctionExpression")) collectFromFunctionLikeSignature(init, exportedName, collected);
|
|
10362
|
+
}
|
|
10363
|
+
continue;
|
|
10364
|
+
}
|
|
10365
|
+
if (declarationNode.type === "ClassDeclaration") {
|
|
10366
|
+
const classBody = declarationNode.body;
|
|
10367
|
+
if (isAstNode(classBody)) {
|
|
10368
|
+
const members = classBody.body;
|
|
10369
|
+
if (Array.isArray(members)) for (const member of members) {
|
|
10370
|
+
if (!isAstNode(member)) continue;
|
|
10371
|
+
if (member.type === "MethodDefinition") {
|
|
10372
|
+
const value = member.value;
|
|
10373
|
+
collectFromFunctionLikeSignature(value, exportedName, collected);
|
|
10374
|
+
} else if (member.type === "PropertyDefinition") {
|
|
10375
|
+
const annotation = member.typeAnnotation;
|
|
10376
|
+
if (isAstNode(annotation)) {
|
|
10377
|
+
const inner = annotation.typeAnnotation;
|
|
10378
|
+
pushTypeReferences(inner, exportedName, collected, annotation);
|
|
10379
|
+
}
|
|
10380
|
+
}
|
|
10381
|
+
}
|
|
10382
|
+
}
|
|
10383
|
+
}
|
|
10384
|
+
}
|
|
10385
|
+
}
|
|
10386
|
+
return collected;
|
|
10387
|
+
};
|
|
10388
|
+
const collectLocalTypeNames = (programNode) => {
|
|
10389
|
+
const localTypeNames = /* @__PURE__ */ new Set();
|
|
10390
|
+
const exportedNames = /* @__PURE__ */ new Set();
|
|
10391
|
+
if (!isAstNode(programNode)) return {
|
|
10392
|
+
localTypeNames,
|
|
10393
|
+
exportedNames
|
|
10394
|
+
};
|
|
10395
|
+
const programBody = programNode.body;
|
|
10396
|
+
if (!Array.isArray(programBody)) return {
|
|
10397
|
+
localTypeNames,
|
|
10398
|
+
exportedNames
|
|
10399
|
+
};
|
|
10400
|
+
for (const statement of programBody) {
|
|
10401
|
+
if (!isAstNode(statement)) continue;
|
|
10402
|
+
if (statement.type === "TSInterfaceDeclaration" || statement.type === "TSTypeAliasDeclaration") {
|
|
10403
|
+
const name = extractIdentifierName(statement.id);
|
|
10404
|
+
if (name) localTypeNames.add(name);
|
|
10405
|
+
continue;
|
|
10406
|
+
}
|
|
10407
|
+
if (statement.type === "ExportNamedDeclaration") {
|
|
10408
|
+
const declarationNode = statement.declaration;
|
|
10409
|
+
if (isAstNode(declarationNode)) {
|
|
10410
|
+
if (declarationNode.type === "TSInterfaceDeclaration" || declarationNode.type === "TSTypeAliasDeclaration") {
|
|
10411
|
+
const name = extractIdentifierName(declarationNode.id);
|
|
10412
|
+
if (name) exportedNames.add(name);
|
|
10413
|
+
continue;
|
|
10414
|
+
}
|
|
10415
|
+
const declaredName = exportedNameOfDeclaration(declarationNode);
|
|
10416
|
+
if (declaredName) exportedNames.add(declaredName);
|
|
10417
|
+
}
|
|
10418
|
+
const specifiers = statement.specifiers;
|
|
10419
|
+
if (Array.isArray(specifiers)) for (const specifier of specifiers) {
|
|
10420
|
+
if (!isAstNode(specifier)) continue;
|
|
10421
|
+
if (specifier.type === "ExportSpecifier") {
|
|
10422
|
+
const exported = specifier.exported;
|
|
10423
|
+
const exportedNameValue = extractIdentifierName(exported);
|
|
10424
|
+
if (exportedNameValue) exportedNames.add(exportedNameValue);
|
|
10425
|
+
}
|
|
10426
|
+
}
|
|
10427
|
+
}
|
|
10428
|
+
}
|
|
10429
|
+
return {
|
|
10430
|
+
localTypeNames,
|
|
10431
|
+
exportedNames
|
|
10432
|
+
};
|
|
10433
|
+
};
|
|
10434
|
+
/**
|
|
10435
|
+
* Storybook CSF3 convention: a story file declares
|
|
10436
|
+
*
|
|
10437
|
+
* const meta = { ... } satisfies Meta<...>;
|
|
10438
|
+
* export default meta;
|
|
10439
|
+
* type Story = StoryObj<typeof meta>;
|
|
10440
|
+
* export const Primary: Story = { ... };
|
|
10441
|
+
*
|
|
10442
|
+
* `Story` is intentionally a local alias — consumers don't import it; the
|
|
10443
|
+
* Storybook runtime reads the default export. Flagging this as a leak
|
|
10444
|
+
* produces near-100% false positives on Storybook codebases, so skip
|
|
10445
|
+
* `*.stories.{ts,tsx,js,jsx,mts,mjs,cts,cjs}` files entirely.
|
|
10446
|
+
*/
|
|
10447
|
+
const STORYBOOK_STORY_FILE_PATTERN = /\.stories\.(?:[cm]?ts|[cm]?js|tsx|jsx)$/;
|
|
10448
|
+
const isStorybookStoryFile = (filePath) => STORYBOOK_STORY_FILE_PATTERN.test(filePath);
|
|
10449
|
+
/**
|
|
10450
|
+
* Detect TypeScript "private type leak": an exported declaration's signature
|
|
10451
|
+
* references a type that was declared locally in the same module but is not
|
|
10452
|
+
* itself exported. Consumers of the export need that type to satisfy the
|
|
10453
|
+
* signature, but cannot import it.
|
|
10454
|
+
*
|
|
10455
|
+
* Skips declaration files (`.d.ts`) — they are pure type modules where this
|
|
10456
|
+
* pattern is the norm. Keeps it simple: doesn't try to chase aliased re-export
|
|
10457
|
+
* paths (deslop-js's broader resolver work covers that elsewhere); a leak
|
|
10458
|
+
* that's actually re-exported gets filtered out at the `exportedNames` set.
|
|
10459
|
+
*/
|
|
10460
|
+
const detectPrivateTypeLeaks = (graph) => {
|
|
10461
|
+
const findings = [];
|
|
10462
|
+
for (const module of graph.modules) {
|
|
10463
|
+
if (module.isDeclarationFile) continue;
|
|
10464
|
+
if (module.isConfigFile) continue;
|
|
10465
|
+
if (!module.isReachable) continue;
|
|
10466
|
+
if (isStorybookStoryFile(module.fileId.path)) continue;
|
|
10467
|
+
let sourceText;
|
|
10468
|
+
try {
|
|
10469
|
+
sourceText = (0, node_fs.readFileSync)(module.fileId.path, "utf-8");
|
|
10470
|
+
} catch {
|
|
10471
|
+
continue;
|
|
10472
|
+
}
|
|
10473
|
+
let parseResult;
|
|
10474
|
+
try {
|
|
10475
|
+
parseResult = (0, oxc_parser.parseSync)(module.fileId.path, sourceText);
|
|
10476
|
+
} catch {
|
|
10477
|
+
continue;
|
|
10478
|
+
}
|
|
10479
|
+
const programNode = parseResult.program;
|
|
10480
|
+
const { localTypeNames, exportedNames } = collectLocalTypeNames(programNode);
|
|
10481
|
+
if (localTypeNames.size === 0) continue;
|
|
10482
|
+
const publicSignatureReferences = collectPublicSignatureReferences(programNode);
|
|
10483
|
+
if (publicSignatureReferences.length === 0) continue;
|
|
10484
|
+
const lineStarts = computeLineStarts(sourceText);
|
|
10485
|
+
const seenPairs = /* @__PURE__ */ new Set();
|
|
10486
|
+
for (const reference of publicSignatureReferences) {
|
|
10487
|
+
if (!localTypeNames.has(reference.typeName)) continue;
|
|
10488
|
+
if (exportedNames.has(reference.typeName)) continue;
|
|
10489
|
+
const pairKey = `${reference.exportName}::${reference.typeName}`;
|
|
10490
|
+
if (seenPairs.has(pairKey)) continue;
|
|
10491
|
+
seenPairs.add(pairKey);
|
|
10492
|
+
const { line, column } = offsetToLineColumn(reference.byteOffset, lineStarts);
|
|
10493
|
+
findings.push({
|
|
10494
|
+
path: module.fileId.path,
|
|
10495
|
+
exportName: reference.exportName,
|
|
10496
|
+
typeName: reference.typeName,
|
|
10497
|
+
line,
|
|
10498
|
+
column,
|
|
10499
|
+
confidence: "high",
|
|
10500
|
+
reason: `${reference.exportName}'s signature references ${reference.typeName}, declared locally but not exported — consumers can't satisfy the type without importing it`
|
|
10501
|
+
});
|
|
10502
|
+
}
|
|
10503
|
+
}
|
|
10504
|
+
findings.sort((leftLeak, rightLeak) => {
|
|
10505
|
+
if (leftLeak.path !== rightLeak.path) return leftLeak.path.localeCompare(rightLeak.path);
|
|
10506
|
+
return leftLeak.line - rightLeak.line;
|
|
10507
|
+
});
|
|
10508
|
+
return findings;
|
|
10509
|
+
};
|
|
10510
|
+
|
|
10511
|
+
//#endregion
|
|
10512
|
+
//#region src/report/typescript-smells.ts
|
|
10513
|
+
const parseSource = (filePath) => {
|
|
10514
|
+
let sourceText;
|
|
10515
|
+
try {
|
|
10516
|
+
sourceText = (0, node_fs.readFileSync)(filePath, "utf-8");
|
|
10517
|
+
} catch {
|
|
10518
|
+
return;
|
|
10519
|
+
}
|
|
10520
|
+
let parseResult;
|
|
10521
|
+
try {
|
|
10522
|
+
parseResult = (0, oxc_parser.parseSync)(filePath, sourceText);
|
|
10523
|
+
} catch {
|
|
10524
|
+
return;
|
|
10525
|
+
}
|
|
10526
|
+
const rawComments = parseResult.comments;
|
|
10527
|
+
const comments = Array.isArray(rawComments) ? rawComments.filter(isParsedSourceComment) : [];
|
|
10528
|
+
return {
|
|
10529
|
+
programNode: parseResult.program,
|
|
10530
|
+
sourceText,
|
|
10531
|
+
lineStarts: computeLineStarts(sourceText),
|
|
10532
|
+
comments
|
|
10533
|
+
};
|
|
10534
|
+
};
|
|
10535
|
+
const isParsedSourceComment = (candidate) => {
|
|
10536
|
+
if (typeof candidate !== "object" || candidate === null) return false;
|
|
10537
|
+
const fields = candidate;
|
|
10538
|
+
return (fields.type === "Line" || fields.type === "Block") && typeof fields.value === "string" && typeof fields.start === "number" && typeof fields.end === "number";
|
|
10539
|
+
};
|
|
10540
|
+
const sliceSnippet = (sourceText, start, end) => {
|
|
10541
|
+
const SNIPPET_BUDGET_CHARS = 80;
|
|
10542
|
+
const raw = sourceText.slice(start, Math.min(end, start + SNIPPET_BUDGET_CHARS)).replace(/\s+/g, " ").trim();
|
|
10543
|
+
return end - start > SNIPPET_BUDGET_CHARS ? `${raw}…` : raw;
|
|
10544
|
+
};
|
|
10545
|
+
const isAnyOrUnknownTypeAnnotation = (typeAnnotation) => {
|
|
10546
|
+
if (!isAstNode(typeAnnotation)) return void 0;
|
|
10547
|
+
if (typeAnnotation.type === "TSAnyKeyword") return "any";
|
|
10548
|
+
if (typeAnnotation.type === "TSUnknownKeyword") return "unknown";
|
|
10549
|
+
};
|
|
10550
|
+
const isLiteralLikeNonNull = (expression) => {
|
|
10551
|
+
if (!isAstNode(expression)) return false;
|
|
10552
|
+
if (expression.type === "Literal") return expression.value !== null;
|
|
10553
|
+
if (expression.type === "TemplateLiteral" || expression.type === "ArrayExpression" || expression.type === "ObjectExpression" || expression.type === "FunctionExpression" || expression.type === "ArrowFunctionExpression" || expression.type === "ClassExpression") return true;
|
|
10554
|
+
return false;
|
|
10555
|
+
};
|
|
10556
|
+
const collectUnnecessaryAssertionsInNode = (node, filePath, sourceText, lineStarts, results) => {
|
|
10557
|
+
if (!isAstNode(node)) return;
|
|
10558
|
+
if (node.type === "TSAsExpression" || node.type === "TSSatisfiesExpression") {
|
|
10559
|
+
const innerExpression = node.expression;
|
|
10560
|
+
const typeAnnotation = node.typeAnnotation;
|
|
10561
|
+
if (node.type === "TSAsExpression") {
|
|
10562
|
+
const outerKind = isAnyOrUnknownTypeAnnotation(typeAnnotation);
|
|
10563
|
+
if (outerKind === void 0 && isAstNode(innerExpression) && innerExpression.type === "TSAsExpression") {
|
|
10564
|
+
const innerTypeAnnotation = innerExpression.typeAnnotation;
|
|
10565
|
+
const innerKind = isAnyOrUnknownTypeAnnotation(innerTypeAnnotation);
|
|
10566
|
+
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);
|
|
10567
|
+
}
|
|
10568
|
+
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);
|
|
10569
|
+
}
|
|
10570
|
+
}
|
|
10571
|
+
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);
|
|
10572
|
+
if (node.type === "TSNonNullExpression") {
|
|
10573
|
+
const innerExpression = node.expression;
|
|
10574
|
+
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);
|
|
10575
|
+
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);
|
|
10576
|
+
}
|
|
10577
|
+
};
|
|
10578
|
+
const pushAssertion = (node, kind, reason, suggestion, filePath, sourceText, lineStarts, results) => {
|
|
10579
|
+
if (!isAstNode(node)) return;
|
|
10580
|
+
const startOffset = node.start;
|
|
10581
|
+
const endOffset = node.end;
|
|
10582
|
+
if (typeof startOffset !== "number" || typeof endOffset !== "number") return;
|
|
10583
|
+
const { line, column } = offsetToLineColumn(startOffset, lineStarts);
|
|
10584
|
+
const isHighConfidenceKind = kind === "double-non-null" || kind === "redundant-non-null-on-literal" || kind === "redundant-double-assertion";
|
|
10585
|
+
results.push({
|
|
10586
|
+
path: filePath,
|
|
10587
|
+
kind,
|
|
10588
|
+
snippet: sliceSnippet(sourceText, startOffset, endOffset),
|
|
10589
|
+
line,
|
|
10590
|
+
column,
|
|
10591
|
+
confidence: isHighConfidenceKind ? "high" : "medium",
|
|
10592
|
+
reason,
|
|
10593
|
+
suggestion
|
|
10594
|
+
});
|
|
10595
|
+
};
|
|
10596
|
+
const visitForUnnecessaryAssertions = (node, filePath, sourceText, lineStarts, results) => {
|
|
10597
|
+
if (!isAstNode(node)) return;
|
|
10598
|
+
collectUnnecessaryAssertionsInNode(node, filePath, sourceText, lineStarts, results);
|
|
10599
|
+
for (const propertyKey of Object.keys(node)) {
|
|
10600
|
+
if (propertyKey === "type" || propertyKey === "start" || propertyKey === "end" || propertyKey === "loc" || propertyKey === "range") continue;
|
|
10601
|
+
const value = node[propertyKey];
|
|
10602
|
+
if (Array.isArray(value)) for (const item of value) visitForUnnecessaryAssertions(item, filePath, sourceText, lineStarts, results);
|
|
10603
|
+
else if (value !== null && typeof value === "object") visitForUnnecessaryAssertions(value, filePath, sourceText, lineStarts, results);
|
|
10604
|
+
}
|
|
10605
|
+
};
|
|
10606
|
+
const importExpressionSpecifier = (importExpression) => {
|
|
10607
|
+
if (!isAstNode(importExpression)) return void 0;
|
|
10608
|
+
if (importExpression.type !== "ImportExpression") return void 0;
|
|
10609
|
+
const sourceNode = importExpression.source;
|
|
10610
|
+
if (!isAstNode(sourceNode)) return void 0;
|
|
10611
|
+
if (sourceNode.type !== "Literal") return void 0;
|
|
10612
|
+
const literalValue = sourceNode.value;
|
|
10613
|
+
return typeof literalValue === "string" ? literalValue : void 0;
|
|
10614
|
+
};
|
|
10615
|
+
const findThenImportInExpressionStatement = (expressionNode) => {
|
|
10616
|
+
if (!isAstNode(expressionNode)) return void 0;
|
|
10617
|
+
if (expressionNode.type !== "CallExpression") return void 0;
|
|
10618
|
+
const callee = expressionNode.callee;
|
|
10619
|
+
if (!isAstNode(callee)) return void 0;
|
|
10620
|
+
if (callee.type !== "MemberExpression" && callee.type !== "StaticMemberExpression") return void 0;
|
|
10621
|
+
const propertyNode = callee.property;
|
|
10622
|
+
const propertyName = isAstNode(propertyNode) ? propertyNode.name : void 0;
|
|
10623
|
+
if (propertyName !== "then" && propertyName !== "catch" && propertyName !== "finally") return void 0;
|
|
10624
|
+
const objectNode = callee.object;
|
|
10625
|
+
const specifier = importExpressionSpecifier(objectNode);
|
|
10626
|
+
if (specifier === void 0) return void 0;
|
|
10627
|
+
return {
|
|
10628
|
+
importExpression: objectNode,
|
|
10629
|
+
specifier
|
|
10630
|
+
};
|
|
10631
|
+
};
|
|
10632
|
+
const findAwaitImportInExpression = (expressionNode) => {
|
|
10633
|
+
if (!isAstNode(expressionNode)) return void 0;
|
|
10634
|
+
if (expressionNode.type !== "AwaitExpression") return void 0;
|
|
10635
|
+
const argumentNode = expressionNode.argument;
|
|
10636
|
+
const specifier = importExpressionSpecifier(argumentNode);
|
|
10637
|
+
if (specifier === void 0) return void 0;
|
|
10638
|
+
return {
|
|
10639
|
+
importExpression: argumentNode,
|
|
10640
|
+
specifier
|
|
10641
|
+
};
|
|
10642
|
+
};
|
|
10643
|
+
const collectLazyImportsAtTopLevel = (programNode, filePath, lineStarts, results) => {
|
|
10644
|
+
if (!isAstNode(programNode)) return;
|
|
10645
|
+
const programBody = programNode.body;
|
|
10646
|
+
if (!Array.isArray(programBody)) return;
|
|
10647
|
+
for (const topLevelStatement of programBody) {
|
|
10648
|
+
if (!isAstNode(topLevelStatement)) continue;
|
|
10649
|
+
if (topLevelStatement.type === "VariableDeclaration") {
|
|
10650
|
+
const declarators = topLevelStatement.declarations;
|
|
10651
|
+
if (!Array.isArray(declarators)) continue;
|
|
10652
|
+
for (const declarator of declarators) {
|
|
10653
|
+
if (!isAstNode(declarator)) continue;
|
|
10654
|
+
const initializer = declarator.init;
|
|
10655
|
+
const awaitImport = findAwaitImportInExpression(initializer);
|
|
10656
|
+
if (awaitImport) recordLazyImport(awaitImport, "top-level-await-import", filePath, lineStarts, results);
|
|
10657
|
+
}
|
|
10658
|
+
continue;
|
|
10659
|
+
}
|
|
10660
|
+
if (topLevelStatement.type === "ExpressionStatement") {
|
|
10661
|
+
const innerExpression = topLevelStatement.expression;
|
|
10662
|
+
const awaitImport = findAwaitImportInExpression(innerExpression);
|
|
10663
|
+
if (awaitImport) {
|
|
10664
|
+
recordLazyImport(awaitImport, "top-level-await-import", filePath, lineStarts, results);
|
|
10665
|
+
continue;
|
|
10666
|
+
}
|
|
10667
|
+
const thenImport = findThenImportInExpressionStatement(innerExpression);
|
|
10668
|
+
if (thenImport) recordLazyImport(thenImport, "top-level-then-import", filePath, lineStarts, results);
|
|
10669
|
+
}
|
|
10670
|
+
}
|
|
10671
|
+
};
|
|
10672
|
+
const recordLazyImport = (match, kind, filePath, lineStarts, results) => {
|
|
10673
|
+
if (!isAstNode(match.importExpression)) return;
|
|
10674
|
+
const startOffset = match.importExpression.start;
|
|
10675
|
+
if (typeof startOffset !== "number") return;
|
|
10676
|
+
const { line, column } = offsetToLineColumn(startOffset, lineStarts);
|
|
10677
|
+
results.push({
|
|
10678
|
+
path: filePath,
|
|
10679
|
+
specifier: match.specifier,
|
|
10680
|
+
kind,
|
|
10681
|
+
line,
|
|
10682
|
+
column,
|
|
10683
|
+
confidence: kind === "top-level-await-import" ? "high" : "medium",
|
|
10684
|
+
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`
|
|
10685
|
+
});
|
|
10686
|
+
};
|
|
10687
|
+
const buildPackageJsonTypeCache = () => {
|
|
10688
|
+
const directoryToType = /* @__PURE__ */ new Map();
|
|
10689
|
+
const resolveModuleType = (filePath) => {
|
|
10690
|
+
let currentDirectory = (0, node_path.dirname)((0, node_path.resolve)(filePath));
|
|
10691
|
+
const visitedDirectories = [];
|
|
10692
|
+
while (true) {
|
|
10693
|
+
visitedDirectories.push(currentDirectory);
|
|
10694
|
+
const cached = directoryToType.get(currentDirectory);
|
|
10695
|
+
if (cached !== void 0) {
|
|
10696
|
+
for (const visitedDirectory of visitedDirectories) directoryToType.set(visitedDirectory, cached);
|
|
10697
|
+
return cached;
|
|
10698
|
+
}
|
|
10699
|
+
const packageJsonPath = (0, node_path.join)(currentDirectory, "package.json");
|
|
10700
|
+
if ((0, node_fs.existsSync)(packageJsonPath)) try {
|
|
10701
|
+
const packageJson = JSON.parse((0, node_fs.readFileSync)(packageJsonPath, "utf-8"));
|
|
10702
|
+
const moduleType = packageJson.type === "module" ? "module" : packageJson.type === "commonjs" ? "commonjs" : void 0;
|
|
10703
|
+
for (const visitedDirectory of visitedDirectories) directoryToType.set(visitedDirectory, moduleType);
|
|
10704
|
+
return moduleType;
|
|
10705
|
+
} catch {
|
|
10706
|
+
for (const visitedDirectory of visitedDirectories) directoryToType.set(visitedDirectory, void 0);
|
|
10707
|
+
return;
|
|
10708
|
+
}
|
|
10709
|
+
const parentDirectory = (0, node_path.dirname)(currentDirectory);
|
|
10710
|
+
if (parentDirectory === currentDirectory) {
|
|
10711
|
+
for (const visitedDirectory of visitedDirectories) directoryToType.set(visitedDirectory, void 0);
|
|
10712
|
+
return;
|
|
10713
|
+
}
|
|
10714
|
+
currentDirectory = parentDirectory;
|
|
10715
|
+
}
|
|
10716
|
+
};
|
|
10717
|
+
return { resolveModuleType };
|
|
10718
|
+
};
|
|
10719
|
+
const isEsmFilePath = (filePath, typeCache) => {
|
|
10720
|
+
if (filePath.endsWith(".mts") || filePath.endsWith(".mjs")) return true;
|
|
10721
|
+
if (filePath.endsWith(".cts") || filePath.endsWith(".cjs")) return false;
|
|
10722
|
+
return typeCache.resolveModuleType(filePath) === "module";
|
|
10723
|
+
};
|
|
10724
|
+
const collectCommonjsInEsm = (programNode, filePath, sourceText, lineStarts, results) => {
|
|
10725
|
+
if (!isAstNode(programNode)) return;
|
|
10726
|
+
visitForCommonjs(programNode, filePath, sourceText, lineStarts, results);
|
|
10727
|
+
};
|
|
10728
|
+
const visitForCommonjs = (node, filePath, sourceText, lineStarts, results) => {
|
|
10729
|
+
if (!isAstNode(node)) return;
|
|
10730
|
+
if (node.type === "CallExpression") {
|
|
10731
|
+
const callee = node.callee;
|
|
10732
|
+
if (isAstNode(callee) && callee.type === "Identifier") {
|
|
10733
|
+
if (callee.name === "require") {
|
|
10734
|
+
const callArguments = node.arguments;
|
|
10735
|
+
if (Array.isArray(callArguments) && callArguments.length > 0) {
|
|
10736
|
+
const firstArgument = callArguments[0];
|
|
10737
|
+
if (isAstNode(firstArgument) && firstArgument.type === "Literal" && typeof firstArgument.value === "string") {
|
|
10738
|
+
const startOffset = node.start;
|
|
10739
|
+
const endOffset = node.end;
|
|
10740
|
+
if (typeof startOffset === "number" && typeof endOffset === "number") {
|
|
10741
|
+
const { line, column } = offsetToLineColumn(startOffset, lineStarts);
|
|
10742
|
+
results.push({
|
|
10743
|
+
path: filePath,
|
|
10744
|
+
kind: "require",
|
|
10745
|
+
line,
|
|
10746
|
+
column,
|
|
10747
|
+
confidence: "high",
|
|
10748
|
+
reason: "synchronous `require()` is unavailable in native ESM — use a static `import` or top-level `await import()`",
|
|
10749
|
+
snippet: sliceSnippet(sourceText, startOffset, endOffset)
|
|
10750
|
+
});
|
|
10751
|
+
}
|
|
10752
|
+
}
|
|
10753
|
+
}
|
|
10754
|
+
}
|
|
10755
|
+
}
|
|
10756
|
+
}
|
|
10757
|
+
if (node.type === "AssignmentExpression") {
|
|
10758
|
+
const leftSide = node.left;
|
|
10759
|
+
if (isAstNode(leftSide)) {
|
|
10760
|
+
if (leftSide.type === "MemberExpression" || leftSide.type === "StaticMemberExpression") {
|
|
10761
|
+
const objectNode = leftSide.object;
|
|
10762
|
+
const propertyNode = leftSide.property;
|
|
10763
|
+
const objectName = isAstNode(objectNode) ? objectNode.name : void 0;
|
|
10764
|
+
const propertyName = isAstNode(propertyNode) ? propertyNode.name : void 0;
|
|
10765
|
+
if (objectName === "module" && propertyName === "exports") {
|
|
10766
|
+
const startOffset = node.start;
|
|
10767
|
+
const endOffset = node.end;
|
|
10768
|
+
if (typeof startOffset === "number" && typeof endOffset === "number") {
|
|
10769
|
+
const { line, column } = offsetToLineColumn(startOffset, lineStarts);
|
|
10770
|
+
results.push({
|
|
10771
|
+
path: filePath,
|
|
10772
|
+
kind: "module-exports",
|
|
10773
|
+
line,
|
|
10774
|
+
column,
|
|
10775
|
+
confidence: "high",
|
|
10776
|
+
reason: "`module.exports = ...` is CommonJS — replace with `export default` or named `export` for ESM",
|
|
10777
|
+
snippet: sliceSnippet(sourceText, startOffset, endOffset)
|
|
10778
|
+
});
|
|
10779
|
+
}
|
|
10780
|
+
} else if (objectName === "exports") {
|
|
10781
|
+
const startOffset = node.start;
|
|
10782
|
+
const endOffset = node.end;
|
|
10783
|
+
if (typeof startOffset === "number" && typeof endOffset === "number") {
|
|
10784
|
+
const { line, column } = offsetToLineColumn(startOffset, lineStarts);
|
|
10785
|
+
results.push({
|
|
10786
|
+
path: filePath,
|
|
10787
|
+
kind: "exports-assignment",
|
|
10788
|
+
line,
|
|
10789
|
+
column,
|
|
10790
|
+
confidence: "high",
|
|
10791
|
+
reason: "`exports.x = ...` is CommonJS — replace with a named `export` for ESM",
|
|
10792
|
+
snippet: sliceSnippet(sourceText, startOffset, endOffset)
|
|
10793
|
+
});
|
|
10794
|
+
}
|
|
10795
|
+
}
|
|
10796
|
+
}
|
|
10797
|
+
}
|
|
10798
|
+
}
|
|
10799
|
+
for (const propertyKey of Object.keys(node)) {
|
|
10800
|
+
if (propertyKey === "type" || propertyKey === "start" || propertyKey === "end" || propertyKey === "loc" || propertyKey === "range") continue;
|
|
10801
|
+
const value = node[propertyKey];
|
|
10802
|
+
if (Array.isArray(value)) for (const item of value) visitForCommonjs(item, filePath, sourceText, lineStarts, results);
|
|
10803
|
+
else if (value !== null && typeof value === "object") visitForCommonjs(value, filePath, sourceText, lineStarts, results);
|
|
10804
|
+
}
|
|
10805
|
+
};
|
|
10806
|
+
const TS_IGNORE_LEADING = /^\s*@ts-ignore\b/;
|
|
10807
|
+
const TS_NOCHECK_LEADING = /^\s*@ts-nocheck\b/;
|
|
10808
|
+
const TS_EXPECT_ERROR_LEADING = /^\s*@ts-expect-error\b(.*)$/;
|
|
10809
|
+
const collectTypeScriptEscapeHatches = (comments, filePath, lineStarts, results) => {
|
|
10810
|
+
for (const comment of comments) {
|
|
10811
|
+
const commentBody = comment.type === "Block" ? comment.value.split("\n")[0] : comment.value;
|
|
10812
|
+
if (TS_IGNORE_LEADING.test(commentBody)) {
|
|
10813
|
+
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);
|
|
10814
|
+
continue;
|
|
10815
|
+
}
|
|
10816
|
+
if (TS_NOCHECK_LEADING.test(commentBody)) {
|
|
10817
|
+
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);
|
|
10818
|
+
continue;
|
|
10819
|
+
}
|
|
10820
|
+
const expectErrorMatch = commentBody.match(TS_EXPECT_ERROR_LEADING);
|
|
10821
|
+
if (expectErrorMatch) {
|
|
10822
|
+
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);
|
|
10823
|
+
}
|
|
10824
|
+
}
|
|
10825
|
+
};
|
|
10826
|
+
const pushEscapeHatch = (commentStartOffset, kind, reason, suggestion, confidence, filePath, lineStarts, results) => {
|
|
10827
|
+
const { line, column } = offsetToLineColumn(commentStartOffset, lineStarts);
|
|
10828
|
+
results.push({
|
|
10829
|
+
path: filePath,
|
|
10830
|
+
kind,
|
|
10831
|
+
line,
|
|
10832
|
+
column,
|
|
10833
|
+
confidence,
|
|
10834
|
+
reason,
|
|
10835
|
+
suggestion
|
|
10836
|
+
});
|
|
10837
|
+
};
|
|
10838
|
+
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");
|
|
10839
|
+
const isTypeScriptFileExtension = (filePath) => filePath.endsWith(".ts") || filePath.endsWith(".tsx") || filePath.endsWith(".mts") || filePath.endsWith(".cts");
|
|
10840
|
+
const detectTypeScriptSmells = (graph) => {
|
|
10841
|
+
const unnecessaryAssertions = [];
|
|
10842
|
+
const lazyImportsAtTopLevel = [];
|
|
10843
|
+
const commonjsInEsm = [];
|
|
10844
|
+
const typeScriptEscapeHatches = [];
|
|
10845
|
+
const packageJsonTypeCache = buildPackageJsonTypeCache();
|
|
10846
|
+
for (const module of graph.modules) {
|
|
10847
|
+
if (module.isDeclarationFile) continue;
|
|
10848
|
+
const filePath = module.fileId.path;
|
|
10849
|
+
if (!isTypeScriptOrJsFile(filePath)) continue;
|
|
10850
|
+
const parsedSource = parseSource(filePath);
|
|
10851
|
+
if (!parsedSource) continue;
|
|
10852
|
+
if (isTypeScriptFileExtension(filePath)) {
|
|
10853
|
+
visitForUnnecessaryAssertions(parsedSource.programNode, filePath, parsedSource.sourceText, parsedSource.lineStarts, unnecessaryAssertions);
|
|
10854
|
+
collectTypeScriptEscapeHatches(parsedSource.comments, filePath, parsedSource.lineStarts, typeScriptEscapeHatches);
|
|
10855
|
+
}
|
|
10856
|
+
collectLazyImportsAtTopLevel(parsedSource.programNode, filePath, parsedSource.lineStarts, lazyImportsAtTopLevel);
|
|
10857
|
+
if (isEsmFilePath(filePath, packageJsonTypeCache)) collectCommonjsInEsm(parsedSource.programNode, filePath, parsedSource.sourceText, parsedSource.lineStarts, commonjsInEsm);
|
|
10858
|
+
}
|
|
10859
|
+
unnecessaryAssertions.sort((leftFinding, rightFinding) => {
|
|
10860
|
+
if (leftFinding.path !== rightFinding.path) return leftFinding.path.localeCompare(rightFinding.path);
|
|
10861
|
+
return leftFinding.line - rightFinding.line;
|
|
10862
|
+
});
|
|
10863
|
+
lazyImportsAtTopLevel.sort((leftFinding, rightFinding) => {
|
|
10864
|
+
if (leftFinding.path !== rightFinding.path) return leftFinding.path.localeCompare(rightFinding.path);
|
|
10865
|
+
return leftFinding.line - rightFinding.line;
|
|
10866
|
+
});
|
|
10867
|
+
commonjsInEsm.sort((leftFinding, rightFinding) => {
|
|
10868
|
+
if (leftFinding.path !== rightFinding.path) return leftFinding.path.localeCompare(rightFinding.path);
|
|
10869
|
+
return leftFinding.line - rightFinding.line;
|
|
10870
|
+
});
|
|
10871
|
+
typeScriptEscapeHatches.sort((leftFinding, rightFinding) => {
|
|
10872
|
+
if (leftFinding.path !== rightFinding.path) return leftFinding.path.localeCompare(rightFinding.path);
|
|
10873
|
+
return leftFinding.line - rightFinding.line;
|
|
10874
|
+
});
|
|
10875
|
+
return {
|
|
10876
|
+
unnecessaryAssertions,
|
|
10877
|
+
lazyImportsAtTopLevel,
|
|
10878
|
+
commonjsInEsm,
|
|
10879
|
+
typeScriptEscapeHatches
|
|
10880
|
+
};
|
|
10881
|
+
};
|
|
10882
|
+
|
|
10883
|
+
//#endregion
|
|
10884
|
+
//#region src/utils/run-safe-detector.ts
|
|
10885
|
+
const runSafeDetector = (input) => {
|
|
10886
|
+
try {
|
|
10887
|
+
return input.detector();
|
|
10888
|
+
} catch (caughtError) {
|
|
10889
|
+
input.errorSink.push(new DetectorError({
|
|
10890
|
+
module: input.module,
|
|
10891
|
+
message: `${input.detectorName} threw ${input.contextDescription}`,
|
|
10892
|
+
detail: describeUnknownError(caughtError)
|
|
10893
|
+
}));
|
|
10894
|
+
return input.fallback;
|
|
10895
|
+
}
|
|
10896
|
+
};
|
|
10897
|
+
|
|
10898
|
+
//#endregion
|
|
10899
|
+
//#region src/semantic/program.ts
|
|
10900
|
+
const failureFor = (reason, message, options = { rootDir: "" }) => {
|
|
10901
|
+
return {
|
|
10902
|
+
reason,
|
|
10903
|
+
message,
|
|
10904
|
+
error: new TypeScriptError({
|
|
10905
|
+
code: {
|
|
10906
|
+
"no-tsconfig": "tsconfig-not-found",
|
|
10907
|
+
"tsconfig-parse-error": "tsconfig-parse-failed",
|
|
10908
|
+
"program-creation-failed": "ts-program-creation-failed",
|
|
10909
|
+
"too-many-files": "ts-program-too-large",
|
|
10910
|
+
"typescript-load-failed": "ts-not-loadable"
|
|
10911
|
+
}[reason],
|
|
10912
|
+
severity: reason === "no-tsconfig" ? "info" : "warning",
|
|
10913
|
+
message,
|
|
10914
|
+
path: options.rootDir || void 0,
|
|
10915
|
+
detail: options.detail
|
|
10916
|
+
})
|
|
10917
|
+
};
|
|
10918
|
+
};
|
|
10919
|
+
const findNearestTsconfig = (rootDir, explicitPath) => {
|
|
10920
|
+
if (explicitPath) {
|
|
10921
|
+
const absoluteExplicit = (0, node_path.resolve)(rootDir, explicitPath);
|
|
10922
|
+
if ((0, node_fs.existsSync)(absoluteExplicit)) return absoluteExplicit;
|
|
10923
|
+
return;
|
|
10924
|
+
}
|
|
10925
|
+
for (const candidateName of DEFAULT_SEMANTIC_TSCONFIG_NAMES) {
|
|
10926
|
+
const candidatePath = (0, node_path.resolve)(rootDir, candidateName);
|
|
10927
|
+
if ((0, node_fs.existsSync)(candidatePath)) return candidatePath;
|
|
10928
|
+
}
|
|
10929
|
+
};
|
|
10930
|
+
const createSemanticContext = (rootDir, tsconfigPath) => {
|
|
10931
|
+
const resolvedTsconfigPath = findNearestTsconfig(rootDir, tsconfigPath);
|
|
10932
|
+
if (!resolvedTsconfigPath) return {
|
|
10933
|
+
ok: false,
|
|
10934
|
+
failure: failureFor("no-tsconfig", `No tsconfig found under ${rootDir}`, { rootDir })
|
|
10935
|
+
};
|
|
10936
|
+
let configFileContent;
|
|
10937
|
+
try {
|
|
10938
|
+
configFileContent = typescript.default.readConfigFile(resolvedTsconfigPath, typescript.default.sys.readFile);
|
|
10939
|
+
} catch (readError) {
|
|
10940
|
+
return {
|
|
10941
|
+
ok: false,
|
|
10942
|
+
failure: failureFor("tsconfig-parse-error", "ts.readConfigFile threw", {
|
|
10943
|
+
rootDir: resolvedTsconfigPath,
|
|
10944
|
+
detail: describeUnknownError(readError)
|
|
10945
|
+
})
|
|
10946
|
+
};
|
|
10947
|
+
}
|
|
10948
|
+
if (configFileContent.error) return {
|
|
10949
|
+
ok: false,
|
|
10950
|
+
failure: failureFor("tsconfig-parse-error", typescript.default.flattenDiagnosticMessageText(configFileContent.error.messageText, "\n"), { rootDir: resolvedTsconfigPath })
|
|
10951
|
+
};
|
|
10952
|
+
let parsedCommandLine;
|
|
10953
|
+
try {
|
|
10954
|
+
parsedCommandLine = typescript.default.parseJsonConfigFileContent(configFileContent.config, typescript.default.sys, (0, node_path.dirname)(resolvedTsconfigPath), {
|
|
10955
|
+
noEmit: true,
|
|
10956
|
+
skipLibCheck: true,
|
|
10957
|
+
allowJs: true,
|
|
10958
|
+
isolatedModules: false
|
|
10959
|
+
}, resolvedTsconfigPath);
|
|
10960
|
+
} catch (parseError) {
|
|
10961
|
+
return {
|
|
10962
|
+
ok: false,
|
|
10963
|
+
failure: failureFor("tsconfig-parse-error", "ts.parseJsonConfigFileContent threw", {
|
|
10964
|
+
rootDir: resolvedTsconfigPath,
|
|
10965
|
+
detail: describeUnknownError(parseError)
|
|
10966
|
+
})
|
|
10967
|
+
};
|
|
10968
|
+
}
|
|
10969
|
+
if (parsedCommandLine.errors.length > 0) {
|
|
10970
|
+
const fatalErrors = parsedCommandLine.errors.filter((diagnostic) => diagnostic.category === typescript.default.DiagnosticCategory.Error);
|
|
10971
|
+
if (fatalErrors.length > 0 && parsedCommandLine.fileNames.length === 0) return {
|
|
10972
|
+
ok: false,
|
|
10973
|
+
failure: failureFor("tsconfig-parse-error", typescript.default.flattenDiagnosticMessageText(fatalErrors[0].messageText, "\n"), { rootDir: resolvedTsconfigPath })
|
|
10974
|
+
};
|
|
10975
|
+
}
|
|
10976
|
+
if (parsedCommandLine.fileNames.length > 5e3) return {
|
|
10977
|
+
ok: false,
|
|
10978
|
+
failure: failureFor("too-many-files", `Project has ${parsedCommandLine.fileNames.length} files, exceeds SEMANTIC_MAX_PROGRAM_FILES=${SEMANTIC_MAX_PROGRAM_FILES}`, { rootDir: resolvedTsconfigPath })
|
|
10979
|
+
};
|
|
10980
|
+
try {
|
|
10981
|
+
const program = typescript.default.createProgram({
|
|
10982
|
+
rootNames: parsedCommandLine.fileNames,
|
|
10983
|
+
options: parsedCommandLine.options,
|
|
10984
|
+
projectReferences: parsedCommandLine.projectReferences
|
|
10985
|
+
});
|
|
10986
|
+
return {
|
|
10987
|
+
ok: true,
|
|
10988
|
+
context: {
|
|
10989
|
+
program,
|
|
10990
|
+
checker: program.getTypeChecker(),
|
|
10991
|
+
rootSourceFiles: program.getSourceFiles().filter((sourceFile) => !sourceFile.isDeclarationFile || sourceFile.fileName.endsWith(".d.ts")),
|
|
10992
|
+
tsconfigPath: resolvedTsconfigPath
|
|
10993
|
+
}
|
|
10994
|
+
};
|
|
10995
|
+
} catch (programError) {
|
|
10996
|
+
return {
|
|
10997
|
+
ok: false,
|
|
10998
|
+
failure: failureFor("program-creation-failed", "ts.createProgram threw", {
|
|
10999
|
+
rootDir: resolvedTsconfigPath,
|
|
11000
|
+
detail: describeUnknownError(programError)
|
|
11001
|
+
})
|
|
11002
|
+
};
|
|
11003
|
+
}
|
|
11004
|
+
};
|
|
11005
|
+
|
|
11006
|
+
//#endregion
|
|
11007
|
+
//#region src/semantic/references.ts
|
|
11008
|
+
const canonicalKeyForSymbol = (symbol) => {
|
|
11009
|
+
return symbol.declarations?.[0] ?? symbol;
|
|
11010
|
+
};
|
|
11011
|
+
const isDeclarationNameIdentifier = (identifier) => {
|
|
8459
11012
|
const parent = identifier.parent;
|
|
8460
11013
|
if (!parent) return false;
|
|
8461
11014
|
if ((typescript.default.isInterfaceDeclaration(parent) || typescript.default.isTypeAliasDeclaration(parent) || typescript.default.isClassDeclaration(parent) || typescript.default.isFunctionDeclaration(parent) || typescript.default.isEnumDeclaration(parent) || typescript.default.isModuleDeclaration(parent) || typescript.default.isVariableDeclaration(parent)) && parent.name === identifier) return true;
|
|
@@ -8767,6 +11320,55 @@ const detectUnusedEnumMembers = (graph, config, context, referenceIndex) => {
|
|
|
8767
11320
|
return findings;
|
|
8768
11321
|
};
|
|
8769
11322
|
|
|
11323
|
+
//#endregion
|
|
11324
|
+
//#region src/utils/is-framework-lifecycle-method.ts
|
|
11325
|
+
/**
|
|
11326
|
+
* Methods invoked by-name by React / Angular runtimes. Static "no caller"
|
|
11327
|
+
* analysis can't see those call sites, so without this allowlist
|
|
11328
|
+
* `unusedClassMembers` would fire on every component.
|
|
11329
|
+
*/
|
|
11330
|
+
const FRAMEWORK_LIFECYCLE_METHODS = new Set([
|
|
11331
|
+
"render",
|
|
11332
|
+
"componentDidMount",
|
|
11333
|
+
"componentDidUpdate",
|
|
11334
|
+
"componentWillUnmount",
|
|
11335
|
+
"shouldComponentUpdate",
|
|
11336
|
+
"getSnapshotBeforeUpdate",
|
|
11337
|
+
"getDerivedStateFromProps",
|
|
11338
|
+
"getDerivedStateFromError",
|
|
11339
|
+
"componentDidCatch",
|
|
11340
|
+
"componentWillMount",
|
|
11341
|
+
"componentWillReceiveProps",
|
|
11342
|
+
"componentWillUpdate",
|
|
11343
|
+
"UNSAFE_componentWillMount",
|
|
11344
|
+
"UNSAFE_componentWillReceiveProps",
|
|
11345
|
+
"UNSAFE_componentWillUpdate",
|
|
11346
|
+
"getChildContext",
|
|
11347
|
+
"contextType",
|
|
11348
|
+
"ngOnInit",
|
|
11349
|
+
"ngOnDestroy",
|
|
11350
|
+
"ngOnChanges",
|
|
11351
|
+
"ngDoCheck",
|
|
11352
|
+
"ngAfterContentInit",
|
|
11353
|
+
"ngAfterContentChecked",
|
|
11354
|
+
"ngAfterViewInit",
|
|
11355
|
+
"ngAfterViewChecked",
|
|
11356
|
+
"ngAcceptInputType",
|
|
11357
|
+
"canActivate",
|
|
11358
|
+
"canDeactivate",
|
|
11359
|
+
"canActivateChild",
|
|
11360
|
+
"canMatch",
|
|
11361
|
+
"resolve",
|
|
11362
|
+
"intercept",
|
|
11363
|
+
"transform",
|
|
11364
|
+
"validate",
|
|
11365
|
+
"registerOnChange",
|
|
11366
|
+
"registerOnTouched",
|
|
11367
|
+
"writeValue",
|
|
11368
|
+
"setDisabledState"
|
|
11369
|
+
]);
|
|
11370
|
+
const isFrameworkLifecycleMethod = (name) => FRAMEWORK_LIFECYCLE_METHODS.has(name);
|
|
11371
|
+
|
|
8770
11372
|
//#endregion
|
|
8771
11373
|
//#region src/semantic/unused-class-members.ts
|
|
8772
11374
|
const isClassExported = (declaration) => {
|
|
@@ -8898,6 +11500,7 @@ const detectUnusedClassMembers = (graph, config, context, referenceIndex, decora
|
|
|
8898
11500
|
if (memberHasExternalReference(memberSymbol, referenceIndex)) continue;
|
|
8899
11501
|
const memberName = typescript.default.isIdentifier(member.name) ? member.name.text : member.name.getText(sourceFile);
|
|
8900
11502
|
if (overriddenMemberNames.has(memberName)) continue;
|
|
11503
|
+
if (isFrameworkLifecycleMethod(memberName)) continue;
|
|
8901
11504
|
const { line: zeroIndexedLine, character: zeroIndexedColumn } = sourceFile.getLineAndCharacterOfPosition(member.getStart(sourceFile));
|
|
8902
11505
|
const line = zeroIndexedLine + 1;
|
|
8903
11506
|
const column = zeroIndexedColumn + 1;
|
|
@@ -9298,6 +11901,22 @@ const generateReport = (graph, config) => {
|
|
|
9298
11901
|
const simplifiableFunctions = config.reportRedundancy ? safeReportDetector("detectSimplifiableFunctions", () => detectSimplifiableFunctions(graph), [], errorSink) : [];
|
|
9299
11902
|
const simplifiableExpressions = config.reportRedundancy ? safeReportDetector("detectSimplifiableExpressions", () => detectSimplifiableExpressions(graph), [], errorSink) : [];
|
|
9300
11903
|
const duplicateConstants = config.reportRedundancy ? safeReportDetector("detectDuplicateConstants", () => detectDuplicateConstants(graph), [], errorSink) : [];
|
|
11904
|
+
const crossFileDuplicateExports = config.reportRedundancy ? safeReportDetector("detectCrossFileDuplicateExports", () => detectCrossFileDuplicateExports(graph), [], errorSink) : [];
|
|
11905
|
+
const duplicateBlockResult = safeReportDetector("detectDuplicateBlocks", () => detectDuplicateBlocks(graph, config.duplicateBlocks, config.rootDir), {
|
|
11906
|
+
duplicateBlocks: [],
|
|
11907
|
+
duplicateBlockClusters: [],
|
|
11908
|
+
shadowedDirectoryPairs: []
|
|
11909
|
+
}, errorSink);
|
|
11910
|
+
const reExportCycles = safeReportDetector("detectReExportCycles", () => detectReExportCycles(graph), [], errorSink);
|
|
11911
|
+
const featureFlags = safeReportDetector("detectFeatureFlags", () => detectFeatureFlags(graph, config.featureFlags), [], errorSink);
|
|
11912
|
+
const complexFunctions = safeReportDetector("detectComplexHotspots", () => detectComplexHotspots(graph, config.complexity), [], errorSink);
|
|
11913
|
+
const privateTypeLeaks = safeReportDetector("detectPrivateTypeLeaks", () => detectPrivateTypeLeaks(graph), [], errorSink);
|
|
11914
|
+
const typeScriptSmellsResult = safeReportDetector("detectTypeScriptSmells", () => detectTypeScriptSmells(graph), {
|
|
11915
|
+
unnecessaryAssertions: [],
|
|
11916
|
+
lazyImportsAtTopLevel: [],
|
|
11917
|
+
commonjsInEsm: [],
|
|
11918
|
+
typeScriptEscapeHatches: []
|
|
11919
|
+
}, errorSink);
|
|
9301
11920
|
let semanticResult;
|
|
9302
11921
|
try {
|
|
9303
11922
|
semanticResult = runSemanticAnalysis(graph, config);
|
|
@@ -9322,6 +11941,7 @@ const generateReport = (graph, config) => {
|
|
|
9322
11941
|
errorSink.push(semanticError);
|
|
9323
11942
|
}
|
|
9324
11943
|
const redundantAliases = config.reportRedundancy ? [...syntacticRedundantAliases, ...semanticResult.redundantAliases] : [];
|
|
11944
|
+
if (featureFlags.length > 0) correlateFlagsWithDeadCode(featureFlags, { unusedExports });
|
|
9325
11945
|
const totalExports = graph.modules.reduce((exportCount, module) => exportCount + module.exports.filter((exportInfo) => !(exportInfo.name === "*" && exportInfo.isNamespaceReExport)).length, 0);
|
|
9326
11946
|
return {
|
|
9327
11947
|
unusedFiles,
|
|
@@ -9342,6 +11962,18 @@ const generateReport = (graph, config) => {
|
|
|
9342
11962
|
simplifiableFunctions,
|
|
9343
11963
|
simplifiableExpressions,
|
|
9344
11964
|
duplicateConstants,
|
|
11965
|
+
crossFileDuplicateExports,
|
|
11966
|
+
duplicateBlocks: duplicateBlockResult.duplicateBlocks,
|
|
11967
|
+
duplicateBlockClusters: duplicateBlockResult.duplicateBlockClusters,
|
|
11968
|
+
shadowedDirectoryPairs: duplicateBlockResult.shadowedDirectoryPairs,
|
|
11969
|
+
reExportCycles,
|
|
11970
|
+
featureFlags,
|
|
11971
|
+
complexFunctions,
|
|
11972
|
+
privateTypeLeaks,
|
|
11973
|
+
unnecessaryAssertions: typeScriptSmellsResult.unnecessaryAssertions,
|
|
11974
|
+
lazyImportsAtTopLevel: typeScriptSmellsResult.lazyImportsAtTopLevel,
|
|
11975
|
+
commonjsInEsm: typeScriptSmellsResult.commonjsInEsm,
|
|
11976
|
+
typeScriptEscapeHatches: typeScriptSmellsResult.typeScriptEscapeHatches,
|
|
9345
11977
|
analysisErrors: errorSink,
|
|
9346
11978
|
totalFiles: graph.modules.length,
|
|
9347
11979
|
totalExports,
|
|
@@ -9353,6 +11985,39 @@ const generateReport = (graph, config) => {
|
|
|
9353
11985
|
//#region src/index.ts
|
|
9354
11986
|
const STYLE_EXTENSIONS = [".css", ".scss"];
|
|
9355
11987
|
const REACT_NATIVE_ENABLERS = ["react-native", "expo"];
|
|
11988
|
+
const basenameFromPath = (filePath) => {
|
|
11989
|
+
const lastSlashIndex = filePath.lastIndexOf("/");
|
|
11990
|
+
return lastSlashIndex === -1 ? filePath : filePath.slice(lastSlashIndex + 1);
|
|
11991
|
+
};
|
|
11992
|
+
/**
|
|
11993
|
+
* Dynamic registry pattern: many codebases use a central "schema/registry"
|
|
11994
|
+
* module that lists tool/command/page filenames as string literals, then a
|
|
11995
|
+
* runner spawns them via `path.resolve(dir, file)` or `import()`. Static
|
|
11996
|
+
* analysis can't follow the indirection, so those targets get falsely
|
|
11997
|
+
* flagged as unused.
|
|
11998
|
+
*
|
|
11999
|
+
* Heuristic: if a parsed string literal exactly matches the basename of
|
|
12000
|
+
* exactly one file in the project, treat that file as an entry point.
|
|
12001
|
+
* Uniqueness guards against false-positives from common names like
|
|
12002
|
+
* `index.ts` matching dozens of unrelated files.
|
|
12003
|
+
*/
|
|
12004
|
+
const markFilenameRegistryEntries = (moduleGraph) => {
|
|
12005
|
+
const basenameToModuleIndex = /* @__PURE__ */ new Map();
|
|
12006
|
+
for (const module of moduleGraph.modules) {
|
|
12007
|
+
const basename = basenameFromPath(module.fileId.path);
|
|
12008
|
+
const existing = basenameToModuleIndex.get(basename);
|
|
12009
|
+
if (existing === void 0) basenameToModuleIndex.set(basename, module.fileId.index);
|
|
12010
|
+
else if (existing !== "ambiguous") basenameToModuleIndex.set(basename, "ambiguous");
|
|
12011
|
+
}
|
|
12012
|
+
for (const module of moduleGraph.modules) for (const referencedFilename of module.referencedFilenames) {
|
|
12013
|
+
const targetIndex = basenameToModuleIndex.get(referencedFilename);
|
|
12014
|
+
if (typeof targetIndex !== "number") continue;
|
|
12015
|
+
const targetModule = moduleGraph.modules[targetIndex];
|
|
12016
|
+
if (!targetModule || targetModule.isEntryPoint) continue;
|
|
12017
|
+
if (targetModule.fileId.index === module.fileId.index) continue;
|
|
12018
|
+
targetModule.isEntryPoint = true;
|
|
12019
|
+
}
|
|
12020
|
+
};
|
|
9356
12021
|
const detectReactNative = (rootDir, workspacePackages) => {
|
|
9357
12022
|
const directoriesToCheck = [rootDir, ...workspacePackages.map((workspacePackage) => workspacePackage.directory)];
|
|
9358
12023
|
for (const directory of directoriesToCheck) {
|
|
@@ -9396,18 +12061,53 @@ const detectReactNative = (rootDir, workspacePackages) => {
|
|
|
9396
12061
|
*
|
|
9397
12062
|
* - `reportRedundancy: true` — on because redundancy findings are mostly
|
|
9398
12063
|
* high-signal and the detectors carry their own confidence tiers.
|
|
12064
|
+
*
|
|
12065
|
+
* - `duplicateBlocks: undefined` — token-based copy-paste detection (suffix
|
|
12066
|
+
* array + LCP) is opt-in. It re-parses every source
|
|
12067
|
+
* file to emit a token stream and adds significant runtime to the scan.
|
|
12068
|
+
* Pass `duplicateBlocks: { enabled: true }` to turn it on.
|
|
9399
12069
|
*/
|
|
9400
12070
|
const fillSemanticConfig = (semanticOverrides) => {
|
|
9401
|
-
|
|
12071
|
+
const overrides = semanticOverrides ?? {};
|
|
12072
|
+
return {
|
|
12073
|
+
enabled: overrides.enabled ?? true,
|
|
12074
|
+
reportUnusedTypes: overrides.reportUnusedTypes ?? true,
|
|
12075
|
+
reportUnusedEnumMembers: overrides.reportUnusedEnumMembers ?? true,
|
|
12076
|
+
reportUnusedClassMembers: overrides.reportUnusedClassMembers ?? false,
|
|
12077
|
+
reportRedundantVariableAliases: overrides.reportRedundantVariableAliases ?? true,
|
|
12078
|
+
reportMisclassifiedDependencies: overrides.reportMisclassifiedDependencies ?? true,
|
|
12079
|
+
reportRoundTripAliases: overrides.reportRoundTripAliases ?? true,
|
|
12080
|
+
decoratorAllowlist: overrides.decoratorAllowlist ?? DEFAULT_SEMANTIC_DECORATOR_ALLOWLIST
|
|
12081
|
+
};
|
|
12082
|
+
};
|
|
12083
|
+
const fillDuplicateBlocksConfig = (duplicateBlocksOverrides) => {
|
|
12084
|
+
const overrides = duplicateBlocksOverrides ?? {};
|
|
12085
|
+
return {
|
|
12086
|
+
enabled: overrides.enabled ?? true,
|
|
12087
|
+
mode: overrides.mode ?? "semantic",
|
|
12088
|
+
minTokens: overrides.minTokens ?? 50,
|
|
12089
|
+
minLines: overrides.minLines ?? 5,
|
|
12090
|
+
minOccurrences: overrides.minOccurrences ?? 2,
|
|
12091
|
+
skipLocal: overrides.skipLocal ?? false
|
|
12092
|
+
};
|
|
12093
|
+
};
|
|
12094
|
+
const fillFeatureFlagsConfig = (flagsOverrides) => {
|
|
12095
|
+
const overrides = flagsOverrides ?? {};
|
|
9402
12096
|
return {
|
|
9403
|
-
enabled:
|
|
9404
|
-
|
|
9405
|
-
|
|
9406
|
-
|
|
9407
|
-
|
|
9408
|
-
|
|
9409
|
-
|
|
9410
|
-
|
|
12097
|
+
enabled: overrides.enabled ?? true,
|
|
12098
|
+
extraEnvPrefixes: overrides.extraEnvPrefixes ?? [],
|
|
12099
|
+
extraSdkFunctionNames: overrides.extraSdkFunctionNames ?? [],
|
|
12100
|
+
detectConfigObjects: overrides.detectConfigObjects ?? false
|
|
12101
|
+
};
|
|
12102
|
+
};
|
|
12103
|
+
const fillComplexityConfig = (complexityOverrides) => {
|
|
12104
|
+
const overrides = complexityOverrides ?? {};
|
|
12105
|
+
return {
|
|
12106
|
+
enabled: overrides.enabled ?? true,
|
|
12107
|
+
cyclomaticThreshold: overrides.cyclomaticThreshold ?? 10,
|
|
12108
|
+
cognitiveThreshold: overrides.cognitiveThreshold ?? 15,
|
|
12109
|
+
paramCountThreshold: overrides.paramCountThreshold ?? 5,
|
|
12110
|
+
functionLineThreshold: overrides.functionLineThreshold ?? 80
|
|
9411
12111
|
};
|
|
9412
12112
|
};
|
|
9413
12113
|
const defineConfig = (options) => ({
|
|
@@ -9419,7 +12119,10 @@ const defineConfig = (options) => ({
|
|
|
9419
12119
|
reportTypes: options.reportTypes ?? false,
|
|
9420
12120
|
includeEntryExports: options.includeEntryExports ?? false,
|
|
9421
12121
|
reportRedundancy: options.reportRedundancy ?? true,
|
|
9422
|
-
semantic: fillSemanticConfig(options.semantic)
|
|
12122
|
+
semantic: fillSemanticConfig(options.semantic),
|
|
12123
|
+
duplicateBlocks: fillDuplicateBlocksConfig(options.duplicateBlocks),
|
|
12124
|
+
featureFlags: fillFeatureFlagsConfig(options.featureFlags),
|
|
12125
|
+
complexity: fillComplexityConfig(options.complexity)
|
|
9423
12126
|
});
|
|
9424
12127
|
const buildEmptyScanResult = (errors, elapsedMs) => ({
|
|
9425
12128
|
unusedFiles: [],
|
|
@@ -9440,6 +12143,18 @@ const buildEmptyScanResult = (errors, elapsedMs) => ({
|
|
|
9440
12143
|
simplifiableFunctions: [],
|
|
9441
12144
|
simplifiableExpressions: [],
|
|
9442
12145
|
duplicateConstants: [],
|
|
12146
|
+
crossFileDuplicateExports: [],
|
|
12147
|
+
duplicateBlocks: [],
|
|
12148
|
+
duplicateBlockClusters: [],
|
|
12149
|
+
shadowedDirectoryPairs: [],
|
|
12150
|
+
reExportCycles: [],
|
|
12151
|
+
featureFlags: [],
|
|
12152
|
+
complexFunctions: [],
|
|
12153
|
+
privateTypeLeaks: [],
|
|
12154
|
+
unnecessaryAssertions: [],
|
|
12155
|
+
lazyImportsAtTopLevel: [],
|
|
12156
|
+
commonjsInEsm: [],
|
|
12157
|
+
typeScriptEscapeHatches: [],
|
|
9443
12158
|
analysisErrors: errors,
|
|
9444
12159
|
totalFiles: 0,
|
|
9445
12160
|
totalExports: 0,
|
|
@@ -9510,11 +12225,7 @@ const analyze = async (config) => {
|
|
|
9510
12225
|
}));
|
|
9511
12226
|
}
|
|
9512
12227
|
const absoluteRoot = (0, node_path.resolve)(config.rootDir);
|
|
9513
|
-
const outputDirectoryExclusions = OUTPUT_DIRECTORIES.flatMap((outputDirectory) => {
|
|
9514
|
-
const exclusions = [`${absoluteRoot}/${outputDirectory}/**`];
|
|
9515
|
-
for (const workspacePackage of workspacePackages) exclusions.push(`${workspacePackage.directory}/${outputDirectory}/**`);
|
|
9516
|
-
return exclusions;
|
|
9517
|
-
});
|
|
12228
|
+
const outputDirectoryExclusions = OUTPUT_DIRECTORIES.flatMap((outputDirectory) => [`${absoluteRoot}/${outputDirectory}/**`, `${absoluteRoot}/**/${outputDirectory}/**`]);
|
|
9518
12229
|
const allExclusionPatterns = [
|
|
9519
12230
|
...workspaceDiscovery.excludedDirectories.map((directory) => `${directory}/**`),
|
|
9520
12231
|
...frameworkIgnorePatterns,
|
|
@@ -9714,6 +12425,7 @@ const analyze = async (config) => {
|
|
|
9714
12425
|
detail: describeUnknownError(reExportError)
|
|
9715
12426
|
}));
|
|
9716
12427
|
}
|
|
12428
|
+
markFilenameRegistryEntries(moduleGraph);
|
|
9717
12429
|
try {
|
|
9718
12430
|
traceReachability(moduleGraph);
|
|
9719
12431
|
} catch (reachabilityError) {
|