@yasainet/eslint 0.0.70 → 0.0.72

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yasainet/eslint",
3
- "version": "0.0.70",
3
+ "version": "0.0.72",
4
4
  "description": "ESLint",
5
5
  "type": "module",
6
6
  "exports": {
@@ -13,6 +13,11 @@ export function createLayersConfigs(featureRoot, { typeAware = true } = {}) {
13
13
  const loggerMessage =
14
14
  "logger is not allowed outside entries. Logging belongs in entries.";
15
15
 
16
+ const aliasDynamicImportSelector =
17
+ "ImportExpression[source.type='Literal'][source.value=/^@\\//]";
18
+ const aliasDynamicImportMessage =
19
+ "Dynamic imports of `@/` aliased paths are not allowed in features layers. They bypass prefix-lib and lateral cardinality (e.g. `await import('@/lib/supabase/admin')` from queries/server.ts escapes the lib-boundary check). Create the correct queries/<prefix>.ts or services/<prefix>.ts file instead. External npm packages can still be lazy-loaded for cold-start optimization.";
20
+
16
21
  const noAnyReturnConfig = {
17
22
  name: "layers/no-any-return",
18
23
  files: [
@@ -87,6 +92,10 @@ export function createLayersConfigs(featureRoot, { typeAware = true } = {}) {
87
92
  "throw is not allowed in queries. Queries must return Supabase's { data, error } shape as-is. Error handling belongs in entries.",
88
93
  },
89
94
  { selector: loggerSelector, message: loggerMessage },
95
+ {
96
+ selector: aliasDynamicImportSelector,
97
+ message: aliasDynamicImportMessage,
98
+ },
90
99
  ],
91
100
  },
92
101
  },
@@ -124,6 +133,24 @@ export function createLayersConfigs(featureRoot, { typeAware = true } = {}) {
124
133
  message:
125
134
  "Dead fallback for nullable error. Check `if (error)` and return the error directly. Unhandled exceptions belong in entries.",
126
135
  },
136
+ {
137
+ selector: aliasDynamicImportSelector,
138
+ message: aliasDynamicImportMessage,
139
+ },
140
+ ],
141
+ },
142
+ },
143
+ // Entries: ban dynamic `@/` imports that bypass cardinality / lateral rules
144
+ {
145
+ name: "layers/entries",
146
+ files: [`${featureRoot}/**/entries/*.ts`],
147
+ rules: {
148
+ "no-restricted-syntax": [
149
+ "error",
150
+ {
151
+ selector: aliasDynamicImportSelector,
152
+ message: aliasDynamicImportMessage,
153
+ },
127
154
  ],
128
155
  },
129
156
  },
@@ -0,0 +1,213 @@
1
+ /**
2
+ * Enforce the canonical `{ data, error: { message } }` shape for FormState types.
3
+ *
4
+ * Targets `TSInterfaceDeclaration` and `TSTypeAliasDeclaration` whose name ends
5
+ * with `FormState`. Required shape:
6
+ *
7
+ * ```ts
8
+ * interface XxxFormState {
9
+ * data: T | null; // T may be any type; `data: null` literal also OK
10
+ * error: { message: string } | null;
11
+ * }
12
+ * ```
13
+ *
14
+ * Why this shape:
15
+ *
16
+ * - Single uniform shape lets entry / hook / UI be templated and lint-checked.
17
+ * - Forbids `code` / `status` / similar discriminator fields that tend to grow
18
+ * defensive branches (e.g. `error.code === "unauthenticated"`) which usually
19
+ * indicate a missing upstream guard (route gate, button conditional render),
20
+ * not a legitimate need for in-action discrimination.
21
+ * - Discriminated unions (`{ data: T; error: null } | { data: null; error: ... }`)
22
+ * are rejected for the same reason — they leak shape complexity into hooks
23
+ * (`useActionState<T | null>`), components (`state?.error`), and entries.
24
+ *
25
+ * Future extension when Stripe / payment libraries actually need `code` / `status`:
26
+ *
27
+ * - a) Add a `// allow-error-code: <reason>` comment opt-out to this rule, so
28
+ * individual FormStates can justify their extra field on the previous line.
29
+ * - b) Change the rule to whitelist a fixed set of allowed extra fields (enum
30
+ * of standard error codes shared across the codebase).
31
+ *
32
+ * Until that need materializes, this rule is strict (no opt-out) per YAGNI.
33
+ */
34
+
35
+ const FORM_STATE_SUFFIX = "FormState";
36
+
37
+ function isNullLiteralType(node) {
38
+ return node?.type === "TSNullKeyword";
39
+ }
40
+
41
+ function isStringType(node) {
42
+ return node?.type === "TSStringKeyword";
43
+ }
44
+
45
+ function isUnionContainingNull(node) {
46
+ if (node?.type !== "TSUnionType") return false;
47
+ return node.types.some(isNullLiteralType);
48
+ }
49
+
50
+ function getNonNullUnionMembers(unionNode) {
51
+ return unionNode.types.filter((t) => !isNullLiteralType(t));
52
+ }
53
+
54
+ function dataTypeAllowsNull(node) {
55
+ if (!node) return false;
56
+ if (isNullLiteralType(node)) return true;
57
+ return isUnionContainingNull(node);
58
+ }
59
+
60
+ function getErrorObjectExtraFields(node) {
61
+ if (node?.type !== "TSTypeLiteral") return [];
62
+ return node.members
63
+ .filter((m) => m.type === "TSPropertySignature")
64
+ .filter((m) => m.key?.type === "Identifier" && m.key.name !== "message")
65
+ .map((m) => m.key.name);
66
+ }
67
+
68
+ function isErrorMessageOnlyShape(node) {
69
+ if (node?.type !== "TSTypeLiteral") return false;
70
+ if (node.members.length !== 1) return false;
71
+ const member = node.members[0];
72
+ if (member.type !== "TSPropertySignature") return false;
73
+ if (member.key?.type !== "Identifier" || member.key.name !== "message") return false;
74
+ const typeAnn = member.typeAnnotation?.typeAnnotation;
75
+ return isStringType(typeAnn);
76
+ }
77
+
78
+ function checkBody(context, idNode, name, members) {
79
+ const props = new Map();
80
+ for (const m of members) {
81
+ if (m.type !== "TSPropertySignature") continue;
82
+ if (m.key?.type !== "Identifier") continue;
83
+ props.set(m.key.name, m);
84
+ }
85
+
86
+ const dataProp = props.get("data");
87
+ if (!dataProp) {
88
+ context.report({
89
+ node: idNode,
90
+ messageId: "dataMissing",
91
+ data: { name },
92
+ });
93
+ } else {
94
+ const dataType = dataProp.typeAnnotation?.typeAnnotation;
95
+ if (!dataTypeAllowsNull(dataType)) {
96
+ context.report({
97
+ node: dataProp,
98
+ messageId: "dataNotNullable",
99
+ data: { name },
100
+ });
101
+ }
102
+ }
103
+
104
+ const errorProp = props.get("error");
105
+ if (!errorProp) {
106
+ context.report({
107
+ node: idNode,
108
+ messageId: "errorMissing",
109
+ data: { name },
110
+ });
111
+ } else {
112
+ const errorType = errorProp.typeAnnotation?.typeAnnotation;
113
+ if (!isUnionContainingNull(errorType)) {
114
+ context.report({
115
+ node: errorProp,
116
+ messageId: "errorNotNullable",
117
+ data: { name },
118
+ });
119
+ } else {
120
+ const nonNull = getNonNullUnionMembers(errorType);
121
+ if (nonNull.length !== 1) {
122
+ context.report({
123
+ node: errorProp,
124
+ messageId: "errorWrongShape",
125
+ data: { name },
126
+ });
127
+ } else {
128
+ const errorObj = nonNull[0];
129
+ if (!isErrorMessageOnlyShape(errorObj)) {
130
+ const extras = getErrorObjectExtraFields(errorObj);
131
+ if (extras.length > 0) {
132
+ for (const extra of extras) {
133
+ context.report({
134
+ node: errorProp,
135
+ messageId: "errorExtraField",
136
+ data: { name, field: extra },
137
+ });
138
+ }
139
+ } else {
140
+ context.report({
141
+ node: errorProp,
142
+ messageId: "errorWrongShape",
143
+ data: { name },
144
+ });
145
+ }
146
+ }
147
+ }
148
+ }
149
+ }
150
+
151
+ for (const [propName, propNode] of props) {
152
+ if (propName !== "data" && propName !== "error") {
153
+ context.report({
154
+ node: propNode,
155
+ messageId: "extraProperty",
156
+ data: { name, field: propName },
157
+ });
158
+ }
159
+ }
160
+ }
161
+
162
+ export const formStateShapeRule = {
163
+ meta: {
164
+ type: "problem",
165
+ docs: {
166
+ description:
167
+ "Enforce { data, error: { message } } shape for FormState types.",
168
+ },
169
+ messages: {
170
+ dataMissing:
171
+ "FormState '{{ name }}' must have a `data` property (use `data: null` if there is no payload).",
172
+ dataNotNullable:
173
+ "FormState '{{ name }}' `data` must allow null (e.g. `data: T | null` or `data: null`).",
174
+ errorMissing:
175
+ "FormState '{{ name }}' must have an `error: { message: string } | null` property.",
176
+ errorNotNullable:
177
+ "FormState '{{ name }}' `error` must be nullable (`{ message: string } | null`).",
178
+ errorWrongShape:
179
+ "FormState '{{ name }}' `error` must be exactly `{ message: string } | null`.",
180
+ errorExtraField:
181
+ "FormState '{{ name }}' `error` object must contain only `message: string`. Forbidden field: '{{ field }}'. See form-state-shape rule docstring for the rationale and the future opt-out plan for Stripe-like cases.",
182
+ extraProperty:
183
+ "FormState '{{ name }}' must contain only `data` and `error`. Forbidden property: '{{ field }}'.",
184
+ discriminatedUnion:
185
+ "FormState '{{ name }}' must be a single interface or type literal, not a discriminated union.",
186
+ },
187
+ schema: [],
188
+ },
189
+ create(context) {
190
+ return {
191
+ TSInterfaceDeclaration(node) {
192
+ const name = node.id?.name;
193
+ if (!name?.endsWith(FORM_STATE_SUFFIX)) return;
194
+ checkBody(context, node.id, name, node.body.body);
195
+ },
196
+ TSTypeAliasDeclaration(node) {
197
+ const name = node.id?.name;
198
+ if (!name?.endsWith(FORM_STATE_SUFFIX)) return;
199
+ const ann = node.typeAnnotation;
200
+ if (ann?.type === "TSUnionType") {
201
+ context.report({
202
+ node: node.id,
203
+ messageId: "discriminatedUnion",
204
+ data: { name },
205
+ });
206
+ return;
207
+ }
208
+ if (ann?.type !== "TSTypeLiteral") return;
209
+ checkBody(context, node.id, name, ann.members);
210
+ },
211
+ };
212
+ },
213
+ };
@@ -2,6 +2,7 @@ import { entrySingleServiceCallRule } from "./entry-single-service-call.mjs";
2
2
  import { entryTemplateRule } from "./entry-template.mjs";
3
3
  import { featureNameRule } from "./feature-name.mjs";
4
4
  import { formStateNamingRule } from "./form-state-naming.mjs";
5
+ import { formStateShapeRule } from "./form-state-shape.mjs";
5
6
  import { importPathStyleRule } from "./import-path-style.mjs";
6
7
  import { layoutMainStructuralOnlyRule } from "./layout-main-structural-only.mjs";
7
8
  import { namespaceImportNameRule } from "./namespace-import-name.mjs";
@@ -19,6 +20,7 @@ export const localPlugin = {
19
20
  "entry-template": entryTemplateRule,
20
21
  "feature-name": featureNameRule,
21
22
  "form-state-naming": formStateNamingRule,
23
+ "form-state-shape": formStateShapeRule,
22
24
  "import-path-style": importPathStyleRule,
23
25
  "layout-main-structural-only": layoutMainStructuralOnlyRule,
24
26
  "namespace-import-name": namespaceImportNameRule,
@@ -151,6 +151,7 @@ export function createNamingConfigs(featureRoot, prefixLibMapping) {
151
151
  plugins: { local: localPlugin },
152
152
  rules: {
153
153
  "local/form-state-naming": "error",
154
+ "local/form-state-shape": "error",
154
155
  },
155
156
  },
156
157
  );