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.mjs CHANGED
@@ -219,6 +219,7 @@ const BUILTIN_MODULES = new Set([
219
219
  ]);
220
220
  const PLATFORM_SUFFIXES = [
221
221
  ".web",
222
+ ".react-native",
222
223
  ".native",
223
224
  ".ios",
224
225
  ".android",
@@ -226,6 +227,7 @@ const PLATFORM_SUFFIXES = [
226
227
  ".windows",
227
228
  ".macos",
228
229
  ".any",
230
+ ".react-server",
229
231
  ".server",
230
232
  ".client"
231
233
  ];
@@ -921,7 +923,7 @@ const visitFunctionParameters = (parameters, captures, functionName) => {
921
923
  inspectTypeAnnotation(parameter.typeAnnotation, captures, "function-parameter", functionName ? `${functionName}(${parameterIdentifierName ?? "?"})` : parameterIdentifierName);
922
924
  }
923
925
  };
924
- const visitFunctionLike = (functionNode, captures, functionName) => {
926
+ const visitFunctionLike$1 = (functionNode, captures, functionName) => {
925
927
  const parameters = functionNode.params;
926
928
  visitFunctionParameters(parameters, captures, functionName);
927
929
  const returnTypeNode = functionNode.returnType;
@@ -936,7 +938,7 @@ const visitVariableDeclaration = (declarationNode, captures, enclosingName) => {
936
938
  const declarationName = getIdentifierName(declarator.id);
937
939
  inspectTypeAnnotation(declarator.typeAnnotation ?? (declarator.id && isOxcAstNode(declarator.id) ? declarator.id.typeAnnotation : void 0), captures, "variable-annotation", declarationName);
938
940
  const initializerNode = declarator.init;
939
- if (isOxcAstNode(initializerNode)) if (initializerNode.type === "ArrowFunctionExpression" || initializerNode.type === "FunctionExpression") visitFunctionLike(initializerNode, captures, declarationName ?? enclosingName);
941
+ if (isOxcAstNode(initializerNode)) if (initializerNode.type === "ArrowFunctionExpression" || initializerNode.type === "FunctionExpression") visitFunctionLike$1(initializerNode, captures, declarationName ?? enclosingName);
940
942
  else walkExpressionForInlineTypes(initializerNode, captures, declarationName ?? enclosingName);
941
943
  }
942
944
  };
@@ -948,7 +950,7 @@ const walkBodyForInlineTypes = (bodyNode, captures, enclosingName, recursionDept
948
950
  for (const statement of statements) {
949
951
  if (!isOxcAstNode(statement)) continue;
950
952
  if (statement.type === "VariableDeclaration") visitVariableDeclaration(statement, captures, enclosingName);
951
- else if (statement.type === "FunctionDeclaration") visitFunctionLike(statement, captures, getIdentifierName(statement.id) ?? enclosingName);
953
+ else if (statement.type === "FunctionDeclaration") visitFunctionLike$1(statement, captures, getIdentifierName(statement.id) ?? enclosingName);
952
954
  else if (statement.type === "TSTypeAliasDeclaration") {
953
955
  const typeAliasName = getIdentifierName(statement.id);
954
956
  captureIfTypeLiteral(statement.typeAnnotation, captures, "local-type-alias", typeAliasName);
@@ -961,7 +963,7 @@ const walkExpressionForInlineTypes = (expressionNode, captures, enclosingName, r
961
963
  if (recursionDepth > 200) return;
962
964
  if (!isOxcAstNode(expressionNode)) return;
963
965
  if (expressionNode.type === "ArrowFunctionExpression" || expressionNode.type === "FunctionExpression") {
964
- visitFunctionLike(expressionNode, captures, enclosingName);
966
+ visitFunctionLike$1(expressionNode, captures, enclosingName);
965
967
  return;
966
968
  }
967
969
  for (const value of Object.values(expressionNode)) if (Array.isArray(value)) for (const element of value) walkExpressionForInlineTypes(element, captures, enclosingName, recursionDepth + 1);
@@ -972,7 +974,7 @@ const visitTopLevelStatement = (statementNode, captures) => {
972
974
  const innerNode = statementNode.type === "ExportNamedDeclaration" || statementNode.type === "ExportDefaultDeclaration" ? statementNode.declaration ?? statementNode : statementNode;
973
975
  const targetNode = isOxcAstNode(innerNode) ? innerNode : statementNode;
974
976
  if (targetNode.type === "FunctionDeclaration") {
975
- visitFunctionLike(targetNode, captures, getIdentifierName(targetNode.id));
977
+ visitFunctionLike$1(targetNode, captures, getIdentifierName(targetNode.id));
976
978
  return;
977
979
  }
978
980
  if (targetNode.type === "VariableDeclaration") {
@@ -992,7 +994,7 @@ const visitTopLevelStatement = (statementNode, captures) => {
992
994
  }
993
995
  if (memberCandidate.type === "MethodDefinition" || memberCandidate.type === "TSAbstractMethodDefinition") {
994
996
  const methodValue = memberCandidate.value;
995
- if (isOxcAstNode(methodValue)) visitFunctionLike(methodValue, captures, qualifiedName);
997
+ if (isOxcAstNode(methodValue)) visitFunctionLike$1(methodValue, captures, qualifiedName);
996
998
  }
997
999
  }
998
1000
  return;
@@ -1047,10 +1049,22 @@ const containsCallOrPromiseSurface = (node, recursionDepth = 0) => {
1047
1049
  }
1048
1050
  return false;
1049
1051
  };
1052
+ const unwrapParenthesizedExpression = (node) => {
1053
+ let current = node;
1054
+ while (current.type === "ParenthesizedExpression") {
1055
+ const inner = current.expression;
1056
+ if (!inner || !isOxcAstNode(inner)) return current;
1057
+ current = inner;
1058
+ }
1059
+ return current;
1060
+ };
1050
1061
  const isSimpleReturnArgument = (argumentNode) => {
1051
1062
  if (!isOxcAstNode(argumentNode)) return false;
1052
- if (argumentNode.type === "BlockStatement") return false;
1053
- if (argumentNode.type === "ObjectExpression") return false;
1063
+ const unwrapped = unwrapParenthesizedExpression(argumentNode);
1064
+ if (unwrapped.type === "BlockStatement") return false;
1065
+ if (unwrapped.type === "ObjectExpression") return false;
1066
+ if (unwrapped.type === "JSXElement") return false;
1067
+ if (unwrapped.type === "JSXFragment") return false;
1054
1068
  return true;
1055
1069
  };
1056
1070
  const detectBlockArrowSingleReturn = (functionNode) => {
@@ -1104,9 +1118,34 @@ const detectRedundantAwaitReturn = (functionNode) => {
1104
1118
  };
1105
1119
  };
1106
1120
  const isAsyncFunction = (functionNode) => Boolean(functionNode.async);
1107
- const detectUselessAsync = (functionNode) => {
1121
+ const containsPromiseTypeReference = (node, recursionDepth = 0) => {
1122
+ if (recursionDepth > 30) return false;
1123
+ if (!isOxcAstNode(node)) return false;
1124
+ if (node.type === "TSTypeReference") {
1125
+ const typeName = node.typeName;
1126
+ if (typeName?.name === "Promise") return true;
1127
+ if (typeName?.right?.name === "Promise") return true;
1128
+ }
1129
+ for (const value of Object.values(node)) if (Array.isArray(value)) {
1130
+ for (const element of value) if (containsPromiseTypeReference(element, recursionDepth + 1)) return true;
1131
+ } else if (isOxcAstNode(value)) {
1132
+ if (containsPromiseTypeReference(value, recursionDepth + 1)) return true;
1133
+ }
1134
+ return false;
1135
+ };
1136
+ const hasExplicitPromiseReturnType = (functionNode) => {
1137
+ const returnType = functionNode.returnType;
1138
+ if (!returnType || !isOxcAstNode(returnType)) return false;
1139
+ const annotation = returnType.typeAnnotation;
1140
+ if (!annotation || !isOxcAstNode(annotation)) return false;
1141
+ return containsPromiseTypeReference(annotation);
1142
+ };
1143
+ const detectUselessAsync = (functionNode, context) => {
1108
1144
  if (!isAsyncFunction(functionNode)) return void 0;
1109
1145
  if (functionNode.type === "ClassDeclaration" || functionNode.type === "MethodDefinition") return;
1146
+ if (context.isMethodContext) return void 0;
1147
+ if (context.isInlineCallback) return void 0;
1148
+ if (hasExplicitPromiseReturnType(functionNode)) return void 0;
1110
1149
  const bodyNode = functionNode.body;
1111
1150
  if (!isOxcAstNode(bodyNode)) return void 0;
1112
1151
  if (containsAwaitExpression(bodyNode)) return void 0;
@@ -1118,14 +1157,14 @@ const detectUselessAsync = (functionNode) => {
1118
1157
  suggestion: "drop `async` (caller's existing `await` keeps the type identical) or add an explicit return type"
1119
1158
  };
1120
1159
  };
1121
- const detectSimplifiableFunctionPatterns = (functionNode) => {
1160
+ const detectSimplifiableFunctionPatterns = (functionNode, context = {}) => {
1122
1161
  if (!isOxcAstNode(functionNode)) return [];
1123
1162
  const findings = [];
1124
1163
  const blockArrow = detectBlockArrowSingleReturn(functionNode);
1125
1164
  if (blockArrow) findings.push(blockArrow);
1126
1165
  const awaitReturn = detectRedundantAwaitReturn(functionNode);
1127
1166
  if (awaitReturn) findings.push(awaitReturn);
1128
- const uselessAsync = detectUselessAsync(functionNode);
1167
+ const uselessAsync = detectUselessAsync(functionNode, context);
1129
1168
  if (uselessAsync) findings.push(uselessAsync);
1130
1169
  return findings;
1131
1170
  };
@@ -1138,9 +1177,12 @@ const inferFunctionName = (functionNode, parentContext) => {
1138
1177
  if (declaredId?.name) return declaredId.name;
1139
1178
  return parentContext;
1140
1179
  };
1141
- const visitFunctionAndDescend = (functionNode, captures, contextName, recursionDepth) => {
1180
+ const visitFunctionAndDescend = (functionNode, captures, contextName, recursionDepth, isMethodContext, isInlineCallback) => {
1142
1181
  const functionName = inferFunctionName(functionNode, contextName);
1143
- const detections = detectSimplifiableFunctionPatterns(functionNode);
1182
+ const detections = detectSimplifiableFunctionPatterns(functionNode, {
1183
+ isMethodContext,
1184
+ isInlineCallback
1185
+ });
1144
1186
  for (const detection of detections) captures.push({
1145
1187
  kind: detection.kind,
1146
1188
  functionName,
@@ -1153,10 +1195,13 @@ const visitFunctionAndDescend = (functionNode, captures, contextName, recursionD
1153
1195
  const parameters = functionNode.params ?? [];
1154
1196
  for (const parameter of parameters) if (isOxcAstNode(parameter)) walkForFunctions(parameter, captures, functionName, recursionDepth + 1);
1155
1197
  };
1198
+ const isObjectMethodShorthand = (node) => (node.type === "Property" || node.type === "ObjectProperty") && node.method === true;
1199
+ const isObjectPropertyAssignment = (node) => (node.type === "Property" || node.type === "ObjectProperty") && node.method !== true;
1200
+ const isCallOrNewExpression = (node) => node.type === "CallExpression" || node.type === "NewExpression";
1156
1201
  const walkForFunctions = (node, captures, contextName, recursionDepth = 0) => {
1157
1202
  if (recursionDepth > 200) return;
1158
1203
  if (looksLikeFunction(node)) {
1159
- visitFunctionAndDescend(node, captures, contextName, recursionDepth);
1204
+ visitFunctionAndDescend(node, captures, contextName, recursionDepth, false, false);
1160
1205
  return;
1161
1206
  }
1162
1207
  let nextContext = contextName;
@@ -1172,6 +1217,35 @@ const walkForFunctions = (node, captures, contextName, recursionDepth = 0) => {
1172
1217
  const className = getIdentifierName(node.id);
1173
1218
  if (className) nextContext = className;
1174
1219
  }
1220
+ if (node.type === "MethodDefinition" || isObjectMethodShorthand(node)) {
1221
+ const methodValue = node.value;
1222
+ if (methodValue && isOxcAstNode(methodValue) && looksLikeFunction(methodValue)) {
1223
+ visitFunctionAndDescend(methodValue, captures, getIdentifierName(node.key) ?? nextContext, recursionDepth + 1, true, false);
1224
+ const keyNode = node.key;
1225
+ if (keyNode && isOxcAstNode(keyNode) && node.computed) walkForFunctions(keyNode, captures, nextContext, recursionDepth + 1);
1226
+ return;
1227
+ }
1228
+ }
1229
+ if (isObjectPropertyAssignment(node)) {
1230
+ const propertyValue = node.value;
1231
+ if (propertyValue && isOxcAstNode(propertyValue) && looksLikeFunction(propertyValue)) {
1232
+ visitFunctionAndDescend(propertyValue, captures, getIdentifierName(node.key) ?? nextContext, recursionDepth + 1, false, true);
1233
+ const keyNode = node.key;
1234
+ if (keyNode && isOxcAstNode(keyNode) && node.computed) walkForFunctions(keyNode, captures, nextContext, recursionDepth + 1);
1235
+ return;
1236
+ }
1237
+ }
1238
+ if (isCallOrNewExpression(node)) {
1239
+ const callee = node.callee;
1240
+ if (callee && isOxcAstNode(callee)) walkForFunctions(callee, captures, nextContext, recursionDepth + 1);
1241
+ const callArguments = node.arguments ?? [];
1242
+ for (const argument of callArguments) {
1243
+ if (!isOxcAstNode(argument)) continue;
1244
+ if (looksLikeFunction(argument)) visitFunctionAndDescend(argument, captures, nextContext, recursionDepth + 1, false, true);
1245
+ else walkForFunctions(argument, captures, nextContext, recursionDepth + 1);
1246
+ }
1247
+ return;
1248
+ }
1175
1249
  for (const value of Object.values(node)) if (Array.isArray(value)) {
1176
1250
  for (const element of value) if (isOxcAstNode(element)) walkForFunctions(element, captures, nextContext, recursionDepth + 1);
1177
1251
  } else if (isOxcAstNode(value)) walkForFunctions(value, captures, nextContext, recursionDepth + 1);
@@ -1504,10 +1578,15 @@ const CSS_EXTENSIONS = [
1504
1578
  ];
1505
1579
  const CSS_IMPORT_PATTERN = /@import\s+(?:url\()?['"]([^'"]+)['"]\)?/g;
1506
1580
  const SCSS_USE_FORWARD_PATTERN = /@(?:use|forward)\s+['"]([^'"]+)['"]/g;
1581
+ const TAILWIND_PLUGIN_REFERENCE_PATTERN = /@(?:plugin|reference|config)\s+['"]([^'"]+)['"]/g;
1507
1582
  const parseCssImports = (filePath) => {
1508
1583
  const sourceText = readFileSync(filePath, "utf-8");
1509
1584
  const imports = [];
1510
- const patterns = [CSS_IMPORT_PATTERN, SCSS_USE_FORWARD_PATTERN];
1585
+ const patterns = [
1586
+ CSS_IMPORT_PATTERN,
1587
+ SCSS_USE_FORWARD_PATTERN,
1588
+ TAILWIND_PLUGIN_REFERENCE_PATTERN
1589
+ ];
1511
1590
  for (const pattern of patterns) {
1512
1591
  let match;
1513
1592
  pattern.lastIndex = 0;
@@ -1530,6 +1609,7 @@ const parseCssImports = (filePath) => {
1530
1609
  memberAccesses: [],
1531
1610
  wholeObjectUses: [],
1532
1611
  localIdentifierReferences: [],
1612
+ referencedFilenames: [],
1533
1613
  redundantTypePatterns: [],
1534
1614
  identityWrappers: [],
1535
1615
  typeDefinitionHashes: [],
@@ -1569,6 +1649,7 @@ const createEmptyParsedSource = () => ({
1569
1649
  memberAccesses: [],
1570
1650
  wholeObjectUses: [],
1571
1651
  localIdentifierReferences: [],
1652
+ referencedFilenames: [],
1572
1653
  redundantTypePatterns: [],
1573
1654
  identityWrappers: [],
1574
1655
  typeDefinitionHashes: [],
@@ -1712,6 +1793,7 @@ const parseSourceFile = (filePath) => {
1712
1793
  ...createEmptyParsedSource(),
1713
1794
  imports,
1714
1795
  exports,
1796
+ referencedFilenames: extractReferencedFilenames(sourceText),
1715
1797
  errors: [...earlyErrors, new ParseError({
1716
1798
  code: "parse-recovered",
1717
1799
  severity: "info",
@@ -1724,6 +1806,7 @@ const parseSourceFile = (filePath) => {
1724
1806
  ...createEmptyParsedSource(),
1725
1807
  imports,
1726
1808
  exports,
1809
+ referencedFilenames: extractReferencedFilenames(sourceText),
1727
1810
  errors: [...earlyErrors, new ParseError({
1728
1811
  code: "parse-failed",
1729
1812
  message: "oxc-parser returned no program body",
@@ -1776,50 +1859,63 @@ const parseSourceFile = (filePath) => {
1776
1859
  safeWalk("collectDryPatterns", () => {
1777
1860
  collectDryPatterns(program.body, sourceText, redundantTypePatterns, identityWrappers, typeDefinitionHashes);
1778
1861
  }, void 0);
1862
+ const inlineTypeLiterals = safeWalk("collectInlineTypeLiterals", () => collectInlineTypeLiterals(program.body), []).map((capture) => ({
1863
+ structuralHash: capture.structuralHash,
1864
+ memberCount: capture.memberCount,
1865
+ preview: capture.preview,
1866
+ context: capture.context,
1867
+ nearestName: capture.nearestName,
1868
+ line: getLineFromOffset(sourceText, capture.startOffset),
1869
+ column: getColumnFromOffset(sourceText, capture.startOffset)
1870
+ }));
1871
+ const simplifiableFunctions = safeWalk("collectSimplifiableFunctions", () => collectSimplifiableFunctions(program.body), []).map((capture) => ({
1872
+ kind: capture.kind,
1873
+ functionName: capture.functionName,
1874
+ line: getLineFromOffset(sourceText, capture.startOffset),
1875
+ column: getColumnFromOffset(sourceText, capture.startOffset),
1876
+ reason: capture.reason,
1877
+ suggestion: capture.suggestion
1878
+ }));
1879
+ const simplifiableExpressions = safeWalk("collectSimplifiableExpressions", () => collectSimplifiableExpressions(program.body), []).map((capture) => ({
1880
+ kind: capture.kind,
1881
+ snippet: capture.snippet,
1882
+ line: getLineFromOffset(sourceText, capture.startOffset),
1883
+ column: getColumnFromOffset(sourceText, capture.startOffset),
1884
+ reason: capture.reason,
1885
+ suggestion: capture.suggestion
1886
+ }));
1887
+ const duplicateConstantCandidates = safeWalk("collectDuplicateConstantCandidates", () => collectDuplicateConstantCandidates(program.body), []).map((capture) => ({
1888
+ constantName: capture.constantName,
1889
+ literalHash: capture.literalHash,
1890
+ literalPreview: capture.literalPreview,
1891
+ line: getLineFromOffset(sourceText, capture.startOffset),
1892
+ column: getColumnFromOffset(sourceText, capture.startOffset)
1893
+ }));
1779
1894
  return {
1780
1895
  imports,
1781
1896
  exports,
1782
1897
  memberAccesses,
1783
1898
  wholeObjectUses,
1784
1899
  localIdentifierReferences,
1900
+ referencedFilenames: extractReferencedFilenames(sourceText),
1785
1901
  redundantTypePatterns,
1786
1902
  identityWrappers,
1787
1903
  typeDefinitionHashes,
1788
- inlineTypeLiterals: safeWalk("collectInlineTypeLiterals", () => collectInlineTypeLiterals(program.body), []).map((capture) => ({
1789
- structuralHash: capture.structuralHash,
1790
- memberCount: capture.memberCount,
1791
- preview: capture.preview,
1792
- context: capture.context,
1793
- nearestName: capture.nearestName,
1794
- line: getLineFromOffset(sourceText, capture.startOffset),
1795
- column: getColumnFromOffset(sourceText, capture.startOffset)
1796
- })),
1797
- simplifiableFunctions: safeWalk("collectSimplifiableFunctions", () => collectSimplifiableFunctions(program.body), []).map((capture) => ({
1798
- kind: capture.kind,
1799
- functionName: capture.functionName,
1800
- line: getLineFromOffset(sourceText, capture.startOffset),
1801
- column: getColumnFromOffset(sourceText, capture.startOffset),
1802
- reason: capture.reason,
1803
- suggestion: capture.suggestion
1804
- })),
1805
- simplifiableExpressions: safeWalk("collectSimplifiableExpressions", () => collectSimplifiableExpressions(program.body), []).map((capture) => ({
1806
- kind: capture.kind,
1807
- snippet: capture.snippet,
1808
- line: getLineFromOffset(sourceText, capture.startOffset),
1809
- column: getColumnFromOffset(sourceText, capture.startOffset),
1810
- reason: capture.reason,
1811
- suggestion: capture.suggestion
1812
- })),
1813
- duplicateConstantCandidates: safeWalk("collectDuplicateConstantCandidates", () => collectDuplicateConstantCandidates(program.body), []).map((capture) => ({
1814
- constantName: capture.constantName,
1815
- literalHash: capture.literalHash,
1816
- literalPreview: capture.literalPreview,
1817
- line: getLineFromOffset(sourceText, capture.startOffset),
1818
- column: getColumnFromOffset(sourceText, capture.startOffset)
1819
- })),
1904
+ inlineTypeLiterals,
1905
+ simplifiableFunctions,
1906
+ simplifiableExpressions,
1907
+ duplicateConstantCandidates,
1820
1908
  errors: [...earlyErrors, ...detectorErrors]
1821
1909
  };
1822
1910
  };
1911
+ const REFERENCED_FILENAME_LITERAL_PATTERN = /(?<![./@\w-])(?:["'`])([a-z][\w-]*\.(?:ts|tsx|js|jsx|mts|mjs|cts|cjs))(?:["'`])/g;
1912
+ const extractReferencedFilenames = (sourceText) => {
1913
+ const captured = /* @__PURE__ */ new Set();
1914
+ REFERENCED_FILENAME_LITERAL_PATTERN.lastIndex = 0;
1915
+ let match;
1916
+ while ((match = REFERENCED_FILENAME_LITERAL_PATTERN.exec(sourceText)) !== null) captured.add(match[1]);
1917
+ return [...captured];
1918
+ };
1823
1919
  const collectDryPatterns = (bodyNodes, sourceText, redundantTypePatterns, identityWrappers, typeDefinitionHashes) => {
1824
1920
  for (const statement of bodyNodes) inspectStatement(statement, sourceText, redundantTypePatterns, identityWrappers, typeDefinitionHashes);
1825
1921
  };
@@ -3079,6 +3175,8 @@ const resolveSourcePath = (distPath, directory) => {
3079
3175
  const sourceCandidate = resolve(directory, withoutExtension + sourceExtension);
3080
3176
  if (existsSync(sourceCandidate)) return sourceCandidate;
3081
3177
  }
3178
+ const indexPrefixedCandidate = resolveWithIndexPrefix(withoutExtension, directory);
3179
+ if (indexPrefixedCandidate) return indexPrefixedCandidate;
3082
3180
  }
3083
3181
  if (matchesOutputDirectory(relativeToDist)) for (const stem of SOURCE_INDEX_FALLBACK_STEMS) for (const sourceExtension of SOURCE_EXTENSIONS$1) {
3084
3182
  const fallbackCandidate = resolve(directory, stem + sourceExtension);
@@ -3092,6 +3190,15 @@ const resolveSourcePath = (distPath, directory) => {
3092
3190
  }
3093
3191
  const indexCandidate = resolve(directory, withoutExtension, "index.ts");
3094
3192
  if (existsSync(indexCandidate)) return indexCandidate;
3193
+ const indexPrefixedCandidate = resolveWithIndexPrefix(withoutExtension, directory);
3194
+ if (indexPrefixedCandidate) return indexPrefixedCandidate;
3195
+ }
3196
+ };
3197
+ const resolveWithIndexPrefix = (stemPath, directory) => {
3198
+ const indexPrefixedStem = `${dirname(stemPath)}/index.${basename(stemPath)}`;
3199
+ for (const sourceExtension of SOURCE_EXTENSIONS$1) {
3200
+ const candidate = resolve(directory, indexPrefixedStem + sourceExtension);
3201
+ if (existsSync(candidate)) return candidate;
3095
3202
  }
3096
3203
  };
3097
3204
 
@@ -3421,8 +3528,11 @@ const resolveEntries = async (config) => {
3421
3528
  }
3422
3529
  const frameworkEntries = detectFrameworkEntries(absoluteRoot);
3423
3530
  const entryEligiblePackages = workspacePackages.filter(isEntryEligible);
3531
+ const monorepoRootForEntries = findMonorepoRoot(absoluteRoot);
3532
+ const ancestorPackageJsonRoots = monorepoRootForEntries && monorepoRootForEntries !== absoluteRoot ? [monorepoRootForEntries] : [];
3424
3533
  const scriptEntries = extractScriptEntries(absoluteRoot);
3425
3534
  for (const workspacePackage of entryEligiblePackages) scriptEntries.push(...extractScriptEntries(workspacePackage.directory));
3535
+ for (const ancestorRoot of ancestorPackageJsonRoots) for (const entryPath of extractScriptEntries(ancestorRoot)) if (entryPath.startsWith(`${absoluteRoot}/`)) scriptEntries.push(entryPath);
3426
3536
  const webpackEntries = extractWebpackEntryPoints(absoluteRoot);
3427
3537
  for (const workspacePackage of entryEligiblePackages) webpackEntries.push(...extractWebpackEntryPoints(workspacePackage.directory));
3428
3538
  const viteEntries = extractViteEntryPoints(absoluteRoot);
@@ -3695,6 +3805,7 @@ const SCRIPT_MULTIPLEXERS = new Set([
3695
3805
  "lerna",
3696
3806
  "ultra"
3697
3807
  ]);
3808
+ const TSCONFIG_PROJECT_FLAGS = new Set(["--project", "-p"]);
3698
3809
  const CONFIG_LIKE_FLAGS = new Set([
3699
3810
  "--config",
3700
3811
  "-c",
@@ -3840,7 +3951,8 @@ const extractScriptFileArguments = (scriptCommand, directory) => {
3840
3951
  const configPath = tokens[tokenIndex + 1].replace(/^['"]|['"]$/g, "");
3841
3952
  if (looksLikeFilePath(configPath)) {
3842
3953
  const absoluteConfigPath = resolve(directory, configPath);
3843
- if (existsSync(absoluteConfigPath)) entries.push(absoluteConfigPath);
3954
+ if (existsSync(absoluteConfigPath)) if (TSCONFIG_PROJECT_FLAGS.has(token) && TSCONFIG_PROJECT_PATTERN.test(absoluteConfigPath)) entries.push(...expandTsConfigProjectEntries(absoluteConfigPath));
3955
+ else entries.push(absoluteConfigPath);
3844
3956
  }
3845
3957
  tokenIndex++;
3846
3958
  }
@@ -3849,9 +3961,11 @@ const extractScriptFileArguments = (scriptCommand, directory) => {
3849
3961
  const equalsIndex = token.indexOf("=");
3850
3962
  if (equalsIndex > 0 && CONFIG_LIKE_FLAGS.has(token.slice(0, equalsIndex))) {
3851
3963
  const configValue = token.slice(equalsIndex + 1);
3964
+ const flagName = token.slice(0, equalsIndex);
3852
3965
  if (configValue && looksLikeFilePath(configValue)) {
3853
3966
  const absoluteConfigPath = resolve(directory, configValue);
3854
- if (existsSync(absoluteConfigPath)) entries.push(absoluteConfigPath);
3967
+ if (existsSync(absoluteConfigPath)) if (TSCONFIG_PROJECT_FLAGS.has(flagName) && TSCONFIG_PROJECT_PATTERN.test(absoluteConfigPath)) entries.push(...expandTsConfigProjectEntries(absoluteConfigPath));
3968
+ else entries.push(absoluteConfigPath);
3855
3969
  }
3856
3970
  continue;
3857
3971
  }
@@ -4154,6 +4268,7 @@ const extractScriptTagsFromHtmlFile = (htmlFilePath) => {
4154
4268
  return entries;
4155
4269
  };
4156
4270
  const TSCONFIG_FILENAME_GLOBS = ["tsconfig.json", "tsconfig.*.json"];
4271
+ const TSCONFIG_PROJECT_PATTERN = /(?:^|[\\/])tsconfig(?:\.[^.]+)?\.json$/;
4157
4272
  const stripJsoncCommentsLocal = (sourceText) => {
4158
4273
  let result = "";
4159
4274
  let insideString = false;
@@ -4225,6 +4340,34 @@ const extractTsConfigIncludeFilesEntries = (directory) => {
4225
4340
  } catch {}
4226
4341
  return entries;
4227
4342
  };
4343
+ const expandTsConfigProjectEntries = (tsconfigAbsolutePath) => {
4344
+ const entries = [];
4345
+ try {
4346
+ const cleaned = stripJsoncCommentsLocal(readFileSync(tsconfigAbsolutePath, "utf-8"));
4347
+ const tsconfigJson = JSON.parse(cleaned);
4348
+ const tsconfigDir = dirname(tsconfigAbsolutePath);
4349
+ if (Array.isArray(tsconfigJson.files)) for (const fileItem of tsconfigJson.files) {
4350
+ if (typeof fileItem !== "string") continue;
4351
+ const candidatePath = resolve(tsconfigDir, fileItem);
4352
+ if (existsSync(candidatePath)) entries.push(candidatePath);
4353
+ }
4354
+ if (Array.isArray(tsconfigJson.include)) for (const includePattern of tsconfigJson.include) {
4355
+ if (typeof includePattern !== "string") continue;
4356
+ const expandedFiles = fg.sync(includePattern, {
4357
+ cwd: tsconfigDir,
4358
+ absolute: true,
4359
+ onlyFiles: true,
4360
+ ignore: [
4361
+ "**/node_modules/**",
4362
+ "**/dist/**",
4363
+ "**/build/**"
4364
+ ]
4365
+ });
4366
+ entries.push(...expandedFiles);
4367
+ }
4368
+ } catch {}
4369
+ return entries;
4370
+ };
4228
4371
  const WRANGLER_TOML_MAIN_PATTERN = /^\s*main\s*=\s*['"]([^'"\n]+)['"]/m;
4229
4372
  const WRANGLER_JSON_MAIN_PATTERN = /"main"\s*:\s*"([^"]+)"/;
4230
4373
  const WRANGLER_SERVICE_BINDINGS_PATTERN = /entry_point\s*=\s*['"]([^'"\n]+)['"]/g;
@@ -5655,6 +5798,40 @@ const discoverToolingEntryPoints = (rootDir, workspacePackages) => {
5655
5798
  };
5656
5799
  };
5657
5800
 
5801
+ //#endregion
5802
+ //#region src/utils/is-platform-builtin-or-virtual.ts
5803
+ const BUILTIN_SUBPATH_NODE_MODULES = new Set([
5804
+ "fs",
5805
+ "dns",
5806
+ "stream",
5807
+ "readline",
5808
+ "timers",
5809
+ "util",
5810
+ "test",
5811
+ "assert",
5812
+ "inspector",
5813
+ "path"
5814
+ ]);
5815
+ /**
5816
+ * True for module specifiers that don't correspond to a real on-disk
5817
+ * package — Node / Bun / Cloudflare / Sass built-ins, the Deno `std`
5818
+ * bare specifier, and Vite `virtual:` modules — so they aren't mistakenly
5819
+ * surfaced as `unused-dependency` or `unresolved-import`.
5820
+ */
5821
+ const isPlatformBuiltinOrVirtualSpecifier = (specifier) => {
5822
+ if (specifier.startsWith("virtual:")) return true;
5823
+ if (specifier === "bun" || specifier.startsWith("bun:")) return true;
5824
+ if (specifier.startsWith("cloudflare:")) return true;
5825
+ if (specifier.startsWith("sass:")) return true;
5826
+ if (specifier === "std" || specifier.startsWith("std/")) return true;
5827
+ const stripped = specifier.startsWith("node:") ? specifier.slice(5) : specifier;
5828
+ const slashIndex = stripped.indexOf("/");
5829
+ if (slashIndex === -1) return BUILTIN_MODULES.has(stripped);
5830
+ const baseName = stripped.slice(0, slashIndex);
5831
+ if (!BUILTIN_MODULES.has(baseName)) return false;
5832
+ return BUILTIN_SUBPATH_NODE_MODULES.has(baseName);
5833
+ };
5834
+
5658
5835
  //#endregion
5659
5836
  //#region src/resolver/resolve.ts
5660
5837
  const fileExistsCache = /* @__PURE__ */ new Map();
@@ -6449,21 +6626,7 @@ const stripJsonComments = (content) => {
6449
6626
  }
6450
6627
  return result.replace(/,(\s*[}\]])/g, "$1");
6451
6628
  };
6452
- const BUILTIN_SUBPATH_MODULES = new Set([
6453
- "fs",
6454
- "dns",
6455
- "stream",
6456
- "readline",
6457
- "timers",
6458
- "util"
6459
- ]);
6460
- const isBuiltinModule = (specifier) => {
6461
- if (specifier.startsWith("node:")) return true;
6462
- const baseName = specifier.split("/")[0];
6463
- if (!BUILTIN_MODULES.has(baseName)) return false;
6464
- if (!specifier.includes("/")) return true;
6465
- return BUILTIN_SUBPATH_MODULES.has(baseName);
6466
- };
6629
+ const isBuiltinModule = (specifier) => isPlatformBuiltinOrVirtualSpecifier(specifier);
6467
6630
  const isBareSpecifier = (specifier) => !specifier.startsWith(".") && !specifier.startsWith("/");
6468
6631
  const extractPackageNameFromSpecifier = (specifier) => {
6469
6632
  if (specifier.startsWith("node:")) return specifier.slice(5).split("/")[0];
@@ -6496,6 +6659,7 @@ const buildDependencyGraph = (inputs) => {
6496
6659
  memberAccesses: input.parsed.memberAccesses,
6497
6660
  wholeObjectUses: input.parsed.wholeObjectUses,
6498
6661
  localIdentifierReferences: input.parsed.localIdentifierReferences,
6662
+ referencedFilenames: input.parsed.referencedFilenames,
6499
6663
  redundantTypePatterns: input.parsed.redundantTypePatterns,
6500
6664
  identityWrappers: input.parsed.identityWrappers,
6501
6665
  typeDefinitionHashes: input.parsed.typeDefinitionHashes,
@@ -6823,6 +6987,21 @@ const hasExcludedExtension = (filePath) => {
6823
6987
  return EXCLUDED_EXTENSIONS.has(filePath.slice(lastDot));
6824
6988
  };
6825
6989
  const isExcludedByPattern = (filePath) => TEST_FILE_PATTERN.test(filePath) || EXCLUDED_DIRECTORY_PATTERN.test(filePath) || CONFIG_FILE_PATTERN.test(filePath);
6990
+ /**
6991
+ * Files the parser couldn't analyze (minified bundles, oversized files, binaries)
6992
+ * have no detectable imports — they're effectively opaque. Flagging them as
6993
+ * "unused" is a false positive because we can't see who imports them, and they
6994
+ * may be static assets, generated bundles, or build artifacts that get loaded
6995
+ * outside the JS module graph (HTML `<script src>`, `vite-plugin-string`, etc.).
6996
+ * The parser already records a `file-minified`/`file-too-large`/`file-binary`
6997
+ * info-level entry in `analysisErrors`, which is the actionable signal.
6998
+ */
6999
+ const PARSE_OPAQUE_ERROR_CODES = new Set([
7000
+ "file-minified",
7001
+ "file-too-large",
7002
+ "file-binary"
7003
+ ]);
7004
+ const isOpaqueToAnalysis = (module) => module.parseErrors.some((parseError) => parseError.code && PARSE_OPAQUE_ERROR_CODES.has(parseError.code));
6826
7005
  const detectOrphanFiles = (graph) => {
6827
7006
  const unusedFiles = [];
6828
7007
  for (const module of graph.modules) {
@@ -6832,6 +7011,7 @@ const detectOrphanFiles = (graph) => {
6832
7011
  if (module.isConfigFile) continue;
6833
7012
  if (hasExcludedExtension(module.fileId.path)) continue;
6834
7013
  if (isExcludedByPattern(module.fileId.path)) continue;
7014
+ if (isOpaqueToAnalysis(module)) continue;
6835
7015
  if (isBarrelWithReachableSources(module, graph)) continue;
6836
7016
  if (hasReachableDirectImporter(module.fileId.index, graph)) continue;
6837
7017
  unusedFiles.push({ path: module.fileId.path });
@@ -7162,13 +7342,13 @@ const detectStalePackages = (graph, config) => {
7162
7342
  const declaredNames = new Set(declaredDependencies.keys());
7163
7343
  const usedPackageNames = collectUsedPackages(graph);
7164
7344
  const monorepoRoot = findMonorepoRoot(config.rootDir);
7165
- const nodeModulesRoot = monorepoRoot ?? config.rootDir;
7345
+ const nodeModulesSearchRoots = monorepoRoot && monorepoRoot !== config.rootDir ? [config.rootDir, monorepoRoot] : [config.rootDir];
7166
7346
  const allPackageJsonPaths = discoverAllPackageJsonPaths(config.rootDir);
7167
7347
  if (monorepoRoot) {
7168
7348
  const monorepoPackageJson = join(monorepoRoot, "package.json");
7169
7349
  if (!allPackageJsonPaths.includes(monorepoPackageJson) && existsSync(monorepoPackageJson)) allPackageJsonPaths.push(monorepoPackageJson);
7170
7350
  }
7171
- const binToPackage = buildBinToPackageMap(nodeModulesRoot, declaredNames);
7351
+ const binToPackage = buildBinToPackageMap(nodeModulesSearchRoots, declaredNames);
7172
7352
  for (const workspacePackageJsonPath of allPackageJsonPaths) {
7173
7353
  const scriptReferenced = collectScriptReferencedPackages(workspacePackageJsonPath, declaredNames, binToPackage);
7174
7354
  for (const packageName of scriptReferenced) usedPackageNames.add(packageName);
@@ -7214,7 +7394,7 @@ const detectStalePackages = (graph, config) => {
7214
7394
  if ("react-dom" in peerDeps && declaredDependencies.get("react-dom") === true) usedPackageNames.add("react-dom");
7215
7395
  } catch {}
7216
7396
  }
7217
- const peerSatisfied = collectPeerSatisfiedPackages(nodeModulesRoot, declaredNames, usedPackageNames);
7397
+ const peerSatisfied = collectPeerSatisfiedPackages(nodeModulesSearchRoots, declaredNames, usedPackageNames);
7218
7398
  for (const packageName of peerSatisfied) usedPackageNames.add(packageName);
7219
7399
  const staticPeerSatisfied = collectStaticPeerSatisfiedPackages(declaredNames, usedPackageNames);
7220
7400
  for (const packageName of staticPeerSatisfied) usedPackageNames.add(packageName);
@@ -7260,14 +7440,14 @@ const hasJsxFiles = (graph) => graph.modules.some((module) => {
7260
7440
  const filePath = module.fileId.path;
7261
7441
  return filePath.endsWith(".tsx") || filePath.endsWith(".jsx");
7262
7442
  });
7263
- const collectPeerSatisfiedPackages = (rootDir, declaredNames, confirmedUsedNames) => {
7443
+ const collectPeerSatisfiedPackages = (nodeModulesSearchRoots, declaredNames, confirmedUsedNames) => {
7264
7444
  const peerSatisfied = /* @__PURE__ */ new Set();
7265
- const nodeModulesDir = join(rootDir, "node_modules");
7266
7445
  for (const installedName of declaredNames) {
7267
7446
  if (!confirmedUsedNames.has(installedName)) continue;
7268
- const packageJsonPath = installedName.startsWith("@") ? join(nodeModulesDir, ...installedName.split("/"), "package.json") : join(nodeModulesDir, installedName, "package.json");
7447
+ const installedPackageJsonPath = findInstalledPackageJsonPath(installedName, nodeModulesSearchRoots);
7448
+ if (!installedPackageJsonPath) continue;
7269
7449
  try {
7270
- const content = readFileSync(packageJsonPath, "utf-8");
7450
+ const content = readFileSync(installedPackageJsonPath, "utf-8");
7271
7451
  const peerDeps = JSON.parse(content).peerDependencies;
7272
7452
  if (peerDeps && typeof peerDeps === "object") {
7273
7453
  for (const peerName of Object.keys(peerDeps)) if (declaredNames.has(peerName)) peerSatisfied.add(peerName);
@@ -7278,6 +7458,12 @@ const collectPeerSatisfiedPackages = (rootDir, declaredNames, confirmedUsedNames
7278
7458
  }
7279
7459
  return peerSatisfied;
7280
7460
  };
7461
+ const findInstalledPackageJsonPath = (packageName, nodeModulesSearchRoots) => {
7462
+ for (const searchRoot of nodeModulesSearchRoots) {
7463
+ const candidatePath = packageName.startsWith("@") ? join(searchRoot, "node_modules", ...packageName.split("/"), "package.json") : join(searchRoot, "node_modules", packageName, "package.json");
7464
+ if (existsSync(candidatePath)) return candidatePath;
7465
+ }
7466
+ };
7281
7467
  const STATIC_PEER_DEPENDENCY_MAP = {
7282
7468
  "@apollo/client": ["graphql"],
7283
7469
  "@docusaurus/core": ["@mdx-js/react"],
@@ -7382,11 +7568,12 @@ const ENV_WRAPPER_BINARY_SET = new Set([
7382
7568
  "env-cmd"
7383
7569
  ]);
7384
7570
  const INLINE_ENV_VAR_PATTERN = /^[A-Z_][A-Z0-9_]*=/;
7385
- const buildBinToPackageMap = (rootDir, declaredNames) => {
7571
+ const buildBinToPackageMap = (nodeModulesSearchRoots, declaredNames) => {
7386
7572
  const binToPackage = /* @__PURE__ */ new Map();
7387
7573
  for (const [binary, packageName] of Object.entries(CLI_BINARY_TO_PACKAGE)) binToPackage.set(binary, packageName);
7388
7574
  for (const packageName of declaredNames) {
7389
- const packageBinJsonPath = packageName.startsWith("@") ? join(rootDir, "node_modules", ...packageName.split("/"), "package.json") : join(rootDir, "node_modules", packageName, "package.json");
7575
+ const packageBinJsonPath = findInstalledPackageJsonPath(packageName, nodeModulesSearchRoots);
7576
+ if (!packageBinJsonPath) continue;
7390
7577
  try {
7391
7578
  const binContent = readFileSync(packageBinJsonPath, "utf-8");
7392
7579
  const binPackageJson = JSON.parse(binContent);
@@ -8096,7 +8283,7 @@ const detectDuplicateImports = (graph) => {
8096
8283
  const findings = [];
8097
8284
  for (const module of graph.modules) {
8098
8285
  if (module.isDeclarationFile) continue;
8099
- const specifierToOccurrences = /* @__PURE__ */ new Map();
8286
+ const groupedByKindAndSpecifier = /* @__PURE__ */ new Map();
8100
8287
  for (const importInfo of module.imports) {
8101
8288
  if (importInfo.isSideEffect) continue;
8102
8289
  if (importInfo.isDynamic) continue;
@@ -8107,18 +8294,21 @@ const detectDuplicateImports = (graph) => {
8107
8294
  importedNames: importInfo.importedNames.map((binding) => binding.isNamespace ? `* as ${binding.alias ?? ""}` : binding.alias ?? binding.name),
8108
8295
  isTypeOnly: importInfo.isTypeOnly
8109
8296
  };
8110
- const existing = specifierToOccurrences.get(importInfo.specifier);
8297
+ const groupKey = `${importInfo.isTypeOnly ? "type" : "value"}:${importInfo.specifier}`;
8298
+ const existing = groupedByKindAndSpecifier.get(groupKey);
8111
8299
  if (existing) existing.push(occurrence);
8112
- else specifierToOccurrences.set(importInfo.specifier, [occurrence]);
8300
+ else groupedByKindAndSpecifier.set(groupKey, [occurrence]);
8113
8301
  }
8114
- for (const [specifier, occurrences] of specifierToOccurrences) {
8302
+ for (const [groupKey, occurrences] of groupedByKindAndSpecifier) {
8115
8303
  if (occurrences.length < 2) continue;
8304
+ const specifier = groupKey.slice(groupKey.indexOf(":") + 1);
8305
+ const kindLabel = groupKey.startsWith("type:") ? "type-only " : "";
8116
8306
  findings.push({
8117
8307
  path: module.fileId.path,
8118
8308
  specifier,
8119
8309
  occurrences,
8120
8310
  confidence: "high",
8121
- reason: `"${specifier}" is imported ${occurrences.length} times in this file — merge into a single statement`
8311
+ reason: `"${specifier}" is imported ${occurrences.length} times in this file as ${kindLabel}imports — merge into a single statement`
8122
8312
  });
8123
8313
  }
8124
8314
  }
@@ -8213,6 +8403,7 @@ const detectDuplicateConstants = (graph) => {
8213
8403
  const uniqueFilePaths = new Set(bucket.occurrences.map((occurrence) => occurrence.path));
8214
8404
  if (uniqueFilePaths.size < 3) continue;
8215
8405
  const uniqueNames = new Set(bucket.occurrences.map((occurrence) => occurrence.constantName));
8406
+ if (uniqueNames.size > 1 && hasDistinctUnitSuffixes([...uniqueNames])) continue;
8216
8407
  findings.push({
8217
8408
  literalHash,
8218
8409
  literalPreview: bucket.literalPreview,
@@ -8223,6 +8414,29 @@ const detectDuplicateConstants = (graph) => {
8223
8414
  }
8224
8415
  return findings;
8225
8416
  };
8417
+ const TRAILING_NAME_TOKEN_PATTERN = /_([A-Z][A-Z0-9]*)$/;
8418
+ const extractTrailingNameToken = (constantName) => {
8419
+ const match = constantName.match(TRAILING_NAME_TOKEN_PATTERN);
8420
+ return match ? match[1] : void 0;
8421
+ };
8422
+ /**
8423
+ * AGENTS.md requires magic numbers to use trailing unit suffixes (`_MS`, `_PX`,
8424
+ * `_TOKENS`, `_WIDTH`, …). When same-value constants carry DIFFERENT trailing
8425
+ * tokens (e.g. `STEP_DELAY_MS = 1000` vs `MINIMUM_TOKENS = 1000`), they
8426
+ * represent semantically distinct quantities that cannot be consolidated —
8427
+ * flagging them as duplicates is misleading. Constants sharing the same
8428
+ * trailing token (e.g. `CACHE_INTERVAL_MS` + `RECONNECT_DELAY_MS`, both `_MS`)
8429
+ * stay flagged because they are at least same-unit and might be extractable.
8430
+ */
8431
+ const hasDistinctUnitSuffixes = (constantNames) => {
8432
+ const trailingTokens = /* @__PURE__ */ new Set();
8433
+ for (const name of constantNames) {
8434
+ const token = extractTrailingNameToken(name);
8435
+ if (!token) return false;
8436
+ trailingTokens.add(token);
8437
+ }
8438
+ return trailingTokens.size > 1;
8439
+ };
8226
8440
  const detectSimplifiableExpressions = (graph) => {
8227
8441
  const findings = [];
8228
8442
  for (const module of graph.modules) {
@@ -8297,134 +8511,2473 @@ const detectDuplicateInlineTypes = (graph) => {
8297
8511
  };
8298
8512
 
8299
8513
  //#endregion
8300
- //#region src/utils/run-safe-detector.ts
8301
- const runSafeDetector = (input) => {
8302
- try {
8303
- return input.detector();
8304
- } catch (caughtError) {
8305
- input.errorSink.push(new DetectorError({
8306
- module: input.module,
8307
- message: `${input.detectorName} threw ${input.contextDescription}`,
8308
- detail: describeUnknownError(caughtError)
8514
+ //#region src/report/cross-file-duplicate-exports.ts
8515
+ const buildReExportSourceSets = (graph) => {
8516
+ const reExportSources = /* @__PURE__ */ new Map();
8517
+ for (const edge of graph.edges) {
8518
+ if (!edge.isReExportEdge) continue;
8519
+ const existing = reExportSources.get(edge.source);
8520
+ if (existing) existing.add(edge.target);
8521
+ else reExportSources.set(edge.source, new Set([edge.target]));
8522
+ }
8523
+ return reExportSources;
8524
+ };
8525
+ /**
8526
+ * Two duplicate-export files "share a common importer" when there exists a
8527
+ * third file that imports from both, OR one duplicate file imports another.
8528
+ * This filters out coincidental duplicates among unrelated leaf modules
8529
+ * (SvelteKit/Next.js route files, scripts in different parts of a monorepo,
8530
+ * etc.) that happen to export the same name but can never be confused at any
8531
+ * import site.
8532
+ */
8533
+ const hasCommonImporter = (moduleIndices, graph) => {
8534
+ if (moduleIndices.length <= 1) return false;
8535
+ const duplicateModuleSet = new Set(moduleIndices);
8536
+ const importerOwner = /* @__PURE__ */ new Map();
8537
+ for (const moduleIndex of moduleIndices) {
8538
+ const importers = graph.reverseEdges.get(moduleIndex) ?? [];
8539
+ for (const importerIndex of importers) {
8540
+ if (duplicateModuleSet.has(importerIndex)) return true;
8541
+ const previousOwner = importerOwner.get(importerIndex);
8542
+ if (previousOwner === void 0) importerOwner.set(importerIndex, moduleIndex);
8543
+ else if (previousOwner !== moduleIndex) return true;
8544
+ }
8545
+ }
8546
+ return false;
8547
+ };
8548
+ /**
8549
+ * Cross-file duplicate exports: the same exported name lives in 2+ files.
8550
+ *
8551
+ * Filters applied (to keep the rule actionable):
8552
+ * - default exports are skipped (every module gets one and it's not actionable)
8553
+ * - re-export chains are pruned: if module A re-exports `Foo` from module B,
8554
+ * the (A, B) pair is one chain, not two real declarations
8555
+ * - TypeScript value/type namespace split: `export const X` and `export type X`
8556
+ * in the same file are distinct in TS's value/type namespaces; same name in a
8557
+ * value file and a type file is not a true duplicate either
8558
+ * - common-importer filter: only report duplicates where two of the duplicate
8559
+ * files share an importer or one imports another, so unrelated route files in
8560
+ * different parts of a repo don't get flagged
8561
+ */
8562
+ const detectCrossFileDuplicateExports = (graph) => {
8563
+ const reExportSources = buildReExportSourceSets(graph);
8564
+ const exportEntriesByName = /* @__PURE__ */ new Map();
8565
+ for (const module of graph.modules) {
8566
+ if (!module.isReachable) continue;
8567
+ if (module.isDeclarationFile) continue;
8568
+ if (module.isEntryPoint) continue;
8569
+ for (const exportInfo of module.exports) {
8570
+ if (exportInfo.isDefault) continue;
8571
+ if (exportInfo.isSynthetic) continue;
8572
+ if (exportInfo.name === "*") continue;
8573
+ if (exportInfo.isReExport) continue;
8574
+ const entry = {
8575
+ moduleIndex: module.fileId.index,
8576
+ path: module.fileId.path,
8577
+ line: exportInfo.line,
8578
+ column: exportInfo.column,
8579
+ isTypeOnly: exportInfo.isTypeOnly
8580
+ };
8581
+ const existing = exportEntriesByName.get(exportInfo.name);
8582
+ if (existing) existing.push(entry);
8583
+ else exportEntriesByName.set(exportInfo.name, [entry]);
8584
+ }
8585
+ }
8586
+ const findings = [];
8587
+ const sortedEntries = [...exportEntriesByName.entries()].sort(([nameA], [nameB]) => nameA.localeCompare(nameB));
8588
+ for (const [name, entries] of sortedEntries) {
8589
+ if (entries.length <= 1) continue;
8590
+ const hasValueExport = entries.some((entry) => !entry.isTypeOnly);
8591
+ const hasTypeExport = entries.some((entry) => entry.isTypeOnly);
8592
+ if (hasValueExport && hasTypeExport) {
8593
+ const valueModuleIndices = new Set(entries.filter((entry) => !entry.isTypeOnly).map((entry) => entry.moduleIndex));
8594
+ const typeModuleIndices = new Set(entries.filter((entry) => entry.isTypeOnly).map((entry) => entry.moduleIndex));
8595
+ if (valueModuleIndices.size <= 1 && typeModuleIndices.size <= 1) continue;
8596
+ }
8597
+ const moduleIndexSet = new Set(entries.map((entry) => entry.moduleIndex));
8598
+ const independentEntries = entries.filter((entry) => {
8599
+ const sources = reExportSources.get(entry.moduleIndex);
8600
+ if (!sources) return true;
8601
+ for (const sourceIndex of sources) if (moduleIndexSet.has(sourceIndex)) return false;
8602
+ return true;
8603
+ });
8604
+ if (independentEntries.length <= 1) continue;
8605
+ if (!hasCommonImporter(independentEntries.map((entry) => entry.moduleIndex), graph)) continue;
8606
+ const locations = independentEntries.map((entry) => ({
8607
+ path: entry.path,
8608
+ line: entry.line,
8609
+ column: entry.column,
8610
+ isTypeOnly: entry.isTypeOnly
8309
8611
  }));
8310
- return input.fallback;
8612
+ findings.push({
8613
+ name,
8614
+ locations,
8615
+ confidence: "medium",
8616
+ reason: `"${name}" is exported from ${locations.length} files that share a common importer — consumers may import the wrong one`
8617
+ });
8311
8618
  }
8619
+ return findings;
8312
8620
  };
8313
8621
 
8314
8622
  //#endregion
8315
- //#region src/semantic/program.ts
8316
- const failureFor = (reason, message, options = { rootDir: "" }) => {
8623
+ //#region src/utils/compute-line-starts.ts
8624
+ const LINE_FEED_CHAR_CODE = 10;
8625
+ const computeLineStarts = (sourceText) => {
8626
+ const lineStarts = [0];
8627
+ for (let charIndex = 0; charIndex < sourceText.length; charIndex++) if (sourceText.charCodeAt(charIndex) === LINE_FEED_CHAR_CODE) lineStarts.push(charIndex + 1);
8628
+ return lineStarts;
8629
+ };
8630
+
8631
+ //#endregion
8632
+ //#region src/utils/offset-to-line-column.ts
8633
+ const offsetToLineColumn = (byteOffset, lineStarts) => {
8634
+ let lowIndex = 0;
8635
+ let highIndex = lineStarts.length - 1;
8636
+ while (lowIndex < highIndex) {
8637
+ const middleIndex = lowIndex + highIndex + 1 >>> 1;
8638
+ if (lineStarts[middleIndex] <= byteOffset) lowIndex = middleIndex;
8639
+ else highIndex = middleIndex - 1;
8640
+ }
8317
8641
  return {
8318
- reason,
8319
- message,
8320
- error: new TypeScriptError({
8321
- code: {
8322
- "no-tsconfig": "tsconfig-not-found",
8323
- "tsconfig-parse-error": "tsconfig-parse-failed",
8324
- "program-creation-failed": "ts-program-creation-failed",
8325
- "too-many-files": "ts-program-too-large",
8326
- "typescript-load-failed": "ts-not-loadable"
8327
- }[reason],
8328
- severity: reason === "no-tsconfig" ? "info" : "warning",
8329
- message,
8330
- path: options.rootDir || void 0,
8331
- detail: options.detail
8332
- })
8642
+ line: lowIndex + 1,
8643
+ column: byteOffset - lineStarts[lowIndex]
8333
8644
  };
8334
8645
  };
8335
- const findNearestTsconfig = (rootDir, explicitPath) => {
8336
- if (explicitPath) {
8337
- const absoluteExplicit = resolve(rootDir, explicitPath);
8338
- if (existsSync(absoluteExplicit)) return absoluteExplicit;
8339
- return;
8340
- }
8341
- for (const candidateName of DEFAULT_SEMANTIC_TSCONFIG_NAMES) {
8342
- const candidatePath = resolve(rootDir, candidateName);
8343
- if (existsSync(candidatePath)) return candidatePath;
8646
+
8647
+ //#endregion
8648
+ //#region src/duplicate-blocks/concatenate.ts
8649
+ const SENTINEL_FILE_INDEX = Number.MAX_SAFE_INTEGER;
8650
+ /**
8651
+ * Rank-reduce token hashes to dense 0..K-1 integers and concatenate every
8652
+ * file's reduced sequence with a unique negative sentinel between files. Dense
8653
+ * ranks shrink the suffix-array's bucket counters from ~4 billion to a few
8654
+ * thousand (the standard prefix-doubling speedup), and negative sentinels
8655
+ * guarantee no real-token suffix can match across a file boundary.
8656
+ */
8657
+ const rankReduceAndConcatenate = (filesHashedTokens) => {
8658
+ const uniqueHashes = /* @__PURE__ */ new Set();
8659
+ for (const fileTokens of filesHashedTokens) for (const hashedToken of fileTokens) uniqueHashes.add(hashedToken.hash);
8660
+ const sortedUniqueHashes = [...uniqueHashes].sort((leftHash, rightHash) => leftHash - rightHash);
8661
+ const hashToRank = /* @__PURE__ */ new Map();
8662
+ for (let rankIndex = 0; rankIndex < sortedUniqueHashes.length; rankIndex++) hashToRank.set(sortedUniqueHashes[rankIndex], rankIndex + 1);
8663
+ const sequenceLength = filesHashedTokens.reduce((runningSum, fileTokens) => runningSum + fileTokens.length, 0) + Math.max(0, filesHashedTokens.length - 1);
8664
+ const tokenSequence = new Array(sequenceLength);
8665
+ const fileOf = new Array(sequenceLength);
8666
+ const fileOffsets = new Array(filesHashedTokens.length);
8667
+ let writeCursor = 0;
8668
+ let nextSentinelValue = -1;
8669
+ for (let fileIndex = 0; fileIndex < filesHashedTokens.length; fileIndex++) {
8670
+ fileOffsets[fileIndex] = writeCursor;
8671
+ const fileTokens = filesHashedTokens[fileIndex];
8672
+ for (const hashedToken of fileTokens) {
8673
+ tokenSequence[writeCursor] = hashToRank.get(hashedToken.hash) ?? 0;
8674
+ fileOf[writeCursor] = fileIndex;
8675
+ writeCursor++;
8676
+ }
8677
+ if (fileIndex < filesHashedTokens.length - 1) {
8678
+ tokenSequence[writeCursor] = nextSentinelValue;
8679
+ fileOf[writeCursor] = SENTINEL_FILE_INDEX;
8680
+ writeCursor++;
8681
+ nextSentinelValue--;
8682
+ }
8344
8683
  }
8345
- };
8346
- const createSemanticContext = (rootDir, tsconfigPath) => {
8347
- const resolvedTsconfigPath = findNearestTsconfig(rootDir, tsconfigPath);
8348
- if (!resolvedTsconfigPath) return {
8349
- ok: false,
8350
- failure: failureFor("no-tsconfig", `No tsconfig found under ${rootDir}`, { rootDir })
8684
+ return {
8685
+ tokenSequence,
8686
+ fileOf,
8687
+ fileOffsets
8351
8688
  };
8352
- let configFileContent;
8353
- try {
8354
- configFileContent = ts.readConfigFile(resolvedTsconfigPath, ts.sys.readFile);
8355
- } catch (readError) {
8356
- return {
8357
- ok: false,
8358
- failure: failureFor("tsconfig-parse-error", "ts.readConfigFile threw", {
8359
- rootDir: resolvedTsconfigPath,
8360
- detail: describeUnknownError(readError)
8361
- })
8362
- };
8689
+ };
8690
+ const SENTINEL_FILE_MARKER = SENTINEL_FILE_INDEX;
8691
+
8692
+ //#endregion
8693
+ //#region src/duplicate-blocks/extract.ts
8694
+ const buildRawBlock = (suffixArray, fileOf, fileOffsets, filesTokenCounts, intervalBegin, intervalEnd, tokenLength) => {
8695
+ const candidateInstances = [];
8696
+ for (let suffixIndex = intervalBegin; suffixIndex < intervalEnd; suffixIndex++) {
8697
+ const startPosition = suffixArray[suffixIndex];
8698
+ const fileIndex = fileOf[startPosition];
8699
+ if (fileIndex === SENTINEL_FILE_MARKER) continue;
8700
+ const tokenOffsetWithinFile = startPosition - fileOffsets[fileIndex];
8701
+ if (tokenOffsetWithinFile + tokenLength > filesTokenCounts[fileIndex]) continue;
8702
+ candidateInstances.push({
8703
+ fileIndex,
8704
+ tokenOffsetWithinFile
8705
+ });
8363
8706
  }
8364
- if (configFileContent.error) return {
8365
- ok: false,
8366
- failure: failureFor("tsconfig-parse-error", ts.flattenDiagnosticMessageText(configFileContent.error.messageText, "\n"), { rootDir: resolvedTsconfigPath })
8707
+ if (candidateInstances.length < 2) return void 0;
8708
+ candidateInstances.sort((leftInstance, rightInstance) => {
8709
+ if (leftInstance.fileIndex !== rightInstance.fileIndex) return leftInstance.fileIndex - rightInstance.fileIndex;
8710
+ return leftInstance.tokenOffsetWithinFile - rightInstance.tokenOffsetWithinFile;
8711
+ });
8712
+ const dedupedInstances = [];
8713
+ for (const instance of candidateInstances) {
8714
+ const lastInstance = dedupedInstances[dedupedInstances.length - 1];
8715
+ if (lastInstance !== void 0 && lastInstance.fileIndex === instance.fileIndex && instance.tokenOffsetWithinFile < lastInstance.tokenOffsetWithinFile + tokenLength) continue;
8716
+ dedupedInstances.push(instance);
8717
+ }
8718
+ if (dedupedInstances.length < 2) return void 0;
8719
+ return {
8720
+ instances: dedupedInstances,
8721
+ tokenLength
8367
8722
  };
8368
- let parsedCommandLine;
8369
- try {
8370
- parsedCommandLine = ts.parseJsonConfigFileContent(configFileContent.config, ts.sys, dirname(resolvedTsconfigPath), {
8371
- noEmit: true,
8372
- skipLibCheck: true,
8373
- allowJs: true,
8374
- isolatedModules: false
8375
- }, resolvedTsconfigPath);
8376
- } catch (parseError) {
8377
- return {
8378
- ok: false,
8379
- failure: failureFor("tsconfig-parse-error", "ts.parseJsonConfigFileContent threw", {
8380
- rootDir: resolvedTsconfigPath,
8381
- detail: describeUnknownError(parseError)
8382
- })
8383
- };
8723
+ };
8724
+ /**
8725
+ * Walks `lcpArray` with a monotone stack to materialize every maximal
8726
+ * interval `[i, j]` whose minimum LCP is >= `minTokens`. Within-file
8727
+ * overlapping occurrences are dropped (keep the earliest non-overlapping
8728
+ * prefix), and any block left with fewer than two occurrences is discarded.
8729
+ */
8730
+ const extractRawDuplicateBlocks = (suffixArray, lcpArray, fileOf, fileOffsets, filesTokenCounts, minTokens) => {
8731
+ const sequenceLength = suffixArray.length;
8732
+ if (sequenceLength < 2) return [];
8733
+ const rawBlocks = [];
8734
+ const monotoneStack = [];
8735
+ for (let scanIndex = 1; scanIndex <= sequenceLength; scanIndex++) {
8736
+ const currentLcp = scanIndex < sequenceLength ? lcpArray[scanIndex] : 0;
8737
+ let intervalStart = scanIndex;
8738
+ while (monotoneStack.length > 0 && monotoneStack[monotoneStack.length - 1].lcpValue > currentLcp) {
8739
+ const popped = monotoneStack.pop();
8740
+ intervalStart = popped.startIndex;
8741
+ if (popped.lcpValue >= minTokens) {
8742
+ const candidate = buildRawBlock(suffixArray, fileOf, fileOffsets, filesTokenCounts, intervalStart - 1, scanIndex, popped.lcpValue);
8743
+ if (candidate) rawBlocks.push(candidate);
8744
+ }
8745
+ }
8746
+ if (scanIndex < sequenceLength) monotoneStack.push({
8747
+ lcpValue: currentLcp,
8748
+ startIndex: intervalStart
8749
+ });
8384
8750
  }
8385
- if (parsedCommandLine.errors.length > 0) {
8386
- const fatalErrors = parsedCommandLine.errors.filter((diagnostic) => diagnostic.category === ts.DiagnosticCategory.Error);
8387
- if (fatalErrors.length > 0 && parsedCommandLine.fileNames.length === 0) return {
8388
- ok: false,
8389
- failure: failureFor("tsconfig-parse-error", ts.flattenDiagnosticMessageText(fatalErrors[0].messageText, "\n"), { rootDir: resolvedTsconfigPath })
8390
- };
8751
+ return rawBlocks;
8752
+ };
8753
+
8754
+ //#endregion
8755
+ //#region src/duplicate-blocks/clusters.ts
8756
+ const baseName = (filePath) => {
8757
+ const trailingSlashIndex = filePath.lastIndexOf("/");
8758
+ return trailingSlashIndex === -1 ? filePath : filePath.slice(trailingSlashIndex + 1);
8759
+ };
8760
+ const buildSuggestions = (files, blocks, totalDuplicatedLines) => {
8761
+ const fileBaseNames = files.map((filePath) => baseName(filePath));
8762
+ if (files.length >= 2 && totalDuplicatedLines >= 50) {
8763
+ const estimatedSavings = blocks.reduce((runningSum, block) => runningSum + block.lineCount * Math.max(0, block.instances.length - 1), 0);
8764
+ return [{
8765
+ kind: "extract-module",
8766
+ description: `Extract ${blocks.length} shared duplicate block${blocks.length === 1 ? "" : "s"} (${totalDuplicatedLines} lines) from ${fileBaseNames.join(", ")} into a shared module`,
8767
+ estimatedSavings
8768
+ }];
8769
+ }
8770
+ return blocks.map((block) => ({
8771
+ kind: "extract-function",
8772
+ description: `Extract shared function (${block.lineCount} lines) from ${fileBaseNames.join(", ")}`,
8773
+ estimatedSavings: block.lineCount * Math.max(0, block.instances.length - 1)
8774
+ }));
8775
+ };
8776
+ const groupDuplicateBlocksIntoClusters = (duplicateBlocks) => {
8777
+ if (duplicateBlocks.length === 0) return [];
8778
+ const fileSetKeyToBucket = /* @__PURE__ */ new Map();
8779
+ for (const block of duplicateBlocks) {
8780
+ const sortedFiles = [...new Set(block.instances.map((instance) => instance.path))].sort();
8781
+ const fileSetKey = sortedFiles.join("|");
8782
+ const existing = fileSetKeyToBucket.get(fileSetKey);
8783
+ if (existing) existing.blocks.push(block);
8784
+ else fileSetKeyToBucket.set(fileSetKey, {
8785
+ files: sortedFiles,
8786
+ blocks: [block]
8787
+ });
8391
8788
  }
8392
- if (parsedCommandLine.fileNames.length > 5e3) return {
8393
- ok: false,
8394
- failure: failureFor("too-many-files", `Project has ${parsedCommandLine.fileNames.length} files, exceeds SEMANTIC_MAX_PROGRAM_FILES=${SEMANTIC_MAX_PROGRAM_FILES}`, { rootDir: resolvedTsconfigPath })
8395
- };
8396
- try {
8397
- const program = ts.createProgram({
8398
- rootNames: parsedCommandLine.fileNames,
8399
- options: parsedCommandLine.options,
8400
- projectReferences: parsedCommandLine.projectReferences
8789
+ const clusters = [];
8790
+ for (const bucket of fileSetKeyToBucket.values()) {
8791
+ const totalDuplicatedLines = bucket.blocks.reduce((runningSum, block) => runningSum + block.lineCount, 0);
8792
+ const totalDuplicatedTokens = bucket.blocks.reduce((runningSum, block) => runningSum + block.tokenCount, 0);
8793
+ clusters.push({
8794
+ files: bucket.files,
8795
+ groups: bucket.blocks,
8796
+ totalDuplicatedLines,
8797
+ totalDuplicatedTokens,
8798
+ suggestions: buildSuggestions(bucket.files, bucket.blocks, totalDuplicatedLines)
8401
8799
  });
8402
- return {
8403
- ok: true,
8404
- context: {
8405
- program,
8406
- checker: program.getTypeChecker(),
8407
- rootSourceFiles: program.getSourceFiles().filter((sourceFile) => !sourceFile.isDeclarationFile || sourceFile.fileName.endsWith(".d.ts")),
8408
- tsconfigPath: resolvedTsconfigPath
8409
- }
8410
- };
8411
- } catch (programError) {
8412
- return {
8413
- ok: false,
8414
- failure: failureFor("program-creation-failed", "ts.createProgram threw", {
8415
- rootDir: resolvedTsconfigPath,
8416
- detail: describeUnknownError(programError)
8417
- })
8418
- };
8419
8800
  }
8801
+ clusters.sort((leftCluster, rightCluster) => {
8802
+ if (leftCluster.totalDuplicatedLines !== rightCluster.totalDuplicatedLines) return rightCluster.totalDuplicatedLines - leftCluster.totalDuplicatedLines;
8803
+ return rightCluster.groups.length - leftCluster.groups.length;
8804
+ });
8805
+ return clusters;
8420
8806
  };
8421
8807
 
8422
8808
  //#endregion
8423
- //#region src/semantic/references.ts
8424
- const canonicalKeyForSymbol = (symbol) => {
8425
- return symbol.declarations?.[0] ?? symbol;
8809
+ //#region src/duplicate-blocks/shadowed-directory-pairs.ts
8810
+ const splitDirectoryAndFile = (filePath) => {
8811
+ const trailingSlashIndex = filePath.lastIndexOf("/");
8812
+ if (trailingSlashIndex === -1) return {
8813
+ directory: "",
8814
+ baseName: filePath
8815
+ };
8816
+ return {
8817
+ directory: filePath.slice(0, trailingSlashIndex + 1),
8818
+ baseName: filePath.slice(trailingSlashIndex + 1)
8819
+ };
8426
8820
  };
8427
- const isDeclarationNameIdentifier = (identifier) => {
8821
+ const toRelative = (filePath, rootDir) => {
8822
+ if (filePath.startsWith(rootDir + "/")) return filePath.slice(rootDir.length + 1);
8823
+ if (filePath === rootDir) return "";
8824
+ return filePath;
8825
+ };
8826
+ /**
8827
+ * Collapse N two-file duplicate-block clusters that share the same
8828
+ * `(directoryA, directoryB)` and matching basenames into a single
8829
+ * `ShadowedDirectoryPair` finding — the directories themselves drifted
8830
+ * (e.g. `src/` vs `deno/lib/`, a fork, a copy-paste of a route tree).
8831
+ */
8832
+ const detectShadowedDirectoryPairs = (duplicateBlockClusters, rootDir) => {
8833
+ const directoryPairBuckets = /* @__PURE__ */ new Map();
8834
+ for (const cluster of duplicateBlockClusters) {
8835
+ if (cluster.files.length !== 2) continue;
8836
+ const [firstFile, secondFile] = cluster.files;
8837
+ const firstSplit = splitDirectoryAndFile(toRelative(firstFile, rootDir));
8838
+ const secondSplit = splitDirectoryAndFile(toRelative(secondFile, rootDir));
8839
+ if (firstSplit.baseName !== secondSplit.baseName) continue;
8840
+ const [smallerDirectory, largerDirectory] = firstSplit.directory <= secondSplit.directory ? [firstSplit.directory, secondSplit.directory] : [secondSplit.directory, firstSplit.directory];
8841
+ const pairKey = `${smallerDirectory}::${largerDirectory}`;
8842
+ const entry = {
8843
+ baseName: firstSplit.baseName,
8844
+ duplicatedLines: cluster.totalDuplicatedLines
8845
+ };
8846
+ const existing = directoryPairBuckets.get(pairKey);
8847
+ if (existing) existing.push(entry);
8848
+ else directoryPairBuckets.set(pairKey, [entry]);
8849
+ }
8850
+ const shadowedDirectoryPairs = [];
8851
+ for (const [pairKey, entries] of directoryPairBuckets) {
8852
+ if (entries.length < 3) continue;
8853
+ const [directoryA, directoryB] = pairKey.split("::");
8854
+ const sharedBaseNames = [...new Set(entries.map((entry) => entry.baseName))].sort();
8855
+ const totalDuplicatedLines = entries.reduce((runningSum, entry) => runningSum + entry.duplicatedLines, 0);
8856
+ shadowedDirectoryPairs.push({
8857
+ directoryA,
8858
+ directoryB,
8859
+ sharedFiles: sharedBaseNames,
8860
+ totalDuplicatedLines
8861
+ });
8862
+ }
8863
+ shadowedDirectoryPairs.sort((leftPair, rightPair) => rightPair.totalDuplicatedLines - leftPair.totalDuplicatedLines);
8864
+ return shadowedDirectoryPairs;
8865
+ };
8866
+
8867
+ //#endregion
8868
+ //#region src/duplicate-blocks/normalize.ts
8869
+ /**
8870
+ * 32-bit FNV-1a. Collisions are tolerable: ties are broken back to the
8871
+ * original (path, offset) tuples downstream, so a rare collision inflates a
8872
+ * duplicate block with one extra spurious instance at worst.
8873
+ */
8874
+ const FNV_OFFSET_BASIS = 2166136261;
8875
+ const FNV_PRIME = 16777619;
8876
+ const hashString = (input) => {
8877
+ let hash = FNV_OFFSET_BASIS;
8878
+ for (let charIndex = 0; charIndex < input.length; charIndex++) {
8879
+ hash ^= input.charCodeAt(charIndex);
8880
+ hash = Math.imul(hash, FNV_PRIME);
8881
+ }
8882
+ return hash >>> 0;
8883
+ };
8884
+ const resolveNormalization = (mode) => {
8885
+ if (mode === "strict") return {
8886
+ ignoreIdentifiers: false,
8887
+ ignoreStringValues: false,
8888
+ ignoreNumericValues: false
8889
+ };
8890
+ return {
8891
+ ignoreIdentifiers: true,
8892
+ ignoreStringValues: true,
8893
+ ignoreNumericValues: true
8894
+ };
8895
+ };
8896
+ const hashSourceToken = (sourceToken, normalization) => {
8897
+ switch (sourceToken.kind) {
8898
+ case "node-enter": return hashString(`n:${sourceToken.payload}`);
8899
+ case "identifier": return normalization.ignoreIdentifiers ? hashString("id:*") : hashString(`id:${sourceToken.payload}`);
8900
+ case "string-literal": return normalization.ignoreStringValues ? hashString("s:*") : hashString(`s:${sourceToken.payload}`);
8901
+ case "numeric-literal": return normalization.ignoreNumericValues ? hashString("num:*") : hashString(`num:${sourceToken.payload}`);
8902
+ case "boolean-literal": return hashString(`b:${sourceToken.payload}`);
8903
+ case "null-literal": return hashString("null");
8904
+ case "template-literal": return hashString("tpl");
8905
+ case "regexp-literal": return hashString("re");
8906
+ default: return hashString("?");
8907
+ }
8908
+ };
8909
+ const normalizeAndHashTokens = (tokens, mode) => {
8910
+ const normalization = resolveNormalization(mode);
8911
+ const hashedTokens = new Array(tokens.length);
8912
+ for (let tokenIndex = 0; tokenIndex < tokens.length; tokenIndex++) hashedTokens[tokenIndex] = {
8913
+ hash: hashSourceToken(tokens[tokenIndex], normalization),
8914
+ originalIndex: tokenIndex
8915
+ };
8916
+ return hashedTokens;
8917
+ };
8918
+
8919
+ //#endregion
8920
+ //#region src/duplicate-blocks/suffix-array.ts
8921
+ /**
8922
+ * Prefix-doubling suffix array with two-pass radix sort, O(N log N).
8923
+ *
8924
+ * Negative values in `tokenSequence` (file-separator sentinels emitted by
8925
+ * `rankReduceAndConcatenate`) are shifted up so all ranks are >= 0. The
8926
+ * shift preserves the property that sentinels sort before all real ranks,
8927
+ * which is what stops cross-file suffix matches.
8928
+ */
8929
+ const buildSuffixArray = (tokenSequence) => {
8930
+ const sequenceLength = tokenSequence.length;
8931
+ if (sequenceLength === 0) return [];
8932
+ let minimumValue = 0;
8933
+ for (let scanIndex = 0; scanIndex < sequenceLength; scanIndex++) if (tokenSequence[scanIndex] < minimumValue) minimumValue = tokenSequence[scanIndex];
8934
+ let currentRanks = new Array(sequenceLength);
8935
+ for (let scanIndex = 0; scanIndex < sequenceLength; scanIndex++) currentRanks[scanIndex] = tokenSequence[scanIndex] - minimumValue;
8936
+ let suffixArray = new Array(sequenceLength);
8937
+ for (let positionIndex = 0; positionIndex < sequenceLength; positionIndex++) suffixArray[positionIndex] = positionIndex;
8938
+ let nextRanks = new Array(sequenceLength);
8939
+ let scratchSuffixArray = new Array(sequenceLength);
8940
+ let maximumRank = 0;
8941
+ for (let scanIndex = 0; scanIndex < sequenceLength; scanIndex++) if (currentRanks[scanIndex] > maximumRank) maximumRank = currentRanks[scanIndex];
8942
+ let stride = 1;
8943
+ while (stride < sequenceLength) {
8944
+ const bucketCount = maximumRank + 2;
8945
+ const buckets = new Array(bucketCount + 1).fill(0);
8946
+ for (let suffixIndex = 0; suffixIndex < sequenceLength; suffixIndex++) {
8947
+ const startPosition = suffixArray[suffixIndex];
8948
+ const secondaryKey = startPosition + stride < sequenceLength ? currentRanks[startPosition + stride] + 1 : 0;
8949
+ buckets[secondaryKey]++;
8950
+ }
8951
+ let prefixSum = 0;
8952
+ for (let bucketIndex = 0; bucketIndex < buckets.length; bucketIndex++) {
8953
+ const bucketCountValue = buckets[bucketIndex];
8954
+ buckets[bucketIndex] = prefixSum;
8955
+ prefixSum += bucketCountValue;
8956
+ }
8957
+ for (let suffixIndex = 0; suffixIndex < sequenceLength; suffixIndex++) {
8958
+ const startPosition = suffixArray[suffixIndex];
8959
+ const secondaryKey = startPosition + stride < sequenceLength ? currentRanks[startPosition + stride] + 1 : 0;
8960
+ scratchSuffixArray[buckets[secondaryKey]] = startPosition;
8961
+ buckets[secondaryKey]++;
8962
+ }
8963
+ for (let bucketIndex = 0; bucketIndex < buckets.length; bucketIndex++) buckets[bucketIndex] = 0;
8964
+ for (let suffixIndex = 0; suffixIndex < sequenceLength; suffixIndex++) {
8965
+ const startPosition = scratchSuffixArray[suffixIndex];
8966
+ buckets[currentRanks[startPosition]]++;
8967
+ }
8968
+ prefixSum = 0;
8969
+ for (let bucketIndex = 0; bucketIndex < buckets.length; bucketIndex++) {
8970
+ const bucketCountValue = buckets[bucketIndex];
8971
+ buckets[bucketIndex] = prefixSum;
8972
+ prefixSum += bucketCountValue;
8973
+ }
8974
+ for (let suffixIndex = 0; suffixIndex < sequenceLength; suffixIndex++) {
8975
+ const startPosition = scratchSuffixArray[suffixIndex];
8976
+ suffixArray[buckets[currentRanks[startPosition]]] = startPosition;
8977
+ buckets[currentRanks[startPosition]]++;
8978
+ }
8979
+ nextRanks[suffixArray[0]] = 0;
8980
+ for (let suffixIndex = 1; suffixIndex < sequenceLength; suffixIndex++) {
8981
+ const previousStart = suffixArray[suffixIndex - 1];
8982
+ const currentStart = suffixArray[suffixIndex];
8983
+ const previousSecondary = previousStart + stride < sequenceLength ? currentRanks[previousStart + stride] : -1;
8984
+ const currentSecondary = currentStart + stride < sequenceLength ? currentRanks[currentStart + stride] : -1;
8985
+ const isSameBucket = currentRanks[previousStart] === currentRanks[currentStart] && previousSecondary === currentSecondary;
8986
+ nextRanks[currentStart] = nextRanks[previousStart] + (isSameBucket ? 0 : 1);
8987
+ }
8988
+ const newMaximumRank = nextRanks[suffixArray[sequenceLength - 1]];
8989
+ [currentRanks, nextRanks] = [nextRanks, currentRanks];
8990
+ if (newMaximumRank === sequenceLength - 1) break;
8991
+ maximumRank = newMaximumRank;
8992
+ stride *= 2;
8993
+ }
8994
+ return suffixArray;
8995
+ };
8996
+ /**
8997
+ * Kasai's O(N) longest-common-prefix array. The `>= 0` check inside the inner
8998
+ * loop is the only non-textbook bit: it prevents a real-token LCP from
8999
+ * accidentally crossing a sentinel boundary (sentinels are negative).
9000
+ */
9001
+ const buildLcpArray = (tokenSequence, suffixArray) => {
9002
+ const sequenceLength = tokenSequence.length;
9003
+ const inverseSuffixArray = new Array(sequenceLength);
9004
+ for (let arrayIndex = 0; arrayIndex < sequenceLength; arrayIndex++) inverseSuffixArray[suffixArray[arrayIndex]] = arrayIndex;
9005
+ const lcpArray = new Array(sequenceLength).fill(0);
9006
+ let runningLcp = 0;
9007
+ for (let positionIndex = 0; positionIndex < sequenceLength; positionIndex++) {
9008
+ if (inverseSuffixArray[positionIndex] === 0) {
9009
+ runningLcp = 0;
9010
+ continue;
9011
+ }
9012
+ const previousStart = suffixArray[inverseSuffixArray[positionIndex] - 1];
9013
+ while (positionIndex + runningLcp < sequenceLength && previousStart + runningLcp < sequenceLength && tokenSequence[positionIndex + runningLcp] === tokenSequence[previousStart + runningLcp] && tokenSequence[positionIndex + runningLcp] >= 0) runningLcp++;
9014
+ lcpArray[inverseSuffixArray[positionIndex]] = runningLcp;
9015
+ if (runningLcp > 0) runningLcp--;
9016
+ }
9017
+ return lcpArray;
9018
+ };
9019
+
9020
+ //#endregion
9021
+ //#region src/utils/is-ast-node.ts
9022
+ const isAstNode = (candidate) => typeof candidate === "object" && candidate !== null && "type" in candidate;
9023
+
9024
+ //#endregion
9025
+ //#region src/duplicate-blocks/token-visitor.ts
9026
+ const NODES_DROPPED_FROM_TOKEN_STREAM = new Set([
9027
+ "ImportDeclaration",
9028
+ "ExportAllDeclaration",
9029
+ "TSTypeAnnotation",
9030
+ "TSTypeAliasDeclaration",
9031
+ "TSInterfaceDeclaration",
9032
+ "TSTypeParameterDeclaration",
9033
+ "TSTypeParameterInstantiation",
9034
+ "TSTypeReference",
9035
+ "TSAnyKeyword",
9036
+ "TSUnknownKeyword",
9037
+ "TSStringKeyword",
9038
+ "TSNumberKeyword",
9039
+ "TSBooleanKeyword",
9040
+ "TSVoidKeyword",
9041
+ "TSUndefinedKeyword",
9042
+ "TSNullKeyword",
9043
+ "TSNeverKeyword",
9044
+ "TSUnionType",
9045
+ "TSIntersectionType",
9046
+ "TSLiteralType",
9047
+ "TSArrayType",
9048
+ "TSTupleType",
9049
+ "TSTypeLiteral",
9050
+ "TSPropertySignature",
9051
+ "TSMethodSignature",
9052
+ "TSCallSignatureDeclaration",
9053
+ "TSConstructSignatureDeclaration",
9054
+ "TSIndexSignature",
9055
+ "TSConditionalType",
9056
+ "TSMappedType",
9057
+ "TSInferType",
9058
+ "TSImportType",
9059
+ "TSQualifiedName",
9060
+ "TSTypeOperator",
9061
+ "TSTypePredicate",
9062
+ "TSFunctionType",
9063
+ "TSConstructorType"
9064
+ ]);
9065
+ const visitChildrenRaw = (node, visit) => {
9066
+ if (!isAstNode(node)) return;
9067
+ for (const key of Object.keys(node)) {
9068
+ if (key === "type" || key === "start" || key === "end" || key === "loc" || key === "range") continue;
9069
+ const value = node[key];
9070
+ if (Array.isArray(value)) for (const item of value) visit(item);
9071
+ else if (value !== null && typeof value === "object") visit(value);
9072
+ }
9073
+ };
9074
+ const safeNumberOrZero = (candidate) => typeof candidate === "number" ? candidate : 0;
9075
+ /**
9076
+ * Walk an oxc AST and emit a flat token stream suitable for suffix-array-based
9077
+ * duplicate-block detection. Two structurally-identical regions of code produce the same
9078
+ * token sequence (modulo identifier/literal-value normalization, applied later
9079
+ * in `normalize.ts`).
9080
+ *
9081
+ * Implementation note: rather than a hand-written keyword/operator lexer-style
9082
+ * visitor, we walk the AST generically and emit one `node-enter` token per
9083
+ * visited node. This trades a slightly different token-density profile for
9084
+ * less code. AST-shape tokens still distinguish
9085
+ * `function add(a, b) { return a + b }` from `const add = (a, b) => a + b`.
9086
+ * Identifiers and value literals get dedicated tokens so semantic-mode
9087
+ * normalization can blind them.
9088
+ *
9089
+ * Imports and type-only constructs are dropped to keep import-block boilerplate
9090
+ * and ambient type declarations from inflating the noise floor.
9091
+ */
9092
+ const tokenizeAst = (program) => {
9093
+ const tokens = [];
9094
+ const visit = (node) => {
9095
+ if (!isAstNode(node)) return;
9096
+ const nodeType = node.type;
9097
+ if (NODES_DROPPED_FROM_TOKEN_STREAM.has(nodeType)) return;
9098
+ const start = safeNumberOrZero(node.start);
9099
+ const end = safeNumberOrZero(node.end);
9100
+ if (nodeType === "Identifier" || nodeType === "PrivateIdentifier") {
9101
+ const identifierName = node.name;
9102
+ tokens.push({
9103
+ kind: "identifier",
9104
+ payload: typeof identifierName === "string" ? identifierName : "",
9105
+ start,
9106
+ end
9107
+ });
9108
+ return;
9109
+ }
9110
+ if (nodeType === "Literal") {
9111
+ const literalValue = node.value;
9112
+ if (typeof literalValue === "string") tokens.push({
9113
+ kind: "string-literal",
9114
+ payload: literalValue,
9115
+ start,
9116
+ end
9117
+ });
9118
+ else if (typeof literalValue === "number") tokens.push({
9119
+ kind: "numeric-literal",
9120
+ payload: String(literalValue),
9121
+ start,
9122
+ end
9123
+ });
9124
+ else if (typeof literalValue === "boolean") tokens.push({
9125
+ kind: "boolean-literal",
9126
+ payload: literalValue ? "true" : "false",
9127
+ start,
9128
+ end
9129
+ });
9130
+ else if (literalValue === null) tokens.push({
9131
+ kind: "null-literal",
9132
+ payload: "null",
9133
+ start,
9134
+ end
9135
+ });
9136
+ else if (node.regex) tokens.push({
9137
+ kind: "regexp-literal",
9138
+ payload: "regex",
9139
+ start,
9140
+ end
9141
+ });
9142
+ else tokens.push({
9143
+ kind: "node-enter",
9144
+ payload: nodeType,
9145
+ start,
9146
+ end
9147
+ });
9148
+ return;
9149
+ }
9150
+ if (nodeType === "TemplateLiteral") {
9151
+ tokens.push({
9152
+ kind: "template-literal",
9153
+ payload: "tpl",
9154
+ start,
9155
+ end
9156
+ });
9157
+ visitChildrenRaw(node, visit);
9158
+ return;
9159
+ }
9160
+ tokens.push({
9161
+ kind: "node-enter",
9162
+ payload: nodeType,
9163
+ start,
9164
+ end
9165
+ });
9166
+ visitChildrenRaw(node, visit);
9167
+ };
9168
+ visit(program);
9169
+ return tokens;
9170
+ };
9171
+
9172
+ //#endregion
9173
+ //#region src/duplicate-blocks/index.ts
9174
+ const isBinaryFile = (sourceText) => {
9175
+ const sampleEnd = Math.min(sourceText.length, BINARY_DETECTION_SAMPLE_BYTES);
9176
+ let nullByteCount = 0;
9177
+ for (let charIndex = 0; charIndex < sampleEnd; charIndex++) if (sourceText.charCodeAt(charIndex) === 0) {
9178
+ nullByteCount++;
9179
+ if (nullByteCount >= 4) return true;
9180
+ }
9181
+ return false;
9182
+ };
9183
+ const isMinifiedSource = (sourceText) => {
9184
+ if (sourceText.length < 5e3) return false;
9185
+ const lineCount = (sourceText.match(/\n/g)?.length ?? 0) + 1;
9186
+ return sourceText.length / lineCount > 500;
9187
+ };
9188
+ const tokenizeFile = (filePath) => {
9189
+ let sourceStat;
9190
+ try {
9191
+ sourceStat = statSync(filePath);
9192
+ } catch {
9193
+ return;
9194
+ }
9195
+ if (sourceStat.size > 2e6) return void 0;
9196
+ let sourceText;
9197
+ try {
9198
+ sourceText = readFileSync(filePath, "utf-8");
9199
+ } catch {
9200
+ return;
9201
+ }
9202
+ if (sourceText.length === 0) return void 0;
9203
+ if (isBinaryFile(sourceText)) return void 0;
9204
+ if (isMinifiedSource(sourceText)) return void 0;
9205
+ let parseResult;
9206
+ try {
9207
+ parseResult = parseSync(filePath, sourceText);
9208
+ } catch {
9209
+ return;
9210
+ }
9211
+ const sourceTokens = tokenizeAst(parseResult.program);
9212
+ if (sourceTokens.length === 0) return void 0;
9213
+ const lineStarts = computeLineStarts(sourceText);
9214
+ return {
9215
+ path: filePath,
9216
+ sourceTokens,
9217
+ lineStarts,
9218
+ lineCount: lineStarts.length
9219
+ };
9220
+ };
9221
+ const buildCloneInstance = (rawInstance, tokenLength, tokenizedFiles) => {
9222
+ const file = tokenizedFiles[rawInstance.fileIndex];
9223
+ const firstToken = file.sourceTokens[rawInstance.tokenOffsetWithinFile];
9224
+ const lastToken = file.sourceTokens[rawInstance.tokenOffsetWithinFile + tokenLength - 1];
9225
+ const startSpan = offsetToLineColumn(firstToken.start, file.lineStarts);
9226
+ const endSpan = offsetToLineColumn(lastToken.end, file.lineStarts);
9227
+ return {
9228
+ path: file.path,
9229
+ startLine: startSpan.line,
9230
+ endLine: endSpan.line,
9231
+ startColumn: startSpan.column,
9232
+ endColumn: endSpan.column
9233
+ };
9234
+ };
9235
+ const directoryOf = (filePath) => dirname(filePath);
9236
+ const filterRawBlocksToReportableDuplicates = (rawBlocks, tokenizedFiles, config) => {
9237
+ const duplicateBlocks = [];
9238
+ for (const rawBlock of rawBlocks) {
9239
+ const instances = rawBlock.instances.map((rawInstance) => buildCloneInstance(rawInstance, rawBlock.tokenLength, tokenizedFiles));
9240
+ let lineCount = 0;
9241
+ for (const instance of instances) {
9242
+ const instanceLineCount = instance.endLine - instance.startLine + 1;
9243
+ if (instanceLineCount > lineCount) lineCount = instanceLineCount;
9244
+ }
9245
+ if (lineCount < config.minLines) continue;
9246
+ if (instances.length < config.minOccurrences) continue;
9247
+ if (config.skipLocal) {
9248
+ if (new Set(instances.map((instance) => directoryOf(instance.path))).size < 2) continue;
9249
+ }
9250
+ const distinctFiles = new Set(instances.map((instance) => instance.path));
9251
+ const confidence = distinctFiles.size >= 2 ? "high" : "medium";
9252
+ duplicateBlocks.push({
9253
+ instances,
9254
+ tokenCount: rawBlock.tokenLength,
9255
+ lineCount,
9256
+ confidence,
9257
+ 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)`
9258
+ });
9259
+ }
9260
+ const maximalBlocks = dropBlocksSubsumedByLongerSibling(duplicateBlocks);
9261
+ maximalBlocks.sort((firstClone, secondClone) => {
9262
+ if (firstClone.lineCount !== secondClone.lineCount) return secondClone.lineCount - firstClone.lineCount;
9263
+ return secondClone.tokenCount - firstClone.tokenCount;
9264
+ });
9265
+ return maximalBlocks;
9266
+ };
9267
+ /**
9268
+ * The suffix-array + LCP-interval scan emits one block per LCP interval, but
9269
+ * nested intervals routinely yield the same set of source spans at multiple
9270
+ * lengths (the same maximal repeat reported at L, L-1, L-2, …). Drop any
9271
+ * block whose every instance is spatially contained inside some other block's
9272
+ * matching instance — that other block is strictly more informative.
9273
+ *
9274
+ * O(N²) worst-case, but N here is post-filter blocks (typically <1000 even on
9275
+ * large monorepos), and the early-exit on instance-count mismatch keeps it
9276
+ * tight in practice.
9277
+ */
9278
+ const dropBlocksSubsumedByLongerSibling = (blocks) => {
9279
+ const sorted = [...blocks].sort((firstBlock, secondBlock) => {
9280
+ if (firstBlock.tokenCount !== secondBlock.tokenCount) return secondBlock.tokenCount - firstBlock.tokenCount;
9281
+ return secondBlock.lineCount - firstBlock.lineCount;
9282
+ });
9283
+ const survivors = [];
9284
+ for (const candidate of sorted) {
9285
+ let subsumed = false;
9286
+ for (const survivor of survivors) {
9287
+ if (survivor.instances.length !== candidate.instances.length) continue;
9288
+ if (allInstancesContainedIn(candidate, survivor)) {
9289
+ subsumed = true;
9290
+ break;
9291
+ }
9292
+ }
9293
+ if (!subsumed) survivors.push(candidate);
9294
+ }
9295
+ return survivors;
9296
+ };
9297
+ const allInstancesContainedIn = (candidate, longer) => {
9298
+ for (const candidateInstance of candidate.instances) {
9299
+ let matched = false;
9300
+ for (const longerInstance of longer.instances) if (candidateInstance.path === longerInstance.path && isSpanContained(candidateInstance, longerInstance)) {
9301
+ matched = true;
9302
+ break;
9303
+ }
9304
+ if (!matched) return false;
9305
+ }
9306
+ return true;
9307
+ };
9308
+ const isSpanContained = (inner, outer) => {
9309
+ const innerStartsAfterOuter = inner.startLine > outer.startLine || inner.startLine === outer.startLine && inner.startColumn >= outer.startColumn;
9310
+ const innerEndsBeforeOuter = inner.endLine < outer.endLine || inner.endLine === outer.endLine && inner.endColumn <= outer.endColumn;
9311
+ return innerStartsAfterOuter && innerEndsBeforeOuter;
9312
+ };
9313
+ /**
9314
+ * Token-based duplicate block detector.
9315
+ *
9316
+ * Pipeline:
9317
+ * 1. Tokenize each file with the AST visitor in `token-visitor.ts`
9318
+ * 2. Hash + normalize tokens with the chosen detection mode
9319
+ * 3. Concatenate every file's hashed tokens with unique negative sentinels
9320
+ * 4. Build a suffix array (prefix doubling + radix sort) and LCP array
9321
+ * 5. Stack-based LCP-interval scan extracts maximal duplicate blocks
9322
+ * 6. Filter on min-tokens / min-lines / min-occurrences / skip-local
9323
+ * 7. Group clones into families; collapse N two-file families with matching
9324
+ * basenames into a `ShadowedDirectoryPair` finding
9325
+ *
9326
+ * Returns empty arrays when `config.enabled` is false.
9327
+ */
9328
+ const detectDuplicateBlocks = (graph, config, rootDir) => {
9329
+ if (!config || !config.enabled) return {
9330
+ duplicateBlocks: [],
9331
+ duplicateBlockClusters: [],
9332
+ shadowedDirectoryPairs: []
9333
+ };
9334
+ const tokenizedFiles = [];
9335
+ for (const module of graph.modules) {
9336
+ if (module.isDeclarationFile) continue;
9337
+ if (module.isConfigFile) continue;
9338
+ const tokenizedFile = tokenizeFile(module.fileId.path);
9339
+ if (!tokenizedFile) continue;
9340
+ tokenizedFiles.push(tokenizedFile);
9341
+ }
9342
+ if (tokenizedFiles.length === 0) return {
9343
+ duplicateBlocks: [],
9344
+ duplicateBlockClusters: [],
9345
+ shadowedDirectoryPairs: []
9346
+ };
9347
+ const filesHashedTokens = tokenizedFiles.map((file) => normalizeAndHashTokens(file.sourceTokens, config.mode));
9348
+ const filesTokenCounts = filesHashedTokens.map((fileTokens) => fileTokens.length);
9349
+ if (!filesTokenCounts.some((count) => count >= config.minTokens)) return {
9350
+ duplicateBlocks: [],
9351
+ duplicateBlockClusters: [],
9352
+ shadowedDirectoryPairs: []
9353
+ };
9354
+ const concatenation = rankReduceAndConcatenate(filesHashedTokens);
9355
+ if (concatenation.tokenSequence.length === 0) return {
9356
+ duplicateBlocks: [],
9357
+ duplicateBlockClusters: [],
9358
+ shadowedDirectoryPairs: []
9359
+ };
9360
+ const suffixArray = buildSuffixArray(concatenation.tokenSequence);
9361
+ const duplicateBlocks = filterRawBlocksToReportableDuplicates(extractRawDuplicateBlocks(suffixArray, buildLcpArray(concatenation.tokenSequence, suffixArray), concatenation.fileOf, concatenation.fileOffsets, filesTokenCounts, config.minTokens), tokenizedFiles, config);
9362
+ const duplicateBlockClusters = groupDuplicateBlocksIntoClusters(duplicateBlocks);
9363
+ return {
9364
+ duplicateBlocks,
9365
+ duplicateBlockClusters,
9366
+ shadowedDirectoryPairs: detectShadowedDirectoryPairs(duplicateBlockClusters, rootDir)
9367
+ };
9368
+ };
9369
+
9370
+ //#endregion
9371
+ //#region src/report/re-export-cycles.ts
9372
+ /**
9373
+ * Reports cycles in the subgraph of `isReExportEdge` edges only. These are
9374
+ * a strict subset of `circularDependencies` but worth separating: every
9375
+ * general cycle can have a legitimate bidirectional-collaboration reason,
9376
+ * but a re-export cycle has none — it always tanks tree-shaking and risks
9377
+ * the "Cannot access X before initialization" TDZ runtime error.
9378
+ */
9379
+ const detectReExportCycles = (graph) => {
9380
+ const adjacency = Array.from({ length: graph.modules.length }, () => []);
9381
+ const reExportTargetSets = Array.from({ length: graph.modules.length }, () => /* @__PURE__ */ new Set());
9382
+ for (const edge of graph.edges) {
9383
+ if (!edge.isReExportEdge) continue;
9384
+ if (edge.target >= graph.modules.length) continue;
9385
+ if (reExportTargetSets[edge.source].has(edge.target)) continue;
9386
+ reExportTargetSets[edge.source].add(edge.target);
9387
+ adjacency[edge.source].push(edge.target);
9388
+ }
9389
+ const sccComponents = computeStronglyConnectedComponents(adjacency);
9390
+ const findings = [];
9391
+ for (const component of sccComponents) {
9392
+ if (component.length === 1) {
9393
+ const onlyNode = component[0];
9394
+ if (!adjacency[onlyNode].includes(onlyNode)) continue;
9395
+ const filePath = graph.modules[onlyNode].fileId.path;
9396
+ findings.push({
9397
+ files: [filePath],
9398
+ kind: "self-loop",
9399
+ confidence: "high",
9400
+ reason: `${filePath} re-exports from itself — the barrel imports its own root, which breaks bundler tree-shaking and risks TDZ runtime errors`
9401
+ });
9402
+ continue;
9403
+ }
9404
+ const sortedFiles = component.map((moduleIndex) => graph.modules[moduleIndex].fileId.path).sort();
9405
+ findings.push({
9406
+ files: sortedFiles,
9407
+ kind: "multi-node",
9408
+ confidence: "high",
9409
+ reason: `${sortedFiles.length} modules form a re-export cycle — refactor consumers to import from the leaf module instead of the barrel`
9410
+ });
9411
+ }
9412
+ findings.sort((firstFinding, secondFinding) => firstFinding.files[0].localeCompare(secondFinding.files[0]));
9413
+ return findings;
9414
+ };
9415
+ /**
9416
+ * Iterative Tarjan's SCC. Singleton components are returned too so the
9417
+ * caller can distinguish a real self-loop from a node with no edges.
9418
+ */
9419
+ const computeStronglyConnectedComponents = (adjacency) => {
9420
+ const nodeCount = adjacency.length;
9421
+ if (nodeCount === 0) return [];
9422
+ const indices = new Array(nodeCount).fill(-1);
9423
+ const lowLinks = new Array(nodeCount).fill(0);
9424
+ const onStack = new Array(nodeCount).fill(false);
9425
+ const tarjanStack = [];
9426
+ const components = [];
9427
+ let nextIndex = 0;
9428
+ for (let startNode = 0; startNode < nodeCount; startNode++) {
9429
+ if (indices[startNode] !== -1) continue;
9430
+ const dfsStack = [{
9431
+ node: startNode,
9432
+ successorPosition: 0
9433
+ }];
9434
+ indices[startNode] = nextIndex;
9435
+ lowLinks[startNode] = nextIndex;
9436
+ nextIndex++;
9437
+ onStack[startNode] = true;
9438
+ tarjanStack.push(startNode);
9439
+ while (dfsStack.length > 0) {
9440
+ const frame = dfsStack[dfsStack.length - 1];
9441
+ const successors = adjacency[frame.node];
9442
+ if (frame.successorPosition < successors.length) {
9443
+ const successorNode = successors[frame.successorPosition];
9444
+ frame.successorPosition++;
9445
+ if (indices[successorNode] === -1) {
9446
+ indices[successorNode] = nextIndex;
9447
+ lowLinks[successorNode] = nextIndex;
9448
+ nextIndex++;
9449
+ onStack[successorNode] = true;
9450
+ tarjanStack.push(successorNode);
9451
+ dfsStack.push({
9452
+ node: successorNode,
9453
+ successorPosition: 0
9454
+ });
9455
+ } else if (onStack[successorNode]) {
9456
+ if (indices[successorNode] < lowLinks[frame.node]) lowLinks[frame.node] = indices[successorNode];
9457
+ }
9458
+ } else {
9459
+ if (lowLinks[frame.node] === indices[frame.node]) {
9460
+ const component = [];
9461
+ let popped;
9462
+ do {
9463
+ popped = tarjanStack.pop();
9464
+ onStack[popped] = false;
9465
+ component.push(popped);
9466
+ } while (popped !== frame.node);
9467
+ components.push(component);
9468
+ }
9469
+ dfsStack.pop();
9470
+ if (dfsStack.length > 0) {
9471
+ const parent = dfsStack[dfsStack.length - 1];
9472
+ if (lowLinks[frame.node] < lowLinks[parent.node]) lowLinks[parent.node] = lowLinks[frame.node];
9473
+ }
9474
+ }
9475
+ }
9476
+ }
9477
+ return components;
9478
+ };
9479
+
9480
+ //#endregion
9481
+ //#region src/report/feature-flags.ts
9482
+ const BUILTIN_SDK_PATTERNS = [
9483
+ {
9484
+ functionName: "useFlag",
9485
+ nameArgIndex: 0,
9486
+ provider: "LaunchDarkly"
9487
+ },
9488
+ {
9489
+ functionName: "useLDFlag",
9490
+ nameArgIndex: 0,
9491
+ provider: "LaunchDarkly"
9492
+ },
9493
+ {
9494
+ functionName: "useFeatureFlag",
9495
+ nameArgIndex: 0,
9496
+ provider: "LaunchDarkly"
9497
+ },
9498
+ {
9499
+ functionName: "variation",
9500
+ nameArgIndex: 0,
9501
+ provider: "LaunchDarkly"
9502
+ },
9503
+ {
9504
+ functionName: "boolVariation",
9505
+ nameArgIndex: 0,
9506
+ provider: "LaunchDarkly"
9507
+ },
9508
+ {
9509
+ functionName: "stringVariation",
9510
+ nameArgIndex: 0,
9511
+ provider: "LaunchDarkly"
9512
+ },
9513
+ {
9514
+ functionName: "numberVariation",
9515
+ nameArgIndex: 0,
9516
+ provider: "LaunchDarkly"
9517
+ },
9518
+ {
9519
+ functionName: "jsonVariation",
9520
+ nameArgIndex: 0,
9521
+ provider: "LaunchDarkly"
9522
+ },
9523
+ {
9524
+ functionName: "useGate",
9525
+ nameArgIndex: 0,
9526
+ provider: "Statsig"
9527
+ },
9528
+ {
9529
+ functionName: "checkGate",
9530
+ nameArgIndex: 0,
9531
+ provider: "Statsig"
9532
+ },
9533
+ {
9534
+ functionName: "useExperiment",
9535
+ nameArgIndex: 0,
9536
+ provider: "Statsig"
9537
+ },
9538
+ {
9539
+ functionName: "useConfig",
9540
+ nameArgIndex: 0,
9541
+ provider: "Statsig"
9542
+ },
9543
+ {
9544
+ functionName: "isEnabled",
9545
+ nameArgIndex: 0,
9546
+ provider: "Unleash"
9547
+ },
9548
+ {
9549
+ functionName: "getVariant",
9550
+ nameArgIndex: 0,
9551
+ provider: "Unleash"
9552
+ },
9553
+ {
9554
+ functionName: "isOn",
9555
+ nameArgIndex: 0,
9556
+ provider: "GrowthBook"
9557
+ },
9558
+ {
9559
+ functionName: "isOff",
9560
+ nameArgIndex: 0,
9561
+ provider: "GrowthBook"
9562
+ },
9563
+ {
9564
+ functionName: "getFeatureValue",
9565
+ nameArgIndex: 0,
9566
+ provider: "GrowthBook"
9567
+ },
9568
+ {
9569
+ functionName: "getTreatment",
9570
+ nameArgIndex: 0,
9571
+ provider: "Split"
9572
+ },
9573
+ {
9574
+ functionName: "useFeatureFlagEnabled",
9575
+ nameArgIndex: 0,
9576
+ provider: "PostHog"
9577
+ },
9578
+ {
9579
+ functionName: "useFeatureFlagPayload",
9580
+ nameArgIndex: 0,
9581
+ provider: "PostHog"
9582
+ },
9583
+ {
9584
+ functionName: "useFeatureFlagVariantKey",
9585
+ nameArgIndex: 0,
9586
+ provider: "PostHog"
9587
+ },
9588
+ {
9589
+ functionName: "getFeatureFlagPayload",
9590
+ nameArgIndex: 0,
9591
+ provider: "PostHog"
9592
+ },
9593
+ {
9594
+ functionName: "getValueAsync",
9595
+ nameArgIndex: 0,
9596
+ provider: "ConfigCat"
9597
+ },
9598
+ {
9599
+ functionName: "getValueDetailsAsync",
9600
+ nameArgIndex: 0,
9601
+ provider: "ConfigCat"
9602
+ },
9603
+ {
9604
+ functionName: "hasFeature",
9605
+ nameArgIndex: 0,
9606
+ provider: "Flagsmith"
9607
+ },
9608
+ {
9609
+ functionName: "useDecision",
9610
+ nameArgIndex: 0,
9611
+ provider: "Optimizely"
9612
+ },
9613
+ {
9614
+ functionName: "getFeatureVariable",
9615
+ nameArgIndex: 0,
9616
+ provider: "Optimizely"
9617
+ },
9618
+ {
9619
+ functionName: "getFeatureVariableBoolean",
9620
+ nameArgIndex: 0,
9621
+ provider: "Optimizely"
9622
+ },
9623
+ {
9624
+ functionName: "getFeatureVariableString",
9625
+ nameArgIndex: 0,
9626
+ provider: "Optimizely"
9627
+ },
9628
+ {
9629
+ functionName: "getFeatureVariableInteger",
9630
+ nameArgIndex: 0,
9631
+ provider: "Optimizely"
9632
+ },
9633
+ {
9634
+ functionName: "getFeatureVariableDouble",
9635
+ nameArgIndex: 0,
9636
+ provider: "Optimizely"
9637
+ },
9638
+ {
9639
+ functionName: "getFeatureVariableJson",
9640
+ nameArgIndex: 0,
9641
+ provider: "Optimizely"
9642
+ },
9643
+ {
9644
+ functionName: "getFeatureVariableJSON",
9645
+ nameArgIndex: 0,
9646
+ provider: "Optimizely"
9647
+ },
9648
+ {
9649
+ functionName: "getStringAssignment",
9650
+ nameArgIndex: 0,
9651
+ provider: "Eppo"
9652
+ },
9653
+ {
9654
+ functionName: "getBooleanAssignment",
9655
+ nameArgIndex: 0,
9656
+ provider: "Eppo"
9657
+ },
9658
+ {
9659
+ functionName: "getNumericAssignment",
9660
+ nameArgIndex: 0,
9661
+ provider: "Eppo"
9662
+ },
9663
+ {
9664
+ functionName: "getIntegerAssignment",
9665
+ nameArgIndex: 0,
9666
+ provider: "Eppo"
9667
+ },
9668
+ {
9669
+ functionName: "getJSONAssignment",
9670
+ nameArgIndex: 0,
9671
+ provider: "Eppo"
9672
+ }
9673
+ ];
9674
+ const VERCEL_FLAGS_FUNCTION_NAMES = new Set(["flag", "evaluate"]);
9675
+ const BUILTIN_ENV_PREFIXES = [
9676
+ "FEATURE_",
9677
+ "NEXT_PUBLIC_FEATURE_",
9678
+ "NEXT_PUBLIC_ENABLE_",
9679
+ "REACT_APP_FEATURE_",
9680
+ "REACT_APP_ENABLE_",
9681
+ "VITE_FEATURE_",
9682
+ "VITE_ENABLE_",
9683
+ "NUXT_PUBLIC_FEATURE_",
9684
+ "ENABLE_",
9685
+ "FF_",
9686
+ "FLAG_",
9687
+ "TOGGLE_"
9688
+ ];
9689
+ const CONFIG_OBJECT_KEYWORDS = new Set([
9690
+ "feature",
9691
+ "features",
9692
+ "featureflags",
9693
+ "featureflag",
9694
+ "flag",
9695
+ "flags",
9696
+ "toggle",
9697
+ "toggles"
9698
+ ]);
9699
+ const getStaticName = (node) => {
9700
+ if (!isAstNode(node)) return void 0;
9701
+ if (node.type === "Identifier" || node.type === "PrivateIdentifier") {
9702
+ const identifierName = node.name;
9703
+ return typeof identifierName === "string" ? identifierName : void 0;
9704
+ }
9705
+ if (node.type === "Literal") {
9706
+ const literalValue = node.value;
9707
+ return typeof literalValue === "string" ? literalValue : void 0;
9708
+ }
9709
+ };
9710
+ const extractStringArgument = (callArguments, argumentIndex) => {
9711
+ if (!Array.isArray(callArguments)) return void 0;
9712
+ const argumentNode = callArguments[argumentIndex];
9713
+ if (!isAstNode(argumentNode)) return void 0;
9714
+ if (argumentNode.type === "Literal") {
9715
+ const literalValue = argumentNode.value;
9716
+ return typeof literalValue === "string" ? literalValue : void 0;
9717
+ }
9718
+ if (argumentNode.type === "ObjectExpression") {
9719
+ const properties = argumentNode.properties;
9720
+ if (!Array.isArray(properties)) return void 0;
9721
+ for (const property of properties) {
9722
+ if (!isAstNode(property)) continue;
9723
+ if (property.type !== "Property") continue;
9724
+ const propertyKey = getStaticName(property.key);
9725
+ if (propertyKey !== "key" && propertyKey !== "name") continue;
9726
+ const propertyValueName = getStaticName(property.value);
9727
+ if (propertyValueName !== void 0) return propertyValueName;
9728
+ }
9729
+ }
9730
+ };
9731
+ const extractProcessEnvName = (memberExpression) => {
9732
+ if (!isAstNode(memberExpression)) return void 0;
9733
+ if (memberExpression.type !== "MemberExpression" && memberExpression.type !== "StaticMemberExpression") return;
9734
+ const propertyName = getStaticName(memberExpression.property);
9735
+ if (propertyName === void 0) return void 0;
9736
+ const objectNode = memberExpression.object;
9737
+ if (!isAstNode(objectNode)) return void 0;
9738
+ if (objectNode.type !== "MemberExpression" && objectNode.type !== "StaticMemberExpression") return;
9739
+ const innerObjectName = getStaticName(objectNode.object);
9740
+ const innerPropertyName = getStaticName(objectNode.property);
9741
+ if (innerObjectName === "process" && innerPropertyName === "env") return propertyName;
9742
+ };
9743
+ const isFlagEnvName = (envName, extraEnvPrefixes) => {
9744
+ for (const prefix of BUILTIN_ENV_PREFIXES) if (envName.startsWith(prefix)) return true;
9745
+ for (const prefix of extraEnvPrefixes) if (envName.startsWith(prefix)) return true;
9746
+ return false;
9747
+ };
9748
+ const collectVercelFlagsImports = (programNode) => {
9749
+ const localNames = /* @__PURE__ */ new Set();
9750
+ if (!isAstNode(programNode)) return localNames;
9751
+ const body = programNode.body;
9752
+ if (!Array.isArray(body)) return localNames;
9753
+ for (const statement of body) {
9754
+ if (!isAstNode(statement)) continue;
9755
+ if (statement.type !== "ImportDeclaration") continue;
9756
+ const sourceLiteral = statement.source;
9757
+ const sourceValue = isAstNode(sourceLiteral) ? sourceLiteral.value : void 0;
9758
+ if (typeof sourceValue !== "string") continue;
9759
+ if (!(sourceValue === "flags" || sourceValue.startsWith("flags/") || sourceValue === "@vercel/flags" || sourceValue.startsWith("@vercel/flags/"))) continue;
9760
+ const specifiers = statement.specifiers;
9761
+ if (!Array.isArray(specifiers)) continue;
9762
+ for (const specifier of specifiers) {
9763
+ if (!isAstNode(specifier)) continue;
9764
+ if (specifier.type === "ImportSpecifier") {
9765
+ const imported = specifier.imported;
9766
+ const local = specifier.local;
9767
+ const importedName = getStaticName(imported);
9768
+ const localName = getStaticName(local);
9769
+ if (importedName && VERCEL_FLAGS_FUNCTION_NAMES.has(importedName) && localName) localNames.add(localName);
9770
+ }
9771
+ }
9772
+ }
9773
+ return localNames;
9774
+ };
9775
+ const visitChildrenWithGuard = (node, visitor) => {
9776
+ if (!isAstNode(node)) return;
9777
+ for (const key of Object.keys(node)) {
9778
+ if (key === "type" || key === "start" || key === "end" || key === "loc" || key === "range") continue;
9779
+ const value = node[key];
9780
+ if (Array.isArray(value)) for (const item of value) visitor(item);
9781
+ else if (value !== null && typeof value === "object") visitor(value);
9782
+ }
9783
+ };
9784
+ const recordFlag = (context, flagName, kind, byteOffset, sdkProvider) => {
9785
+ const { line, column } = offsetToLineColumn(byteOffset, context.lineStarts);
9786
+ context.results.push({
9787
+ path: context.filePath,
9788
+ name: flagName,
9789
+ kind,
9790
+ line,
9791
+ column,
9792
+ sdkProvider,
9793
+ guardLineStart: context.guard?.startLine,
9794
+ guardLineEnd: context.guard?.endLine,
9795
+ guardsDeadCode: false
9796
+ });
9797
+ };
9798
+ const visitNode$1 = (node, context) => {
9799
+ if (!isAstNode(node)) return;
9800
+ if (node.type === "IfStatement") {
9801
+ const start = node.start;
9802
+ const end = node.end;
9803
+ const guard = typeof start === "number" && typeof end === "number" ? {
9804
+ startLine: offsetToLineColumn(start, context.lineStarts).line,
9805
+ endLine: offsetToLineColumn(end, context.lineStarts).line
9806
+ } : void 0;
9807
+ const previousGuard = context.guard;
9808
+ context.guard = guard;
9809
+ visitNode$1(node.test, context);
9810
+ context.guard = previousGuard;
9811
+ visitNode$1(node.consequent, context);
9812
+ visitNode$1(node.alternate, context);
9813
+ return;
9814
+ }
9815
+ if (node.type === "ConditionalExpression") {
9816
+ const start = node.start;
9817
+ const end = node.end;
9818
+ const guard = typeof start === "number" && typeof end === "number" ? {
9819
+ startLine: offsetToLineColumn(start, context.lineStarts).line,
9820
+ endLine: offsetToLineColumn(end, context.lineStarts).line
9821
+ } : void 0;
9822
+ const previousGuard = context.guard;
9823
+ context.guard = guard;
9824
+ visitNode$1(node.test, context);
9825
+ context.guard = previousGuard;
9826
+ visitNode$1(node.consequent, context);
9827
+ visitNode$1(node.alternate, context);
9828
+ return;
9829
+ }
9830
+ visitFlagPatternsInExpression(node, context);
9831
+ visitChildrenWithGuard(node, (child) => visitNode$1(child, context));
9832
+ };
9833
+ const visitFlagPatternsInExpression = (node, context) => {
9834
+ if (!isAstNode(node)) return;
9835
+ if (node.type === "MemberExpression" || node.type === "StaticMemberExpression") {
9836
+ const envName = extractProcessEnvName(node);
9837
+ if (envName !== void 0 && isFlagEnvName(envName, context.envPrefixes)) {
9838
+ const start = node.start;
9839
+ if (typeof start === "number") recordFlag(context, envName, "env-var", start, void 0);
9840
+ } else if (context.detectConfigObjects) {
9841
+ const objectName = getStaticName(node.object);
9842
+ const propertyName = getStaticName(node.property);
9843
+ if (objectName && propertyName) {
9844
+ if (CONFIG_OBJECT_KEYWORDS.has(objectName.toLowerCase()) || CONFIG_OBJECT_KEYWORDS.has(propertyName.toLowerCase())) {
9845
+ const start = node.start;
9846
+ if (typeof start === "number") recordFlag(context, `${objectName}.${propertyName}`, "config-object", start, void 0);
9847
+ }
9848
+ }
9849
+ }
9850
+ }
9851
+ if (node.type === "CallExpression") {
9852
+ const callee = node.callee;
9853
+ let functionName;
9854
+ if (isAstNode(callee)) {
9855
+ if (callee.type === "Identifier") functionName = getStaticName(callee);
9856
+ else if (callee.type === "MemberExpression" || callee.type === "StaticMemberExpression") functionName = getStaticName(callee.property);
9857
+ }
9858
+ if (functionName !== void 0) {
9859
+ if (context.vercelFlagsLocalNames.has(functionName) || VERCEL_FLAGS_FUNCTION_NAMES.has(functionName)) {
9860
+ const callArguments = node.arguments;
9861
+ const flagName = extractStringArgument(callArguments, 0);
9862
+ if (flagName !== void 0) {
9863
+ const start = node.start;
9864
+ if (typeof start === "number") recordFlag(context, flagName, "sdk-call", start, "Vercel Flags");
9865
+ }
9866
+ return;
9867
+ }
9868
+ for (const sdkPattern of context.sdkPatterns) {
9869
+ if (sdkPattern.functionName !== functionName) continue;
9870
+ const callArguments = node.arguments;
9871
+ const flagName = extractStringArgument(callArguments, sdkPattern.nameArgIndex);
9872
+ if (flagName === void 0) continue;
9873
+ const start = node.start;
9874
+ if (typeof start === "number") recordFlag(context, flagName, "sdk-call", start, sdkPattern.provider === "" ? void 0 : sdkPattern.provider);
9875
+ break;
9876
+ }
9877
+ }
9878
+ }
9879
+ };
9880
+ const buildSdkPatterns = (extraSdkFunctionNames) => {
9881
+ const merged = [...BUILTIN_SDK_PATTERNS];
9882
+ for (const extraName of extraSdkFunctionNames) merged.push({
9883
+ functionName: extraName,
9884
+ nameArgIndex: 0,
9885
+ provider: ""
9886
+ });
9887
+ return merged;
9888
+ };
9889
+ const detectFeatureFlags = (graph, config) => {
9890
+ if (!config?.enabled) return [];
9891
+ const sdkPatterns = buildSdkPatterns(config.extraSdkFunctionNames);
9892
+ const collectedFlags = [];
9893
+ for (const module of graph.modules) {
9894
+ if (module.isDeclarationFile) continue;
9895
+ if (module.isConfigFile) continue;
9896
+ let sourceText;
9897
+ try {
9898
+ sourceText = readFileSync(module.fileId.path, "utf-8");
9899
+ } catch {
9900
+ continue;
9901
+ }
9902
+ let parseResult;
9903
+ try {
9904
+ parseResult = parseSync(module.fileId.path, sourceText);
9905
+ } catch {
9906
+ continue;
9907
+ }
9908
+ const lineStarts = computeLineStarts(sourceText);
9909
+ const vercelFlagsLocalNames = collectVercelFlagsImports(parseResult.program);
9910
+ const visitContext = {
9911
+ filePath: module.fileId.path,
9912
+ lineStarts,
9913
+ results: [],
9914
+ envPrefixes: config.extraEnvPrefixes,
9915
+ sdkPatterns,
9916
+ detectConfigObjects: config.detectConfigObjects,
9917
+ vercelFlagsLocalNames,
9918
+ guard: void 0
9919
+ };
9920
+ visitNode$1(parseResult.program, visitContext);
9921
+ collectedFlags.push(...visitContext.results);
9922
+ }
9923
+ collectedFlags.sort((leftFlag, rightFlag) => {
9924
+ if (leftFlag.path !== rightFlag.path) return leftFlag.path.localeCompare(rightFlag.path);
9925
+ if (leftFlag.line !== rightFlag.line) return leftFlag.line - rightFlag.line;
9926
+ return leftFlag.column - rightFlag.column;
9927
+ });
9928
+ return collectedFlags;
9929
+ };
9930
+ /**
9931
+ * Mark each flag whose guard span overlaps an unused export as
9932
+ * `guardsDeadCode: true`.
9933
+ */
9934
+ const correlateFlagsWithDeadCode = (flags, scanResult) => {
9935
+ if (flags.length === 0 || scanResult.unusedExports.length === 0) return;
9936
+ const unusedByFile = /* @__PURE__ */ new Map();
9937
+ for (const unusedExport of scanResult.unusedExports) {
9938
+ const existing = unusedByFile.get(unusedExport.path);
9939
+ if (existing) existing.push(unusedExport.line);
9940
+ else unusedByFile.set(unusedExport.path, [unusedExport.line]);
9941
+ }
9942
+ for (const flag of flags) {
9943
+ if (flag.guardLineStart === void 0 || flag.guardLineEnd === void 0) continue;
9944
+ const linesInFile = unusedByFile.get(flag.path);
9945
+ if (!linesInFile) continue;
9946
+ const guardStart = flag.guardLineStart;
9947
+ const guardEnd = flag.guardLineEnd;
9948
+ for (const unusedLine of linesInFile) if (unusedLine >= guardStart && unusedLine <= guardEnd) {
9949
+ flag.guardsDeadCode = true;
9950
+ break;
9951
+ }
9952
+ }
9953
+ };
9954
+
9955
+ //#endregion
9956
+ //#region src/report/complexity.ts
9957
+ const incrementCyclomatic = (state) => {
9958
+ const topFrame = state.frameStack[state.frameStack.length - 1];
9959
+ if (topFrame) topFrame.cyclomaticComplexity++;
9960
+ };
9961
+ const incrementCognitiveWithNesting = (state) => {
9962
+ const topFrame = state.frameStack[state.frameStack.length - 1];
9963
+ if (topFrame) topFrame.cognitiveComplexity += 1 + topFrame.nestingLevel;
9964
+ };
9965
+ const incrementCognitiveFlat = (state) => {
9966
+ const topFrame = state.frameStack[state.frameStack.length - 1];
9967
+ if (topFrame) topFrame.cognitiveComplexity++;
9968
+ };
9969
+ const handleLogicalOperator = (operator, state) => {
9970
+ const topFrame = state.frameStack[state.frameStack.length - 1];
9971
+ if (!topFrame) return;
9972
+ if (topFrame.lastLogicalOperator === void 0) {
9973
+ topFrame.cognitiveComplexity++;
9974
+ topFrame.lastLogicalOperator = operator;
9975
+ return;
9976
+ }
9977
+ if (topFrame.lastLogicalOperator === operator) return;
9978
+ topFrame.cognitiveComplexity++;
9979
+ topFrame.lastLogicalOperator = operator;
9980
+ };
9981
+ const resetLogicalOperator = (state) => {
9982
+ const topFrame = state.frameStack[state.frameStack.length - 1];
9983
+ if (topFrame) topFrame.lastLogicalOperator = void 0;
9984
+ };
9985
+ const incrementNesting = (state) => {
9986
+ const topFrame = state.frameStack[state.frameStack.length - 1];
9987
+ if (topFrame) topFrame.nestingLevel++;
9988
+ };
9989
+ const decrementNesting = (state) => {
9990
+ const topFrame = state.frameStack[state.frameStack.length - 1];
9991
+ if (topFrame && topFrame.nestingLevel > 0) topFrame.nestingLevel--;
9992
+ };
9993
+ const countParameters = (parametersNode) => {
9994
+ if (!isAstNode(parametersNode)) return 0;
9995
+ const params = parametersNode;
9996
+ if (Array.isArray(params.params)) return params.params.length;
9997
+ if (Array.isArray(params.items)) return params.items.length;
9998
+ return 0;
9999
+ };
10000
+ const visitChildrenGeneric = (node, visitor) => {
10001
+ if (!isAstNode(node)) return;
10002
+ for (const key of Object.keys(node)) {
10003
+ if (key === "type" || key === "start" || key === "end" || key === "loc" || key === "range") continue;
10004
+ const value = node[key];
10005
+ if (Array.isArray(value)) for (const item of value) visitor(item);
10006
+ else if (value !== null && typeof value === "object") visitor(value);
10007
+ }
10008
+ };
10009
+ const pushFunctionFrame = (functionName, startOffset, endOffset, parameterCount, state) => {
10010
+ state.frameStack.push({
10011
+ functionName,
10012
+ startOffset,
10013
+ endOffset,
10014
+ cyclomaticComplexity: 1,
10015
+ cognitiveComplexity: 0,
10016
+ nestingLevel: 0,
10017
+ lastLogicalOperator: void 0,
10018
+ parameterCount
10019
+ });
10020
+ };
10021
+ const popFunctionFrame = (state) => {
10022
+ const completedFrame = state.frameStack.pop();
10023
+ if (!completedFrame) return;
10024
+ const { line, column } = offsetToLineColumn(completedFrame.startOffset, state.lineStarts);
10025
+ const endLine = offsetToLineColumn(completedFrame.endOffset, state.lineStarts).line;
10026
+ state.results.push({
10027
+ path: state.filePath,
10028
+ functionName: completedFrame.functionName,
10029
+ line,
10030
+ column,
10031
+ cyclomatic: completedFrame.cyclomaticComplexity,
10032
+ cognitive: completedFrame.cognitiveComplexity,
10033
+ lineCount: Math.max(1, endLine - line + 1),
10034
+ paramCount: completedFrame.parameterCount,
10035
+ confidence: "medium",
10036
+ reason: ""
10037
+ });
10038
+ };
10039
+ const visitFunctionLike = (node, kind, state) => {
10040
+ if (!isAstNode(node)) return;
10041
+ const functionName = state.pendingFunctionName ?? (() => {
10042
+ const idNode = node.id;
10043
+ const idName = isAstNode(idNode) ? idNode.name : void 0;
10044
+ return typeof idName === "string" ? idName : kind === "arrow" ? "<arrow>" : "<anonymous>";
10045
+ })();
10046
+ state.pendingFunctionName = void 0;
10047
+ const isNested = state.frameStack.length > 0;
10048
+ if (isNested) incrementNesting(state);
10049
+ const startOffset = node.start;
10050
+ const endOffset = node.end;
10051
+ const parameterCount = countParameters(node.params);
10052
+ pushFunctionFrame(functionName, typeof startOffset === "number" ? startOffset : 0, typeof endOffset === "number" ? endOffset : 0, parameterCount, state);
10053
+ visitChildrenGeneric(node, (child) => visitNode(child, state));
10054
+ popFunctionFrame(state);
10055
+ if (isNested) decrementNesting(state);
10056
+ };
10057
+ const visitNode = (node, state) => {
10058
+ if (!isAstNode(node)) return;
10059
+ switch (node.type) {
10060
+ case "FunctionDeclaration":
10061
+ case "FunctionExpression":
10062
+ case "MethodDefinition":
10063
+ if (node.type === "MethodDefinition") {
10064
+ const keyNode = node.key;
10065
+ const keyName = isAstNode(keyNode) ? keyNode.name ?? keyNode.value : void 0;
10066
+ if (typeof keyName === "string") state.pendingFunctionName = keyName;
10067
+ visitChildrenGeneric(node, (child) => visitNode(child, state));
10068
+ state.pendingFunctionName = void 0;
10069
+ return;
10070
+ }
10071
+ visitFunctionLike(node, "function", state);
10072
+ return;
10073
+ case "ArrowFunctionExpression":
10074
+ visitFunctionLike(node, "arrow", state);
10075
+ return;
10076
+ case "VariableDeclarator": {
10077
+ const declaratorId = node.id;
10078
+ const declaratorIdName = isAstNode(declaratorId) ? declaratorId.name : void 0;
10079
+ if (typeof declaratorIdName === "string") state.pendingFunctionName = declaratorIdName;
10080
+ visitChildrenGeneric(node, (child) => visitNode(child, state));
10081
+ state.pendingFunctionName = void 0;
10082
+ return;
10083
+ }
10084
+ case "PropertyDefinition": {
10085
+ const keyNode = node.key;
10086
+ const keyName = isAstNode(keyNode) ? keyNode.name : void 0;
10087
+ if (typeof keyName === "string") state.pendingFunctionName = keyName;
10088
+ visitChildrenGeneric(node, (child) => visitNode(child, state));
10089
+ state.pendingFunctionName = void 0;
10090
+ return;
10091
+ }
10092
+ case "IfStatement":
10093
+ incrementCyclomatic(state);
10094
+ incrementCognitiveWithNesting(state);
10095
+ incrementNesting(state);
10096
+ visitChildrenGeneric(node, (child) => visitNode(child, state));
10097
+ decrementNesting(state);
10098
+ resetLogicalOperator(state);
10099
+ return;
10100
+ case "ForStatement":
10101
+ case "ForInStatement":
10102
+ case "ForOfStatement":
10103
+ case "WhileStatement":
10104
+ case "DoWhileStatement":
10105
+ incrementCyclomatic(state);
10106
+ incrementCognitiveWithNesting(state);
10107
+ incrementNesting(state);
10108
+ visitChildrenGeneric(node, (child) => visitNode(child, state));
10109
+ decrementNesting(state);
10110
+ return;
10111
+ case "SwitchCase": {
10112
+ const testNode = node.test;
10113
+ if (testNode !== null && testNode !== void 0) {
10114
+ incrementCyclomatic(state);
10115
+ incrementCognitiveFlat(state);
10116
+ }
10117
+ visitChildrenGeneric(node, (child) => visitNode(child, state));
10118
+ return;
10119
+ }
10120
+ case "CatchClause":
10121
+ incrementCyclomatic(state);
10122
+ incrementCognitiveWithNesting(state);
10123
+ incrementNesting(state);
10124
+ visitChildrenGeneric(node, (child) => visitNode(child, state));
10125
+ decrementNesting(state);
10126
+ return;
10127
+ case "ConditionalExpression":
10128
+ incrementCyclomatic(state);
10129
+ incrementCognitiveWithNesting(state);
10130
+ visitChildrenGeneric(node, (child) => visitNode(child, state));
10131
+ return;
10132
+ case "LogicalExpression": {
10133
+ const operator = node.operator;
10134
+ if (operator === "&&" || operator === "||" || operator === "??") {
10135
+ incrementCyclomatic(state);
10136
+ handleLogicalOperator(operator, state);
10137
+ }
10138
+ visitChildrenGeneric(node, (child) => visitNode(child, state));
10139
+ return;
10140
+ }
10141
+ case "AssignmentExpression": {
10142
+ const operator = node.operator;
10143
+ if (operator === "&&=" || operator === "||=" || operator === "??=") incrementCyclomatic(state);
10144
+ visitChildrenGeneric(node, (child) => visitNode(child, state));
10145
+ return;
10146
+ }
10147
+ case "ChainExpression":
10148
+ incrementCyclomatic(state);
10149
+ visitChildrenGeneric(node, (child) => visitNode(child, state));
10150
+ return;
10151
+ default: visitChildrenGeneric(node, (child) => visitNode(child, state));
10152
+ }
10153
+ };
10154
+ const annotateConfidence = (finding, config) => {
10155
+ const breaches = [];
10156
+ if (finding.cyclomatic >= config.cyclomaticThreshold) breaches.push(`cyclomatic ${finding.cyclomatic} ≥ ${config.cyclomaticThreshold}`);
10157
+ if (finding.cognitive >= config.cognitiveThreshold) breaches.push(`cognitive ${finding.cognitive} ≥ ${config.cognitiveThreshold}`);
10158
+ if (finding.paramCount >= config.paramCountThreshold) breaches.push(`paramCount ${finding.paramCount} ≥ ${config.paramCountThreshold}`);
10159
+ if (finding.lineCount >= config.functionLineThreshold) breaches.push(`lineCount ${finding.lineCount} ≥ ${config.functionLineThreshold}`);
10160
+ return {
10161
+ confidence: breaches.length >= 2 ? "high" : "medium",
10162
+ reason: `${finding.functionName} breaches ${breaches.length} threshold${breaches.length === 1 ? "" : "s"}: ${breaches.join(", ")}`
10163
+ };
10164
+ };
10165
+ /**
10166
+ * Per-function cyclomatic + cognitive complexity.
10167
+ *
10168
+ * Cyclomatic (McCabe): 1 + decision points. Counts if/for/while/do/case/catch,
10169
+ * the ?: ternary, &&, ||, ??, &&=/||=/??=, and ?. (optional chaining).
10170
+ *
10171
+ * Cognitive (SonarSource): structural increments with nesting penalty.
10172
+ * Operator-sequence rule: a run of the same logical operator is +1 total;
10173
+ * each operator change adds another +1.
10174
+ *
10175
+ * Returns only functions whose metrics breach at least one threshold from
10176
+ * `config`. Threshold breach count tunes the `confidence` field.
10177
+ */
10178
+ const detectComplexHotspots = (graph, config) => {
10179
+ if (!config?.enabled) return [];
10180
+ const hotspotFindings = [];
10181
+ for (const module of graph.modules) {
10182
+ if (module.isDeclarationFile) continue;
10183
+ if (module.isConfigFile) continue;
10184
+ let sourceText;
10185
+ try {
10186
+ sourceText = readFileSync(module.fileId.path, "utf-8");
10187
+ } catch {
10188
+ continue;
10189
+ }
10190
+ let parseResult;
10191
+ try {
10192
+ parseResult = parseSync(module.fileId.path, sourceText);
10193
+ } catch {
10194
+ continue;
10195
+ }
10196
+ const visitState = {
10197
+ filePath: module.fileId.path,
10198
+ lineStarts: computeLineStarts(sourceText),
10199
+ results: [],
10200
+ frameStack: [],
10201
+ pendingFunctionName: void 0
10202
+ };
10203
+ visitNode(parseResult.program, visitState);
10204
+ for (const result of visitState.results) {
10205
+ if (!(result.cyclomatic >= config.cyclomaticThreshold || result.cognitive >= config.cognitiveThreshold || result.paramCount >= config.paramCountThreshold || result.lineCount >= config.functionLineThreshold)) continue;
10206
+ const annotated = annotateConfidence(result, config);
10207
+ hotspotFindings.push({
10208
+ ...result,
10209
+ confidence: annotated.confidence,
10210
+ reason: annotated.reason
10211
+ });
10212
+ }
10213
+ }
10214
+ hotspotFindings.sort((leftFinding, rightFinding) => {
10215
+ const leftScore = leftFinding.cyclomatic + leftFinding.cognitive;
10216
+ const rightScore = rightFinding.cyclomatic + rightFinding.cognitive;
10217
+ if (leftScore !== rightScore) return rightScore - leftScore;
10218
+ if (leftFinding.path !== rightFinding.path) return leftFinding.path.localeCompare(rightFinding.path);
10219
+ return leftFinding.line - rightFinding.line;
10220
+ });
10221
+ return hotspotFindings;
10222
+ };
10223
+
10224
+ //#endregion
10225
+ //#region src/report/private-type-leaks.ts
10226
+ const extractIdentifierName = (node) => {
10227
+ if (!isAstNode(node)) return void 0;
10228
+ if (node.type === "Identifier") {
10229
+ const identifierName = node.name;
10230
+ return typeof identifierName === "string" ? identifierName : void 0;
10231
+ }
10232
+ };
10233
+ const collectTypeReferenceNamesFromTypeNode = (typeNode, into) => {
10234
+ if (!isAstNode(typeNode)) return;
10235
+ if (typeNode.type === "TSTypeReference") {
10236
+ const referencedTypeName = typeNode.typeName;
10237
+ if (isAstNode(referencedTypeName) && referencedTypeName.type === "Identifier") {
10238
+ const name = referencedTypeName.name;
10239
+ if (typeof name === "string") into.add(name);
10240
+ }
10241
+ }
10242
+ for (const key of Object.keys(typeNode)) {
10243
+ if (key === "type" || key === "start" || key === "end") continue;
10244
+ const value = typeNode[key];
10245
+ if (Array.isArray(value)) for (const item of value) collectTypeReferenceNamesFromTypeNode(item, into);
10246
+ else if (value !== null && typeof value === "object") collectTypeReferenceNamesFromTypeNode(value, into);
10247
+ }
10248
+ };
10249
+ const isExportedDeclaration = (statement) => {
10250
+ if (!isAstNode(statement)) return false;
10251
+ return statement.type === "ExportNamedDeclaration" || statement.type === "ExportDefaultDeclaration";
10252
+ };
10253
+ const declarationOf = (statement) => {
10254
+ if (!isAstNode(statement)) return void 0;
10255
+ return statement.declaration;
10256
+ };
10257
+ const exportedNameOfDeclaration = (declarationNode) => {
10258
+ if (!isAstNode(declarationNode)) return void 0;
10259
+ if (declarationNode.type === "FunctionDeclaration" || declarationNode.type === "ClassDeclaration") return extractIdentifierName(declarationNode.id);
10260
+ if (declarationNode.type === "VariableDeclaration") {
10261
+ const declarators = declarationNode.declarations;
10262
+ if (Array.isArray(declarators) && declarators.length > 0) {
10263
+ const firstDeclarator = declarators[0];
10264
+ if (isAstNode(firstDeclarator)) return extractIdentifierName(firstDeclarator.id);
10265
+ }
10266
+ }
10267
+ if (declarationNode.type === "TSInterfaceDeclaration" || declarationNode.type === "TSTypeAliasDeclaration") return extractIdentifierName(declarationNode.id);
10268
+ };
10269
+ const collectFromFunctionLikeSignature = (functionLikeNode, exportName, collected) => {
10270
+ if (!isAstNode(functionLikeNode)) return;
10271
+ const params = functionLikeNode.params;
10272
+ if (Array.isArray(params)) for (const param of params) collectFromParameter(param, exportName, collected);
10273
+ const returnTypeAnnotation = functionLikeNode.returnType;
10274
+ if (isAstNode(returnTypeAnnotation)) {
10275
+ const annotation = returnTypeAnnotation.typeAnnotation;
10276
+ pushTypeReferences(annotation, exportName, collected, returnTypeAnnotation);
10277
+ }
10278
+ };
10279
+ const collectFromParameter = (parameterNode, exportName, collected) => {
10280
+ if (!isAstNode(parameterNode)) return;
10281
+ const annotation = parameterNode.typeAnnotation;
10282
+ if (isAstNode(annotation)) {
10283
+ const innerTypeNode = annotation.typeAnnotation;
10284
+ pushTypeReferences(innerTypeNode, exportName, collected, annotation);
10285
+ }
10286
+ };
10287
+ const pushTypeReferences = (typeNode, exportName, collected, spanFallbackNode) => {
10288
+ if (!isAstNode(typeNode)) return;
10289
+ const referencedTypeNames = /* @__PURE__ */ new Set();
10290
+ collectTypeReferenceNamesFromTypeNode(typeNode, referencedTypeNames);
10291
+ for (const referencedName of referencedTypeNames) {
10292
+ const offset = typeNode.start;
10293
+ const fallbackOffset = isAstNode(spanFallbackNode) && typeof spanFallbackNode.start === "number" ? spanFallbackNode.start : 0;
10294
+ collected.push({
10295
+ exportName,
10296
+ typeName: referencedName,
10297
+ byteOffset: typeof offset === "number" ? offset : fallbackOffset
10298
+ });
10299
+ }
10300
+ };
10301
+ const collectPublicSignatureReferences = (programNode) => {
10302
+ const collected = [];
10303
+ if (!isAstNode(programNode)) return collected;
10304
+ const programBody = programNode.body;
10305
+ if (!Array.isArray(programBody)) return collected;
10306
+ for (const statement of programBody) {
10307
+ if (!isExportedDeclaration(statement)) continue;
10308
+ const declarationNode = declarationOf(statement);
10309
+ if (declarationNode === void 0 || declarationNode === null) continue;
10310
+ const exportedName = exportedNameOfDeclaration(declarationNode);
10311
+ if (!exportedName) continue;
10312
+ if (isAstNode(declarationNode)) {
10313
+ if (declarationNode.type === "FunctionDeclaration" || declarationNode.type === "ArrowFunctionExpression" || declarationNode.type === "FunctionExpression") {
10314
+ collectFromFunctionLikeSignature(declarationNode, exportedName, collected);
10315
+ continue;
10316
+ }
10317
+ if (declarationNode.type === "VariableDeclaration") {
10318
+ const declarators = declarationNode.declarations;
10319
+ if (Array.isArray(declarators)) for (const declarator of declarators) {
10320
+ if (!isAstNode(declarator)) continue;
10321
+ const id = declarator.id;
10322
+ if (isAstNode(id)) {
10323
+ const annotation = id.typeAnnotation;
10324
+ if (isAstNode(annotation)) {
10325
+ const inner = annotation.typeAnnotation;
10326
+ pushTypeReferences(inner, exportedName, collected, annotation);
10327
+ }
10328
+ }
10329
+ const init = declarator.init;
10330
+ if (isAstNode(init) && (init.type === "ArrowFunctionExpression" || init.type === "FunctionExpression")) collectFromFunctionLikeSignature(init, exportedName, collected);
10331
+ }
10332
+ continue;
10333
+ }
10334
+ if (declarationNode.type === "ClassDeclaration") {
10335
+ const classBody = declarationNode.body;
10336
+ if (isAstNode(classBody)) {
10337
+ const members = classBody.body;
10338
+ if (Array.isArray(members)) for (const member of members) {
10339
+ if (!isAstNode(member)) continue;
10340
+ if (member.type === "MethodDefinition") {
10341
+ const value = member.value;
10342
+ collectFromFunctionLikeSignature(value, exportedName, collected);
10343
+ } else if (member.type === "PropertyDefinition") {
10344
+ const annotation = member.typeAnnotation;
10345
+ if (isAstNode(annotation)) {
10346
+ const inner = annotation.typeAnnotation;
10347
+ pushTypeReferences(inner, exportedName, collected, annotation);
10348
+ }
10349
+ }
10350
+ }
10351
+ }
10352
+ }
10353
+ }
10354
+ }
10355
+ return collected;
10356
+ };
10357
+ const collectLocalTypeNames = (programNode) => {
10358
+ const localTypeNames = /* @__PURE__ */ new Set();
10359
+ const exportedNames = /* @__PURE__ */ new Set();
10360
+ if (!isAstNode(programNode)) return {
10361
+ localTypeNames,
10362
+ exportedNames
10363
+ };
10364
+ const programBody = programNode.body;
10365
+ if (!Array.isArray(programBody)) return {
10366
+ localTypeNames,
10367
+ exportedNames
10368
+ };
10369
+ for (const statement of programBody) {
10370
+ if (!isAstNode(statement)) continue;
10371
+ if (statement.type === "TSInterfaceDeclaration" || statement.type === "TSTypeAliasDeclaration") {
10372
+ const name = extractIdentifierName(statement.id);
10373
+ if (name) localTypeNames.add(name);
10374
+ continue;
10375
+ }
10376
+ if (statement.type === "ExportNamedDeclaration") {
10377
+ const declarationNode = statement.declaration;
10378
+ if (isAstNode(declarationNode)) {
10379
+ if (declarationNode.type === "TSInterfaceDeclaration" || declarationNode.type === "TSTypeAliasDeclaration") {
10380
+ const name = extractIdentifierName(declarationNode.id);
10381
+ if (name) exportedNames.add(name);
10382
+ continue;
10383
+ }
10384
+ const declaredName = exportedNameOfDeclaration(declarationNode);
10385
+ if (declaredName) exportedNames.add(declaredName);
10386
+ }
10387
+ const specifiers = statement.specifiers;
10388
+ if (Array.isArray(specifiers)) for (const specifier of specifiers) {
10389
+ if (!isAstNode(specifier)) continue;
10390
+ if (specifier.type === "ExportSpecifier") {
10391
+ const exported = specifier.exported;
10392
+ const exportedNameValue = extractIdentifierName(exported);
10393
+ if (exportedNameValue) exportedNames.add(exportedNameValue);
10394
+ }
10395
+ }
10396
+ }
10397
+ }
10398
+ return {
10399
+ localTypeNames,
10400
+ exportedNames
10401
+ };
10402
+ };
10403
+ /**
10404
+ * Storybook CSF3 convention: a story file declares
10405
+ *
10406
+ * const meta = { ... } satisfies Meta<...>;
10407
+ * export default meta;
10408
+ * type Story = StoryObj<typeof meta>;
10409
+ * export const Primary: Story = { ... };
10410
+ *
10411
+ * `Story` is intentionally a local alias — consumers don't import it; the
10412
+ * Storybook runtime reads the default export. Flagging this as a leak
10413
+ * produces near-100% false positives on Storybook codebases, so skip
10414
+ * `*.stories.{ts,tsx,js,jsx,mts,mjs,cts,cjs}` files entirely.
10415
+ */
10416
+ const STORYBOOK_STORY_FILE_PATTERN = /\.stories\.(?:[cm]?ts|[cm]?js|tsx|jsx)$/;
10417
+ const isStorybookStoryFile = (filePath) => STORYBOOK_STORY_FILE_PATTERN.test(filePath);
10418
+ /**
10419
+ * Detect TypeScript "private type leak": an exported declaration's signature
10420
+ * references a type that was declared locally in the same module but is not
10421
+ * itself exported. Consumers of the export need that type to satisfy the
10422
+ * signature, but cannot import it.
10423
+ *
10424
+ * Skips declaration files (`.d.ts`) — they are pure type modules where this
10425
+ * pattern is the norm. Keeps it simple: doesn't try to chase aliased re-export
10426
+ * paths (deslop-js's broader resolver work covers that elsewhere); a leak
10427
+ * that's actually re-exported gets filtered out at the `exportedNames` set.
10428
+ */
10429
+ const detectPrivateTypeLeaks = (graph) => {
10430
+ const findings = [];
10431
+ for (const module of graph.modules) {
10432
+ if (module.isDeclarationFile) continue;
10433
+ if (module.isConfigFile) continue;
10434
+ if (!module.isReachable) continue;
10435
+ if (isStorybookStoryFile(module.fileId.path)) continue;
10436
+ let sourceText;
10437
+ try {
10438
+ sourceText = readFileSync(module.fileId.path, "utf-8");
10439
+ } catch {
10440
+ continue;
10441
+ }
10442
+ let parseResult;
10443
+ try {
10444
+ parseResult = parseSync(module.fileId.path, sourceText);
10445
+ } catch {
10446
+ continue;
10447
+ }
10448
+ const programNode = parseResult.program;
10449
+ const { localTypeNames, exportedNames } = collectLocalTypeNames(programNode);
10450
+ if (localTypeNames.size === 0) continue;
10451
+ const publicSignatureReferences = collectPublicSignatureReferences(programNode);
10452
+ if (publicSignatureReferences.length === 0) continue;
10453
+ const lineStarts = computeLineStarts(sourceText);
10454
+ const seenPairs = /* @__PURE__ */ new Set();
10455
+ for (const reference of publicSignatureReferences) {
10456
+ if (!localTypeNames.has(reference.typeName)) continue;
10457
+ if (exportedNames.has(reference.typeName)) continue;
10458
+ const pairKey = `${reference.exportName}::${reference.typeName}`;
10459
+ if (seenPairs.has(pairKey)) continue;
10460
+ seenPairs.add(pairKey);
10461
+ const { line, column } = offsetToLineColumn(reference.byteOffset, lineStarts);
10462
+ findings.push({
10463
+ path: module.fileId.path,
10464
+ exportName: reference.exportName,
10465
+ typeName: reference.typeName,
10466
+ line,
10467
+ column,
10468
+ confidence: "high",
10469
+ reason: `${reference.exportName}'s signature references ${reference.typeName}, declared locally but not exported — consumers can't satisfy the type without importing it`
10470
+ });
10471
+ }
10472
+ }
10473
+ findings.sort((leftLeak, rightLeak) => {
10474
+ if (leftLeak.path !== rightLeak.path) return leftLeak.path.localeCompare(rightLeak.path);
10475
+ return leftLeak.line - rightLeak.line;
10476
+ });
10477
+ return findings;
10478
+ };
10479
+
10480
+ //#endregion
10481
+ //#region src/report/typescript-smells.ts
10482
+ const parseSource = (filePath) => {
10483
+ let sourceText;
10484
+ try {
10485
+ sourceText = readFileSync(filePath, "utf-8");
10486
+ } catch {
10487
+ return;
10488
+ }
10489
+ let parseResult;
10490
+ try {
10491
+ parseResult = parseSync(filePath, sourceText);
10492
+ } catch {
10493
+ return;
10494
+ }
10495
+ const rawComments = parseResult.comments;
10496
+ const comments = Array.isArray(rawComments) ? rawComments.filter(isParsedSourceComment) : [];
10497
+ return {
10498
+ programNode: parseResult.program,
10499
+ sourceText,
10500
+ lineStarts: computeLineStarts(sourceText),
10501
+ comments
10502
+ };
10503
+ };
10504
+ const isParsedSourceComment = (candidate) => {
10505
+ if (typeof candidate !== "object" || candidate === null) return false;
10506
+ const fields = candidate;
10507
+ return (fields.type === "Line" || fields.type === "Block") && typeof fields.value === "string" && typeof fields.start === "number" && typeof fields.end === "number";
10508
+ };
10509
+ const sliceSnippet = (sourceText, start, end) => {
10510
+ const SNIPPET_BUDGET_CHARS = 80;
10511
+ const raw = sourceText.slice(start, Math.min(end, start + SNIPPET_BUDGET_CHARS)).replace(/\s+/g, " ").trim();
10512
+ return end - start > SNIPPET_BUDGET_CHARS ? `${raw}…` : raw;
10513
+ };
10514
+ const isAnyOrUnknownTypeAnnotation = (typeAnnotation) => {
10515
+ if (!isAstNode(typeAnnotation)) return void 0;
10516
+ if (typeAnnotation.type === "TSAnyKeyword") return "any";
10517
+ if (typeAnnotation.type === "TSUnknownKeyword") return "unknown";
10518
+ };
10519
+ const isLiteralLikeNonNull = (expression) => {
10520
+ if (!isAstNode(expression)) return false;
10521
+ if (expression.type === "Literal") return expression.value !== null;
10522
+ if (expression.type === "TemplateLiteral" || expression.type === "ArrayExpression" || expression.type === "ObjectExpression" || expression.type === "FunctionExpression" || expression.type === "ArrowFunctionExpression" || expression.type === "ClassExpression") return true;
10523
+ return false;
10524
+ };
10525
+ const collectUnnecessaryAssertionsInNode = (node, filePath, sourceText, lineStarts, results) => {
10526
+ if (!isAstNode(node)) return;
10527
+ if (node.type === "TSAsExpression" || node.type === "TSSatisfiesExpression") {
10528
+ const innerExpression = node.expression;
10529
+ const typeAnnotation = node.typeAnnotation;
10530
+ if (node.type === "TSAsExpression") {
10531
+ const outerKind = isAnyOrUnknownTypeAnnotation(typeAnnotation);
10532
+ if (outerKind === void 0 && isAstNode(innerExpression) && innerExpression.type === "TSAsExpression") {
10533
+ const innerTypeAnnotation = innerExpression.typeAnnotation;
10534
+ const innerKind = isAnyOrUnknownTypeAnnotation(innerTypeAnnotation);
10535
+ 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);
10536
+ }
10537
+ 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);
10538
+ }
10539
+ }
10540
+ 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);
10541
+ if (node.type === "TSNonNullExpression") {
10542
+ const innerExpression = node.expression;
10543
+ 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);
10544
+ 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);
10545
+ }
10546
+ };
10547
+ const pushAssertion = (node, kind, reason, suggestion, filePath, sourceText, lineStarts, results) => {
10548
+ if (!isAstNode(node)) return;
10549
+ const startOffset = node.start;
10550
+ const endOffset = node.end;
10551
+ if (typeof startOffset !== "number" || typeof endOffset !== "number") return;
10552
+ const { line, column } = offsetToLineColumn(startOffset, lineStarts);
10553
+ const isHighConfidenceKind = kind === "double-non-null" || kind === "redundant-non-null-on-literal" || kind === "redundant-double-assertion";
10554
+ results.push({
10555
+ path: filePath,
10556
+ kind,
10557
+ snippet: sliceSnippet(sourceText, startOffset, endOffset),
10558
+ line,
10559
+ column,
10560
+ confidence: isHighConfidenceKind ? "high" : "medium",
10561
+ reason,
10562
+ suggestion
10563
+ });
10564
+ };
10565
+ const visitForUnnecessaryAssertions = (node, filePath, sourceText, lineStarts, results) => {
10566
+ if (!isAstNode(node)) return;
10567
+ collectUnnecessaryAssertionsInNode(node, filePath, sourceText, lineStarts, results);
10568
+ for (const propertyKey of Object.keys(node)) {
10569
+ if (propertyKey === "type" || propertyKey === "start" || propertyKey === "end" || propertyKey === "loc" || propertyKey === "range") continue;
10570
+ const value = node[propertyKey];
10571
+ if (Array.isArray(value)) for (const item of value) visitForUnnecessaryAssertions(item, filePath, sourceText, lineStarts, results);
10572
+ else if (value !== null && typeof value === "object") visitForUnnecessaryAssertions(value, filePath, sourceText, lineStarts, results);
10573
+ }
10574
+ };
10575
+ const importExpressionSpecifier = (importExpression) => {
10576
+ if (!isAstNode(importExpression)) return void 0;
10577
+ if (importExpression.type !== "ImportExpression") return void 0;
10578
+ const sourceNode = importExpression.source;
10579
+ if (!isAstNode(sourceNode)) return void 0;
10580
+ if (sourceNode.type !== "Literal") return void 0;
10581
+ const literalValue = sourceNode.value;
10582
+ return typeof literalValue === "string" ? literalValue : void 0;
10583
+ };
10584
+ const findThenImportInExpressionStatement = (expressionNode) => {
10585
+ if (!isAstNode(expressionNode)) return void 0;
10586
+ if (expressionNode.type !== "CallExpression") return void 0;
10587
+ const callee = expressionNode.callee;
10588
+ if (!isAstNode(callee)) return void 0;
10589
+ if (callee.type !== "MemberExpression" && callee.type !== "StaticMemberExpression") return void 0;
10590
+ const propertyNode = callee.property;
10591
+ const propertyName = isAstNode(propertyNode) ? propertyNode.name : void 0;
10592
+ if (propertyName !== "then" && propertyName !== "catch" && propertyName !== "finally") return void 0;
10593
+ const objectNode = callee.object;
10594
+ const specifier = importExpressionSpecifier(objectNode);
10595
+ if (specifier === void 0) return void 0;
10596
+ return {
10597
+ importExpression: objectNode,
10598
+ specifier
10599
+ };
10600
+ };
10601
+ const findAwaitImportInExpression = (expressionNode) => {
10602
+ if (!isAstNode(expressionNode)) return void 0;
10603
+ if (expressionNode.type !== "AwaitExpression") return void 0;
10604
+ const argumentNode = expressionNode.argument;
10605
+ const specifier = importExpressionSpecifier(argumentNode);
10606
+ if (specifier === void 0) return void 0;
10607
+ return {
10608
+ importExpression: argumentNode,
10609
+ specifier
10610
+ };
10611
+ };
10612
+ const collectLazyImportsAtTopLevel = (programNode, filePath, lineStarts, results) => {
10613
+ if (!isAstNode(programNode)) return;
10614
+ const programBody = programNode.body;
10615
+ if (!Array.isArray(programBody)) return;
10616
+ for (const topLevelStatement of programBody) {
10617
+ if (!isAstNode(topLevelStatement)) continue;
10618
+ if (topLevelStatement.type === "VariableDeclaration") {
10619
+ const declarators = topLevelStatement.declarations;
10620
+ if (!Array.isArray(declarators)) continue;
10621
+ for (const declarator of declarators) {
10622
+ if (!isAstNode(declarator)) continue;
10623
+ const initializer = declarator.init;
10624
+ const awaitImport = findAwaitImportInExpression(initializer);
10625
+ if (awaitImport) recordLazyImport(awaitImport, "top-level-await-import", filePath, lineStarts, results);
10626
+ }
10627
+ continue;
10628
+ }
10629
+ if (topLevelStatement.type === "ExpressionStatement") {
10630
+ const innerExpression = topLevelStatement.expression;
10631
+ const awaitImport = findAwaitImportInExpression(innerExpression);
10632
+ if (awaitImport) {
10633
+ recordLazyImport(awaitImport, "top-level-await-import", filePath, lineStarts, results);
10634
+ continue;
10635
+ }
10636
+ const thenImport = findThenImportInExpressionStatement(innerExpression);
10637
+ if (thenImport) recordLazyImport(thenImport, "top-level-then-import", filePath, lineStarts, results);
10638
+ }
10639
+ }
10640
+ };
10641
+ const recordLazyImport = (match, kind, filePath, lineStarts, results) => {
10642
+ if (!isAstNode(match.importExpression)) return;
10643
+ const startOffset = match.importExpression.start;
10644
+ if (typeof startOffset !== "number") return;
10645
+ const { line, column } = offsetToLineColumn(startOffset, lineStarts);
10646
+ results.push({
10647
+ path: filePath,
10648
+ specifier: match.specifier,
10649
+ kind,
10650
+ line,
10651
+ column,
10652
+ confidence: kind === "top-level-await-import" ? "high" : "medium",
10653
+ 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`
10654
+ });
10655
+ };
10656
+ const buildPackageJsonTypeCache = () => {
10657
+ const directoryToType = /* @__PURE__ */ new Map();
10658
+ const resolveModuleType = (filePath) => {
10659
+ let currentDirectory = dirname(resolve(filePath));
10660
+ const visitedDirectories = [];
10661
+ while (true) {
10662
+ visitedDirectories.push(currentDirectory);
10663
+ const cached = directoryToType.get(currentDirectory);
10664
+ if (cached !== void 0) {
10665
+ for (const visitedDirectory of visitedDirectories) directoryToType.set(visitedDirectory, cached);
10666
+ return cached;
10667
+ }
10668
+ const packageJsonPath = join(currentDirectory, "package.json");
10669
+ if (existsSync(packageJsonPath)) try {
10670
+ const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf-8"));
10671
+ const moduleType = packageJson.type === "module" ? "module" : packageJson.type === "commonjs" ? "commonjs" : void 0;
10672
+ for (const visitedDirectory of visitedDirectories) directoryToType.set(visitedDirectory, moduleType);
10673
+ return moduleType;
10674
+ } catch {
10675
+ for (const visitedDirectory of visitedDirectories) directoryToType.set(visitedDirectory, void 0);
10676
+ return;
10677
+ }
10678
+ const parentDirectory = dirname(currentDirectory);
10679
+ if (parentDirectory === currentDirectory) {
10680
+ for (const visitedDirectory of visitedDirectories) directoryToType.set(visitedDirectory, void 0);
10681
+ return;
10682
+ }
10683
+ currentDirectory = parentDirectory;
10684
+ }
10685
+ };
10686
+ return { resolveModuleType };
10687
+ };
10688
+ const isEsmFilePath = (filePath, typeCache) => {
10689
+ if (filePath.endsWith(".mts") || filePath.endsWith(".mjs")) return true;
10690
+ if (filePath.endsWith(".cts") || filePath.endsWith(".cjs")) return false;
10691
+ return typeCache.resolveModuleType(filePath) === "module";
10692
+ };
10693
+ const collectCommonjsInEsm = (programNode, filePath, sourceText, lineStarts, results) => {
10694
+ if (!isAstNode(programNode)) return;
10695
+ visitForCommonjs(programNode, filePath, sourceText, lineStarts, results);
10696
+ };
10697
+ const visitForCommonjs = (node, filePath, sourceText, lineStarts, results) => {
10698
+ if (!isAstNode(node)) return;
10699
+ if (node.type === "CallExpression") {
10700
+ const callee = node.callee;
10701
+ if (isAstNode(callee) && callee.type === "Identifier") {
10702
+ if (callee.name === "require") {
10703
+ const callArguments = node.arguments;
10704
+ if (Array.isArray(callArguments) && callArguments.length > 0) {
10705
+ const firstArgument = callArguments[0];
10706
+ if (isAstNode(firstArgument) && firstArgument.type === "Literal" && typeof firstArgument.value === "string") {
10707
+ const startOffset = node.start;
10708
+ const endOffset = node.end;
10709
+ if (typeof startOffset === "number" && typeof endOffset === "number") {
10710
+ const { line, column } = offsetToLineColumn(startOffset, lineStarts);
10711
+ results.push({
10712
+ path: filePath,
10713
+ kind: "require",
10714
+ line,
10715
+ column,
10716
+ confidence: "high",
10717
+ reason: "synchronous `require()` is unavailable in native ESM — use a static `import` or top-level `await import()`",
10718
+ snippet: sliceSnippet(sourceText, startOffset, endOffset)
10719
+ });
10720
+ }
10721
+ }
10722
+ }
10723
+ }
10724
+ }
10725
+ }
10726
+ if (node.type === "AssignmentExpression") {
10727
+ const leftSide = node.left;
10728
+ if (isAstNode(leftSide)) {
10729
+ if (leftSide.type === "MemberExpression" || leftSide.type === "StaticMemberExpression") {
10730
+ const objectNode = leftSide.object;
10731
+ const propertyNode = leftSide.property;
10732
+ const objectName = isAstNode(objectNode) ? objectNode.name : void 0;
10733
+ const propertyName = isAstNode(propertyNode) ? propertyNode.name : void 0;
10734
+ if (objectName === "module" && propertyName === "exports") {
10735
+ const startOffset = node.start;
10736
+ const endOffset = node.end;
10737
+ if (typeof startOffset === "number" && typeof endOffset === "number") {
10738
+ const { line, column } = offsetToLineColumn(startOffset, lineStarts);
10739
+ results.push({
10740
+ path: filePath,
10741
+ kind: "module-exports",
10742
+ line,
10743
+ column,
10744
+ confidence: "high",
10745
+ reason: "`module.exports = ...` is CommonJS — replace with `export default` or named `export` for ESM",
10746
+ snippet: sliceSnippet(sourceText, startOffset, endOffset)
10747
+ });
10748
+ }
10749
+ } else if (objectName === "exports") {
10750
+ const startOffset = node.start;
10751
+ const endOffset = node.end;
10752
+ if (typeof startOffset === "number" && typeof endOffset === "number") {
10753
+ const { line, column } = offsetToLineColumn(startOffset, lineStarts);
10754
+ results.push({
10755
+ path: filePath,
10756
+ kind: "exports-assignment",
10757
+ line,
10758
+ column,
10759
+ confidence: "high",
10760
+ reason: "`exports.x = ...` is CommonJS — replace with a named `export` for ESM",
10761
+ snippet: sliceSnippet(sourceText, startOffset, endOffset)
10762
+ });
10763
+ }
10764
+ }
10765
+ }
10766
+ }
10767
+ }
10768
+ for (const propertyKey of Object.keys(node)) {
10769
+ if (propertyKey === "type" || propertyKey === "start" || propertyKey === "end" || propertyKey === "loc" || propertyKey === "range") continue;
10770
+ const value = node[propertyKey];
10771
+ if (Array.isArray(value)) for (const item of value) visitForCommonjs(item, filePath, sourceText, lineStarts, results);
10772
+ else if (value !== null && typeof value === "object") visitForCommonjs(value, filePath, sourceText, lineStarts, results);
10773
+ }
10774
+ };
10775
+ const TS_IGNORE_LEADING = /^\s*@ts-ignore\b/;
10776
+ const TS_NOCHECK_LEADING = /^\s*@ts-nocheck\b/;
10777
+ const TS_EXPECT_ERROR_LEADING = /^\s*@ts-expect-error\b(.*)$/;
10778
+ const collectTypeScriptEscapeHatches = (comments, filePath, lineStarts, results) => {
10779
+ for (const comment of comments) {
10780
+ const commentBody = comment.type === "Block" ? comment.value.split("\n")[0] : comment.value;
10781
+ if (TS_IGNORE_LEADING.test(commentBody)) {
10782
+ 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);
10783
+ continue;
10784
+ }
10785
+ if (TS_NOCHECK_LEADING.test(commentBody)) {
10786
+ 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);
10787
+ continue;
10788
+ }
10789
+ const expectErrorMatch = commentBody.match(TS_EXPECT_ERROR_LEADING);
10790
+ if (expectErrorMatch) {
10791
+ 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);
10792
+ }
10793
+ }
10794
+ };
10795
+ const pushEscapeHatch = (commentStartOffset, kind, reason, suggestion, confidence, filePath, lineStarts, results) => {
10796
+ const { line, column } = offsetToLineColumn(commentStartOffset, lineStarts);
10797
+ results.push({
10798
+ path: filePath,
10799
+ kind,
10800
+ line,
10801
+ column,
10802
+ confidence,
10803
+ reason,
10804
+ suggestion
10805
+ });
10806
+ };
10807
+ 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");
10808
+ const isTypeScriptFileExtension = (filePath) => filePath.endsWith(".ts") || filePath.endsWith(".tsx") || filePath.endsWith(".mts") || filePath.endsWith(".cts");
10809
+ const detectTypeScriptSmells = (graph) => {
10810
+ const unnecessaryAssertions = [];
10811
+ const lazyImportsAtTopLevel = [];
10812
+ const commonjsInEsm = [];
10813
+ const typeScriptEscapeHatches = [];
10814
+ const packageJsonTypeCache = buildPackageJsonTypeCache();
10815
+ for (const module of graph.modules) {
10816
+ if (module.isDeclarationFile) continue;
10817
+ const filePath = module.fileId.path;
10818
+ if (!isTypeScriptOrJsFile(filePath)) continue;
10819
+ const parsedSource = parseSource(filePath);
10820
+ if (!parsedSource) continue;
10821
+ if (isTypeScriptFileExtension(filePath)) {
10822
+ visitForUnnecessaryAssertions(parsedSource.programNode, filePath, parsedSource.sourceText, parsedSource.lineStarts, unnecessaryAssertions);
10823
+ collectTypeScriptEscapeHatches(parsedSource.comments, filePath, parsedSource.lineStarts, typeScriptEscapeHatches);
10824
+ }
10825
+ collectLazyImportsAtTopLevel(parsedSource.programNode, filePath, parsedSource.lineStarts, lazyImportsAtTopLevel);
10826
+ if (isEsmFilePath(filePath, packageJsonTypeCache)) collectCommonjsInEsm(parsedSource.programNode, filePath, parsedSource.sourceText, parsedSource.lineStarts, commonjsInEsm);
10827
+ }
10828
+ unnecessaryAssertions.sort((leftFinding, rightFinding) => {
10829
+ if (leftFinding.path !== rightFinding.path) return leftFinding.path.localeCompare(rightFinding.path);
10830
+ return leftFinding.line - rightFinding.line;
10831
+ });
10832
+ lazyImportsAtTopLevel.sort((leftFinding, rightFinding) => {
10833
+ if (leftFinding.path !== rightFinding.path) return leftFinding.path.localeCompare(rightFinding.path);
10834
+ return leftFinding.line - rightFinding.line;
10835
+ });
10836
+ commonjsInEsm.sort((leftFinding, rightFinding) => {
10837
+ if (leftFinding.path !== rightFinding.path) return leftFinding.path.localeCompare(rightFinding.path);
10838
+ return leftFinding.line - rightFinding.line;
10839
+ });
10840
+ typeScriptEscapeHatches.sort((leftFinding, rightFinding) => {
10841
+ if (leftFinding.path !== rightFinding.path) return leftFinding.path.localeCompare(rightFinding.path);
10842
+ return leftFinding.line - rightFinding.line;
10843
+ });
10844
+ return {
10845
+ unnecessaryAssertions,
10846
+ lazyImportsAtTopLevel,
10847
+ commonjsInEsm,
10848
+ typeScriptEscapeHatches
10849
+ };
10850
+ };
10851
+
10852
+ //#endregion
10853
+ //#region src/utils/run-safe-detector.ts
10854
+ const runSafeDetector = (input) => {
10855
+ try {
10856
+ return input.detector();
10857
+ } catch (caughtError) {
10858
+ input.errorSink.push(new DetectorError({
10859
+ module: input.module,
10860
+ message: `${input.detectorName} threw ${input.contextDescription}`,
10861
+ detail: describeUnknownError(caughtError)
10862
+ }));
10863
+ return input.fallback;
10864
+ }
10865
+ };
10866
+
10867
+ //#endregion
10868
+ //#region src/semantic/program.ts
10869
+ const failureFor = (reason, message, options = { rootDir: "" }) => {
10870
+ return {
10871
+ reason,
10872
+ message,
10873
+ error: new TypeScriptError({
10874
+ code: {
10875
+ "no-tsconfig": "tsconfig-not-found",
10876
+ "tsconfig-parse-error": "tsconfig-parse-failed",
10877
+ "program-creation-failed": "ts-program-creation-failed",
10878
+ "too-many-files": "ts-program-too-large",
10879
+ "typescript-load-failed": "ts-not-loadable"
10880
+ }[reason],
10881
+ severity: reason === "no-tsconfig" ? "info" : "warning",
10882
+ message,
10883
+ path: options.rootDir || void 0,
10884
+ detail: options.detail
10885
+ })
10886
+ };
10887
+ };
10888
+ const findNearestTsconfig = (rootDir, explicitPath) => {
10889
+ if (explicitPath) {
10890
+ const absoluteExplicit = resolve(rootDir, explicitPath);
10891
+ if (existsSync(absoluteExplicit)) return absoluteExplicit;
10892
+ return;
10893
+ }
10894
+ for (const candidateName of DEFAULT_SEMANTIC_TSCONFIG_NAMES) {
10895
+ const candidatePath = resolve(rootDir, candidateName);
10896
+ if (existsSync(candidatePath)) return candidatePath;
10897
+ }
10898
+ };
10899
+ const createSemanticContext = (rootDir, tsconfigPath) => {
10900
+ const resolvedTsconfigPath = findNearestTsconfig(rootDir, tsconfigPath);
10901
+ if (!resolvedTsconfigPath) return {
10902
+ ok: false,
10903
+ failure: failureFor("no-tsconfig", `No tsconfig found under ${rootDir}`, { rootDir })
10904
+ };
10905
+ let configFileContent;
10906
+ try {
10907
+ configFileContent = ts.readConfigFile(resolvedTsconfigPath, ts.sys.readFile);
10908
+ } catch (readError) {
10909
+ return {
10910
+ ok: false,
10911
+ failure: failureFor("tsconfig-parse-error", "ts.readConfigFile threw", {
10912
+ rootDir: resolvedTsconfigPath,
10913
+ detail: describeUnknownError(readError)
10914
+ })
10915
+ };
10916
+ }
10917
+ if (configFileContent.error) return {
10918
+ ok: false,
10919
+ failure: failureFor("tsconfig-parse-error", ts.flattenDiagnosticMessageText(configFileContent.error.messageText, "\n"), { rootDir: resolvedTsconfigPath })
10920
+ };
10921
+ let parsedCommandLine;
10922
+ try {
10923
+ parsedCommandLine = ts.parseJsonConfigFileContent(configFileContent.config, ts.sys, dirname(resolvedTsconfigPath), {
10924
+ noEmit: true,
10925
+ skipLibCheck: true,
10926
+ allowJs: true,
10927
+ isolatedModules: false
10928
+ }, resolvedTsconfigPath);
10929
+ } catch (parseError) {
10930
+ return {
10931
+ ok: false,
10932
+ failure: failureFor("tsconfig-parse-error", "ts.parseJsonConfigFileContent threw", {
10933
+ rootDir: resolvedTsconfigPath,
10934
+ detail: describeUnknownError(parseError)
10935
+ })
10936
+ };
10937
+ }
10938
+ if (parsedCommandLine.errors.length > 0) {
10939
+ const fatalErrors = parsedCommandLine.errors.filter((diagnostic) => diagnostic.category === ts.DiagnosticCategory.Error);
10940
+ if (fatalErrors.length > 0 && parsedCommandLine.fileNames.length === 0) return {
10941
+ ok: false,
10942
+ failure: failureFor("tsconfig-parse-error", ts.flattenDiagnosticMessageText(fatalErrors[0].messageText, "\n"), { rootDir: resolvedTsconfigPath })
10943
+ };
10944
+ }
10945
+ if (parsedCommandLine.fileNames.length > 5e3) return {
10946
+ ok: false,
10947
+ failure: failureFor("too-many-files", `Project has ${parsedCommandLine.fileNames.length} files, exceeds SEMANTIC_MAX_PROGRAM_FILES=${SEMANTIC_MAX_PROGRAM_FILES}`, { rootDir: resolvedTsconfigPath })
10948
+ };
10949
+ try {
10950
+ const program = ts.createProgram({
10951
+ rootNames: parsedCommandLine.fileNames,
10952
+ options: parsedCommandLine.options,
10953
+ projectReferences: parsedCommandLine.projectReferences
10954
+ });
10955
+ return {
10956
+ ok: true,
10957
+ context: {
10958
+ program,
10959
+ checker: program.getTypeChecker(),
10960
+ rootSourceFiles: program.getSourceFiles().filter((sourceFile) => !sourceFile.isDeclarationFile || sourceFile.fileName.endsWith(".d.ts")),
10961
+ tsconfigPath: resolvedTsconfigPath
10962
+ }
10963
+ };
10964
+ } catch (programError) {
10965
+ return {
10966
+ ok: false,
10967
+ failure: failureFor("program-creation-failed", "ts.createProgram threw", {
10968
+ rootDir: resolvedTsconfigPath,
10969
+ detail: describeUnknownError(programError)
10970
+ })
10971
+ };
10972
+ }
10973
+ };
10974
+
10975
+ //#endregion
10976
+ //#region src/semantic/references.ts
10977
+ const canonicalKeyForSymbol = (symbol) => {
10978
+ return symbol.declarations?.[0] ?? symbol;
10979
+ };
10980
+ const isDeclarationNameIdentifier = (identifier) => {
8428
10981
  const parent = identifier.parent;
8429
10982
  if (!parent) return false;
8430
10983
  if ((ts.isInterfaceDeclaration(parent) || ts.isTypeAliasDeclaration(parent) || ts.isClassDeclaration(parent) || ts.isFunctionDeclaration(parent) || ts.isEnumDeclaration(parent) || ts.isModuleDeclaration(parent) || ts.isVariableDeclaration(parent)) && parent.name === identifier) return true;
@@ -8736,6 +11289,55 @@ const detectUnusedEnumMembers = (graph, config, context, referenceIndex) => {
8736
11289
  return findings;
8737
11290
  };
8738
11291
 
11292
+ //#endregion
11293
+ //#region src/utils/is-framework-lifecycle-method.ts
11294
+ /**
11295
+ * Methods invoked by-name by React / Angular runtimes. Static "no caller"
11296
+ * analysis can't see those call sites, so without this allowlist
11297
+ * `unusedClassMembers` would fire on every component.
11298
+ */
11299
+ const FRAMEWORK_LIFECYCLE_METHODS = new Set([
11300
+ "render",
11301
+ "componentDidMount",
11302
+ "componentDidUpdate",
11303
+ "componentWillUnmount",
11304
+ "shouldComponentUpdate",
11305
+ "getSnapshotBeforeUpdate",
11306
+ "getDerivedStateFromProps",
11307
+ "getDerivedStateFromError",
11308
+ "componentDidCatch",
11309
+ "componentWillMount",
11310
+ "componentWillReceiveProps",
11311
+ "componentWillUpdate",
11312
+ "UNSAFE_componentWillMount",
11313
+ "UNSAFE_componentWillReceiveProps",
11314
+ "UNSAFE_componentWillUpdate",
11315
+ "getChildContext",
11316
+ "contextType",
11317
+ "ngOnInit",
11318
+ "ngOnDestroy",
11319
+ "ngOnChanges",
11320
+ "ngDoCheck",
11321
+ "ngAfterContentInit",
11322
+ "ngAfterContentChecked",
11323
+ "ngAfterViewInit",
11324
+ "ngAfterViewChecked",
11325
+ "ngAcceptInputType",
11326
+ "canActivate",
11327
+ "canDeactivate",
11328
+ "canActivateChild",
11329
+ "canMatch",
11330
+ "resolve",
11331
+ "intercept",
11332
+ "transform",
11333
+ "validate",
11334
+ "registerOnChange",
11335
+ "registerOnTouched",
11336
+ "writeValue",
11337
+ "setDisabledState"
11338
+ ]);
11339
+ const isFrameworkLifecycleMethod = (name) => FRAMEWORK_LIFECYCLE_METHODS.has(name);
11340
+
8739
11341
  //#endregion
8740
11342
  //#region src/semantic/unused-class-members.ts
8741
11343
  const isClassExported = (declaration) => {
@@ -8867,6 +11469,7 @@ const detectUnusedClassMembers = (graph, config, context, referenceIndex, decora
8867
11469
  if (memberHasExternalReference(memberSymbol, referenceIndex)) continue;
8868
11470
  const memberName = ts.isIdentifier(member.name) ? member.name.text : member.name.getText(sourceFile);
8869
11471
  if (overriddenMemberNames.has(memberName)) continue;
11472
+ if (isFrameworkLifecycleMethod(memberName)) continue;
8870
11473
  const { line: zeroIndexedLine, character: zeroIndexedColumn } = sourceFile.getLineAndCharacterOfPosition(member.getStart(sourceFile));
8871
11474
  const line = zeroIndexedLine + 1;
8872
11475
  const column = zeroIndexedColumn + 1;
@@ -9267,6 +11870,22 @@ const generateReport = (graph, config) => {
9267
11870
  const simplifiableFunctions = config.reportRedundancy ? safeReportDetector("detectSimplifiableFunctions", () => detectSimplifiableFunctions(graph), [], errorSink) : [];
9268
11871
  const simplifiableExpressions = config.reportRedundancy ? safeReportDetector("detectSimplifiableExpressions", () => detectSimplifiableExpressions(graph), [], errorSink) : [];
9269
11872
  const duplicateConstants = config.reportRedundancy ? safeReportDetector("detectDuplicateConstants", () => detectDuplicateConstants(graph), [], errorSink) : [];
11873
+ const crossFileDuplicateExports = config.reportRedundancy ? safeReportDetector("detectCrossFileDuplicateExports", () => detectCrossFileDuplicateExports(graph), [], errorSink) : [];
11874
+ const duplicateBlockResult = safeReportDetector("detectDuplicateBlocks", () => detectDuplicateBlocks(graph, config.duplicateBlocks, config.rootDir), {
11875
+ duplicateBlocks: [],
11876
+ duplicateBlockClusters: [],
11877
+ shadowedDirectoryPairs: []
11878
+ }, errorSink);
11879
+ const reExportCycles = safeReportDetector("detectReExportCycles", () => detectReExportCycles(graph), [], errorSink);
11880
+ const featureFlags = safeReportDetector("detectFeatureFlags", () => detectFeatureFlags(graph, config.featureFlags), [], errorSink);
11881
+ const complexFunctions = safeReportDetector("detectComplexHotspots", () => detectComplexHotspots(graph, config.complexity), [], errorSink);
11882
+ const privateTypeLeaks = safeReportDetector("detectPrivateTypeLeaks", () => detectPrivateTypeLeaks(graph), [], errorSink);
11883
+ const typeScriptSmellsResult = safeReportDetector("detectTypeScriptSmells", () => detectTypeScriptSmells(graph), {
11884
+ unnecessaryAssertions: [],
11885
+ lazyImportsAtTopLevel: [],
11886
+ commonjsInEsm: [],
11887
+ typeScriptEscapeHatches: []
11888
+ }, errorSink);
9270
11889
  let semanticResult;
9271
11890
  try {
9272
11891
  semanticResult = runSemanticAnalysis(graph, config);
@@ -9291,6 +11910,7 @@ const generateReport = (graph, config) => {
9291
11910
  errorSink.push(semanticError);
9292
11911
  }
9293
11912
  const redundantAliases = config.reportRedundancy ? [...syntacticRedundantAliases, ...semanticResult.redundantAliases] : [];
11913
+ if (featureFlags.length > 0) correlateFlagsWithDeadCode(featureFlags, { unusedExports });
9294
11914
  const totalExports = graph.modules.reduce((exportCount, module) => exportCount + module.exports.filter((exportInfo) => !(exportInfo.name === "*" && exportInfo.isNamespaceReExport)).length, 0);
9295
11915
  return {
9296
11916
  unusedFiles,
@@ -9311,6 +11931,18 @@ const generateReport = (graph, config) => {
9311
11931
  simplifiableFunctions,
9312
11932
  simplifiableExpressions,
9313
11933
  duplicateConstants,
11934
+ crossFileDuplicateExports,
11935
+ duplicateBlocks: duplicateBlockResult.duplicateBlocks,
11936
+ duplicateBlockClusters: duplicateBlockResult.duplicateBlockClusters,
11937
+ shadowedDirectoryPairs: duplicateBlockResult.shadowedDirectoryPairs,
11938
+ reExportCycles,
11939
+ featureFlags,
11940
+ complexFunctions,
11941
+ privateTypeLeaks,
11942
+ unnecessaryAssertions: typeScriptSmellsResult.unnecessaryAssertions,
11943
+ lazyImportsAtTopLevel: typeScriptSmellsResult.lazyImportsAtTopLevel,
11944
+ commonjsInEsm: typeScriptSmellsResult.commonjsInEsm,
11945
+ typeScriptEscapeHatches: typeScriptSmellsResult.typeScriptEscapeHatches,
9314
11946
  analysisErrors: errorSink,
9315
11947
  totalFiles: graph.modules.length,
9316
11948
  totalExports,
@@ -9322,6 +11954,39 @@ const generateReport = (graph, config) => {
9322
11954
  //#region src/index.ts
9323
11955
  const STYLE_EXTENSIONS = [".css", ".scss"];
9324
11956
  const REACT_NATIVE_ENABLERS = ["react-native", "expo"];
11957
+ const basenameFromPath = (filePath) => {
11958
+ const lastSlashIndex = filePath.lastIndexOf("/");
11959
+ return lastSlashIndex === -1 ? filePath : filePath.slice(lastSlashIndex + 1);
11960
+ };
11961
+ /**
11962
+ * Dynamic registry pattern: many codebases use a central "schema/registry"
11963
+ * module that lists tool/command/page filenames as string literals, then a
11964
+ * runner spawns them via `path.resolve(dir, file)` or `import()`. Static
11965
+ * analysis can't follow the indirection, so those targets get falsely
11966
+ * flagged as unused.
11967
+ *
11968
+ * Heuristic: if a parsed string literal exactly matches the basename of
11969
+ * exactly one file in the project, treat that file as an entry point.
11970
+ * Uniqueness guards against false-positives from common names like
11971
+ * `index.ts` matching dozens of unrelated files.
11972
+ */
11973
+ const markFilenameRegistryEntries = (moduleGraph) => {
11974
+ const basenameToModuleIndex = /* @__PURE__ */ new Map();
11975
+ for (const module of moduleGraph.modules) {
11976
+ const basename = basenameFromPath(module.fileId.path);
11977
+ const existing = basenameToModuleIndex.get(basename);
11978
+ if (existing === void 0) basenameToModuleIndex.set(basename, module.fileId.index);
11979
+ else if (existing !== "ambiguous") basenameToModuleIndex.set(basename, "ambiguous");
11980
+ }
11981
+ for (const module of moduleGraph.modules) for (const referencedFilename of module.referencedFilenames) {
11982
+ const targetIndex = basenameToModuleIndex.get(referencedFilename);
11983
+ if (typeof targetIndex !== "number") continue;
11984
+ const targetModule = moduleGraph.modules[targetIndex];
11985
+ if (!targetModule || targetModule.isEntryPoint) continue;
11986
+ if (targetModule.fileId.index === module.fileId.index) continue;
11987
+ targetModule.isEntryPoint = true;
11988
+ }
11989
+ };
9325
11990
  const detectReactNative = (rootDir, workspacePackages) => {
9326
11991
  const directoriesToCheck = [rootDir, ...workspacePackages.map((workspacePackage) => workspacePackage.directory)];
9327
11992
  for (const directory of directoriesToCheck) {
@@ -9365,18 +12030,53 @@ const detectReactNative = (rootDir, workspacePackages) => {
9365
12030
  *
9366
12031
  * - `reportRedundancy: true` — on because redundancy findings are mostly
9367
12032
  * high-signal and the detectors carry their own confidence tiers.
12033
+ *
12034
+ * - `duplicateBlocks: undefined` — token-based copy-paste detection (suffix
12035
+ * array + LCP) is opt-in. It re-parses every source
12036
+ * file to emit a token stream and adds significant runtime to the scan.
12037
+ * Pass `duplicateBlocks: { enabled: true }` to turn it on.
9368
12038
  */
9369
12039
  const fillSemanticConfig = (semanticOverrides) => {
9370
- if (semanticOverrides === void 0) return void 0;
12040
+ const overrides = semanticOverrides ?? {};
12041
+ return {
12042
+ enabled: overrides.enabled ?? true,
12043
+ reportUnusedTypes: overrides.reportUnusedTypes ?? true,
12044
+ reportUnusedEnumMembers: overrides.reportUnusedEnumMembers ?? true,
12045
+ reportUnusedClassMembers: overrides.reportUnusedClassMembers ?? false,
12046
+ reportRedundantVariableAliases: overrides.reportRedundantVariableAliases ?? true,
12047
+ reportMisclassifiedDependencies: overrides.reportMisclassifiedDependencies ?? true,
12048
+ reportRoundTripAliases: overrides.reportRoundTripAliases ?? true,
12049
+ decoratorAllowlist: overrides.decoratorAllowlist ?? DEFAULT_SEMANTIC_DECORATOR_ALLOWLIST
12050
+ };
12051
+ };
12052
+ const fillDuplicateBlocksConfig = (duplicateBlocksOverrides) => {
12053
+ const overrides = duplicateBlocksOverrides ?? {};
12054
+ return {
12055
+ enabled: overrides.enabled ?? true,
12056
+ mode: overrides.mode ?? "semantic",
12057
+ minTokens: overrides.minTokens ?? 50,
12058
+ minLines: overrides.minLines ?? 5,
12059
+ minOccurrences: overrides.minOccurrences ?? 2,
12060
+ skipLocal: overrides.skipLocal ?? false
12061
+ };
12062
+ };
12063
+ const fillFeatureFlagsConfig = (flagsOverrides) => {
12064
+ const overrides = flagsOverrides ?? {};
9371
12065
  return {
9372
- enabled: semanticOverrides.enabled ?? false,
9373
- reportUnusedTypes: semanticOverrides.reportUnusedTypes ?? true,
9374
- reportUnusedEnumMembers: semanticOverrides.reportUnusedEnumMembers ?? true,
9375
- reportUnusedClassMembers: semanticOverrides.reportUnusedClassMembers ?? false,
9376
- reportRedundantVariableAliases: semanticOverrides.reportRedundantVariableAliases ?? true,
9377
- reportMisclassifiedDependencies: semanticOverrides.reportMisclassifiedDependencies ?? true,
9378
- reportRoundTripAliases: semanticOverrides.reportRoundTripAliases ?? true,
9379
- decoratorAllowlist: semanticOverrides.decoratorAllowlist ?? DEFAULT_SEMANTIC_DECORATOR_ALLOWLIST
12066
+ enabled: overrides.enabled ?? true,
12067
+ extraEnvPrefixes: overrides.extraEnvPrefixes ?? [],
12068
+ extraSdkFunctionNames: overrides.extraSdkFunctionNames ?? [],
12069
+ detectConfigObjects: overrides.detectConfigObjects ?? false
12070
+ };
12071
+ };
12072
+ const fillComplexityConfig = (complexityOverrides) => {
12073
+ const overrides = complexityOverrides ?? {};
12074
+ return {
12075
+ enabled: overrides.enabled ?? true,
12076
+ cyclomaticThreshold: overrides.cyclomaticThreshold ?? 10,
12077
+ cognitiveThreshold: overrides.cognitiveThreshold ?? 15,
12078
+ paramCountThreshold: overrides.paramCountThreshold ?? 5,
12079
+ functionLineThreshold: overrides.functionLineThreshold ?? 80
9380
12080
  };
9381
12081
  };
9382
12082
  const defineConfig = (options) => ({
@@ -9388,7 +12088,10 @@ const defineConfig = (options) => ({
9388
12088
  reportTypes: options.reportTypes ?? false,
9389
12089
  includeEntryExports: options.includeEntryExports ?? false,
9390
12090
  reportRedundancy: options.reportRedundancy ?? true,
9391
- semantic: fillSemanticConfig(options.semantic)
12091
+ semantic: fillSemanticConfig(options.semantic),
12092
+ duplicateBlocks: fillDuplicateBlocksConfig(options.duplicateBlocks),
12093
+ featureFlags: fillFeatureFlagsConfig(options.featureFlags),
12094
+ complexity: fillComplexityConfig(options.complexity)
9392
12095
  });
9393
12096
  const buildEmptyScanResult = (errors, elapsedMs) => ({
9394
12097
  unusedFiles: [],
@@ -9409,6 +12112,18 @@ const buildEmptyScanResult = (errors, elapsedMs) => ({
9409
12112
  simplifiableFunctions: [],
9410
12113
  simplifiableExpressions: [],
9411
12114
  duplicateConstants: [],
12115
+ crossFileDuplicateExports: [],
12116
+ duplicateBlocks: [],
12117
+ duplicateBlockClusters: [],
12118
+ shadowedDirectoryPairs: [],
12119
+ reExportCycles: [],
12120
+ featureFlags: [],
12121
+ complexFunctions: [],
12122
+ privateTypeLeaks: [],
12123
+ unnecessaryAssertions: [],
12124
+ lazyImportsAtTopLevel: [],
12125
+ commonjsInEsm: [],
12126
+ typeScriptEscapeHatches: [],
9412
12127
  analysisErrors: errors,
9413
12128
  totalFiles: 0,
9414
12129
  totalExports: 0,
@@ -9479,11 +12194,7 @@ const analyze = async (config) => {
9479
12194
  }));
9480
12195
  }
9481
12196
  const absoluteRoot = resolve(config.rootDir);
9482
- const outputDirectoryExclusions = OUTPUT_DIRECTORIES.flatMap((outputDirectory) => {
9483
- const exclusions = [`${absoluteRoot}/${outputDirectory}/**`];
9484
- for (const workspacePackage of workspacePackages) exclusions.push(`${workspacePackage.directory}/${outputDirectory}/**`);
9485
- return exclusions;
9486
- });
12197
+ const outputDirectoryExclusions = OUTPUT_DIRECTORIES.flatMap((outputDirectory) => [`${absoluteRoot}/${outputDirectory}/**`, `${absoluteRoot}/**/${outputDirectory}/**`]);
9487
12198
  const allExclusionPatterns = [
9488
12199
  ...workspaceDiscovery.excludedDirectories.map((directory) => `${directory}/**`),
9489
12200
  ...frameworkIgnorePatterns,
@@ -9683,6 +12394,7 @@ const analyze = async (config) => {
9683
12394
  detail: describeUnknownError(reExportError)
9684
12395
  }));
9685
12396
  }
12397
+ markFilenameRegistryEntries(moduleGraph);
9686
12398
  try {
9687
12399
  traceReachability(moduleGraph);
9688
12400
  } catch (reachabilityError) {