import type { CatalogProduct, CatalogRule, KnobEntry } from "#types/catalog.types"; import { commandOutput, commandVersion } from "#core/adapters/invocation.adapter"; import { INTEGER_TYPE } from "#configuration/constants/knob.constants"; import { byRuleId } from "#core/comparators/rule.comparator"; import { defineProducer } from "#core/registries/producer.registry"; import { firstRemoteText } from "#core/adapters/remote.adapter"; import { isRecord } from "#core/selectors/record.selector"; import { isThreshold } from "#core/predicates/knob.predicate"; import { parseYaml } from "#core/parsers/config.yaml.parser"; import { tallyBy } from "#core/aggregators/catalog.aggregator"; import { withoutRubyTags } from "#core/parsers/knob.parser"; const TOOL = "rubocop"; const PLUGINS_SOURCE = "rubocop-plugins"; const DOCS = "https://docs.rubocop.org"; const DESCRIPTION_KEY = "Description:"; const ENABLED_KEY = "Enabled:"; const ENABLED_LEVEL = "enabled"; const DISABLED_LEVEL = "disabled"; const PENDING_LEVEL = "pending"; const REPOSITORIES: readonly [string, string][] = [ ["rubocop", "rubocop/rubocop"], ["rubocop-rails", "rubocop/rubocop-rails"], ["rubocop-rspec", "rubocop/rubocop-rspec"], ["rubocop-performance", "rubocop/rubocop-performance"], ["rubocop-minitest", "rubocop/rubocop-minitest"], ["rubocop-rake", "rubocop/rubocop-rake"], ["rubocop-thread_safety", "rubocop/rubocop-thread_safety"], ["rubocop-capybara", "rubocop/rubocop-capybara"], ["rubocop-factory_bot", "rubocop/rubocop-factory_bot"], ["rubocop-graphql", "DmitryTsepelev/rubocop-graphql"], ["rubocop-sequel", "rubocop/rubocop-sequel"], ]; const META_KEYS: ReadonlySet = new Set([ "Description", "Enabled", "VersionAdded", "VersionChanged", "StyleGuide", "Reference", "References", "SafeAutoCorrect", "Safe", "AutoCorrect", "Include", "Exclude", "Severity", "Details", ]); interface Cop { department: string; description: string | null; level: string | null; name: string; } const stripQuotes = function stripQuotes(value: string): string { const text = value.trim(); const quoted = (text.startsWith("'") && text.endsWith("'")) || (text.startsWith('"') && text.endsWith('"')); return quoted ? text.slice(1, -1) : text; }; const levelOfDeclared = function levelOfDeclared(declared: string): string { if (declared === "false") { return DISABLED_LEVEL; } return declared === PENDING_LEVEL ? PENDING_LEVEL : ENABLED_LEVEL; }; const isCopHeading = function isCopHeading(line: string): boolean { const unindented = line.length > 0 && !line.startsWith(" ") && !line.startsWith("\t"); return unindented && line.includes("/") && line.endsWith(":"); }; const updatedCop = function updatedCop(cop: Cop, text: string): Cop { if (text.startsWith(DESCRIPTION_KEY)) { return { ...cop, description: stripQuotes(text.slice(DESCRIPTION_KEY.length)) }; } if (text.startsWith(ENABLED_KEY)) { return { ...cop, level: levelOfDeclared(text.slice(ENABLED_KEY.length).trim()) }; } return cop; }; const readCopLine = function readCopLine(cops: readonly Cop[], line: string): Cop[] { if (isCopHeading(line)) { const name = line.slice(0, -1); return [...cops, { department: name.slice(0, name.indexOf("/")), description: null, level: null, name }]; } const last = cops.at(-1); return last === undefined ? [...cops] : [...cops.slice(0, -1), updatedCop(last, line.trim())]; }; const copsOf = function copsOf(lines: readonly string[]): Cop[] { let cops: Cop[] = []; for (const line of lines) { cops = readCopLine(cops, line); } return cops; }; const defaultConfig = async function defaultConfig(repository: string): Promise> { const root = `https://raw.githubusercontent.com/${repository}`; const text = await firstRemoteText([`${root}/master/config/default.yml`, `${root}/main/config/default.yml`]); const doc = parseYaml(withoutRubyTags(text ?? "")); return isRecord(doc) ? doc : {}; }; const copEntries = function copEntries(doc: Record): [string, Record][] { return Object.entries(doc).filter( (entry): entry is [string, Record] => entry[0].includes("/") && isRecord(entry[1]), ); }; const levelOf = function levelOf(body: Record): string { if (body["Enabled"] === false) { return DISABLED_LEVEL; } return body["Enabled"] === PENDING_LEVEL ? PENDING_LEVEL : ENABLED_LEVEL; }; const coreProduct = function coreProduct(): CatalogProduct { const version = commandVersion(TOOL, ["--version"]); const lines = commandOutput(TOOL, ["--show-cops"]) .split("\n") .map((line) => (line.endsWith("\r") ? line.slice(0, -1) : line)); const rules = copsOf(lines) .map((cop): CatalogRule => ({ canonical: null, category: cop.department, defaultLevel: cop.level ?? ENABLED_LEVEL, description: cop.description, ecosystem: "ruby", name: cop.name, ruleId: cop.name, tool: TOOL, toolVersion: version, url: `${DOCS}/rubocop/cops_${cop.department.toLowerCase()}.html`, })) .toSorted(byRuleId); return { rules, source: TOOL, summary: { byCategory: tallyBy(rules, "category"), tool: TOOL, toolVersion: version, total: rules.length }, }; }; const pluginRules = function pluginRules(tool: string, doc: Record): CatalogRule[] { return copEntries(doc).map(([key, body]): CatalogRule => { const description = body["Description"]; return { canonical: null, category: key.slice(0, key.indexOf("/")), defaultLevel: levelOf(body), description: typeof description === "string" ? description : null, ecosystem: "ruby", name: key, ruleId: key, tool, toolVersion: "latest", url: `${DOCS}/${tool}/cops.html`, }; }); }; const pluginProduct = async function pluginProduct(): Promise { const plugins = REPOSITORIES.filter(([tool]) => tool !== TOOL); const docs = await Promise.all(plugins.map(async ([, repository]) => defaultConfig(repository))); const perPlugin = plugins.map(([tool], index) => ({ rules: pluginRules(tool, docs[index] ?? {}), tool })); const rules = perPlugin.flatMap((entry) => entry.rules).toSorted(byRuleId); const extensions = perPlugin.map((entry) => ({ cops: entry.rules.length, tool: entry.tool })); return { rules, source: PLUGINS_SOURCE, summary: { extensions, tool: PLUGINS_SOURCE, total: rules.length } }; }; const typeOf = function typeOf(value: unknown): string { if (typeof value === "number") { return INTEGER_TYPE; } if (typeof value === "boolean") { return "boolean"; } return Array.isArray(value) ? "array" : "string"; }; const copKnobs = function copKnobs(tool: string, cop: string, body: Record): KnobEntry[] { return Object.entries(body) .filter(([key]) => !META_KEYS.has(key)) .map(([key, value]): KnobEntry => { const type = typeOf(value); const fallback = typeof value === "object" ? null : value; return [ tool, cop, { default: fallback, knob: key, threshold: type === INTEGER_TYPE && isThreshold(key), type }, ]; }); }; const knobs = async function knobs(): Promise { const docs = await Promise.all(REPOSITORIES.map(async ([, repository]) => defaultConfig(repository))); return REPOSITORIES.flatMap(([tool], index) => copEntries(docs[index] ?? {}).flatMap(([cop, body]) => copKnobs(tool, cop, body)), ); }; defineProducer({ knobs, name: TOOL, produce: async () => [coreProduct(), await pluginProduct()], refresh: "manual" });