@krizaka/config 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +17 -0
- package/eslint/env-names.js +27 -0
- package/eslint/index.js +5 -1
- package/eslint/plugin.js +2 -1
- package/eslint/rules.js +137 -0
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -109,6 +109,23 @@ only as a dependency of a facade is caught too), and allows it from `node_module
|
|
|
109
109
|
`noLocaleTernary({ files, routing })`: `routing` lists the files allowed to compare locales (default: `proxy.ts`,
|
|
110
110
|
`middleware.ts`, `i18n.ts`, `seo.ts`, `app/[locale]/layout.tsx`, `I18nProvider.tsx`).
|
|
111
111
|
|
|
112
|
+
### Environment variable names (`krizaka/env-name`)
|
|
113
|
+
|
|
114
|
+
Environment variables are named by bounded context (krizaka/.github#46): `<CONTEXT>_<THING>` with CONTEXT one of
|
|
115
|
+
`APP_` `DATABASE_` `AUTH_` `MEDIA_` `MAIL_` `PUSH_` `PAYMENTS_` `REALTIME_` `ABUSE_` `OBSERVABILITY_` `AI_` `OPERATOR_`.
|
|
116
|
+
The rule — on in `krizakaBase` and `krizakaNext` — refuses a name read through `process.env.X`, `process.env["X"]` or
|
|
117
|
+
`import.meta.env.X` that starts (after `NEXT_PUBLIC_`, `EXPO_PUBLIC_` or `VITE_`) with:
|
|
118
|
+
|
|
119
|
+
- a **product**: the organisation's name, the repository's own (the first word of its package.json name,
|
|
120
|
+
`acme-monorepo` → `ACME_`), or one you name — a deployment already says which product it is;
|
|
121
|
+
- a **vendor** (`BUNNY_`, `MAILGUN_`, `RESEND_`, `STRIPE_`, `GOOGLE_`, `EXPO_`, `REDIS_`, `VERCEL_`…): the vendor is a
|
|
122
|
+
value (`MEDIA_STORAGE_PROVIDER=bunny`, `MAIL_PROVIDER=mailgun`) and its settings sit under the context
|
|
123
|
+
(`MEDIA_STORAGE_API_KEY`, `MAIL_API_KEY`).
|
|
124
|
+
|
|
125
|
+
The platforms' own names (`GITHUB_TOKEN`, `VERCEL_URL`, `EAS_BUILD`, …) pass. Tune it with
|
|
126
|
+
`envNames({ products, vendors, allow })` from `@krizaka/config/eslint/env-names`; `envNamePrefixProblem(name)` gives
|
|
127
|
+
the same verdict to a script (a `.env.example` or deployment spec check).
|
|
128
|
+
|
|
112
129
|
## The ratchet
|
|
113
130
|
|
|
114
131
|
A repository with debt cannot turn the rules on in one pull request. The ratchet lets it adopt them now: it counts
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// Environment variables are named by bounded context (krizaka/.github#46): `<CONTEXT>_<THING>`, never after a product
|
|
2
|
+
// (the repository's own, from its package.json, or one a config names) nor a vendor (BUNNY_…, MAILGUN_…) — a vendor is a value (MEDIA_STORAGE_PROVIDER=bunny). The rule
|
|
3
|
+
// `krizaka/env-name` reads `process.env.X`, `process.env["X"]` and `import.meta.env.X`; krizakaBase turns it on.
|
|
4
|
+
|
|
5
|
+
import { krizakaPlugin } from "./plugin.js";
|
|
6
|
+
|
|
7
|
+
export { krizakaPlugin } from "./plugin.js";
|
|
8
|
+
export { ENV_PLATFORM_NAMES, ENV_PRODUCT_PREFIXES, ENV_VENDOR_PREFIXES, envNamePrefixProblem, ownProductPrefix } from "./rules.js";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Flat config: `krizaka/env-name` on the given files.
|
|
12
|
+
* @param {{ files?: string[], products?: string[], vendors?: string[], allow?: string[] }} [options]
|
|
13
|
+
* `products`, `vendors`: extra refused prefixes; `allow`: extra names a platform sets.
|
|
14
|
+
* @returns {import("eslint").Linter.Config[]}
|
|
15
|
+
*/
|
|
16
|
+
export function envNames({ files = ["**/*.{js,jsx,mjs,cjs,ts,tsx,mts,cts}"], products = [], vendors = [], allow = [] } = {}) {
|
|
17
|
+
return [
|
|
18
|
+
{
|
|
19
|
+
name: "krizaka/env-name",
|
|
20
|
+
files,
|
|
21
|
+
plugins: { krizaka: krizakaPlugin },
|
|
22
|
+
rules: { "krizaka/env-name": ["error", { products, vendors, allow }] },
|
|
23
|
+
},
|
|
24
|
+
];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export default envNames;
|
package/eslint/index.js
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
// The base flat config of every Krizaka JavaScript repository: @eslint/js and typescript-eslint recommended,
|
|
2
|
-
// the React hooks rules,
|
|
2
|
+
// the React hooks rules, a light import ordering (autofixable, a warning) and env names by bounded context.
|
|
3
3
|
import js from "@eslint/js";
|
|
4
4
|
import reactHooks from "eslint-plugin-react-hooks";
|
|
5
5
|
import simpleImportSort from "eslint-plugin-simple-import-sort";
|
|
6
6
|
import globals from "globals";
|
|
7
7
|
import tseslint from "typescript-eslint";
|
|
8
8
|
|
|
9
|
+
import { envNames } from "./env-names.js";
|
|
10
|
+
|
|
9
11
|
/** Generated or vendored output, never linted. */
|
|
10
12
|
export const KRIZAKA_IGNORES = Object.freeze([
|
|
11
13
|
"**/node_modules/**",
|
|
@@ -43,6 +45,8 @@ export const krizakaCommon = [
|
|
|
43
45
|
"simple-import-sort/exports": "warn",
|
|
44
46
|
},
|
|
45
47
|
},
|
|
48
|
+
// Environment variables named by bounded context, never a product or a vendor (krizaka/.github#46).
|
|
49
|
+
...envNames(),
|
|
46
50
|
];
|
|
47
51
|
|
|
48
52
|
/**
|
package/eslint/plugin.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// The `krizaka` ESLint plugin: one object carrying every rule, so that configs registering it compose (flat config
|
|
2
2
|
// refuses two different objects under the same plugin name).
|
|
3
|
-
import { noDirectTanstackImport, noFetchInClientEffect, noLocaleTernaryRule,noLocalTableOrForm } from "./rules.js";
|
|
3
|
+
import { envName, noDirectTanstackImport, noFetchInClientEffect, noLocaleTernaryRule, noLocalTableOrForm } from "./rules.js";
|
|
4
4
|
|
|
5
5
|
/** @type {import("eslint").ESLint.Plugin} */
|
|
6
6
|
export const krizakaPlugin = {
|
|
@@ -10,6 +10,7 @@ export const krizakaPlugin = {
|
|
|
10
10
|
"no-fetch-in-client-effect": noFetchInClientEffect,
|
|
11
11
|
"no-direct-tanstack-import": noDirectTanstackImport,
|
|
12
12
|
"no-local-table-or-form": noLocalTableOrForm,
|
|
13
|
+
"env-name": envName,
|
|
13
14
|
},
|
|
14
15
|
};
|
|
15
16
|
|
package/eslint/rules.js
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
// The rule modules of the `krizaka` ESLint plugin (eslint/plugin.js). Patterns come from ../patterns.js, which the
|
|
2
2
|
// ratchet reads too: a rule and its counter never diverge.
|
|
3
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
|
|
3
6
|
import { countFormFields, EFFECT_NAMES_SOURCE, FETCH_NAMES_SOURCE, TANSTACK_SCOPE } from "../patterns.js";
|
|
4
7
|
|
|
5
8
|
/** @typedef {import("eslint").Rule.RuleModule} RuleModule */
|
|
@@ -127,3 +130,137 @@ export const noLocalTableOrForm = {
|
|
|
127
130
|
};
|
|
128
131
|
},
|
|
129
132
|
};
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* The organisation's name is never a prefix; a repository's own product is found from its package.json (below) and a
|
|
136
|
+
* config may name more (`products`): a deployment already says which product it is (krizaka/.github#46).
|
|
137
|
+
*/
|
|
138
|
+
export const ENV_PRODUCT_PREFIXES = Object.freeze(["KRIZAKA"]);
|
|
139
|
+
|
|
140
|
+
/** The bounded contexts a name starts with (krizaka/.github#46). */
|
|
141
|
+
export const ENV_CONTEXT_WORDS = Object.freeze(["APP", "DATABASE", "AUTH", "MEDIA", "MAIL", "PUSH", "PAYMENTS", "REALTIME", "ABUSE", "OBSERVABILITY", "AI", "OPERATOR"]);
|
|
142
|
+
|
|
143
|
+
/** @type {Map<string, string | null>} */
|
|
144
|
+
const ownProductCache = new Map();
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* The product a repository is: the first word of the nearest package.json's name, scope removed
|
|
148
|
+
* (`acme-monorepo` → ACME, `@org/acme-admin` → ACME); null for a one-word name (`@org/realtime`) or a context word.
|
|
149
|
+
* @param {string} cwd
|
|
150
|
+
*/
|
|
151
|
+
export function ownProductPrefix(cwd) {
|
|
152
|
+
if (ownProductCache.has(cwd)) return ownProductCache.get(cwd) ?? null;
|
|
153
|
+
let found = null;
|
|
154
|
+
for (let dir = cwd; ; dir = dirname(dir)) {
|
|
155
|
+
const file = join(dir, "package.json");
|
|
156
|
+
if (existsSync(file)) {
|
|
157
|
+
try {
|
|
158
|
+
const name = String(JSON.parse(readFileSync(file, "utf8")).name ?? "");
|
|
159
|
+
// A product names its packages `<product>-<part>` (acme-monorepo, @org/acme-admin); a one-word package
|
|
160
|
+
// (@org/realtime, @org/ui) is a building block, not a product, and a context word is never one.
|
|
161
|
+
const words = name.replace(/^@[^/]+\//, "").split(/[-_.]/).filter(Boolean);
|
|
162
|
+
const word = words.length > 1 ? words[0].toUpperCase() : "";
|
|
163
|
+
found = /^[A-Z][A-Z0-9]+$/.test(word) && !ENV_CONTEXT_WORDS.includes(word) ? word : null;
|
|
164
|
+
} catch {
|
|
165
|
+
found = null;
|
|
166
|
+
}
|
|
167
|
+
break;
|
|
168
|
+
}
|
|
169
|
+
if (dirname(dir) === dir) break;
|
|
170
|
+
}
|
|
171
|
+
ownProductCache.set(cwd, found);
|
|
172
|
+
return found;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Vendors: a vendor is a value (`MEDIA_STORAGE_PROVIDER=bunny`, `MAIL_PROVIDER=mailgun`), never a name. */
|
|
176
|
+
export const ENV_VENDOR_PREFIXES = Object.freeze([
|
|
177
|
+
"BUNNY", "MAILGUN", "RESEND", "SENDGRID", "POSTMARK", "STRIPE", "CCBILL", "SEGPAY", "NOWPAYMENTS", "PAYPAL",
|
|
178
|
+
"TURNSTILE", "CLOUDFLARE", "VERCEL", "DIGITALOCEAN", "DO", "AWS", "GCP", "AZURE", "GOOGLE", "FACEBOOK", "APPLE",
|
|
179
|
+
"GITHUB", "GH", "EXPO", "FIREBASE", "FCM", "APNS", "REDIS", "VALKEY", "OPENAI", "ANTHROPIC", "OLLAMA", "SENTRY",
|
|
180
|
+
"TWILIO", "SUPABASE", "MAPBOX",
|
|
181
|
+
]);
|
|
182
|
+
|
|
183
|
+
/** Names the platforms set themselves (GitHub Actions, Vercel, EAS): read, never chosen. */
|
|
184
|
+
export const ENV_PLATFORM_NAMES = Object.freeze([
|
|
185
|
+
"GITHUB_ACTIONS", "GITHUB_TOKEN", "GITHUB_SHA", "GITHUB_REF", "GITHUB_REF_NAME", "GITHUB_REPOSITORY", "GITHUB_RUN_ID",
|
|
186
|
+
"GITHUB_RUN_NUMBER", "GITHUB_WORKSPACE", "GITHUB_OUTPUT", "GITHUB_STEP_SUMMARY", "GITHUB_EVENT_NAME", "GITHUB_SERVER_URL",
|
|
187
|
+
"VERCEL", "VERCEL_ENV", "VERCEL_URL", "VERCEL_GIT_COMMIT_SHA", "EAS_BUILD", "EAS_BUILD_PLATFORM", "EAS_BUILD_PROFILE",
|
|
188
|
+
"EXPO_TOKEN",
|
|
189
|
+
]);
|
|
190
|
+
|
|
191
|
+
/** The framework prefixes a client-exposed value needs; the name after them follows the same rule. */
|
|
192
|
+
const PUBLIC_PREFIX = /^(?:NEXT_PUBLIC_|EXPO_PUBLIC_|VITE_)/;
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* The prefix that makes an environment variable name refused, or null. Exported for scripts and tests.
|
|
196
|
+
* @param {string} name
|
|
197
|
+
* @param {{ products?: readonly string[], vendors?: readonly string[], allow?: readonly string[] }} [options]
|
|
198
|
+
* @returns {{ prefix: string, kind: "product" | "vendor" } | null}
|
|
199
|
+
*/
|
|
200
|
+
export function envNamePrefixProblem(name, { products = ENV_PRODUCT_PREFIXES, vendors = ENV_VENDOR_PREFIXES, allow = ENV_PLATFORM_NAMES } = {}) {
|
|
201
|
+
if (allow.includes(name)) return null;
|
|
202
|
+
const bare = name.replace(PUBLIC_PREFIX, "");
|
|
203
|
+
const head = bare.split("_")[0] ?? "";
|
|
204
|
+
if (bare === head) return null; // one word (PORT, CI): no prefix to judge
|
|
205
|
+
if (products.includes(head)) return { prefix: head, kind: "product" };
|
|
206
|
+
if (vendors.includes(head)) return { prefix: head, kind: "vendor" };
|
|
207
|
+
return null;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** The name read by `process.env.X`, `process.env["X"]` or `import.meta.env.X`, or null. */
|
|
211
|
+
function envNameOf(/** @type {any} */ node) {
|
|
212
|
+
const object = node.object;
|
|
213
|
+
const isProcessEnv = object?.type === "MemberExpression" && object.object?.type === "Identifier" && object.object.name === "process" && !object.computed && object.property?.name === "env";
|
|
214
|
+
const isImportMetaEnv = object?.type === "MemberExpression" && object.object?.type === "MetaProperty" && !object.computed && object.property?.name === "env";
|
|
215
|
+
if (!isProcessEnv && !isImportMetaEnv) return null;
|
|
216
|
+
if (!node.computed && node.property?.type === "Identifier") return node.property.name;
|
|
217
|
+
if (node.computed && node.property?.type === "Literal" && typeof node.property.value === "string") return node.property.value;
|
|
218
|
+
return null;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Environment variables are named by bounded context — `<CONTEXT>_<THING>` (APP_, DATABASE_, AUTH_, MEDIA_, MAIL_,
|
|
223
|
+
* PUSH_, PAYMENTS_, REALTIME_, ABUSE_, OBSERVABILITY_, AI_, OPERATOR_) — never after a product or a vendor
|
|
224
|
+
* (krizaka/.github#46). The products refused: the organisation's name and the repository's own (its package.json). Options:
|
|
225
|
+
* `products`, `vendors` (extra prefixes), `allow` (extra names a platform sets).
|
|
226
|
+
* @type {RuleModule}
|
|
227
|
+
*/
|
|
228
|
+
export const envName = {
|
|
229
|
+
meta: {
|
|
230
|
+
type: "problem",
|
|
231
|
+
docs: { description: "Forbid environment variable names prefixed with a product or a vendor (krizaka/.github#46)." },
|
|
232
|
+
schema: [
|
|
233
|
+
{
|
|
234
|
+
type: "object",
|
|
235
|
+
properties: {
|
|
236
|
+
products: { type: "array", items: { type: "string" } },
|
|
237
|
+
vendors: { type: "array", items: { type: "string" } },
|
|
238
|
+
allow: { type: "array", items: { type: "string" } },
|
|
239
|
+
},
|
|
240
|
+
additionalProperties: false,
|
|
241
|
+
},
|
|
242
|
+
],
|
|
243
|
+
messages: {
|
|
244
|
+
product:
|
|
245
|
+
"{{name}} is named after a product ({{prefix}}_): name it by its bounded context, <CONTEXT>_<THING> (APP_, DATABASE_, AUTH_, MEDIA_, MAIL_, PUSH_, PAYMENTS_, REALTIME_, ABUSE_, OBSERVABILITY_, AI_, OPERATOR_).",
|
|
246
|
+
vendor:
|
|
247
|
+
"{{name}} is named after a vendor ({{prefix}}_): the vendor is a value (<CONTEXT>_PROVIDER={{lower}}) and its settings sit under the context (e.g. MEDIA_STORAGE_API_KEY, MAIL_API_KEY).",
|
|
248
|
+
},
|
|
249
|
+
},
|
|
250
|
+
create(context) {
|
|
251
|
+
const options = context.options[0] ?? {};
|
|
252
|
+
const own = ownProductPrefix(context.cwd);
|
|
253
|
+
const products = [...ENV_PRODUCT_PREFIXES, ...(own && !ENV_VENDOR_PREFIXES.includes(own) ? [own] : []), ...(options.products ?? [])];
|
|
254
|
+
const vendors = [...ENV_VENDOR_PREFIXES, ...(options.vendors ?? [])];
|
|
255
|
+
const allow = [...ENV_PLATFORM_NAMES, ...(options.allow ?? [])];
|
|
256
|
+
return {
|
|
257
|
+
/** @param {any} node */
|
|
258
|
+
MemberExpression(node) {
|
|
259
|
+
const name = envNameOf(node);
|
|
260
|
+
if (!name) return;
|
|
261
|
+
const problem = envNamePrefixProblem(name, { products, vendors, allow });
|
|
262
|
+
if (problem) context.report({ node, messageId: problem.kind, data: { name, prefix: problem.prefix, lower: problem.prefix.toLowerCase() } });
|
|
263
|
+
},
|
|
264
|
+
};
|
|
265
|
+
},
|
|
266
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@krizaka/config",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "The shared configuration of every Krizaka JavaScript repository: ESLint flat configs (base, Next.js, the four UI rules, the three stack rules, no locale ternary), the dependency-cruiser boundary, tsconfig bases, Prettier, EditorConfig, and the krizaka-ratchet debt counter.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"krizaka",
|
|
@@ -28,6 +28,7 @@
|
|
|
28
28
|
"./eslint/krizaka-ui": "./eslint/krizaka-ui.js",
|
|
29
29
|
"./eslint/no-locale-ternary": "./eslint/no-locale-ternary.js",
|
|
30
30
|
"./eslint/stack": "./eslint/stack.js",
|
|
31
|
+
"./eslint/env-names": "./eslint/env-names.js",
|
|
31
32
|
"./eslint/plugin": "./eslint/plugin.js",
|
|
32
33
|
"./dependency-cruiser": "./dependency-cruiser.cjs",
|
|
33
34
|
"./patterns": "./patterns.js",
|