@noctcore/eslint-plugin-contracts 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,83 @@
1
+ # `noctcore-contracts/fetch-must-check-ok`
2
+
3
+ > A fetch response must be checked with `.ok` or a status comparison before `.json()` parses its body.
4
+
5
+ Ported from tsforge's `typescript-core/fetch-must-check-ok` (MIT).
6
+
7
+ ## Why
8
+
9
+ `fetch` rejects only on a network failure. A 4xx or 5xx resolves normally, and `.json()` then parses
10
+ the error body as if it were data. Wrapping the call in `try` does not help on its own: the code
11
+ fails closed, but every bad response surfaces as a parse or schema error that says nothing about the
12
+ status the server actually sent. Check the response first, and the failure is named where it happens.
13
+
14
+ ## What it flags
15
+
16
+ A `.json()` read on a response bound from a configured fetch callee when no check governs it.
17
+
18
+ ```ts
19
+ // ✗ no check at all
20
+ export async function loadUser(id: string) {
21
+ const res = await fetch(`/api/users/${id}`);
22
+ return res.json();
23
+ }
24
+
25
+ // ✗ the check comes after the body is already parsed
26
+ const data = await res.json();
27
+ if (!res.ok) throw new Error('failed');
28
+
29
+ // ✗ a single-code guard lets every other error through
30
+ if (res.status === 404) return null;
31
+ return res.json();
32
+
33
+ // ✗ `||` runs the parse exactly on the failure path
34
+ return res.ok || res.json();
35
+ ```
36
+
37
+ ```ts
38
+ // ✓ guard clause
39
+ export async function loadUser(id: string) {
40
+ const res = await fetch(`/api/users/${id}`);
41
+ if (!res.ok) {
42
+ throw new Error(`user request failed: ${res.status}`);
43
+ }
44
+ return res.json();
45
+ }
46
+
47
+ // ✓ the parse sits in the branch the check permits
48
+ return res.ok ? res.json() : null;
49
+
50
+ // ✓ a status split at the success/error boundary, or a switch on success codes
51
+ if (res.status >= 400) return null;
52
+ switch (res.status) { case 200: case 201: return res.json(); default: return null; }
53
+
54
+ // ✓ an assertion helper (`assert`, `invariant`, `ensure*`, `expect*`)
55
+ invariant(res.ok, 'user request failed');
56
+ return res.json();
57
+ ```
58
+
59
+ Three response shapes are tracked: `const res = await fetch(...)` then `res.json()` in the same block,
60
+ `fetch(...).then((res) => ...)`, and the unbound `(await fetch(...)).json()`, which is always reported.
61
+ Aliases work (`const ok = res.ok; if (!ok) throw ...`). A bare `if (res.status)` or
62
+ `typeof res.status === 'number'` is not a check: both are true for a 500.
63
+
64
+ ## Options
65
+
66
+ | Option | Type | Default | Meaning |
67
+ | --- | --- | --- | --- |
68
+ | `fetchFunctions` | `string[]` | `['fetch']` | Callees that return a `Response`: a bare name or a dotted path. |
69
+
70
+ List every name you want tracked, including `fetch` itself:
71
+
72
+ ```js
73
+ 'noctcore-contracts/fetch-must-check-ok': ['error', {
74
+ fetchFunctions: ['fetch', 'globalThis.fetch', 'fetchWithRetry'],
75
+ }]
76
+ ```
77
+
78
+ ## Limits
79
+
80
+ Purely syntactic, no type information. A response assigned later (`let res; res = await fetch(...)`),
81
+ passed to another function, or returned from a wrapper that is not in `fetchFunctions` is not tracked.
82
+ A nested function that reuses the response's name is treated as the same binding. Clients whose
83
+ `.json()` already throws on a bad status (ky, for example) do not belong in `fetchFunctions`.
@@ -0,0 +1,75 @@
1
+ # `noctcore-contracts/schema-enum-field-consistency`
2
+
3
+ > A field that is an enum in one zod object schema must not be `z.string()` in another schema of the
4
+ > same module.
5
+
6
+ ## Why
7
+
8
+ When schemas double as wire types (tRPC procedures, a shared contract package), the output schema is
9
+ the type the client imports. If a field is an enum on the inputs but `z.string()` on the output, the
10
+ widened type leaks `string` to every consumer, which then narrows or casts by hand. Each schema is
11
+ correct on its own, so neither the compiler nor review catches it.
12
+
13
+ ## What it flags
14
+
15
+ Per file, purely syntactic, no type information. The rule collects every property of every
16
+ `z.object` / `z.strictObject` / `z.looseObject` and every `.extend` / `.safeExtend` shape, unwraps
17
+ `.optional()`, `.nullable()`, `.nullish()`, `.default()`, `.prefault()`, `.catch()`, `.describe()`,
18
+ `.meta()` and `.readonly()`, and classifies each value:
19
+
20
+ - **enum**: `z.enum(...)`, `z.nativeEnum(...)`, a `z.union` of only `z.literal`s, a multi-value
21
+ `z.literal([...])`, `.extract()` / `.exclude()` of an enum, or an identifier whose same-file
22
+ declaration is one of those (or an import matching `enumIdentifierPattern`)
23
+ - **string**: a chain rooted at `z.string()` with no `.pipe()` or `.transform()`
24
+ - anything else is ignored, including `z.union([z.string(), z.null()])`
25
+
26
+ Every **string** occurrence of a key that is **enum** elsewhere in the file is reported. No autofix:
27
+ the right fix may be a data migration (the stored column was free text), not a schema edit.
28
+
29
+ ```ts
30
+ // ✗ the output widens what both inputs narrow
31
+ export const statusSchema = z.enum(['OPEN', 'CLOSED']);
32
+
33
+ export const ticketCreateInput = z.object({ status: statusSchema.default('OPEN') });
34
+ export const ticketUpdateInput = z.object({ status: statusSchema.optional() });
35
+ export const ticketOutput = z.object({
36
+ id: z.string(),
37
+ status: z.string().nullable(),
38
+ });
39
+ ```
40
+
41
+ ```ts
42
+ // ✓ the output reuses the enum
43
+ export const ticketOutput = z.object({
44
+ id: z.string(),
45
+ status: statusSchema.nullable(),
46
+ });
47
+ ```
48
+
49
+ ## Options
50
+
51
+ | Option | Type | Default | Meaning |
52
+ | --- | --- | --- | --- |
53
+ | `zodIdentifiers` | `string[]` | `['z']` | Names zod is imported under. |
54
+ | `ignoreFields` | `string[]` | `[]` | Keys that genuinely mean different things in two schemas. |
55
+ | `enumIdentifierPattern` | `string` (regex source) | unset | An imported identifier counts as an enum only when its name matches. |
56
+
57
+ Imported identifiers are **not** assumed to be enums by default, so `email: emailSchema` (a string
58
+ schema from another module) is not flagged against `email: z.string()`. If your project names its
59
+ enum schemas consistently, opt them in:
60
+
61
+ ```js
62
+ 'noctcore-contracts/schema-enum-field-consistency': ['error', {
63
+ zodIdentifiers: ['z'],
64
+ enumIdentifierPattern: '(Status|Kind|Role)Schema$',
65
+ ignoreFields: ['type'],
66
+ }]
67
+ ```
68
+
69
+ Scope the rule to your schema modules with the config block's `files` (for example
70
+ `files: ['src/schemas/**/*.ts']`); the rule itself assumes no layout.
71
+
72
+ ## When not to use it
73
+
74
+ If a module intentionally keeps free-text and enum variants of the same key (a legacy import format
75
+ beside the validated one), list that key in `ignoreFields` rather than turning the rule off.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@noctcore/eslint-plugin-contracts",
3
- "version": "0.2.0",
3
+ "version": "0.3.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",
@@ -60,6 +60,6 @@
60
60
  "@typescript-eslint/rule-tester": "^8.61.1",
61
61
  "tsup": "^8.5.1",
62
62
  "typescript": "^5.6.0",
63
- "vitest": "^3"
63
+ "vitest": "^4"
64
64
  }
65
65
  }