@noctcore/eslint-plugin-contracts 0.1.0 → 0.2.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 +18 -7
- package/dist/index.cjs +474 -37
- package/dist/index.d.cts +53 -0
- package/dist/index.d.ts +53 -0
- package/dist/index.js +464 -37
- package/docs/rules/env-var-schema-parity.md +54 -0
- package/docs/rules/require-error-cause.md +67 -0
- package/docs/rules/require-registered-keys.md +62 -0
- package/docs/rules/require-schema-parse-at-boundary.md +47 -0
- package/docs/rules/restrict-throw-to-taxonomy.md +54 -0
- package/package.json +1 -1
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# `noctcore-contracts/env-var-schema-parity`
|
|
2
|
+
|
|
3
|
+
> Every `process.env.FOO` / `import.meta.env.FOO` key must be declared in a schema file.
|
|
4
|
+
|
|
5
|
+
## Why
|
|
6
|
+
|
|
7
|
+
An env var that is read in code but declared nowhere is config drift waiting to fail in production:
|
|
8
|
+
nothing documents it, nothing validates it, and nothing provisions it in a fresh environment. A typo
|
|
9
|
+
(`process.env.DATABSE_URL`) reads `undefined` and fails deep in a request. Cross-checking every access
|
|
10
|
+
against a single schema file — a `.env.example` or a zod-env module — keeps config access and config
|
|
11
|
+
declaration in lockstep.
|
|
12
|
+
|
|
13
|
+
## What it flags
|
|
14
|
+
|
|
15
|
+
A static `process.env.FOO` or `import.meta.env.FOO` access whose key `FOO` is not declared in the
|
|
16
|
+
configured schema file:
|
|
17
|
+
|
|
18
|
+
```ts
|
|
19
|
+
// schema (.env.example) declares DATABASE_URL, PORT, NODE_ENV
|
|
20
|
+
|
|
21
|
+
// ✗
|
|
22
|
+
const secret = process.env.MISSING_KEY;
|
|
23
|
+
const flag = import.meta.env.ALSO_MISSING;
|
|
24
|
+
|
|
25
|
+
// ✓
|
|
26
|
+
const url = process.env.DATABASE_URL;
|
|
27
|
+
const port = import.meta.env.PORT;
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Only static accesses are policed. Computed (`process.env[dynamic]`) and destructured reads are left
|
|
31
|
+
alone.
|
|
32
|
+
|
|
33
|
+
## Options
|
|
34
|
+
|
|
35
|
+
| Option | Type | Default | Meaning |
|
|
36
|
+
| --- | --- | --- | --- |
|
|
37
|
+
| `schema` | `string` | — | Path to the env declaration source (`.env.example` or a zod-env module). Relative paths resolve against the ESLint working directory. |
|
|
38
|
+
|
|
39
|
+
**Inert until configured.** With no `schema` the rule reports nothing. The schema is read once per
|
|
40
|
+
resolved path and cached for the process. The parser is format-agnostic and permissive: it unions
|
|
41
|
+
dotenv keys (`FOO=...`) with object-property keys (`FOO: z.string()`, `'FOO': ...`), so it accepts
|
|
42
|
+
both a `.env.example` and a zod-env module — and over-collection only ever suppresses a report, never
|
|
43
|
+
invents one. If the schema cannot be read, the rule goes inert rather than flagging every access.
|
|
44
|
+
|
|
45
|
+
```js
|
|
46
|
+
'noctcore-contracts/env-var-schema-parity': ['error', { schema: '.env.example' }]
|
|
47
|
+
// or a zod-env module:
|
|
48
|
+
'noctcore-contracts/env-var-schema-parity': ['error', { schema: 'src/env.ts' }]
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## When not to use it
|
|
52
|
+
|
|
53
|
+
If you have no single schema of record for env vars, or read env dynamically by design, leave this
|
|
54
|
+
rule off (or unset `schema` to keep it inert).
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# `noctcore-contracts/require-error-cause`
|
|
2
|
+
|
|
3
|
+
> Re-throwing inside a `catch` without `{ cause }` severs the chain to the original error. 🔧
|
|
4
|
+
|
|
5
|
+
## Why
|
|
6
|
+
|
|
7
|
+
When you catch an error and throw a new one, the new error is what reaches your logger. If you do not
|
|
8
|
+
forward the caught error as `cause`, the underlying failure — its message, stack, and any nested
|
|
9
|
+
cause — is gone. The stack you see points only at the re-throw site. `Error`'s standard `cause`
|
|
10
|
+
option (and every `*Error` subclass that forwards it) preserves the chain:
|
|
11
|
+
|
|
12
|
+
```ts
|
|
13
|
+
try {
|
|
14
|
+
await db.query(sql);
|
|
15
|
+
} catch (err) {
|
|
16
|
+
throw new QueryError('load failed', { cause: err }); // chain intact
|
|
17
|
+
}
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## What it flags
|
|
21
|
+
|
|
22
|
+
A `throw new SomeError(...)` inside a `catch` block when **no** argument carries a `cause` property.
|
|
23
|
+
Deliberately conservative:
|
|
24
|
+
|
|
25
|
+
- Only constructors whose simple name ends in `Error` or `Exception` are treated as errors
|
|
26
|
+
(`Error`, `TypeError`, `ValidationError`, `HttpException`). Throwing `new Response(...)` for
|
|
27
|
+
control flow is not policed.
|
|
28
|
+
- Fires only when the enclosing catch binds a plain identifier (`catch (err)`). A parameterless
|
|
29
|
+
`catch {}` or a destructured binding (`catch ({ message })`) has no single name to attach, so the
|
|
30
|
+
throw is left alone.
|
|
31
|
+
- Any `cause` property (whatever its value), and any spread the rule cannot see through, counts as
|
|
32
|
+
"has a cause" and suppresses the report — a hand-written cause is never second-guessed.
|
|
33
|
+
|
|
34
|
+
A `throw` nested in a closure declared inside the catch is still flagged: the binding is genuinely in
|
|
35
|
+
scope there. Nested `try/catch` uses the nearest binding.
|
|
36
|
+
|
|
37
|
+
```ts
|
|
38
|
+
// ✗ drops the cause → autofixes to `new Error('failed', { cause: err })`
|
|
39
|
+
try { work(); } catch (err) { throw new Error('failed'); }
|
|
40
|
+
|
|
41
|
+
// ✗ merges into an existing options object
|
|
42
|
+
try { work(); } catch (err) { throw new HttpError('failed', { status: 500 }); }
|
|
43
|
+
|
|
44
|
+
// ✓
|
|
45
|
+
try { work(); } catch (err) { throw new Error('failed', { cause: err }); }
|
|
46
|
+
try { work(); } catch (err) { throw err; }
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Fix
|
|
50
|
+
|
|
51
|
+
Autofix attaches `{ cause: <binding> }`:
|
|
52
|
+
|
|
53
|
+
- one argument (`new Error('msg')`) → appends a new options object;
|
|
54
|
+
- an existing options object → merges `cause` in;
|
|
55
|
+
- an empty options object (`{}`) → fills it.
|
|
56
|
+
|
|
57
|
+
A zero-argument `new SomeError()` is **reported but not autofixed** — inserting an options object as
|
|
58
|
+
the first argument could clobber a positional message the rule cannot see.
|
|
59
|
+
|
|
60
|
+
## Options
|
|
61
|
+
|
|
62
|
+
None.
|
|
63
|
+
|
|
64
|
+
## When not to use it
|
|
65
|
+
|
|
66
|
+
If your error classes do not accept an options object with `cause` (pre-ES2022 targets without a
|
|
67
|
+
polyfill), or you deliberately model errors without chaining, leave this rule off.
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# `noctcore-contracts/require-registered-keys`
|
|
2
|
+
|
|
3
|
+
> The key/name argument of a configured sink API must be an imported constant, not a raw string.
|
|
4
|
+
|
|
5
|
+
## Why
|
|
6
|
+
|
|
7
|
+
String keys threaded into sink APIs — storage slots, event channels, feature flags, query-cache keys
|
|
8
|
+
— are a classic drift hazard. One call spells the key `'user-profile'`, another `'userProfile'`, and
|
|
9
|
+
the two silently never meet: a write lands in one slot, a read misses it. Funnelling every key through
|
|
10
|
+
an imported constant from a single registry module makes the key a shared fact, and a typo becomes a
|
|
11
|
+
compile error instead of a runtime miss.
|
|
12
|
+
|
|
13
|
+
## What it flags
|
|
14
|
+
|
|
15
|
+
A **raw string literal** in the configured key position of a configured sink call:
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
// with sinks: [{ callee: 'localStorage.getItem', argIndex: 0 }, { callee: 'emitter.on', argIndex: 0 }]
|
|
19
|
+
|
|
20
|
+
// ✗
|
|
21
|
+
localStorage.getItem('user-profile');
|
|
22
|
+
emitter.on('task-done', handler);
|
|
23
|
+
|
|
24
|
+
// ✓ imported constant
|
|
25
|
+
import { USER_PROFILE_KEY, TASK_DONE } from '@/keys';
|
|
26
|
+
localStorage.getItem(USER_PROFILE_KEY);
|
|
27
|
+
emitter.on(TASK_DONE, handler);
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Only string literals are flagged. An already-imported identifier, a template literal, or any computed
|
|
31
|
+
expression is left alone — those are not the raw-string smell.
|
|
32
|
+
|
|
33
|
+
## Options
|
|
34
|
+
|
|
35
|
+
| Option | Type | Default | Meaning |
|
|
36
|
+
| --- | --- | --- | --- |
|
|
37
|
+
| `sinks` | `{ callee: string, argIndex: number }[]` | `[]` | Callees (dotted paths) and the zero-based argument index to police. |
|
|
38
|
+
| `registry` | `string` | — | Optional module the constants should be imported from; named in the report. |
|
|
39
|
+
|
|
40
|
+
**Inert until configured.** With `sinks` empty the rule reports nothing — there is no universal set of
|
|
41
|
+
key sinks to hard-code, so you declare which callees matter for your project.
|
|
42
|
+
|
|
43
|
+
```js
|
|
44
|
+
'noctcore-contracts/require-registered-keys': ['error', {
|
|
45
|
+
sinks: [
|
|
46
|
+
{ callee: 'localStorage.getItem', argIndex: 0 },
|
|
47
|
+
{ callee: 'localStorage.setItem', argIndex: 0 },
|
|
48
|
+
{ callee: 'emitter.on', argIndex: 0 },
|
|
49
|
+
{ callee: 'queryClient.getQueryData', argIndex: 0 },
|
|
50
|
+
],
|
|
51
|
+
registry: '@/keys',
|
|
52
|
+
}]
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
`callee` is matched against the call's dotted identifier path (`localStorage.getItem`, `emitter.on`).
|
|
56
|
+
A callee that is computed or not a plain identifier chain (`this.emitter.on`, `obj[k].on`, `a().b`)
|
|
57
|
+
cannot be matched and is skipped.
|
|
58
|
+
|
|
59
|
+
## When not to use it
|
|
60
|
+
|
|
61
|
+
If your keys are already constants, or you have no registry module to import from, leave `sinks` empty
|
|
62
|
+
(the rule stays inert) or the rule off.
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# `noctcore-contracts/require-schema-parse-at-boundary`
|
|
2
|
+
|
|
3
|
+
> Parse external boundary data at runtime — don't assert its shape with `as T`.
|
|
4
|
+
|
|
5
|
+
## Why
|
|
6
|
+
|
|
7
|
+
External data — a fetch body, a `JSON.parse` result, a message-event payload — has whatever shape the
|
|
8
|
+
sender actually sent, which TypeScript never sees. Asserting it with `as User` is a compile-time
|
|
9
|
+
promise the runtime never checks: a field the server dropped is now `undefined` masquerading as a
|
|
10
|
+
`string`, and the corruption surfaces far from the boundary. Parsing with a runtime schema
|
|
11
|
+
(zod/valibot) validates the shape at the edge and fails loudly there:
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
const user = UserSchema.parse(await res.json()); // validated
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## What it flags
|
|
18
|
+
|
|
19
|
+
This is a **conservative syntactic slice** of a concept that is fully general only with type
|
|
20
|
+
information. It flags a cast applied **directly** to a call site that is unmistakably a boundary read:
|
|
21
|
+
|
|
22
|
+
```ts
|
|
23
|
+
// ✗
|
|
24
|
+
const user = JSON.parse(raw) as User;
|
|
25
|
+
const users = JSON.parse(raw) as User[];
|
|
26
|
+
const user = (await res.json()) as User;
|
|
27
|
+
|
|
28
|
+
// ✓
|
|
29
|
+
const user = UserSchema.parse(JSON.parse(raw));
|
|
30
|
+
const user = UserSchema.parse(await res.json());
|
|
31
|
+
const data = JSON.parse(raw) as unknown; // safe widening, not a shape claim
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Only these forms are flagged: `JSON.parse(...) as T` and `(await <expr>.json()) as T`, and only when
|
|
35
|
+
the cast target is a shape claim (a named type or array — `as User`, `as User[]`). Casts to `unknown`,
|
|
36
|
+
`any`, or `const` are the safe/neutral forms and are never flagged. A boundary value stored in a
|
|
37
|
+
variable and cast later is out of syntactic reach — enforce that with a type-aware setup.
|
|
38
|
+
|
|
39
|
+
## Options
|
|
40
|
+
|
|
41
|
+
None.
|
|
42
|
+
|
|
43
|
+
## When not to use it
|
|
44
|
+
|
|
45
|
+
If you deliberately trust certain boundaries (an internal service with a shared type package) or have
|
|
46
|
+
a type-aware lint pass that supersedes this heuristic, leave this rule off. It ships `off` in the
|
|
47
|
+
`recommended` preset for that reason — enable it where the boundary is genuinely untrusted.
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# `noctcore-contracts/restrict-throw-to-taxonomy`
|
|
2
|
+
|
|
3
|
+
> `throw` only members of your error taxonomy — never an ad hoc built-in nor a bare value.
|
|
4
|
+
|
|
5
|
+
## Why
|
|
6
|
+
|
|
7
|
+
A codebase that throws a curated set of error types can handle them exhaustively at the boundary:
|
|
8
|
+
one place maps `AppError` → HTTP status, `ValidationError` → 422, and so on. Throwing an arbitrary
|
|
9
|
+
built-in (`TypeError`, `RangeError`) or, worse, a bare value (`throw 'nope'`, `throw { code }`)
|
|
10
|
+
breaks that: a non-Error carries no stack and no cause, and an unclassified error falls through every
|
|
11
|
+
`instanceof` branch.
|
|
12
|
+
|
|
13
|
+
## What it flags
|
|
14
|
+
|
|
15
|
+
- `throw new SomethingError(...)` whose constructor is **not** in the `allow` list.
|
|
16
|
+
- `throw <non-Error value>` — a string, number, boolean, template literal, object, or array literal.
|
|
17
|
+
|
|
18
|
+
Conservative on the ambiguous forms. A bare identifier (`throw err` — the re-throw), a member
|
|
19
|
+
(`throw ctx.error`), and a call (`throw makeError()`) are all left alone: a syntactic rule cannot know
|
|
20
|
+
whether they resolve to an Error, and re-throwing a caught error is the most common `throw` there is.
|
|
21
|
+
|
|
22
|
+
```ts
|
|
23
|
+
// ✗ built-in not in the taxonomy
|
|
24
|
+
throw new TypeError('bad');
|
|
25
|
+
|
|
26
|
+
// ✗ bare values
|
|
27
|
+
throw 'boom';
|
|
28
|
+
throw { code: 500 };
|
|
29
|
+
|
|
30
|
+
// ✓ (default allow is ['Error'])
|
|
31
|
+
throw new Error('boom');
|
|
32
|
+
try { work(); } catch (err) { throw err; }
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Options
|
|
36
|
+
|
|
37
|
+
| Option | Type | Default | Meaning |
|
|
38
|
+
| --- | --- | --- | --- |
|
|
39
|
+
| `allow` | `string[]` | `['Error']` | Constructor names permitted as throw targets. Extend it with your base errors **and** any built-ins you legitimately raise. |
|
|
40
|
+
|
|
41
|
+
```js
|
|
42
|
+
'noctcore-contracts/restrict-throw-to-taxonomy': ['error', {
|
|
43
|
+
allow: ['AppError', 'ValidationError', 'NotFoundError', 'TypeError'],
|
|
44
|
+
}]
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
The default `['Error']` is intentionally strict — it assumes a taxonomy rooted at `Error` and treats
|
|
48
|
+
every other built-in as something you should opt into. Widen `allow` to match your project's error
|
|
49
|
+
model on day one.
|
|
50
|
+
|
|
51
|
+
## When not to use it
|
|
52
|
+
|
|
53
|
+
If you have no error taxonomy yet, or intentionally throw built-ins throughout, leave this rule off
|
|
54
|
+
until you have a base error class worth enforcing.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@noctcore/eslint-plugin-contracts",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "ESLint rules for shared contract, config, error-handling, and money-precision conventions (zod schema naming, wire discriminants, no-direct-process-env, decimal money).",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|