@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.
- package/README.md +34 -0
- package/dist/chunk-VFCX3QKZ.js +47 -0
- package/dist/chunk-Z7TXSZR4.js +63 -0
- package/dist/i18n.cjs +317 -0
- package/dist/i18n.d.cts +60 -0
- package/dist/i18n.d.ts +60 -0
- package/dist/i18n.js +266 -0
- package/dist/index.js +13 -49
- package/dist/prisma.cjs +442 -0
- package/dist/prisma.d.cts +174 -0
- package/dist/prisma.d.ts +174 -0
- package/dist/prisma.js +356 -0
- package/dist/resolved-config.cjs +144 -0
- package/dist/resolved-config.d.cts +46 -0
- package/dist/resolved-config.d.ts +46 -0
- package/dist/resolved-config.js +75 -0
- package/docs/rules/eslint-config-no-warn.md +71 -0
- package/docs/rules/prisma-method-surface.md +65 -0
- package/docs/rules/tenant-model-registry-parity.md +117 -0
- package/docs/rules/translation-dead-keys.md +98 -0
- package/package.json +35 -3
|
@@ -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.
|
|
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/
|
|
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
|
}
|