From 8a2dd8226435e782f1b7a87ede0c1efd257e3763 Mon Sep 17 00:00:00 2001 From: Naomi Panda Date: Thu, 9 Jul 2026 08:52:38 +0200 Subject: [PATCH 1/3] feat: detect unused packages unused packages are detected by checking whether a 'library' function call has a reads|calls edge pointing towards it --- src/linter/linter-rules.ts | 4 +- src/linter/rules/unused-import.ts | 89 +++++++++++++++++++ .../linter/lint-unused-import.test.ts | 74 +++++++++++++++ 3 files changed, 166 insertions(+), 1 deletion(-) create mode 100644 src/linter/rules/unused-import.ts create mode 100644 test/functionality/linter/lint-unused-import.test.ts diff --git a/src/linter/linter-rules.ts b/src/linter/linter-rules.ts index f1a32f788b4..6a09c9f78a9 100644 --- a/src/linter/linter-rules.ts +++ b/src/linter/linter-rules.ts @@ -16,6 +16,7 @@ import { SOFTWARE_HAS_LICENSE } from './rules/software-has-license'; import { SOFTWARE_HAS_TESTS } from './rules/software-has-tests'; import { NO_LEAKED_CREDENTIALS } from './rules/no-leaked-credentials'; import { UNDEFINED_SYMBOL } from './rules/undefined-symbol'; +import { UNUSED_IMPORT } from './rules/unused-import'; /** * The registry of currently supported linting rules. @@ -38,7 +39,8 @@ export const LintingRules = { 'software-has-license': SOFTWARE_HAS_LICENSE, 'software-has-tests': SOFTWARE_HAS_TESTS, 'no-leaked-credentials': NO_LEAKED_CREDENTIALS, - 'undefined-symbol': UNDEFINED_SYMBOL + 'undefined-symbol': UNDEFINED_SYMBOL, + 'unused-import': UNUSED_IMPORT } as const; export type LintingRuleNames = keyof typeof LintingRules; diff --git a/src/linter/rules/unused-import.ts b/src/linter/rules/unused-import.ts new file mode 100644 index 00000000000..bab011d99f4 --- /dev/null +++ b/src/linter/rules/unused-import.ts @@ -0,0 +1,89 @@ +import { + LintingPrettyPrintContext, + type LintingResult, + LintingResultCertainty, + type LintingRule, + LintingRuleCertainty +} from '../linter-format'; +import { SourceLocation } from '../../util/range'; +import type { MergeableRecord } from '../../util/objects'; +import { Q } from '../../search/flowr-search-builder'; +import { LintingRuleTag } from '../linter-tags'; +import { isNotUndefined, isUndefined } from '../../util/assert'; +import type { Writable } from 'ts-essentials'; +import { VertexType } from '../../dataflow/graph/vertex'; +import { getOriginInDfg, OriginType } from '../../dataflow/origin/dfg-get-origin'; +import { EdgeType } from '../../dataflow/graph/edge'; +import { Identifier } from '../../dataflow/environments/identifier'; +import type { PkgDbSource } from '../../project/plugins/package-version-plugins/flowr-analyzer-package-versions-pkgdb-plugin'; + +export type UnusedImportResult = LintingResult; + +export interface UnusedImportConfig extends MergeableRecord { + pkgDb: PkgDbSource | undefined +}; + +export type UnusedImportMetadata = MergeableRecord; + +const LibraryLoadFunctions = new Set(['library', 'require', 'requireNamespace', 'loadNamespace', 'attachNamespace', 'load_all', 'use', 'p_load']); + + + +export const UNUSED_IMPORT = { + createSearch: () => Q.var('library') + .filter(VertexType.FunctionCall), + processSearchResult: async(elements, _config, data) => { + const dataflow = await data.dataflow(); + return { + results: + elements.getElements() + .filter(element => { + //if can't export we don't know if it used or not + const unknownIds = new Set(); + for(const e of dataflow.graph.unknownSideEffects) { + unknownIds.add(String(typeof e === 'object' ? e.id : e)); + } + if(unknownIds.size > 0) { + for(const [id, v] of dataflow.graph.verticesOfType(VertexType.FunctionCall)) { + if(LibraryLoadFunctions.has(Identifier.getName(v.name)) && unknownIds.has(String(id))) { + return false; + } + } + } + + const origins = getOriginInDfg(dataflow.graph, element.node.info.id); + if(isNotUndefined(origins)) { + const builtIn = origins.every(e => e.type === OriginType.BuiltInFunctionOrigin); + if(!builtIn){ + return false; + } + } + return isUndefined(dataflow.graph.ingoingEdges(element.node.info.id)) || dataflow.graph.ingoingEdges(element.node.info.id)?.values().filter(edge => edge.types === EdgeType.Calls + EdgeType.Reads).toArray().length === 0; + })/*.map(v => { + console.log(v) + console.log('loc', SourceLocation.fromNode(v.node)) + return v + })*/ + .map(element => ({ + certainty: LintingResultCertainty.Uncertain, + involvedId: element.node.info.id, + loc: SourceLocation.fromNode(element.node) + })) + .filter(element => isNotUndefined(element.loc)) as Writable[], + '.meta': {} + }; + }, + prettyPrint: { + [LintingPrettyPrintContext.Query]: result => `Code at ${SourceLocation.format(result.loc)}`, + [LintingPrettyPrintContext.Full]: result => `Code at ${SourceLocation.format(result.loc)} is an unused package.` + }, + info: { + name: 'Unused Import', + tags: [LintingRuleTag.Smell, LintingRuleTag.Readability], + certainty: LintingRuleCertainty.BestEffort, + description: 'Checks whether an imported package is used.', + defaultConfig: { + pkgDb: undefined + } + } +} as const satisfies LintingRule; \ No newline at end of file diff --git a/test/functionality/linter/lint-unused-import.test.ts b/test/functionality/linter/lint-unused-import.test.ts new file mode 100644 index 00000000000..bcec59cebf3 --- /dev/null +++ b/test/functionality/linter/lint-unused-import.test.ts @@ -0,0 +1,74 @@ +import { describe } from 'vitest'; +import { assertLinter } from '../_helper/linter'; +import { withTreeSitter } from '../_helper/shell'; +import { PkgDatabase } from '../../../src/project/plugins/package-version-plugins/pkgdb'; +import { LintingResultCertainty } from '../../../src/linter/linter-format'; + +//const ggplot2Callable = ['+', 'ggplot', 'aes', 'geom_point', 'geom_line', 'theme_bw', 'coord_cartesian', 'ggsave', 'fortify', 'scale_type']; +/*const namespaceInfo = setCallable(FlowrNamespaceFile.from(new FlowrInlineTextFile('NAMESPACE', `S3method(fortify,data.frame) +S3method(fortify,lm) +S3method(fortify,default) +S3method(fortify,map) +S3method(scale_type,Date) +S3method(scale_type,POSIXt) +S3method(scale_type,character) +S3method(scale_type,numeric) +S3method(scale_type,factor) +S3method(scale_type,default) +S3method(ggplot,default) +S3method(print,ggproto) +S3method(print,rel) +export("+") +export(ggplot) +export(aes) +export(geom_point) +export(geom_line) +export(theme_bw) +export(coord_cartesian) +export(ggsave) +export(fortify) +export(scale_type) +exportPattern("^[^\\\\.]\\\\.*$") +import(grid) +import(rlang) +importFrom(scales,alpha) +importFrom(stats,setNames)`)).content().current, ggplot2Callable);*/ + +const pkgDatabase = PkgDatabase.fromObject({ + format: 'flowr-pkgdb', + schema: 4, + scope: 'latest', + content: { version: 1, date: '2026-05-23', hash: 'x', generated: 0, packages: 1, versions: 1 }, + strings: [], + pkgs: { ggplot2: ['3.5.1', ['ggplot', 'aes', 'geom_point']], random1: ['1.0.0', ['test1', 'test2']] } +}); + +describe('flowR linter', withTreeSitter(parser => { + describe('unused import', () => { + /*test('Several libraries, check env structure', async() => { + const analyzer = await new FlowrAnalyzerBuilder() + .setParser(parser) + .build(); + analyzer.context().deps.addDependency(new Package({ + name: 'ggplot2', + namespaceInfo: namespaceInfo + })); + analyzer.context().deps.addDependency(new Package({ + name: 'random1', + namespaceInfo: setCallable(FlowrNamespaceFile.from(new FlowrInlineTextFile('NAMESPACE', 'export(test1)\nexport(test2)')).content().current, ['test1', 'test2']) + })); + analyzer.addRequest('library(ggplot2)\nlibrary(random1)\nggplot()'); + const df = await analyzer.dataflow(); + const result = await analyzer.query([{ + type: 'linter' + } +]); console.log(result.linter.results["unused-import"]) + });*/ + assertLinter('a', parser, 'library(ggplot2)', 'unused-import', [ + { + certainty: LintingResultCertainty.Uncertain, + loc: [1, 1, 1, 16], + }, + ], undefined, { pkgDb: pkgDatabase }); + }); +})); \ No newline at end of file From ea7c3f0ed8dbdd69208f9cea65166f209b91f9d7 Mon Sep 17 00:00:00 2001 From: Naomi Panda Date: Thu, 16 Jul 2026 08:34:01 +0200 Subject: [PATCH 2/3] refactor: requested fixes --- src/documentation/wiki-package-database.ts | 2 +- src/linter/rules/unused-import.ts | 152 ++++++++++++------ .../linter/lint-unused-import.test.ts | 99 ++++++------ 3 files changed, 154 insertions(+), 99 deletions(-) diff --git a/src/documentation/wiki-package-database.ts b/src/documentation/wiki-package-database.ts index 1ecdea058b9..f2c15277216 100644 --- a/src/documentation/wiki-package-database.ts +++ b/src/documentation/wiki-package-database.ts @@ -32,7 +32,7 @@ export class WikiPackageDatabase extends DocMaker<'wiki/Package Database.md'> { flowR ships a database of CRAN package exports so it can resolve calls into the packages you load. After \`library(ggplot2)\`, a call to \`ggplot()\` resolves to \`ggplot2::ggplot\`. This also backs qualified names (a bare \`map()\` after \`library(purrr)\` is \`purrr::map\`, not the \`maps\` plot), the -${ctx.linkPage('wiki/Query API', 'dependencies and call-context queries')}, and the ${ctx.linkPage('wiki/Linter', '`undefined-symbol` rule')}. +${ctx.linkPage('wiki/Query API', 'dependencies and call-context queries')}, the ${ctx.linkPage('wiki/Linter', '`undefined-symbol` rule')}, and the ${ctx.linkPage('wiki/Linter', '`unused-import` rule')}. ## Configuration diff --git a/src/linter/rules/unused-import.ts b/src/linter/rules/unused-import.ts index bab011d99f4..788fc7d377b 100644 --- a/src/linter/rules/unused-import.ts +++ b/src/linter/rules/unused-import.ts @@ -9,81 +9,143 @@ import { SourceLocation } from '../../util/range'; import type { MergeableRecord } from '../../util/objects'; import { Q } from '../../search/flowr-search-builder'; import { LintingRuleTag } from '../linter-tags'; -import { isNotUndefined, isUndefined } from '../../util/assert'; +import { isNotUndefined } from '../../util/assert'; import type { Writable } from 'ts-essentials'; -import { VertexType } from '../../dataflow/graph/vertex'; +import { VertexType, type DataflowGraphVertexFunctionCall } from '../../dataflow/graph/vertex'; import { getOriginInDfg, OriginType } from '../../dataflow/origin/dfg-get-origin'; -import { EdgeType } from '../../dataflow/graph/edge'; -import { Identifier } from '../../dataflow/environments/identifier'; -import type { PkgDbSource } from '../../project/plugins/package-version-plugins/flowr-analyzer-package-versions-pkgdb-plugin'; +import { DfEdge, EdgeType } from '../../dataflow/graph/edge'; +import type { NodeId } from '../../r-bridge/lang-4.x/ast/model/processing/node-id'; +import { LibraryFunctions } from '../../queries/catalog/dependencies-query/function-info/library-functions'; +import { pMatch } from '../../dataflow/internal/linker'; +import { resolveIdToValue } from '../../dataflow/eval/resolve/alias-tracking'; +import { valueSetGuard } from '../../dataflow/eval/values/general'; -export type UnusedImportResult = LintingResult; +export interface UnusedImportResult extends LintingResult{ + readonly version: string[][] +} export interface UnusedImportConfig extends MergeableRecord { - pkgDb: PkgDbSource | undefined + /* Packages that only work on load and should therefore not be considered */ + whitelist: string[] }; export type UnusedImportMetadata = MergeableRecord; -const LibraryLoadFunctions = new Set(['library', 'require', 'requireNamespace', 'loadNamespace', 'attachNamespace', 'load_all', 'use', 'p_load']); - - - +/** + * Flags imported functions that are not required for the code to run. We assume this applies to packages that do not have + * an ingoing reads-edge. We only consider packages that could be resolved from the `flowr-pkgdb` database. + */ export const UNUSED_IMPORT = { - createSearch: () => Q.var('library') - .filter(VertexType.FunctionCall), - processSearchResult: async(elements, _config, data) => { + //createSearch: () => Q.fromQuery({ type: 'call-context', callName: '^(library)|(use)|(require)$' }), + createSearch: () => Q.all().filter(VertexType.FunctionCall), + processSearchResult: async(elements, config, data) => { const dataflow = await data.dataflow(); + //get library functions to filter out + let ecurrent = dataflow.environment.current; + const usedpackages = new Set(); + while(ecurrent !== undefined){ + if(isNotUndefined(ecurrent.n)){ + usedpackages.add(ecurrent.n); + } + ecurrent = ecurrent.parent; + } + const libraryFunctions = LibraryFunctions.filter(v => usedpackages.has(v.package) || v.package === 'base').reduce((a, b) => { + return a.add(b.name); + }, new Set) + + const deps = data.inspectContext().deps; + const dependencyToVersion: Map = new Map(); + for(const dep of deps.getDependencies()){ + dependencyToVersion.set(dep.name, new String(dep.deriveVersion())) + } + const whitelist = new Set(config.whitelist); + const unknownIds = new Set(); + for(const e of dataflow.graph.unknownSideEffects) { + unknownIds.add(typeof e === 'object' ? e.id : String(e)); + } + + //all library calls we want to test + const libraryFCalls = elements.getElements() + .filter(element => { + if(isNotUndefined(element.node.lexeme) && !libraryFunctions.has(element.node.lexeme)){ + return false; + } + //for libraries where we can't read the export, cannot decide whether they are used or not + if(unknownIds.has(element.node.info.id)){ + return false; + } + //is of built-in origin + const origins = getOriginInDfg(dataflow.graph, element.node.info.id); + if(isNotUndefined(origins)) { + const builtIn = origins.every(e => e.type === OriginType.BuiltInFunctionOrigin); + if(!builtIn){ + return false; + } + } + return true; + }); + //all NodeIds that have an ingoing read-edge + const readEdges = new Set(dataflow.graph.edges().flatMap(e => e[1].entries()).filter(entry => { + if(DfEdge.includesType(entry[1], EdgeType.Reads)){ + return true; + } elseĀ { + return false; + } + }).map(e => e[0])); return { results: - elements.getElements() - .filter(element => { - //if can't export we don't know if it used or not - const unknownIds = new Set(); - for(const e of dataflow.graph.unknownSideEffects) { - unknownIds.add(String(typeof e === 'object' ? e.id : e)); - } - if(unknownIds.size > 0) { - for(const [id, v] of dataflow.graph.verticesOfType(VertexType.FunctionCall)) { - if(LibraryLoadFunctions.has(Identifier.getName(v.name)) && unknownIds.has(String(id))) { - return false; + libraryFCalls.filter(c => !readEdges.has(c.node.info.id)) + .map(element => { + const fCall = dataflow.graph.getVertex(element.node.info.id) as DataflowGraphVertexFunctionCall; + const paramMap = { + 'package': 'pkg', + } as const; + let arg = [] + const mapping = pMatch(fCall.args, paramMap); + const mappedToF = mapping.get('pkg') ?? []; + for(const argId of mappedToF) { + const res = resolveIdToValue(argId, { graph: dataflow.graph, environment: fCall.environment, ctx: data.inspectContext() }); + const values = valueSetGuard(res); + if(values?.type === 'set' && values.elements.length !== 0){ + for(const elem of values.elements){ + if(elem.type === 'string' && 'str' in elem.value){ + arg.push(elem.value.str) + } } } } - - const origins = getOriginInDfg(dataflow.graph, element.node.info.id); - if(isNotUndefined(origins)) { - const builtIn = origins.every(e => e.type === OriginType.BuiltInFunctionOrigin); - if(!builtIn){ - return false; - } - } - return isUndefined(dataflow.graph.ingoingEdges(element.node.info.id)) || dataflow.graph.ingoingEdges(element.node.info.id)?.values().filter(edge => edge.types === EdgeType.Calls + EdgeType.Reads).toArray().length === 0; - })/*.map(v => { - console.log(v) - console.log('loc', SourceLocation.fromNode(v.node)) - return v - })*/ - .map(element => ({ + return { + element: element, + lib: arg + }}) + //only packages for which we have a package db entry + .filter(({ lib }) => { + return !lib.some(v => !dependencyToVersion.has(v)); + }) + //don't consider entries in whitelist + .filter(({ lib }) => !lib.some(v => whitelist.has(v))) + .map(({ element, lib }) => ({ certainty: LintingResultCertainty.Uncertain, involvedId: element.node.info.id, - loc: SourceLocation.fromNode(element.node) + loc: SourceLocation.fromNode(element.node), + //of form: [[package, version]] + version: lib.map(v => [v, dependencyToVersion.get(v)]) })) .filter(element => isNotUndefined(element.loc)) as Writable[], '.meta': {} }; }, prettyPrint: { - [LintingPrettyPrintContext.Query]: result => `Code at ${SourceLocation.format(result.loc)}`, - [LintingPrettyPrintContext.Full]: result => `Code at ${SourceLocation.format(result.loc)} is an unused package.` + [LintingPrettyPrintContext.Query]: result => `Import at ${SourceLocation.format(result.loc)}`, + [LintingPrettyPrintContext.Full]: result => `Import at ${SourceLocation.format(result.loc)} is unused. Used version is ${result.version}.` }, info: { name: 'Unused Import', tags: [LintingRuleTag.Smell, LintingRuleTag.Readability], certainty: LintingRuleCertainty.BestEffort, - description: 'Checks whether an imported package is used.', + description: 'Flags imported packages that are not required for the code to run. Packages that are only used on load might be mistaken as such and should therefore be added to the whitelist in the configuration.', defaultConfig: { - pkgDb: undefined + whitelist: [] } } } as const satisfies LintingRule; \ No newline at end of file diff --git a/test/functionality/linter/lint-unused-import.test.ts b/test/functionality/linter/lint-unused-import.test.ts index bcec59cebf3..5e30694726f 100644 --- a/test/functionality/linter/lint-unused-import.test.ts +++ b/test/functionality/linter/lint-unused-import.test.ts @@ -1,40 +1,10 @@ import { describe } from 'vitest'; import { assertLinter } from '../_helper/linter'; import { withTreeSitter } from '../_helper/shell'; -import { PkgDatabase } from '../../../src/project/plugins/package-version-plugins/pkgdb'; +import { PkgDbBuilder } from '../../../src/project/plugins/package-version-plugins/pkgdb'; import { LintingResultCertainty } from '../../../src/linter/linter-format'; -//const ggplot2Callable = ['+', 'ggplot', 'aes', 'geom_point', 'geom_line', 'theme_bw', 'coord_cartesian', 'ggsave', 'fortify', 'scale_type']; -/*const namespaceInfo = setCallable(FlowrNamespaceFile.from(new FlowrInlineTextFile('NAMESPACE', `S3method(fortify,data.frame) -S3method(fortify,lm) -S3method(fortify,default) -S3method(fortify,map) -S3method(scale_type,Date) -S3method(scale_type,POSIXt) -S3method(scale_type,character) -S3method(scale_type,numeric) -S3method(scale_type,factor) -S3method(scale_type,default) -S3method(ggplot,default) -S3method(print,ggproto) -S3method(print,rel) -export("+") -export(ggplot) -export(aes) -export(geom_point) -export(geom_line) -export(theme_bw) -export(coord_cartesian) -export(ggsave) -export(fortify) -export(scale_type) -exportPattern("^[^\\\\.]\\\\.*$") -import(grid) -import(rlang) -importFrom(scales,alpha) -importFrom(stats,setNames)`)).content().current, ggplot2Callable);*/ - -const pkgDatabase = PkgDatabase.fromObject({ +/*const pkgDatabase = PkgDatabase.fromObject({ format: 'flowr-pkgdb', schema: 4, scope: 'latest', @@ -42,33 +12,56 @@ const pkgDatabase = PkgDatabase.fromObject({ strings: [], pkgs: { ggplot2: ['3.5.1', ['ggplot', 'aes', 'geom_point']], random1: ['1.0.0', ['test1', 'test2']] } }); +console.log('version', pkgDatabase.content.version)*/ + +const b = new PkgDbBuilder(); +b.addPackage('ggplot2', { latest: '3.5.1' }); +b.addVersion('ggplot2', '3.5.1', { exported: ['ggplot', 'aes', 'geom_point'], internal: [], deprecated: [], cran: true }); +b.addPackage('ggplot', { latest: '1.1.0' }); +b.addVersion('random1', '1.0.0', { exported: ['test1', 'test2'], internal: [], deprecated: [], cran: true }); +b.addVersion('random1', '1.1.0', { exported: ['test1', 'test3'], internal: [], deprecated: [], cran: true }); +b.addPackage('p', { latest: '1.0' }); +b.addVersion('p', '1.0', { exported: ['f'], internal: [], deprecated: [], cran: true }); +const pkg = b.build('all', { version: 1, date: '2026-05-23', generated: 0 }); + + describe('flowR linter', withTreeSitter(parser => { describe('unused import', () => { - /*test('Several libraries, check env structure', async() => { - const analyzer = await new FlowrAnalyzerBuilder() - .setParser(parser) - .build(); - analyzer.context().deps.addDependency(new Package({ - name: 'ggplot2', - namespaceInfo: namespaceInfo - })); - analyzer.context().deps.addDependency(new Package({ - name: 'random1', - namespaceInfo: setCallable(FlowrNamespaceFile.from(new FlowrInlineTextFile('NAMESPACE', 'export(test1)\nexport(test2)')).content().current, ['test1', 'test2']) - })); - analyzer.addRequest('library(ggplot2)\nlibrary(random1)\nggplot()'); - const df = await analyzer.dataflow(); - const result = await analyzer.query([{ - type: 'linter' - } -]); console.log(result.linter.results["unused-import"]) - });*/ - assertLinter('a', parser, 'library(ggplot2)', 'unused-import', [ + assertLinter('Unused Import', parser, 'library(ggplot2)', 'unused-import', [ { certainty: LintingResultCertainty.Uncertain, loc: [1, 1, 1, 16], + version: [['ggplot2', '3.5.1']] + }, + ], undefined, { pkgDb: pkg }); + assertLinter('Used and unused imports', parser, 'library(p)\nlibrary(ggplot2)\nlibrary(random1)\nggplot()', 'unused-import', [ + { + certainty: LintingResultCertainty.Uncertain, + loc: [3, 1, 3, 16], + version: [['random1', '1.1.0']] + }, + ], undefined, { pkgDb: pkg }); + assertLinter('Used and unused imports with require', parser, 'require(ggplot2)\nrequire(random1)\naes()', 'unused-import', [ + { + certainty: LintingResultCertainty.Uncertain, + loc: [2, 1, 2, 16], + version: [['random1', '1.1.0']] + }, + ], undefined, { pkgDb: pkg }); + assertLinter('Not in package database', parser, 'library(ggplot2)\nlibrary(random1)\nlibrary(notInDb)\naes()', 'unused-import', [ + { + certainty: LintingResultCertainty.Uncertain, + loc: [2, 1, 2, 16], + version: [['random1', '1.1.0']] + } + ], undefined, { pkgDb: pkg }); + assertLinter('Whitelisted package', parser, 'require(p)\nrequire(ggplot2)\nrequire(random1)\naes()', 'unused-import', [ + { + certainty: LintingResultCertainty.Uncertain, + loc: [1, 1, 1, 10], + version: [['p', '1.0']] }, - ], undefined, { pkgDb: pkgDatabase }); + ], undefined, { pkgDb: pkg, whitelist: ['random1'] }); }); })); \ No newline at end of file From ad8354182ab83db666b4fd20e99f63bba1bb3834 Mon Sep 17 00:00:00 2001 From: Naomi Panda Date: Sat, 18 Jul 2026 18:15:42 +0200 Subject: [PATCH 3/3] feat-fix(wip): fixed by now using dependencies query --- src/linter/rules/unused-import.ts | 129 ++++++------------ .../linter/lint-unused-import.test.ts | 28 ++-- 2 files changed, 55 insertions(+), 102 deletions(-) diff --git a/src/linter/rules/unused-import.ts b/src/linter/rules/unused-import.ts index 788fc7d377b..93795a54a28 100644 --- a/src/linter/rules/unused-import.ts +++ b/src/linter/rules/unused-import.ts @@ -11,17 +11,14 @@ import { Q } from '../../search/flowr-search-builder'; import { LintingRuleTag } from '../linter-tags'; import { isNotUndefined } from '../../util/assert'; import type { Writable } from 'ts-essentials'; -import { VertexType, type DataflowGraphVertexFunctionCall } from '../../dataflow/graph/vertex'; import { getOriginInDfg, OriginType } from '../../dataflow/origin/dfg-get-origin'; import { DfEdge, EdgeType } from '../../dataflow/graph/edge'; import type { NodeId } from '../../r-bridge/lang-4.x/ast/model/processing/node-id'; -import { LibraryFunctions } from '../../queries/catalog/dependencies-query/function-info/library-functions'; -import { pMatch } from '../../dataflow/internal/linker'; -import { resolveIdToValue } from '../../dataflow/eval/resolve/alias-tracking'; -import { valueSetGuard } from '../../dataflow/eval/values/general'; +import type { PkgDb } from '../../project/plugins/package-version-plugins/pkgdb'; +import { Enrichment } from '../../search/search-executor/search-enrichers'; export interface UnusedImportResult extends LintingResult{ - readonly version: string[][] + readonly version: [string, string] } export interface UnusedImportConfig extends MergeableRecord { @@ -36,108 +33,70 @@ export type UnusedImportMetadata = MergeableRecord; * an ingoing reads-edge. We only consider packages that could be resolved from the `flowr-pkgdb` database. */ export const UNUSED_IMPORT = { - //createSearch: () => Q.fromQuery({ type: 'call-context', callName: '^(library)|(use)|(require)$' }), - createSearch: () => Q.all().filter(VertexType.FunctionCall), + createSearch: () => Q.fromQuery({ type: 'dependencies', 'enabledCategories': ['library'] }), processSearchResult: async(elements, config, data) => { const dataflow = await data.dataflow(); - //get library functions to filter out - let ecurrent = dataflow.environment.current; - const usedpackages = new Set(); - while(ecurrent !== undefined){ - if(isNotUndefined(ecurrent.n)){ - usedpackages.add(ecurrent.n); - } - ecurrent = ecurrent.parent; - } - const libraryFunctions = LibraryFunctions.filter(v => usedpackages.has(v.package) || v.package === 'base').reduce((a, b) => { - return a.add(b.name); - }, new Set) - - const deps = data.inspectContext().deps; - const dependencyToVersion: Map = new Map(); - for(const dep of deps.getDependencies()){ - dependencyToVersion.set(dep.name, new String(dep.deriveVersion())) + // needs a package database to compute + if(!(config.pkgDb !== null && typeof config.pkgDb === 'object' && 'format' in config.pkgDb && config.pkgDb.format === 'flowr-pkgdb' && 'pkgs' in config.pkgDb && (config.pkgDb as PkgDb).pkgs !== null)){ + return { results: [], '.meta': {} }; } const whitelist = new Set(config.whitelist); const unknownIds = new Set(); for(const e of dataflow.graph.unknownSideEffects) { - unknownIds.add(typeof e === 'object' ? e.id : String(e)); + unknownIds.add(typeof e === 'object' && 'id' in e ? e.id : e); } - - //all library calls we want to test - const libraryFCalls = elements.getElements() + const libraryCalls = elements.enrichmentContent(Enrichment.QueryData).queries['dependencies'].library .filter(element => { - if(isNotUndefined(element.node.lexeme) && !libraryFunctions.has(element.node.lexeme)){ + //packages that could not be resolved from package database + if(unknownIds.has(element.nodeId)){ return false; } - //for libraries where we can't read the export, cannot decide whether they are used or not - if(unknownIds.has(element.node.info.id)){ - return false; - } - //is of built-in origin - const origins = getOriginInDfg(dataflow.graph, element.node.info.id); - if(isNotUndefined(origins)) { - const builtIn = origins.every(e => e.type === OriginType.BuiltInFunctionOrigin); - if(!builtIn){ - return false; + const origins = getOriginInDfg(dataflow.graph, element.nodeId); + if(isNotUndefined(origins)) { + const builtIn = origins.every(e => e.type === OriginType.BuiltInFunctionOrigin); + if(!builtIn){ + return false; + } } - } - return true; - }); + return true; + }); //all NodeIds that have an ingoing read-edge const readEdges = new Set(dataflow.graph.edges().flatMap(e => e[1].entries()).filter(entry => { - if(DfEdge.includesType(entry[1], EdgeType.Reads)){ + //nodes with "::" are definitely read + if(typeof entry[0] === 'string' && entry[0].includes('::')){ return true; - } elseĀ { - return false; + } else if(DfEdge.includesType(entry[1], EdgeType.Reads)){ + return true; + } else{ + return false; } }).map(e => e[0])); + + const dependencyToVersion = Object.entries((config.pkgDb as PkgDb).pkgs).reduce((map, entry) => { + map.set(entry[0], entry[1][0]); + return map; + }, new Map()); + const idToDependecyName = libraryCalls.filter(element => !readEdges.has(element.nodeId) && isNotUndefined(element.value) && !whitelist.has(element.value)) + .reduce((map, element) => { + map.set(element.nodeId, element.value); + return map; + }, new Map()); return { results: - libraryFCalls.filter(c => !readEdges.has(c.node.info.id)) - .map(element => { - const fCall = dataflow.graph.getVertex(element.node.info.id) as DataflowGraphVertexFunctionCall; - const paramMap = { - 'package': 'pkg', - } as const; - let arg = [] - const mapping = pMatch(fCall.args, paramMap); - const mappedToF = mapping.get('pkg') ?? []; - for(const argId of mappedToF) { - const res = resolveIdToValue(argId, { graph: dataflow.graph, environment: fCall.environment, ctx: data.inspectContext() }); - const values = valueSetGuard(res); - if(values?.type === 'set' && values.elements.length !== 0){ - for(const elem of values.elements){ - if(elem.type === 'string' && 'str' in elem.value){ - arg.push(elem.value.str) - } - } - } - } - return { - element: element, - lib: arg - }}) - //only packages for which we have a package db entry - .filter(({ lib }) => { - return !lib.some(v => !dependencyToVersion.has(v)); - }) - //don't consider entries in whitelist - .filter(({ lib }) => !lib.some(v => whitelist.has(v))) - .map(({ element, lib }) => ({ - certainty: LintingResultCertainty.Uncertain, - involvedId: element.node.info.id, - loc: SourceLocation.fromNode(element.node), - //of form: [[package, version]] - version: lib.map(v => [v, dependencyToVersion.get(v)]) - })) - .filter(element => isNotUndefined(element.loc)) as Writable[], + elements.getElements().filter(element => { + return idToDependecyName.has(element.node.info.id); + }).map(element => ({ + certainty: LintingResultCertainty.Uncertain, + involvedId: element.node.info.id, + loc: SourceLocation.fromNode(element.node), + version: [idToDependecyName.get(element.node.info.id), dependencyToVersion.get(idToDependecyName.get(element.node.info.id))] + })).filter(element => isNotUndefined(element.loc)) as Writable[], '.meta': {} }; }, prettyPrint: { [LintingPrettyPrintContext.Query]: result => `Import at ${SourceLocation.format(result.loc)}`, - [LintingPrettyPrintContext.Full]: result => `Import at ${SourceLocation.format(result.loc)} is unused. Used version is ${result.version}.` + [LintingPrettyPrintContext.Full]: result => `Import at ${SourceLocation.format(result.loc)} is unused. Used version is ${result.version.join()}.` }, info: { name: 'Unused Import', diff --git a/test/functionality/linter/lint-unused-import.test.ts b/test/functionality/linter/lint-unused-import.test.ts index 5e30694726f..be0187d9537 100644 --- a/test/functionality/linter/lint-unused-import.test.ts +++ b/test/functionality/linter/lint-unused-import.test.ts @@ -4,20 +4,10 @@ import { withTreeSitter } from '../_helper/shell'; import { PkgDbBuilder } from '../../../src/project/plugins/package-version-plugins/pkgdb'; import { LintingResultCertainty } from '../../../src/linter/linter-format'; -/*const pkgDatabase = PkgDatabase.fromObject({ - format: 'flowr-pkgdb', - schema: 4, - scope: 'latest', - content: { version: 1, date: '2026-05-23', hash: 'x', generated: 0, packages: 1, versions: 1 }, - strings: [], - pkgs: { ggplot2: ['3.5.1', ['ggplot', 'aes', 'geom_point']], random1: ['1.0.0', ['test1', 'test2']] } -}); -console.log('version', pkgDatabase.content.version)*/ - const b = new PkgDbBuilder(); b.addPackage('ggplot2', { latest: '3.5.1' }); b.addVersion('ggplot2', '3.5.1', { exported: ['ggplot', 'aes', 'geom_point'], internal: [], deprecated: [], cran: true }); -b.addPackage('ggplot', { latest: '1.1.0' }); +b.addPackage('random1', { latest: '1.1.0' }); b.addVersion('random1', '1.0.0', { exported: ['test1', 'test2'], internal: [], deprecated: [], cran: true }); b.addVersion('random1', '1.1.0', { exported: ['test1', 'test3'], internal: [], deprecated: [], cran: true }); b.addPackage('p', { latest: '1.0' }); @@ -25,42 +15,46 @@ b.addVersion('p', '1.0', { exported: ['f'], internal: [], deprecated: [], cran: const pkg = b.build('all', { version: 1, date: '2026-05-23', generated: 0 }); - describe('flowR linter', withTreeSitter(parser => { describe('unused import', () => { assertLinter('Unused Import', parser, 'library(ggplot2)', 'unused-import', [ { certainty: LintingResultCertainty.Uncertain, loc: [1, 1, 1, 16], - version: [['ggplot2', '3.5.1']] + version: ['ggplot2', '3.5.1'] }, ], undefined, { pkgDb: pkg }); assertLinter('Used and unused imports', parser, 'library(p)\nlibrary(ggplot2)\nlibrary(random1)\nggplot()', 'unused-import', [ + { + certainty: LintingResultCertainty.Uncertain, + loc: [1, 1, 1, 10], + version: ['p', '1.0'] + }, { certainty: LintingResultCertainty.Uncertain, loc: [3, 1, 3, 16], - version: [['random1', '1.1.0']] + version: ['random1', '1.1.0'] }, ], undefined, { pkgDb: pkg }); assertLinter('Used and unused imports with require', parser, 'require(ggplot2)\nrequire(random1)\naes()', 'unused-import', [ { certainty: LintingResultCertainty.Uncertain, loc: [2, 1, 2, 16], - version: [['random1', '1.1.0']] + version: ['random1', '1.1.0'] }, ], undefined, { pkgDb: pkg }); assertLinter('Not in package database', parser, 'library(ggplot2)\nlibrary(random1)\nlibrary(notInDb)\naes()', 'unused-import', [ { certainty: LintingResultCertainty.Uncertain, loc: [2, 1, 2, 16], - version: [['random1', '1.1.0']] + version: ['random1', '1.1.0'] } ], undefined, { pkgDb: pkg }); assertLinter('Whitelisted package', parser, 'require(p)\nrequire(ggplot2)\nrequire(random1)\naes()', 'unused-import', [ { certainty: LintingResultCertainty.Uncertain, loc: [1, 1, 1, 10], - version: [['p', '1.0']] + version: ['p', '1.0'] }, ], undefined, { pkgDb: pkg, whitelist: ['random1'] }); });