From 7e483d46fad7341abcb74111fa96a88705eb9518 Mon Sep 17 00:00:00 2001 From: Lars Kappert Date: Sun, 22 Oct 2023 17:03:16 +0200 Subject: [PATCH] Add `jsonExt` reporter to output JSON with row/col of exports/types issues (#288) --- README.md | 2 +- src/reporters/index.ts | 2 + src/reporters/jsonExt.ts | 101 +++++++++++++++++++++++ src/util/cli-arguments.ts | 2 +- test/cli-reporter-jsonExt.test.ts | 131 ++++++++++++++++++++++++++++++ 5 files changed, 236 insertions(+), 2 deletions(-) create mode 100644 src/reporters/jsonExt.ts create mode 100644 test/cli-reporter-jsonExt.test.ts diff --git a/README.md b/README.md index 66fc6a174..6d84c568f 100644 --- a/README.md +++ b/README.md @@ -704,7 +704,7 @@ $ npx knip --help -n, --no-progress Don't show dynamic progress updates (automatically enabled in CI environments) --preprocessor Preprocess the results before providing it to the reporter(s), can be repeated --preprocessor-options Pass extra options to the preprocessor (as JSON string, see --reporter-options example) - --reporter Select reporter: symbols, compact, codeowners, json, can be repeated (default: symbols) + --reporter Select reporter: symbols, compact, codeowners, json, jsonExt, can be repeated (default: symbols) --reporter-options Pass extra options to the reporter (as JSON string, see example) --no-config-hints Suppress configuration hints --no-exit-code Always exit with code zero (0) diff --git a/src/reporters/index.ts b/src/reporters/index.ts index 8a0dea3f6..1e725e3bb 100644 --- a/src/reporters/index.ts +++ b/src/reporters/index.ts @@ -1,6 +1,7 @@ import codeowners from './codeowners.js'; import compact from './compact.js'; import json from './json.js'; +import jsonExt from './jsonExt.js'; import symbols from './symbols.js'; export default { @@ -8,4 +9,5 @@ export default { compact, codeowners, json, + jsonExt, }; diff --git a/src/reporters/jsonExt.ts b/src/reporters/jsonExt.ts new file mode 100644 index 000000000..27540b86f --- /dev/null +++ b/src/reporters/jsonExt.ts @@ -0,0 +1,101 @@ +import { OwnershipEngine } from '@snyk/github-codeowners/dist/lib/ownership/index.js'; +import { isFile } from '../util/fs.js'; +import { relative, resolve } from '../util/path.js'; +import type { Report, ReporterOptions, IssueSet, IssueRecords, SymbolIssueType, Issue } from '../types/issues.js'; +import type { Entries } from 'type-fest'; + +type ExtraReporterOptions = { + codeowners?: string; +}; + +type Item = { name: string; pos?: number; line?: number; col?: number }; + +type Row = { + file: string; + owners: Array<{ name: string }>; + files?: boolean; + dependencies?: Array<{ name: string }>; + devDependencies?: Array<{ name: string }>; + optionalPeerDependencies?: Array<{ name: string }>; + unlisted?: Array<{ name: string }>; + binaries?: Array<{ name: string }>; + unresolved?: Array<{ name: string }>; + exports?: Array; + types?: Array; + duplicates?: Array; + enumMembers?: Record>; + classMembers?: Record>; +}; + +const mergeTypes = (type: SymbolIssueType) => + type === 'exports' || type === 'nsExports' ? 'exports' : type === 'types' || type === 'nsTypes' ? 'types' : type; + +export default async ({ report, issues, options }: ReporterOptions) => { + let opts: ExtraReporterOptions = {}; + try { + opts = options ? JSON.parse(options) : opts; + } catch (error) { + console.error(error); + } + + const json: Record = {}; + const codeownersFilePath = resolve(opts.codeowners ?? '.github/CODEOWNERS'); + const codeownersEngine = isFile(codeownersFilePath) && OwnershipEngine.FromCodeownersFile(codeownersFilePath); + + const flatten = (issues: IssueRecords): Issue[] => Object.values(issues).flatMap(Object.values); + + const initRow = (filePath: string) => { + const file = relative(filePath); + const row: Row = { + file, + ...(codeownersEngine && { owners: codeownersEngine.calcFileOwnership(file) }), + ...(report.files && { files: false }), + ...(report.dependencies && { dependencies: [] }), + ...(report.devDependencies && { devDependencies: [] }), + ...(report.optionalPeerDependencies && { optionalPeerDependencies: [] }), + ...(report.unlisted && { unlisted: [] }), + ...(report.binaries && { binaries: [] }), + ...(report.unresolved && { unresolved: [] }), + ...((report.exports || report.nsExports) && { exports: [] }), + ...((report.types || report.nsTypes) && { types: [] }), + ...(report.enumMembers && { enumMembers: {} }), + ...(report.classMembers && { classMembers: {} }), + ...(report.duplicates && { duplicates: [] }), + }; + return row; + }; + + for (const [reportType, isReportType] of Object.entries(report) as Entries) { + if (isReportType) { + if (reportType === 'files') { + Array.from(issues[reportType] as IssueSet).forEach(filePath => { + json[filePath] = json[filePath] ?? initRow(filePath); + json[filePath][reportType] = true; + }); + } else { + const type = mergeTypes(reportType); + flatten(issues[reportType] as IssueRecords).forEach(issue => { + const { filePath, symbol, symbols, parentSymbol } = issue; + json[filePath] = json[filePath] ?? initRow(filePath); + if (type === 'duplicates') { + symbols && json[filePath][type]?.push(symbols.map(symbol => ({ name: symbol }))); + } else if (type === 'enumMembers' || type === 'classMembers') { + const item = json[filePath][type]; + if (parentSymbol && item) { + item[parentSymbol] = item[parentSymbol] ?? []; + item[parentSymbol].push({ name: issue.symbol, line: issue.line, col: issue.col, pos: issue.pos }); + } + } else { + if (type === 'exports' || type === 'types') { + json[filePath][type]?.push({ name: issue.symbol, line: issue.line, col: issue.col, pos: issue.pos }); + } else { + json[filePath][type]?.push({ name: symbol }); + } + } + }); + } + } + } + + console.log(JSON.stringify(Object.values(json))); +}; diff --git a/src/util/cli-arguments.ts b/src/util/cli-arguments.ts index a9ae2b2ae..6b62064a5 100644 --- a/src/util/cli-arguments.ts +++ b/src/util/cli-arguments.ts @@ -21,7 +21,7 @@ Options: -n, --no-progress Don't show dynamic progress updates (automatically enabled in CI environments) --preprocessor Preprocess the results before providing it to the reporter(s), can be repeated --preprocessor-options Pass extra options to the preprocessor (as JSON string, see --reporter-options example) - --reporter Select reporter: symbols, compact, codeowners, json, can be repeated (default: symbols) + --reporter Select reporter: symbols, compact, codeowners, json, jsonExt, can be repeated (default: symbols) --reporter-options Pass extra options to the reporter (as JSON string, see example) --no-config-hints Suppress configuration hints --no-exit-code Always exit with code zero (0) diff --git a/test/cli-reporter-jsonExt.test.ts b/test/cli-reporter-jsonExt.test.ts new file mode 100644 index 000000000..0eaf03f21 --- /dev/null +++ b/test/cli-reporter-jsonExt.test.ts @@ -0,0 +1,131 @@ +import assert from 'node:assert/strict'; +import { EOL } from 'node:os'; +import test from 'node:test'; +import { resolve } from '../src/util/path.js'; +import { execFactory } from './helpers/execKnip.js'; + +const cwd = resolve('fixtures/exports'); + +const exec = execFactory(cwd); + +test('knip --reporter jsonext', () => { + const json = [ + { + file: 'default.ts', + files: false, + dependencies: [], + devDependencies: [], + optionalPeerDependencies: [], + unlisted: [], + binaries: [], + unresolved: [], + exports: [{ name: 'NamedExport', line: 1, col: 14, pos: 13 }], + types: [], + enumMembers: {}, + classMembers: {}, + duplicates: [], + }, + { + file: 'dynamic-import.ts', + files: false, + dependencies: [], + devDependencies: [], + optionalPeerDependencies: [], + unlisted: [], + binaries: [], + unresolved: [], + exports: [{ name: 'unusedZero', line: 3, col: 14, pos: 39 }], + types: [], + enumMembers: {}, + classMembers: {}, + duplicates: [], + }, + { + file: 'my-mix.ts', + files: false, + dependencies: [], + devDependencies: [], + optionalPeerDependencies: [], + unlisted: [], + binaries: [], + unresolved: [], + exports: [{ name: 'unusedInMix', line: 1, col: 14, pos: 13 }], + types: [], + enumMembers: {}, + classMembers: {}, + duplicates: [], + }, + { + file: 'my-module.ts', + files: false, + dependencies: [], + devDependencies: [], + optionalPeerDependencies: [], + unlisted: [], + binaries: [], + unresolved: [], + exports: [ + { name: 'unusedNumber', line: 14, col: 14, pos: 562 }, + { name: 'unusedFunction', line: 15, col: 14, pos: 593 }, + { name: 'default', line: 21, col: 8, pos: 727 }, + ], + types: [{ name: 'MyAnyType', line: 19, col: 13, pos: 702 }], + enumMembers: {}, + classMembers: {}, + duplicates: [[{ name: 'exportedResult' }, { name: 'default' }]], + }, + { + file: 'named-exports.ts', + files: false, + dependencies: [], + devDependencies: [], + optionalPeerDependencies: [], + unlisted: [], + binaries: [], + unresolved: [], + exports: [ + { name: 'renamedExport', line: 6, col: 30, pos: 179 }, + { name: 'namedExport', line: 7, col: 15, pos: 215 }, + ], + types: [], + enumMembers: {}, + classMembers: {}, + duplicates: [], + }, + { + file: 'my-namespace.ts', + files: false, + dependencies: [], + devDependencies: [], + optionalPeerDependencies: [], + unlisted: [], + binaries: [], + unresolved: [], + exports: [{ name: 'nsUnusedKey', line: 3, col: 14, pos: 84 }], + types: [{ name: 'MyNamespace', line: 5, col: 18, pos: 119 }], + enumMembers: {}, + classMembers: {}, + duplicates: [], + }, + { + file: 'types.ts', + files: false, + dependencies: [], + devDependencies: [], + optionalPeerDependencies: [], + unlisted: [], + binaries: [], + unresolved: [], + exports: [], + types: [ + { name: 'MyEnum', line: 3, col: 13, pos: 71 }, + { name: 'MyType', line: 8, col: 14, pos: 145 }, + ], + enumMembers: {}, + classMembers: {}, + duplicates: [], + }, + ]; + + assert.equal(exec('knip --reporter jsonExt'), JSON.stringify(json) + EOL); +});