diff options
Diffstat (limited to 'packages/eslint-plugin-turbo/lib')
5 files changed, 310 insertions, 0 deletions
diff --git a/packages/eslint-plugin-turbo/lib/configs/recommended.ts b/packages/eslint-plugin-turbo/lib/configs/recommended.ts new file mode 100644 index 0000000..e247503 --- /dev/null +++ b/packages/eslint-plugin-turbo/lib/configs/recommended.ts @@ -0,0 +1,26 @@ +import { RULES } from "../constants"; +import getEnvVarDependencies from "../utils/getEnvVarDependencies"; + +// Add the environment variables into the ESLint incremental cache key. +const envVars = getEnvVarDependencies({ + cwd: process.cwd(), +}); +const settings = { + turbo: { + envVars: envVars + ? Object.values(envVars) + .flatMap((s) => Array.from(s)) + .sort() + : [], + }, +}; + +const config = { + settings, + plugins: ["turbo"], + rules: { + [`turbo/${RULES.noUndeclaredEnvVars}`]: "error", + }, +}; + +export default config; diff --git a/packages/eslint-plugin-turbo/lib/constants.ts b/packages/eslint-plugin-turbo/lib/constants.ts new file mode 100644 index 0000000..5af2e6f --- /dev/null +++ b/packages/eslint-plugin-turbo/lib/constants.ts @@ -0,0 +1,5 @@ +const RULES = { + noUndeclaredEnvVars: `no-undeclared-env-vars`, +}; + +export { RULES }; diff --git a/packages/eslint-plugin-turbo/lib/index.ts b/packages/eslint-plugin-turbo/lib/index.ts new file mode 100644 index 0000000..e7f113c --- /dev/null +++ b/packages/eslint-plugin-turbo/lib/index.ts @@ -0,0 +1,17 @@ +import { RULES } from "./constants"; + +// rules +import noUndeclaredEnvVars from "./rules/no-undeclared-env-vars"; + +// configs +import recommended from "./configs/recommended"; + +const rules = { + [RULES.noUndeclaredEnvVars]: noUndeclaredEnvVars, +}; + +const configs = { + recommended, +}; + +export { rules, configs }; diff --git a/packages/eslint-plugin-turbo/lib/rules/no-undeclared-env-vars.ts b/packages/eslint-plugin-turbo/lib/rules/no-undeclared-env-vars.ts new file mode 100644 index 0000000..372d21a --- /dev/null +++ b/packages/eslint-plugin-turbo/lib/rules/no-undeclared-env-vars.ts @@ -0,0 +1,187 @@ +import type { Rule } from "eslint"; +import path from "path"; +import { Node, MemberExpression } from "estree"; +import { RULES } from "../constants"; +import getEnvVarDependencies from "../utils/getEnvVarDependencies"; + +const meta: Rule.RuleMetaData = { + type: "problem", + docs: { + description: + "Do not allow the use of `process.env` without including the env key in any turbo.json", + category: "Configuration Issues", + recommended: true, + url: `https://github.com/vercel/turbo/tree/main/packages/eslint-plugin-turbo/docs/rules/${RULES.noUndeclaredEnvVars}.md`, + }, + schema: [ + { + type: "object", + default: {}, + additionalProperties: false, + properties: { + // override cwd, primarily exposed for easier testing + cwd: { + require: false, + type: "string", + }, + allowList: { + default: [], + type: "array", + items: { + type: "string", + }, + }, + }, + }, + ], +}; + +/** + * Normalize the value of the cwd + * Extracted from eslint + * SPDX-License-Identifier: MIT + */ +function normalizeCwd( + cwd: string | undefined, + options: Array<any> +): string | undefined { + if (options?.[0]?.cwd) { + return options[0].cwd; + } + + if (cwd) { + return cwd; + } + if (typeof process === "object") { + return process.cwd(); + } + + return undefined; +} + +function create(context: Rule.RuleContext): Rule.RuleListener { + const { options, getPhysicalFilename } = context; + const allowList: Array<string> = options?.[0]?.allowList || []; + const regexAllowList: Array<RegExp> = []; + allowList.forEach((allowed) => { + try { + regexAllowList.push(new RegExp(allowed)); + } catch (err) { + // log the error, but just move on without this allowList entry + console.error(`Unable to convert "${allowed}" to regex`); + } + }); + + const cwd = normalizeCwd( + context.getCwd ? context.getCwd() : undefined, + options + ); + const filePath = getPhysicalFilename(); + const allTurboVars = getEnvVarDependencies({ + cwd, + }); + + // if allTurboVars is null, something went wrong reading from the turbo config + // (this is different from finding a config with no env vars present, which would + // return an empty set) - so there is no point continuing if we have nothing to check against + if (!allTurboVars) { + // return of {} bails early from a rule check + return {}; + } + + const globalTurboVars = allTurboVars["//"]; + const hasWorkspaceConfigs = Object.keys(allTurboVars).length > 1; + + // find any workspace configs that match the current file path + // find workspace config (if any) that match the current file path + const workspaceKey = Object.keys(allTurboVars).find( + (workspacePath) => filePath !== "//" && filePath.startsWith(workspacePath) + ); + + let workspaceTurboVars: Set<string> | null = null; + if (workspaceKey) { + workspaceTurboVars = allTurboVars[workspaceKey]; + } + + const checkKey = (node: Node, envKey?: string) => { + if ( + envKey && + !globalTurboVars.has(envKey) && + !regexAllowList.some((regex) => regex.test(envKey)) + ) { + // if we have a workspace config, check that too + if (workspaceTurboVars && workspaceTurboVars.has(envKey)) { + return {}; + } else { + let message = `{{ envKey }} is not listed as a dependency in ${ + hasWorkspaceConfigs ? "root turbo.json" : "turbo.json" + }`; + if (workspaceKey && workspaceTurboVars) { + if (cwd) { + // if we have a cwd, we can provide a relative path to the workspace config + message = `{{ envKey }} is not listed as a dependency in the root turbo.json or workspace (${path.relative( + cwd, + workspaceKey + )}) turbo.json`; + } else { + message = `{{ envKey }} is not listed as a dependency in the root turbo.json or workspace turbo.json`; + } + } + + context.report({ + node, + message, + data: { envKey }, + }); + } + } + }; + + const isComputed = ( + node: MemberExpression & Rule.NodeParentExtension + ): boolean => { + if ("computed" in node.parent) { + return node.parent.computed; + } + + return false; + }; + + return { + MemberExpression(node) { + // we only care about complete process env declarations and non-computed keys + if ( + "name" in node.object && + "name" in node.property && + !isComputed(node) + ) { + const objectName = node.object.name; + const propertyName = node.property.name; + + // we're doing something with process.env + if (objectName === "process" && propertyName === "env") { + // destructuring from process.env + if ("id" in node.parent && node.parent.id?.type === "ObjectPattern") { + const values = node.parent.id.properties.values(); + Array.from(values).forEach((item) => { + if ("key" in item && "name" in item.key) { + checkKey(node.parent, item.key.name); + } + }); + } + + // accessing key on process.env + else if ( + "property" in node.parent && + "name" in node.parent.property + ) { + checkKey(node.parent, node.parent.property?.name); + } + } + } + }, + }; +} + +const rule = { create, meta }; +export default rule; diff --git a/packages/eslint-plugin-turbo/lib/utils/getEnvVarDependencies.ts b/packages/eslint-plugin-turbo/lib/utils/getEnvVarDependencies.ts new file mode 100644 index 0000000..a57e5eb --- /dev/null +++ b/packages/eslint-plugin-turbo/lib/utils/getEnvVarDependencies.ts @@ -0,0 +1,75 @@ +import { getTurboConfigs } from "@turbo/utils"; + +function findDependsOnEnvVars({ + dependencies, +}: { + dependencies?: Array<string>; +}) { + if (dependencies) { + return ( + dependencies + // filter for dep env vars + .filter((dep) => dep.startsWith("$")) + // remove leading $ + .map((envVar) => envVar.slice(1, envVar.length)) + ); + } + + return []; +} + +function getEnvVarDependencies({ + cwd, +}: { + cwd: string | undefined; +}): Record<string, Set<string>> | null { + const turboConfigs = getTurboConfigs(cwd); + + if (!turboConfigs.length) { + return null; + } + + const envVars: Record<string, Set<string>> = { + "//": new Set(), + }; + + turboConfigs.forEach((turboConfig) => { + const { config, workspacePath, isRootConfig } = turboConfig; + + const key = isRootConfig ? "//" : workspacePath; + if (!envVars[key]) { + envVars[key] = new Set(); + } + + // handle globals + if (!("extends" in config)) { + const { globalDependencies = [], globalEnv = [] } = config; + + const keys = [ + ...findDependsOnEnvVars({ + dependencies: globalDependencies, + }), + ...globalEnv, + ]; + keys.forEach((k) => envVars[key].add(k)); + } + + // handle pipelines + const { pipeline = {} } = config; + Object.values(pipeline).forEach(({ env, dependsOn }) => { + if (dependsOn) { + findDependsOnEnvVars({ dependencies: dependsOn }).forEach((k) => + envVars[key].add(k) + ); + } + + if (env) { + env.forEach((k) => envVars[key].add(k)); + } + }); + }); + + return envVars; +} + +export default getEnvVarDependencies; |
