@noctcore/lint-meta-rules 0.2.0 → 0.4.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.
@@ -0,0 +1,46 @@
1
+ import { IMetaRule } from '@noctcore/harness';
2
+
3
+ /**
4
+ * Options for {@link createEslintConfigNoWarnRule}.
5
+ *
6
+ * Every path is relative to the repo root the harness runs in.
7
+ */
8
+ interface EslintConfigNoWarnOptions {
9
+ /** Rule id, for running more than one instance. Default `eslint-config-no-warn`. */
10
+ readonly id?: string;
11
+ /**
12
+ * Directories whose ESLint config is resolved, as globs (`apps/*`) or plain
13
+ * paths (`.` for the repo root). A match is checked only when it holds one of
14
+ * `configFiles`. Default `['.', 'apps/*', 'packages/*']`.
15
+ */
16
+ readonly packages?: readonly string[];
17
+ /**
18
+ * The file names that mark a directory as owning a flat config. Default
19
+ * `eslint.config.js`, `eslint.config.mjs`, `eslint.config.cjs`.
20
+ */
21
+ readonly configFiles?: readonly string[];
22
+ /**
23
+ * Files, relative to each package, the config is resolved FOR. Resolution is
24
+ * glob matching, so they need not exist; pick one per file shape the config
25
+ * scopes blocks to. Default: a `.ts`, `.tsx`, `.test.ts` and `.test.tsx` file
26
+ * under `src/`.
27
+ */
28
+ readonly probes?: readonly string[];
29
+ /** Whether a violation fails CI. Default `true`. */
30
+ readonly ciCritical?: boolean;
31
+ }
32
+ /**
33
+ * ESLint severities must be `error` or `off`, never `warn`, as RESOLVED.
34
+ *
35
+ * Resolves each package's effective flat config through ESLint's
36
+ * `calculateConfigForFile` and reports every rule that ends up at `warn`. That is
37
+ * the point of the rule: a preset spread into the config (a `recommended` block
38
+ * that ships rules at `warn`) injects the severity without a `"warn"` literal in
39
+ * any file the project owns, so a text scan such as `no-warn-severity` passes it.
40
+ *
41
+ * Fails closed: a config that cannot be resolved at all is a violation, never a
42
+ * silent pass. Needs the optional `eslint` peer.
43
+ */
44
+ declare function createEslintConfigNoWarnRule(options?: EslintConfigNoWarnOptions): IMetaRule;
45
+
46
+ export { type EslintConfigNoWarnOptions, createEslintConfigNoWarnRule };
@@ -0,0 +1,46 @@
1
+ import { IMetaRule } from '@noctcore/harness';
2
+
3
+ /**
4
+ * Options for {@link createEslintConfigNoWarnRule}.
5
+ *
6
+ * Every path is relative to the repo root the harness runs in.
7
+ */
8
+ interface EslintConfigNoWarnOptions {
9
+ /** Rule id, for running more than one instance. Default `eslint-config-no-warn`. */
10
+ readonly id?: string;
11
+ /**
12
+ * Directories whose ESLint config is resolved, as globs (`apps/*`) or plain
13
+ * paths (`.` for the repo root). A match is checked only when it holds one of
14
+ * `configFiles`. Default `['.', 'apps/*', 'packages/*']`.
15
+ */
16
+ readonly packages?: readonly string[];
17
+ /**
18
+ * The file names that mark a directory as owning a flat config. Default
19
+ * `eslint.config.js`, `eslint.config.mjs`, `eslint.config.cjs`.
20
+ */
21
+ readonly configFiles?: readonly string[];
22
+ /**
23
+ * Files, relative to each package, the config is resolved FOR. Resolution is
24
+ * glob matching, so they need not exist; pick one per file shape the config
25
+ * scopes blocks to. Default: a `.ts`, `.tsx`, `.test.ts` and `.test.tsx` file
26
+ * under `src/`.
27
+ */
28
+ readonly probes?: readonly string[];
29
+ /** Whether a violation fails CI. Default `true`. */
30
+ readonly ciCritical?: boolean;
31
+ }
32
+ /**
33
+ * ESLint severities must be `error` or `off`, never `warn`, as RESOLVED.
34
+ *
35
+ * Resolves each package's effective flat config through ESLint's
36
+ * `calculateConfigForFile` and reports every rule that ends up at `warn`. That is
37
+ * the point of the rule: a preset spread into the config (a `recommended` block
38
+ * that ships rules at `warn`) injects the severity without a `"warn"` literal in
39
+ * any file the project owns, so a text scan such as `no-warn-severity` passes it.
40
+ *
41
+ * Fails closed: a config that cannot be resolved at all is a violation, never a
42
+ * silent pass. Needs the optional `eslint` peer.
43
+ */
44
+ declare function createEslintConfigNoWarnRule(options?: EslintConfigNoWarnOptions): IMetaRule;
45
+
46
+ export { type EslintConfigNoWarnOptions, createEslintConfigNoWarnRule };
@@ -0,0 +1,75 @@
1
+ import {
2
+ resolveRules,
3
+ severityOf
4
+ } from "./chunk-VFCX3QKZ.js";
5
+
6
+ // src/resolved-config/eslint-config-no-warn.ts
7
+ var DEFAULT_ID = "eslint-config-no-warn";
8
+ var DEFAULT_PACKAGES = [".", "apps/*", "packages/*"];
9
+ var DEFAULT_CONFIG_FILES = ["eslint.config.js", "eslint.config.mjs", "eslint.config.cjs"];
10
+ var DEFAULT_PROBES = [
11
+ "src/__lint_meta_probe__.ts",
12
+ "src/__lint_meta_probe__.tsx",
13
+ "src/__lint_meta_probe__.test.ts",
14
+ "src/__lint_meta_probe__.test.tsx"
15
+ ];
16
+ function findConfigDirs(ctx, packages, configFiles) {
17
+ const byDir = /* @__PURE__ */ new Map();
18
+ for (const pattern of packages) {
19
+ const base = pattern.replace(/\/+$/u, "");
20
+ for (const name of configFiles) {
21
+ const matches = base === "." || base === "" ? ctx.exists(name) ? [name] : [] : ctx.glob(`${base}/${name}`);
22
+ for (const configFile of matches) {
23
+ const slash = configFile.lastIndexOf("/");
24
+ const dir = slash === -1 ? "." : configFile.slice(0, slash);
25
+ if (!byDir.has(dir)) byDir.set(dir, configFile);
26
+ }
27
+ }
28
+ }
29
+ return [...byDir].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).map(([dir, configFile]) => ({ dir, configFile }));
30
+ }
31
+ function createEslintConfigNoWarnRule(options = {}) {
32
+ const id = options.id ?? DEFAULT_ID;
33
+ const packages = options.packages ?? DEFAULT_PACKAGES;
34
+ const configFiles = options.configFiles ?? DEFAULT_CONFIG_FILES;
35
+ const probes = options.probes ?? DEFAULT_PROBES;
36
+ return {
37
+ id,
38
+ category: "config",
39
+ ciCritical: options.ciCritical ?? true,
40
+ description: 'Every rule in the RESOLVED ESLint config is "error" or "off", never "warn", including severities a spread preset injects.',
41
+ async runAsync(ctx) {
42
+ const results = await Promise.all(
43
+ findConfigDirs(ctx, packages, configFiles).map(async ({ dir, configFile }) => {
44
+ const outcome = await resolveRules(ctx.root, dir, probes);
45
+ if (!outcome.ok) {
46
+ return [
47
+ {
48
+ file: configFile,
49
+ rule: id,
50
+ message: `Could not resolve the effective ESLint config for "${dir}", so its severities cannot be checked (if it imports a workspace package, build that first): ${outcome.error}`
51
+ }
52
+ ];
53
+ }
54
+ const warned = /* @__PURE__ */ new Set();
55
+ for (const rules of outcome.rules) {
56
+ for (const [ruleId, entry] of Object.entries(rules)) {
57
+ if (severityOf(entry) === 1) warned.add(ruleId);
58
+ }
59
+ }
60
+ return [...warned].sort().map(
61
+ (ruleId) => ({
62
+ file: configFile,
63
+ rule: id,
64
+ message: `Rule "${ruleId}" resolves to "warn" in "${dir}". ESLint severities must be "error" or "off", never "warn" (this is the RESOLVED severity, so it may come from a spread preset rather than a literal in the config file). Override it explicitly.`
65
+ })
66
+ );
67
+ })
68
+ );
69
+ return results.flat();
70
+ }
71
+ };
72
+ }
73
+ export {
74
+ createEslintConfigNoWarnRule
75
+ };
@@ -0,0 +1,71 @@
1
+ # `eslint-config-no-warn`
2
+
3
+ > Every rule in the RESOLVED ESLint config is `error` or `off`, never `warn`, including severities a
4
+ > spread preset injects.
5
+
6
+ Import it from the `resolved-config` entry point, which (unlike the main one) loads ESLint:
7
+
8
+ ```ts
9
+ import { createEslintConfigNoWarnRule } from '@noctcore/lint-meta-rules/resolved-config';
10
+ ```
11
+
12
+ ## Why
13
+
14
+ A `warn` neither fails CI nor gets fixed. [`no-warn-severity`](./no-warn-severity.md) scans config
15
+ TEXT for a `'warn'` literal, which misses the common case: a preset spread into the config
16
+ (`...reactHooks.configs['recommended-latest']`) ships rules at `warn`, and no file the project owns
17
+ spells the word. This rule asks ESLint itself. It resolves each package's effective flat config with
18
+ `calculateConfigForFile` and reports every rule that ends up at severity 1, however it got there.
19
+
20
+ Use both: the text scan is instant and runs anywhere; this one is the one a preset cannot slip past.
21
+
22
+ ## What it flags
23
+
24
+ For every directory matched by `packages` that holds one of `configFiles`, it resolves the config for
25
+ each of `probes` (glob matching only, so the probe files need not exist) and reports, once per rule
26
+ id, every rule whose resolved severity is `warn` (`1`, `'warn'`, or `['warn', ...]`).
27
+
28
+ It **fails closed**:
29
+
30
+ - a config that cannot be loaded (a missing import, an unbuilt workspace package) is a violation;
31
+ - a probe whose resolution THROWS is a violation even when another probe resolves, so a config that
32
+ breaks for one file shape cannot pass on the shapes that still work;
33
+ - a package whose config ignores every probe is a violation, since nothing was checked.
34
+
35
+ A probe that is merely ignored is fine while another probe resolves.
36
+
37
+ Async: it implements the harness's `runAsync` (`@noctcore/harness` 0.3.0 or newer).
38
+
39
+ ## Factory
40
+
41
+ ```ts
42
+ createEslintConfigNoWarnRule(options?: EslintConfigNoWarnOptions): IMetaRule
43
+ ```
44
+
45
+ | Option | Type | Default | Meaning |
46
+ | --- | --- | --- | --- |
47
+ | `id` | `string` | `'eslint-config-no-warn'` | Rule id, for running more than one instance. |
48
+ | `packages` | `string[]` | `['.', 'apps/*', 'packages/*']` | Directories whose config is resolved: globs, or `.` for the root. A match is checked only when it holds one of `configFiles`. |
49
+ | `configFiles` | `string[]` | `['eslint.config.js', 'eslint.config.mjs', 'eslint.config.cjs']` | File names that mark a directory as owning a flat config. The first one present names the violation's file. |
50
+ | `probes` | `string[]` | `src/__lint_meta_probe__.{ts,tsx,test.ts,test.tsx}` | Files, relative to each package, the config is resolved for. Add one per file shape your config scopes blocks to (`.js`, `.vue`, `e2e/**`). |
51
+ | `ciCritical` | `boolean` | `true` | Whether a violation fails CI. |
52
+
53
+ ## Worked example: a pnpm monorepo
54
+
55
+ Settly-style layout, where every app and package owns a config built from a shared
56
+ `@repo/eslint-config`, and the root config only lints tooling:
57
+
58
+ ```ts
59
+ createEslintConfigNoWarnRule({ packages: ['apps/*', 'packages/*'] });
60
+ ```
61
+
62
+ Deleting the three `react-hooks/*` overrides from the shared React config (so the
63
+ `recommended-latest` preset's `warn` shows through, with no `warn` literal anywhere) reports three
64
+ rules in each of the three packages that spread it.
65
+
66
+ ## Notes
67
+
68
+ - The rule resolves with the `eslint` that `@noctcore/lint-meta-rules` resolves, which is the
69
+ consumer's own install when it is hoisted.
70
+ - If a config imports a workspace package that must be built first, build it before lint-meta, or
71
+ the rule reports that the config could not be resolved.
@@ -0,0 +1,65 @@
1
+ # `prisma-method-surface`
2
+
3
+ > The Prisma reads and writes your rules police partition the generated client's `<Model>Delegate`
4
+ > method surface exactly, so a Prisma upgrade cannot add an unguarded method.
5
+
6
+ Import it from the `prisma` entry point:
7
+
8
+ ```ts
9
+ import { createPrismaMethodSurfaceRule } from '@noctcore/lint-meta-rules/prisma';
10
+ ```
11
+
12
+ ## Why
13
+
14
+ Rules that guard Prisma calls by method name (tenant fences, single-writer fences, transaction
15
+ rules) read a hand-written list of methods. The list falls behind the client: Prisma adds a method
16
+ (`createManyAndReturn` and `updateManyAndReturn` both arrived this way), nobody edits the list, and
17
+ the new method is invisible to every fence at once. It reads like ordinary code, not like evasion,
18
+ which is what makes the gap dangerous.
19
+
20
+ This reads the GENERATED client, which changes the moment Prisma is upgraded, and asserts the
21
+ configured reads and writes are exactly its delegate surface.
22
+
23
+ ## What it flags
24
+
25
+ - a delegate method that is neither a configured read nor a configured write (the upgrade case);
26
+ - a configured method the client no longer exposes;
27
+ - a delegate whose method surface differs from the others (the lists are model-agnostic);
28
+ - a method configured as both a read and a write.
29
+
30
+ It **fails closed**: no generated client under `clientGlobs` is a violation, because a checkout that
31
+ never ran `prisma generate` is exactly where drift hides. Run `prisma generate` before lint-meta.
32
+
33
+ Members are read line by line: a delegate method is a generic, `name<T ...>(...)`, at the interface's
34
+ member indentation. `fields`, the symbol brand and `$`-prefixed members are not query methods.
35
+
36
+ ## Factory
37
+
38
+ ```ts
39
+ createPrismaMethodSurfaceRule(options?: PrismaMethodSurfaceOptions): IMetaRule
40
+ ```
41
+
42
+ | Option | Type | Default | Meaning |
43
+ | --- | --- | --- | --- |
44
+ | `id` | `string` | `'prisma-method-surface'` | Rule id. |
45
+ | `clientGlobs` | `string[]` | `['generated/prisma/models/*.ts', 'node_modules/.prisma/client/index.d.ts']` | Generated client files declaring the delegates: the `prisma-client` generator's per-model files, or the `prisma-client-js` generator's single `index.d.ts`. Point it at your generator's `output`. |
46
+ | `writeMethods` | `string[]` | `PRISMA_WRITE_METHODS` from `@noctcore/eslint-plugin-prisma` | The methods your rules police as writes. |
47
+ | `readMethods` | `string[]` | `PRISMA_READ_METHODS` from `@noctcore/eslint-plugin-prisma` | The methods your rules know to be reads. |
48
+ | `ciCritical` | `boolean` | `true` | Whether a violation fails CI. |
49
+
50
+ With the defaults, the rule checks the exact lists `@noctcore/eslint-plugin-prisma`'s rules read.
51
+ If your own rules read their own lists, pass those.
52
+
53
+ ## Worked example: Settly
54
+
55
+ Settly's schema generates into `packages/database/generated/prisma` with the `prisma-client`
56
+ generator:
57
+
58
+ ```ts
59
+ createPrismaMethodSurfaceRule({
60
+ clientGlobs: ['packages/database/generated/prisma/models/*.ts'],
61
+ });
62
+ ```
63
+
64
+ Adding a method to every generated delegate reports it as unguarded; adding it to one model only
65
+ reports that delegate as divergent; removing the generated folder reports that no client was found.
@@ -0,0 +1,117 @@
1
+ # `tenant-model-registry-parity`
2
+
3
+ > Every tenant-bearing Prisma model is scoped by the runtime tenant extension or exempt with a
4
+ > reason, and the tenant lint rules resolve with exactly that registry.
5
+
6
+ Import it from the `prisma` entry point:
7
+
8
+ ```ts
9
+ import { createTenantModelRegistryParityRule } from '@noctcore/lint-meta-rules/prisma';
10
+ ```
11
+
12
+ ## Why
13
+
14
+ A multi-tenant Prisma project keeps "which models are tenant-scoped?" in two hand-maintained places:
15
+ the model map of its tenant-scope client extension (the runtime boundary) and the model list its
16
+ tenant lint rules are configured with (the static boundary). Checking those two against each other
17
+ catches a list edited on one side. It cannot catch the expensive case: a migration adds a
18
+ tenant-bearing table and touches neither list, so the table gets no runtime scope and no lint
19
+ coverage while both lists still agree.
20
+
21
+ So the SCHEMA is the third input. The set of tenant-bearing models is derived from it, and every one
22
+ must be in the runtime map or in an explicit `unscopedByDesign` exemption with a written reason. A
23
+ new tenant-scoped model cannot be added without the guardrails learning about it.
24
+
25
+ The static side is read from the RESOLVED ESLint config, not by parsing the config file, so it
26
+ asserts what the rules actually receive: moving the array, spreading a different preset, or passing
27
+ one rule a hand-written list is all caught.
28
+
29
+ ## What it flags
30
+
31
+ 1. **Schema vs registry** (via `reconcileTenantRegistry` from `@noctcore/eslint-plugin-prisma`): a
32
+ model carrying every `tenantFields` column that is neither scoped nor exempt; a model both scoped
33
+ and exempt; a scoped model or exemption no schema model maps to.
34
+ 2. **Hand scope** (when `requireHandScope`): an exemption with no `handScopedModels` columns and no
35
+ `handScopePending` note; a `handScopedModels` or `handScopePending` entry for a model that is not
36
+ exempt.
37
+ 3. **Registry vs resolved config** (unless `eslint: false`): for each of `modelRules`, a rule that is
38
+ off or not configured, a rule with no `modelsOption`, a scoped model missing from the option, or
39
+ an option model that is not scoped; and a `handScopedRule` whose `handScopedOption` does not equal
40
+ `registry.handScopedModels`.
41
+
42
+ It **fails closed**: an unreadable runtime map, a missing schema, or a config that cannot be
43
+ resolved is a violation, never an empty registry. Schema findings are still reported when the config
44
+ cannot be resolved.
45
+
46
+ Async: it implements the harness's `runAsync` (`@noctcore/harness` 0.3.0 or newer).
47
+
48
+ ## Factory
49
+
50
+ ```ts
51
+ createTenantModelRegistryParityRule(options?: TenantModelRegistryParityOptions): IMetaRule
52
+ ```
53
+
54
+ | Option | Type | Default | Meaning |
55
+ | --- | --- | --- | --- |
56
+ | `id` | `string` | `'tenant-model-registry-parity'` | Rule id. |
57
+ | `schemaPath` | `string` | `'prisma/schema.prisma'` | The schema: a `.prisma` file, or a folder whose `.prisma` files (recursively) are read together. |
58
+ | `tenantFields` | `string[]` | `['tenantId']` | Columns that make a model tenant-bearing; a model must carry ALL of them. |
59
+ | `registry` | `TenantRegistry` | `{ scopedModels: [], unscopedByDesign: {} }` | The project's registry as injected DATA: `scopedModels`, `unscopedByDesign`, `handScopedModels`, `handScopePending` (the `TenantRegistry` type from `@noctcore/eslint-plugin-prisma`). |
60
+ | `scopedModelsSource` | `{ file, exportName }` | none | Read the scoped models from the runtime source instead of `registry.scopedModels`: the top-level keys of the object literal assigned to `exportName` in `file`. |
61
+ | `requireHandScope` | `boolean` | `true` | Require every exemption to name its hand-scope columns or record why it cannot. |
62
+ | `eslint` | `TenantRegistryEslintOptions \| false` | `{}` | The static side (below), or `false` to check only the registry against the schema. |
63
+ | `registryFile` | `string` | `scopedModelsSource.file`, else `schemaPath` | Path reported for registry-side violations. |
64
+ | `ciCritical` | `boolean` | `true` | Whether a violation fails CI. |
65
+
66
+ `eslint` options:
67
+
68
+ | Option | Type | Default | Meaning |
69
+ | --- | --- | --- | --- |
70
+ | `cwd` | `string` | `'.'` | Directory whose ESLint config is resolved. |
71
+ | `probe` | `string` | `'src/__lint_meta_probe__.ts'` | File, relative to `cwd`, the config is resolved for. Match the glob your tenant rules are scoped to. |
72
+ | `modelRules` | `string[]` | the `noctcore-prisma` rules `no-cross-tenant-id-in-where`, `tenant-scoped-tables-require-where`, `tenant-write-must-carry-tenant-id` | Rules whose model option must equal the scoped models. |
73
+ | `modelsOption` | `string` | `'tenantModels'` | The option those rules take the model list in. |
74
+ | `handScopedRule` | `string \| null` | `'noctcore-prisma/tenant-scoped-tables-require-where'` | The rule whose hand-scope option must equal `registry.handScopedModels`; `null` skips it. Checked only when the registry has hand-scoped models. |
75
+ | `handScopedOption` | `string` | `'handScopedModels'` | The option that rule takes the map in. |
76
+ | `configFile` | `string` | `cwd` | Path reported for config-side violations: where a maintainer edits the list. |
77
+
78
+ Keys are Prisma delegate accessors (`invoice` for `model Invoice`).
79
+
80
+ ## Worked example: Settly
81
+
82
+ Settly scopes on `tenantId`, splits its schema per domain, keeps the runtime map in its API's tenant
83
+ extension and the exemption maps in a registry module its ESLint config also reads:
84
+
85
+ ```ts
86
+ import { createTenantModelRegistryParityRule } from '@noctcore/lint-meta-rules/prisma';
87
+
88
+ import { HAND_SCOPED_MODELS, HAND_SCOPE_PENDING, UNSCOPED_BY_DESIGN } from './tenant-registry';
89
+
90
+ createTenantModelRegistryParityRule({
91
+ schemaPath: 'packages/database/prisma/schema',
92
+ tenantFields: ['tenantId'],
93
+ scopedModelsSource: {
94
+ file: 'apps/api/src/common/database/tenant-scope.extension.ts',
95
+ exportName: 'TENANT_SCOPED_MODELS',
96
+ },
97
+ registry: {
98
+ scopedModels: [],
99
+ unscopedByDesign: UNSCOPED_BY_DESIGN,
100
+ handScopedModels: HAND_SCOPED_MODELS,
101
+ handScopePending: HAND_SCOPE_PENDING,
102
+ },
103
+ eslint: {
104
+ cwd: 'apps/api',
105
+ probe: 'src/modules/example/example.service.ts',
106
+ configFile: 'packages/eslint-config/nestjs.js',
107
+ },
108
+ });
109
+ ```
110
+
111
+ The registry is data the lint-meta registry imports from wherever the project keeps it; the rule
112
+ never reads a project file by a hardcoded path.
113
+
114
+ ## When not to use it
115
+
116
+ A project with row-level security as its tenant boundary, or with no client-side scope map, has no
117
+ runtime registry to reconcile: use `eslint: false` and the schema half alone, or skip the rule.
@@ -0,0 +1,98 @@
1
+ # `translation-dead-keys`
2
+
3
+ > Every translation catalog key is reachable from the source: named by a translation call, or spelled
4
+ > by some string in the code.
5
+
6
+ Import it from the `i18n` entry point, which (unlike the main one) loads ESLint:
7
+
8
+ ```ts
9
+ import { createTranslationDeadKeysRule } from '@noctcore/lint-meta-rules/i18n';
10
+ ```
11
+
12
+ ## Why
13
+
14
+ A catalog key nothing uses is still translated, reviewed and shipped in every language. Finding them is
15
+ a whole-program question, so it is not an ESLint rule: a per-file rule never sees every call site
16
+ (editor runs, `--cache`, lint-staged, sharded workers), and keys flow as data (key tables, key-building
17
+ helpers, server-sent codes) that no call-site analysis follows.
18
+
19
+ This rule resolves call sites with the same visitor and catalog loader as
20
+ `noctcore-contracts/translation-key-exists` (exported by `@noctcore/eslint-plugin-contracts`), so the
21
+ two checks never disagree on which key a call means. It then adds the data routes a per-file rule
22
+ cannot see.
23
+
24
+ ## What counts as reached
25
+
26
+ A key is reached, and never reported, when ANY of these holds:
27
+
28
+ - a translation call resolves to it: `t('key')`, `t('ns:key')`, `t('key', { ns })`,
29
+ `useTranslation('ns', { keyPrefix })`, `<Trans i18nKey>`, a `TFunction<'ns'>` parameter, the
30
+ `fallbackNamespaces`;
31
+ - a template key's static head is a prefix of it: `` t(`status.${s}`) `` reaches every `status.*`;
32
+ - a `returnObjects` call names one of its ancestors;
33
+ - ANY string literal in the scanned source equals the key, `ns:key`, or its plural/context base
34
+ (`key` reaches `key_one`, `key_ordinal_few`, `key_male`), in any namespace;
35
+ - ANY template literal or `+` chain in the scanned source can produce it:
36
+ `` `nav.${id}.label` `` and `'errors.' + code` are patterns, not just call arguments;
37
+ - it matches an `allow` pattern.
38
+
39
+ And it reports **no dead key at all** when a scanned file cannot be analysed (a parse error), or when
40
+ `sourceGlobs` match nothing: in both cases the rule would otherwise call reached keys dead.
41
+
42
+ What is left was named by no call and spelled by no string. On a production app with 1,824 keys this
43
+ reported 55, and every one of them had zero references outside the catalogs.
44
+
45
+ ## Blind spots
46
+
47
+ It is conservative, not complete. It reports a reached key as dead when the key arrives by a route it
48
+ cannot see:
49
+
50
+ - **Keys that never appear in the scanned source**: server-sent codes, a CMS, another app, JSON
51
+ config. List them in `allow`, or add the files that spell them to `sourceGlobs`.
52
+ - **A variable key under a `keyPrefix` binding**: `useTranslation('ns', { keyPrefix: 'form' })` then
53
+ `t(field)` with `field = 'name'` reaches `form.name`, but no string spells `form.name`. (A static key
54
+ under a `keyPrefix` is resolved correctly.)
55
+ - **Keys built by anything other than `+` or a template literal**: `[a, b].join('.')`,
56
+ `` `${a}` `` split across variables, `String.prototype.concat`.
57
+
58
+ It also misses some dead keys, by design: any string that happens to equal a key (in any namespace)
59
+ keeps it alive, and a broad pattern such as `` `${x}.title` `` keeps every `*.title` alive.
60
+
61
+ ## Factory
62
+
63
+ ```ts
64
+ createTranslationDeadKeysRule(options?: TranslationDeadKeysOptions): IMetaRule
65
+ ```
66
+
67
+ Inert until both `catalogs` and `sourceGlobs` are set; there are no built-in paths.
68
+
69
+ | Option | Type | Default | Meaning |
70
+ | --- | --- | --- | --- |
71
+ | `catalogs` | `CatalogSource[]` | `[]` | The catalogs to check, in `translation-key-exists`' shape (`{ file, namespace?, keyPath? }`, `{ns}` placeholders allowed). List ONE language: a key is dead or alive regardless of how many languages translate it. |
72
+ | `sourceGlobs` | `string[]` | `[]` | Every file that can reach a key: call sites AND key tables. Include tests if a key used only by a test should count as alive. |
73
+ | `namespaces` | `string[]` | derived | The namespaces to check. Derived from `catalogs`: a `{ns}` file segment is globbed, a trailing `{ns}` keyPath segment lists the object's keys. Required when a `{ns}` sits anywhere else. |
74
+ | `allow` | `string[]` | `[]` | `ns:key` patterns reached from outside the scanned source; `*` matches any run of characters. |
75
+ | `skipDirs` | `string[]` | `['node_modules', '.git', 'dist', '.turbo', 'coverage']` | Source paths with any of these segments are skipped. |
76
+ | `id` | `string` | `'translation-dead-keys'` | Rule id, for running more than one instance. |
77
+ | `ciCritical` | `boolean` | `true` | Whether a dead key fails CI. |
78
+
79
+ Every resolution option of `translation-key-exists` is accepted and means the same thing:
80
+ `defaultNamespace`, `fallbackNamespaces`, `hooks`, `instances`, `functions`, `typeNames`,
81
+ `transComponents`, `namespaceIdentifiers`, `nsSeparator`, `keySeparator`. Pass them the same values.
82
+
83
+ ```ts
84
+ createTranslationDeadKeysRule({
85
+ catalogs: [
86
+ { file: 'apps/web/src/lib/i18n/locales/pl.json', keyPath: '{ns}' },
87
+ { file: 'apps/web/src/features/{ns}/locales/pl.json' },
88
+ ],
89
+ defaultNamespace: 'common',
90
+ namespaceIdentifiers: { HELP_NS: 'help' },
91
+ sourceGlobs: ['apps/web/src/**/*.ts', 'apps/web/src/**/*.tsx'],
92
+ });
93
+ ```
94
+
95
+ ## Requirements
96
+
97
+ The `i18n` entry needs the optional peers `eslint` (>= 9) and `@typescript-eslint/parser`. Scanned
98
+ files are parsed as TypeScript with JSX enabled, without type information.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@noctcore/lint-meta-rules",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "Portable, parameterized lint-meta rules — whole-repo / cross-file invariants ESLint cannot reach — for the @noctcore/harness lint-meta runner.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -13,6 +13,21 @@
13
13
  "import": "./dist/index.js",
14
14
  "require": "./dist/index.cjs"
15
15
  },
16
+ "./i18n": {
17
+ "types": "./dist/i18n.d.ts",
18
+ "import": "./dist/i18n.js",
19
+ "require": "./dist/i18n.cjs"
20
+ },
21
+ "./prisma": {
22
+ "types": "./dist/prisma.d.ts",
23
+ "import": "./dist/prisma.js",
24
+ "require": "./dist/prisma.cjs"
25
+ },
26
+ "./resolved-config": {
27
+ "types": "./dist/resolved-config.d.ts",
28
+ "import": "./dist/resolved-config.js",
29
+ "require": "./dist/resolved-config.cjs"
30
+ },
16
31
  "./package.json": "./package.json"
17
32
  },
18
33
  "files": [
@@ -41,15 +56,32 @@
41
56
  "homepage": "https://github.com/noctcore/eslint-plugins/tree/main/packages/lint-meta-rules",
42
57
  "bugs": "https://github.com/noctcore/eslint-plugins/issues",
43
58
  "scripts": {
44
- "build": "tsup src/index.ts --format esm,cjs --dts --clean",
59
+ "build": "tsup src/index.ts src/i18n.ts src/prisma.ts src/resolved-config.ts --format esm,cjs --dts --clean",
45
60
  "typecheck": "tsc --noEmit",
46
61
  "test": "bun test"
47
62
  },
48
63
  "dependencies": {
49
- "@noctcore/harness": "^0.3.0"
64
+ "@noctcore/eslint-plugin-contracts": "^0.5.0",
65
+ "@noctcore/eslint-plugin-prisma": "^0.3.0",
66
+ "@noctcore/harness": "^0.3.0",
67
+ "@typescript-eslint/utils": "^8.61.1"
68
+ },
69
+ "peerDependencies": {
70
+ "@typescript-eslint/parser": "^8.0.0",
71
+ "eslint": ">=9.0.0"
72
+ },
73
+ "peerDependenciesMeta": {
74
+ "@typescript-eslint/parser": {
75
+ "optional": true
76
+ },
77
+ "eslint": {
78
+ "optional": true
79
+ }
50
80
  },
51
81
  "devDependencies": {
52
82
  "@types/node": "^22.0.0",
83
+ "@typescript-eslint/parser": "^8.61.1",
84
+ "eslint": "^10.7.0",
53
85
  "tsup": "^8.5.1",
54
86
  "typescript": "^5.6.0"
55
87
  }