|
| 1 | +import type { |
| 2 | + Audit, |
| 3 | + AuditOutput, |
| 4 | + Group, |
| 5 | + Issue, |
| 6 | + IssueSeverity, |
| 7 | + PluginConfig, |
| 8 | +} from "@code-pushup/models"; |
| 9 | +import { |
| 10 | + capitalize, |
| 11 | + compareIssueSeverity, |
| 12 | + countOccurrences, |
| 13 | + executeProcess, |
| 14 | + objectToEntries, |
| 15 | + pluralizeToken, |
| 16 | + truncateIssueMessage, |
| 17 | +} from "@code-pushup/utils"; |
| 18 | + |
| 19 | +// FOR FUTURE REFERENCE: PyLint has a default scoring formula: |
| 20 | +// 10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10) |
| 21 | +// https://pylint.readthedocs.io/en/stable/user_guide/configuration/all-options.html#evaluation |
| 22 | + |
| 23 | +export default async function pylintPlugin( |
| 24 | + patterns: string[] |
| 25 | +): Promise<PluginConfig> { |
| 26 | + const enabledMessages = await findEnabledMessages(patterns); |
| 27 | + const audits = listAudits(enabledMessages); |
| 28 | + const groups = listGroups(enabledMessages); |
| 29 | + |
| 30 | + return { |
| 31 | + slug: "pylint", |
| 32 | + title: "PyLint", |
| 33 | + icon: "python", |
| 34 | + audits, |
| 35 | + groups, |
| 36 | + runner: () => runLint(patterns, audits), |
| 37 | + }; |
| 38 | +} |
| 39 | + |
| 40 | +type PylintJson2 = { |
| 41 | + messages: PylintMessage[]; |
| 42 | + statistics: PylintStatistics; |
| 43 | +}; |
| 44 | + |
| 45 | +type PylintMessageType = |
| 46 | + | "fatal" |
| 47 | + | "error" |
| 48 | + | "warning" |
| 49 | + | "refactor" |
| 50 | + | "convention" |
| 51 | + | "info"; |
| 52 | + |
| 53 | +type PylintMessage = { |
| 54 | + type: PylintMessageType; |
| 55 | + symbol: string; |
| 56 | + message: string; |
| 57 | + messageId: string; |
| 58 | + confidence: string; |
| 59 | + module: string; |
| 60 | + obj: string; |
| 61 | + line: number; |
| 62 | + column: number; |
| 63 | + endLine: number | null; |
| 64 | + endColumn: number | null; |
| 65 | + path: string; |
| 66 | + absolutePath: string; |
| 67 | +}; |
| 68 | + |
| 69 | +type PylintStatistics = { |
| 70 | + messageTypeCount: Record<PylintMessageType, number>; |
| 71 | + modulesLinted: number; |
| 72 | + score: number; |
| 73 | +}; |
| 74 | + |
| 75 | +type EnabledMessage = { |
| 76 | + symbol: string; |
| 77 | + messageId: string; |
| 78 | +}; |
| 79 | + |
| 80 | +async function findEnabledMessages( |
| 81 | + patterns: string[] |
| 82 | +): Promise<EnabledMessage[]> { |
| 83 | + const { stdout } = await executeProcess({ |
| 84 | + command: "python", |
| 85 | + args: ["-m", "pylint", "--list-msgs-enabled", ...patterns], |
| 86 | + }); |
| 87 | + |
| 88 | + const lines = stdout.split("\n"); |
| 89 | + const enabledStart = lines.indexOf("Enabled messages:"); |
| 90 | + const enabledEnd = lines.findIndex( |
| 91 | + (line, i) => i > enabledStart && !line.startsWith(" ") |
| 92 | + ); |
| 93 | + const enabledLines = lines.slice(enabledStart, enabledEnd); |
| 94 | + |
| 95 | + return enabledLines |
| 96 | + .map((line): EnabledMessage | null => { |
| 97 | + const match = line.match(/^ ([\w-]+) \(([A-Z]\d+)\)$/); |
| 98 | + if (!match) { |
| 99 | + return null; |
| 100 | + } |
| 101 | + const [, symbol, messageId] = match; |
| 102 | + return { symbol, messageId }; |
| 103 | + }) |
| 104 | + .filter((msg): msg is EnabledMessage => msg != null); |
| 105 | +} |
| 106 | + |
| 107 | +function listAudits(enabledMessages: EnabledMessage[]): Audit[] { |
| 108 | + return enabledMessages.map(({ symbol, messageId }): Audit => { |
| 109 | + const type = messageIdToType(messageId); |
| 110 | + return { |
| 111 | + slug: symbol, |
| 112 | + title: `${symbol} (${messageId})`, |
| 113 | + ...(type && { |
| 114 | + docsUrl: `https://pylint.readthedocs.io/en/stable/user_guide/messages/${type}/${symbol}.html`, |
| 115 | + }), |
| 116 | + }; |
| 117 | + }); |
| 118 | +} |
| 119 | + |
| 120 | +function listGroups(enabledMessages: EnabledMessage[]): Group[] { |
| 121 | + // source: https://github.com/pylint-dev/pylint/blob/main/pylint/config/help_formatter.py#L47-L53 |
| 122 | + const descriptions: Record<PylintMessageType, string> = { |
| 123 | + info: "for informational messages", |
| 124 | + convention: "for programming standard violation", |
| 125 | + refactor: "for bad code smell", |
| 126 | + warning: "for python specific problems", |
| 127 | + error: "for probable bugs in the code", |
| 128 | + fatal: "if an error occurred which prevented pylint from doing further processing", |
| 129 | + }; |
| 130 | + |
| 131 | + const categoriesMap = enabledMessages.reduce<Record<string, string[]>>( |
| 132 | + (acc, { symbol, messageId }) => { |
| 133 | + const type = messageIdToType(messageId); |
| 134 | + if (!type) { |
| 135 | + return acc; |
| 136 | + } |
| 137 | + return { ...acc, [type]: [...(acc[type] ?? []), symbol] }; |
| 138 | + }, |
| 139 | + {} |
| 140 | + ); |
| 141 | + return Object.entries(categoriesMap).map( |
| 142 | + ([type, symbols]): Group => ({ |
| 143 | + slug: type, |
| 144 | + title: capitalize(type), |
| 145 | + description: descriptions[type], |
| 146 | + docsUrl: `https://pylint.readthedocs.io/en/stable/user_guide/messages/messages_overview.html#${type}`, |
| 147 | + refs: symbols.map((symbol) => ({ slug: symbol, weight: 1 })), |
| 148 | + }) |
| 149 | + ); |
| 150 | +} |
| 151 | + |
| 152 | +function messageIdToType(messageId: string): PylintMessageType | null { |
| 153 | + switch (messageId[0]) { |
| 154 | + case "F": |
| 155 | + return "fatal"; |
| 156 | + case "E": |
| 157 | + return "error"; |
| 158 | + case "W": |
| 159 | + return "warning"; |
| 160 | + case "R": |
| 161 | + return "refactor"; |
| 162 | + case "C": |
| 163 | + return "convention"; |
| 164 | + case "I": |
| 165 | + return "info"; |
| 166 | + default: |
| 167 | + return null; |
| 168 | + } |
| 169 | +} |
| 170 | + |
| 171 | +async function runLint( |
| 172 | + patterns: string[], |
| 173 | + audits: Audit[] |
| 174 | +): Promise<AuditOutput[]> { |
| 175 | + const { stdout, stderr } = await executeProcess({ |
| 176 | + command: "python", |
| 177 | + args: ["-m", "pylint", "--output-format=json2", ...patterns], |
| 178 | + ignoreExitCode: true, |
| 179 | + }); |
| 180 | + |
| 181 | + if (stderr) { |
| 182 | + throw new Error(stderr); |
| 183 | + } |
| 184 | + |
| 185 | + const result = JSON.parse(stdout) as PylintJson2; |
| 186 | + |
| 187 | + const issuesMap = result.messages.reduce<Record<string, Issue[]>>( |
| 188 | + (acc, message) => ({ |
| 189 | + ...acc, |
| 190 | + [message.symbol]: [ |
| 191 | + ...(acc[message.symbol] ?? []), |
| 192 | + messageToIssue(message), |
| 193 | + ], |
| 194 | + }), |
| 195 | + {} |
| 196 | + ); |
| 197 | + |
| 198 | + return audits.map(({ slug }): AuditOutput => { |
| 199 | + const issues = issuesMap[slug] ?? []; |
| 200 | + |
| 201 | + const severityCounts = countOccurrences( |
| 202 | + issues.map(({ severity }) => severity) |
| 203 | + ); |
| 204 | + const severities = objectToEntries(severityCounts); |
| 205 | + const summaryText = |
| 206 | + [...severities] |
| 207 | + .sort((a, b) => -compareIssueSeverity(a[0], b[0])) |
| 208 | + .map(([severity, count = 0]) => pluralizeToken(severity, count)) |
| 209 | + .join(", ") || "passed"; |
| 210 | + |
| 211 | + return { |
| 212 | + slug, |
| 213 | + score: Number(issues.length === 0), |
| 214 | + value: issues.length, |
| 215 | + displayValue: summaryText, |
| 216 | + details: { issues }, |
| 217 | + }; |
| 218 | + }); |
| 219 | +} |
| 220 | + |
| 221 | +function messageToIssue({ |
| 222 | + type, |
| 223 | + message, |
| 224 | + path, |
| 225 | + line, |
| 226 | + column, |
| 227 | + endLine, |
| 228 | + endColumn, |
| 229 | +}: PylintMessage): Issue { |
| 230 | + return { |
| 231 | + message: truncateIssueMessage(message.replace(/_/g, "\\_")), |
| 232 | + severity: messageTypeToSeverity(type), |
| 233 | + source: { |
| 234 | + file: path, |
| 235 | + position: { |
| 236 | + startLine: line, |
| 237 | + startColumn: column + 1, |
| 238 | + ...(endLine != null && { endLine }), |
| 239 | + ...(endColumn != null && { endColumn: endColumn + 1 }), |
| 240 | + }, |
| 241 | + }, |
| 242 | + }; |
| 243 | +} |
| 244 | + |
| 245 | +function messageTypeToSeverity(type: PylintMessageType): IssueSeverity { |
| 246 | + switch (type) { |
| 247 | + case "fatal": |
| 248 | + case "error": |
| 249 | + return "error"; |
| 250 | + case "warning": |
| 251 | + return "warning"; |
| 252 | + case "refactor": |
| 253 | + case "convention": |
| 254 | + case "info": |
| 255 | + return "info"; |
| 256 | + } |
| 257 | +} |
0 commit comments