@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.
@@ -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.1.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",