diff --git a/CHANGELOG.md b/CHANGELOG.md index b0aae91..c262e14 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +## [9.0.4] - 12 Feb 2023 + +### Changed + +- Improve handling of transitive imports like 'a -> b -> c' especially where 'export *' is used - #216 +- Improve output format when the unused item involves an 'export *'. + ## [9.0.3] - 5 Feb 2023 ### Changed diff --git a/example/import-and-re-export-2/execute.sh b/example/import-and-re-export-2/execute.sh new file mode 100755 index 0000000..e06d25e --- /dev/null +++ b/example/import-and-re-export-2/execute.sh @@ -0,0 +1 @@ +../../bin/ts-unused-exports ./tsconfig.json diff --git a/example/import-and-re-export-2/src/a/index.ts b/example/import-and-re-export-2/src/a/index.ts new file mode 100644 index 0000000..465cb28 --- /dev/null +++ b/example/import-and-re-export-2/src/a/index.ts @@ -0,0 +1,2 @@ +export const A = 1; +export const A_2 = 1; diff --git a/example/import-and-re-export-2/src/b.ts b/example/import-and-re-export-2/src/b.ts new file mode 100644 index 0000000..331456d --- /dev/null +++ b/example/import-and-re-export-2/src/b.ts @@ -0,0 +1,2 @@ +export * as fromA from './a'; +export const B_unused = 1; diff --git a/example/import-and-re-export-2/src/c.ts b/example/import-and-re-export-2/src/c.ts new file mode 100644 index 0000000..ab29e24 --- /dev/null +++ b/example/import-and-re-export-2/src/c.ts @@ -0,0 +1,2 @@ +import { fromA } from './b'; +export const C_unused = 1; diff --git a/example/import-and-re-export-2/tsconfig.json b/example/import-and-re-export-2/tsconfig.json new file mode 100644 index 0000000..28203e5 --- /dev/null +++ b/example/import-and-re-export-2/tsconfig.json @@ -0,0 +1,3 @@ +{ + "include": ["src"] +} \ No newline at end of file diff --git a/example/import-and-re-export/execute.sh b/example/import-and-re-export/execute.sh new file mode 100755 index 0000000..e06d25e --- /dev/null +++ b/example/import-and-re-export/execute.sh @@ -0,0 +1 @@ +../../bin/ts-unused-exports ./tsconfig.json diff --git a/example/import-and-re-export/src/a/a/a.ts b/example/import-and-re-export/src/a/a/a.ts new file mode 100644 index 0000000..ab6e023 --- /dev/null +++ b/example/import-and-re-export/src/a/a/a.ts @@ -0,0 +1,2 @@ +export const a = 0 +export const unusedInInnerIndex = 0 diff --git a/example/import-and-re-export/src/a/a/a_unused.ts b/example/import-and-re-export/src/a/a/a_unused.ts new file mode 100644 index 0000000..1b8142d --- /dev/null +++ b/example/import-and-re-export/src/a/a/a_unused.ts @@ -0,0 +1 @@ +export const unusedInA = 0 diff --git a/example/import-and-re-export/src/a/a/index.ts b/example/import-and-re-export/src/a/a/index.ts new file mode 100644 index 0000000..21c83d4 --- /dev/null +++ b/example/import-and-re-export/src/a/a/index.ts @@ -0,0 +1 @@ +export * from './a' diff --git a/example/import-and-re-export/src/a/index.ts b/example/import-and-re-export/src/a/index.ts new file mode 100644 index 0000000..937ecdc --- /dev/null +++ b/example/import-and-re-export/src/a/index.ts @@ -0,0 +1,2 @@ +export * from './a' + diff --git a/example/import-and-re-export/src/index.ts b/example/import-and-re-export/src/index.ts new file mode 100644 index 0000000..97c6dcd --- /dev/null +++ b/example/import-and-re-export/src/index.ts @@ -0,0 +1,3 @@ +import { a } from './a' + +export const unusedInTopIndex = 0 diff --git a/example/import-and-re-export/tsconfig.json b/example/import-and-re-export/tsconfig.json new file mode 100644 index 0000000..28203e5 --- /dev/null +++ b/example/import-and-re-export/tsconfig.json @@ -0,0 +1,3 @@ +{ + "include": ["src"] +} \ No newline at end of file diff --git a/features/export-statements-star.feature b/features/export-statements-star.feature index e779daa..5036874 100644 --- a/features/export-statements-star.feature +++ b/features/export-statements-star.feature @@ -12,6 +12,148 @@ Scenario: export * from ./a/a.ts When analyzing "tsconfig.json" Then the result is { "b.ts": ["A_unused"] } +Scenario: export * from ./a/a/a.ts + Given file "a/a/a.ts" is + """ + export const A = 1; + export const A_innermost_unused = 1; + """ + And file "a/a/index.ts" is + """ + export * from "./a"; + """ + And file "a/a.ts" is export * from './a'; + And file "a/index.ts" is + """ + export * from "./a"; + export const A_unused = 1; + """ + And file "c.ts" is import { A } from './a'; + + When analyzing "tsconfig.json" + # note A_innermost_unused is not detected - that would require parsing for all usages of the namespace + Then the result is { "a/index.ts": ["A_unused"] } + +Scenario: export * from ./a1/a2/a.ts + Given file "a1/a2/a.ts" is + """ + export const A = 1; + export const A_innermost_unused = 1; + """ + And file "a1/a2/index.ts" is + """ + export * from "./a"; + """ + And file "a1/a.ts" is export * from './a2'; + And file "a1/index.ts" is + """ + export * from "./a"; + export const A_unused = 1; + """ + And file "c.ts" is import { A } from './a1'; + + When analyzing "tsconfig.json" + # note A_innermost_unused is not detected - that would require parsing for all usages of the namespace + Then the result is { "a1/index.ts": ["A_unused"] } + +Scenario: export * from ./a1/a2/a.ts - import skipping the mid-level - import from innermost index + Given file "a1/a2/a.ts" is + """ + export const A = 1; + export const A_innermost_unused = 1; + """ + And file "a1/a2/index.ts" is + """ + export * from "./a"; + """ + # note this IS flagged as unused + And file "a1/a.ts" is export * from './a2'; + And file "a1/index.ts" is + """ + // skips the mid-level file a1/a.ts + export * from "./a1/a2"; + export const A_unused = 1; + """ + And file "c.ts" is import { A } from './a1'; + + When analyzing "tsconfig.json" + # note A_innermost_unused is NOT detected + Then the result is { "a1/a_ts":["* -> /a1/a2/a"], "a1/index.ts": ["A_unused"] } + +Scenario: export * from ./a1/a2/a.ts - import skipping the mid-level - import from innermost a.ts + Given file "a1/a2/a.ts" is + """ + export const A = 1; + export const A_innermost_unused = 1; + """ + # TODO this could be flagged as unused + And file "a1/a2/index.ts" is + """ + export * from "./a"; + """ + # TODO this could be flagged as unused + And file "a1/a.ts" is export * from './a'; + And file "a1/index.ts" is + """ + // skips the mid-level - imports directly from a1/a2/a.ts NOT the index, so not from an export * + export * from "./a1/a2/a"; + export const A_unused = 1; + """ + And file "c.ts" is import { A } from './a1'; + + When analyzing "tsconfig.json" + # note A_innermost_unused is NOT detected + # TODO review - A is flagged via "a1/a2/index_ts":["A"...] - which is strictly correct, although could be annoying + Then the result is { "a1/index.ts": ["A_unused"],"a1/a2/index_ts":["A","A_innermost_unused"] } + +Scenario: export * from ./a/a/a.ts - import skipping the mid-level + Given file "a/a/a.ts" is + """ + export const A = 1; + export const A_innermost_unused = 1; + """ + And file "a/a/index.ts" is + # TODO this could be flagged as unused + """ + export * from "./a"; + """ + And file "a/a.ts" is export * from './a'; + And file "a/index.ts" is + """ + // skips the mid-level + export * from "./a/a"; + export const A_unused = 1; + """ + And file "c.ts" is import { A } from './a'; + + When analyzing "tsconfig.json" + # note A_innermost_unused IS detected + Then the result is { "a/index.ts": ["A_unused", "A_innermost_unused"] } + +Scenario: export * from ./a/a/a.ts - import skipping the mid-level + Given file "a/a/a.ts" is + """ + export const A = 1; + export const A_innermost_unused = 1; + """ + And file "a/a/index.ts" is + """ + export * from "./a"; + """ + # TODO this could be flagged as unused + And file "a/a.ts" is export * from './a'; + And file "a/index.ts" is + """ + // skips the mid-level - imports directly from a/a/a.ts NOT the index, so not from an export * + export * from "./a/a/a"; + export const A_unused = 1; + """ + And file "c.ts" is import { A } from './a'; + + When analyzing "tsconfig.json" + # note A_innermost_unused is NOT detected + Then the result is { "a/index.ts": ["A_unused"] } + Scenario: export * from ./a Given file "a/index.ts" is """ @@ -34,8 +176,8 @@ Scenario: export * as fromA from ./a/a.ts And file "c.ts" is import { fromA } from './b'; When analyzing "tsconfig.json" + # note A_unused is not detected - that would require parsing for all usages of the namespace Then the result is { } -# note A_unused is not detected - that would require parsing for all usages of the namespace Scenario: export * as fromA from ./a/a.ts Given file "a/a.ts" is diff --git a/ispec/run.sh b/ispec/run.sh index f810494..e5cf89f 100755 --- a/ispec/run.sh +++ b/ispec/run.sh @@ -83,6 +83,14 @@ pushd ../example/path-alias-and-sub-folders-import-from-index run_itest popd +pushd ../example/import-and-re-export +run_itest +popd + +pushd ../example/import-and-re-export-2 +run_itest +popd + pushd ../example/with-js run_itest popd diff --git a/package-lock.json b/package-lock.json index b639a84..b3a5732 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "ts-unused-exports", - "version": "9.0.3", + "version": "9.0.4", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/package.json b/package.json index 20c352b..0570aa9 100755 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ts-unused-exports", - "version": "9.0.3", + "version": "9.0.4", "description": "ts-unused-exports finds unused exported symbols in your Typescript project", "main": "lib/app.js", "repository": { @@ -21,6 +21,7 @@ }, "homepage": "https://github.com/pzavolinsky/ts-unused-exports", "scripts": { + "bump": "npm version patch", "build": "tsc", "exec": "bin/ts-unused-exports", "lint:fix": "./node_modules/.bin/eslint --ext .ts src --fix", diff --git a/src/analyzer.ts b/src/analyzer.ts index fe047fa..d4dcb2d 100644 --- a/src/analyzer.ts +++ b/src/analyzer.ts @@ -1,6 +1,7 @@ import { Analysis, ExtraCommandLineOptions, + ExtraOptionsForPresentation, File, LocationInFile, } from './types'; @@ -25,6 +26,8 @@ interface FileExports { interface ExportItem { exports: FileExports; path: string; + // Was the file imported at least once. If so, then can mark any export * as used (handles transitive export(import) like a -> b -> c). + fileWasImported: boolean; } interface ExportMap { @@ -61,7 +64,7 @@ const getFileExports = (file: File): ExportItem => { } }); - return { exports, path: file.fullPath }; + return { exports, path: file.fullPath, fileWasImported: false }; }; const getExportMap = (files: File[]): ExportMap => { @@ -74,7 +77,10 @@ const getExportMap = (files: File[]): ExportMap => { const processImports = (file: File, exportMap: ExportMap): void => { Object.keys(file.imports).forEach((key) => { - let ex = exportMap[removeFileExtensionToAllowForJs(key)]?.exports; + const importedFileExports = + exportMap[removeFileExtensionToAllowForJs(key)] || null; + + let ex = importedFileExports?.exports || null; // Handle imports from an index file if (!ex) { @@ -121,9 +127,15 @@ const processImports = (file: File, exportMap: ExportMap): void => { }, }; } + + // DEV DEBUG - console.log(`Marking as used: ${imp} in ${file.path}`); ex[imp].usageCount++; }; + if (!!importedFileExports) { + importedFileExports.fileWasImported = true; + } + file.imports[key].forEach((imp) => { imp === '*' ? Object.keys(ex).forEach(addUsage) : addUsage(imp); }); @@ -164,6 +176,12 @@ const expandExportFromStarOrStarAsForFile = ( // Mark the items as imported, for the imported file: const importedFileExports = exportMap[removeExportStarPrefix(ex)]; if (importedFileExports) { + // DEV DEBUG + /* console.log( + `Marking as used: ${key} in ${removeExportStarPrefix(ex)}`, + ); + console.dir(Object.keys(exportMap));*/ + importedFileExports.fileWasImported = true; importedFileExports.exports[key].usageCount++; } }); @@ -233,9 +251,24 @@ const areEqual = (files1: string[], files2: string[]): boolean => { return files1.every((f) => files2.includes(f)); }; +const makeExportStarRelativeForPresentation = ( + baseUrl: string | undefined, + filePath: string, +): string => { + if (!filePath.startsWith('*')) { + return filePath; + } + + const filePathNoStar = removeExportStarPrefix(filePath); + if (!!baseUrl && filePathNoStar.startsWith(baseUrl)) { + return `* -> ${filePathNoStar.substring(baseUrl.length)}`; + } + return filePath; +}; + export default ( files: File[], - extraOptions?: ExtraCommandLineOptions, + extraOptions?: ExtraCommandLineOptions & ExtraOptionsForPresentation, ): Analysis => { const filteredFiles = filterFiles(files, extraOptions); @@ -252,9 +285,15 @@ export default ( if (shouldPathBeExcludedFromResults(path, extraOptions)) return; - const unusedExports = Object.keys(exports).filter( - (k) => exports[k].usageCount === 0, - ); + const unusedExports = Object.keys(exports).filter((k) => { + // If the file was imported at least once, then do NOT consider any of its 'export (import) *' as unused. + // This avoids false positives with transitive import/exports like a -> b -> c. + if (expItem.fileWasImported && k.startsWith('*')) { + return false; + } + + return exports[k].usageCount === 0; + }); if (unusedExports.length === 0) { return; @@ -265,7 +304,10 @@ export default ( analysis[path] = []; unusedExports.forEach((e) => { analysis[path].push({ - exportName: e, + exportName: makeExportStarRelativeForPresentation( + extraOptions?.baseUrl, + e, + ), location: exports[e].location, }); }); diff --git a/src/app.ts b/src/app.ts index 0c7dbfe..061fe2d 100644 --- a/src/app.ts +++ b/src/app.ts @@ -60,5 +60,9 @@ export default (tsconfigPath: string, files?: string[]): Analysis => { const args = extractOptionsFromFiles(files); const tsConfig = loadTsConfig(tsconfigPath, args.tsFiles); - return analyze(parseFiles(tsConfig, args.options), args.options); + const options = { + ...args.options, + baseUrl: tsConfig.baseUrl, + }; + return analyze(parseFiles(tsConfig, args.options), options); }; diff --git a/src/types.ts b/src/types.ts index 459a5e7..945eec8 100644 --- a/src/types.ts +++ b/src/types.ts @@ -57,3 +57,7 @@ export interface ExtraCommandLineOptions { silent?: boolean; findCompletelyUnusedFiles?: boolean; } + +export interface ExtraOptionsForPresentation { + baseUrl?: string; +}