@yasainet/eslint 0.0.54 → 0.0.56

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.54",
3
+ "version": "0.0.56",
4
4
  "description": "ESLint",
5
5
  "type": "module",
6
6
  "exports": {
@@ -162,13 +162,27 @@ function makeConfig(name, files, ...patternArrays) {
162
162
  };
163
163
  }
164
164
 
165
+ // In ESLint flat config, when multiple matching configs set the same rule
166
+ // (`no-restricted-imports`), the later config's options REPLACE the earlier
167
+ // ones — patterns are not merged. Page / hooks / components boundary configs
168
+ // run after libBoundaryConfigs and would silently drop lib + mapping bans
169
+ // unless we re-include those patterns explicitly.
165
170
  /** Next.js-only: restrict page.tsx to only import interactors. */
166
171
  export const pageBoundaryConfigs = [
167
172
  {
168
173
  name: "imports/page-boundary",
169
174
  files: ["src/app/**/page.tsx"],
170
175
  rules: {
171
- "no-restricted-imports": ["error", { patterns: PAGE_BOUNDARY_PATTERNS }],
176
+ "no-restricted-imports": [
177
+ "error",
178
+ {
179
+ patterns: [
180
+ ...PAGE_BOUNDARY_PATTERNS,
181
+ ...LIB_BOUNDARY_PATTERNS,
182
+ ...MAPPING_PATTERNS,
183
+ ],
184
+ },
185
+ ],
172
186
  },
173
187
  },
174
188
  ];
@@ -179,7 +193,16 @@ export const hooksBoundaryConfigs = [
179
193
  name: "imports/hooks-boundary",
180
194
  files: ["src/features/**/hooks/*.ts"],
181
195
  rules: {
182
- "no-restricted-imports": ["error", { patterns: HOOKS_BOUNDARY_PATTERNS }],
196
+ "no-restricted-imports": [
197
+ "error",
198
+ {
199
+ patterns: [
200
+ ...HOOKS_BOUNDARY_PATTERNS,
201
+ ...LIB_BOUNDARY_PATTERNS,
202
+ ...MAPPING_PATTERNS,
203
+ ],
204
+ },
205
+ ],
183
206
  },
184
207
  },
185
208
  ];
@@ -192,7 +215,13 @@ export const componentsBoundaryConfigs = [
192
215
  rules: {
193
216
  "no-restricted-imports": [
194
217
  "error",
195
- { patterns: COMPONENTS_BOUNDARY_PATTERNS },
218
+ {
219
+ patterns: [
220
+ ...COMPONENTS_BOUNDARY_PATTERNS,
221
+ ...LIB_BOUNDARY_PATTERNS,
222
+ ...MAPPING_PATTERNS,
223
+ ],
224
+ },
196
225
  ],
197
226
  },
198
227
  },
@@ -6,6 +6,8 @@ import { noAnyReturnRule } from "./no-any-return.mjs";
6
6
  import { queriesExportRule } from "./queries-export.mjs";
7
7
  import { queriesNamespaceImportRule } from "./queries-namespace-import.mjs";
8
8
  import { schemaNamingRule } from "./schema-naming.mjs";
9
+ import { supabaseColumnsSatisfiesRule } from "./supabase-columns-satisfies.mjs";
10
+ import { supabaseSelectTypedColumnsRule } from "./supabase-select-typed-columns.mjs";
9
11
 
10
12
  /** Single plugin object to avoid ESLint "Cannot redefine plugin" errors. */
11
13
  export const localPlugin = {
@@ -18,5 +20,7 @@ export const localPlugin = {
18
20
  "queries-export": queriesExportRule,
19
21
  "queries-namespace-import": queriesNamespaceImportRule,
20
22
  "schema-naming": schemaNamingRule,
23
+ "supabase-columns-satisfies": supabaseColumnsSatisfiesRule,
24
+ "supabase-select-typed-columns": supabaseSelectTypedColumnsRule,
21
25
  },
22
26
  };
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Enforce `[...] as const satisfies readonly (keyof Tables<"...">)[]` for
3
+ * `*_COLUMNS` constant declarations.
4
+ *
5
+ * Apply to `**\/queries/*.query.ts`. Without `as const satisfies`, typos
6
+ * in column names slip past the lint phase — Supabase only complains at
7
+ * runtime when the query executes. The `satisfies` annotation forces
8
+ * TypeScript to validate every entry against the schema before the code
9
+ * ever runs. The actual `keyof Tables<"...">` content is left to the type
10
+ * checker; the lint rule only verifies the syntactic shape.
11
+ */
12
+
13
+ const COLUMNS_NAME = /^[A-Z][A-Z0-9_]*_COLUMNS$/;
14
+
15
+ /** Unwrap nested type assertions and find the underlying expression. */
16
+ function unwrapTypeAssertions(node) {
17
+ let current = node;
18
+ while (
19
+ current &&
20
+ (current.type === "TSAsExpression" ||
21
+ current.type === "TSSatisfiesExpression")
22
+ ) {
23
+ current = current.expression;
24
+ }
25
+ return current;
26
+ }
27
+
28
+ function isAsConstSatisfies(initNode) {
29
+ if (!initNode) return false;
30
+ if (initNode.type !== "TSSatisfiesExpression") return false;
31
+ const inner = initNode.expression;
32
+ if (inner.type !== "TSAsExpression") return false;
33
+ const ann = inner.typeAnnotation;
34
+ if (!ann) return false;
35
+ if (ann.type !== "TSTypeReference") return false;
36
+ if (ann.typeName.type !== "Identifier") return false;
37
+ if (ann.typeName.name !== "const") return false;
38
+ if (inner.expression.type !== "ArrayExpression") return false;
39
+ return true;
40
+ }
41
+
42
+ export const supabaseColumnsSatisfiesRule = {
43
+ meta: {
44
+ type: "problem",
45
+ messages: {
46
+ missing:
47
+ 'Column constant `{{ name }}` must use `[...] as const satisfies readonly (keyof Tables<"table">)[]` so column names are validated against the schema at compile time.',
48
+ },
49
+ schema: [],
50
+ },
51
+ create(context) {
52
+ return {
53
+ VariableDeclarator(node) {
54
+ if (node.id.type !== "Identifier") return;
55
+ if (!COLUMNS_NAME.test(node.id.name)) return;
56
+ // Only enforce on array initializers. String literals like
57
+ // POST_UPSERT_CONFLICT_COLUMNS are PostgREST conflict-target specs,
58
+ // not column lists, and are out of scope.
59
+ const inner = unwrapTypeAssertions(node.init);
60
+ if (!inner || inner.type !== "ArrayExpression") return;
61
+ if (isAsConstSatisfies(node.init)) return;
62
+ context.report({
63
+ node: node.id,
64
+ messageId: "missing",
65
+ data: { name: node.id.name },
66
+ });
67
+ },
68
+ };
69
+ },
70
+ };
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Enforce typed column constants for Supabase `.select()` calls.
3
+ *
4
+ * Apply to `**\/queries/*.query.ts`. Forces `.select()` to take the form
5
+ * `joinColumns(<X_COLUMNS>)` where `X_COLUMNS` is an UPPER_SNAKE identifier
6
+ * ending with `_COLUMNS`. The identifier is expected to be declared with
7
+ * `as const satisfies readonly (keyof Tables<"table">)[]` so the column
8
+ * names are validated against the schema at compile time. The `satisfies`
9
+ * shape itself is enforced by the companion `supabase-columns-satisfies`
10
+ * rule.
11
+ *
12
+ * `joinColumns()` is a project-supplied helper that comma-joins a const
13
+ * string tuple while preserving the literal string type so Supabase's
14
+ * `.select()` type parser can infer the row shape (a plain `.join(",")`
15
+ * widens to `string` and breaks inference).
16
+ *
17
+ * Banned:
18
+ * .select() implicit "all columns"
19
+ * .select("*") silent exposure of new schema columns
20
+ * .select("id, name") inline literal, invisible to grep
21
+ * .select(`${x}, y`) dynamic concatenation
22
+ * .select(POST_LIST_COLUMNS.join(",")) plain .join widens to `string`, breaks inference
23
+ * .select(someVar) non-conforming variable
24
+ *
25
+ * Allowed:
26
+ * .select(joinColumns(POST_LIST_COLUMNS)) typed constant via project helper
27
+ *
28
+ * Why: column lists must be (1) named for grep / review, (2) checked
29
+ * against the schema, (3) never silently grow on schema additions.
30
+ * For column-level access control, use Postgres views (`from("posts_public")`).
31
+ */
32
+
33
+ const COLUMNS_NAME = /^[A-Z][A-Z0-9_]*_COLUMNS$/;
34
+
35
+ function asJoinColumnsCall(arg) {
36
+ if (!arg) return null;
37
+ if (arg.type !== "CallExpression") return null;
38
+ if (arg.callee.type !== "Identifier") return null;
39
+ if (arg.callee.name !== "joinColumns") return null;
40
+ if (arg.arguments.length !== 1) return null;
41
+ if (arg.arguments[0].type !== "Identifier") return null;
42
+ return arg.arguments[0];
43
+ }
44
+
45
+ export const supabaseSelectTypedColumnsRule = {
46
+ meta: {
47
+ type: "problem",
48
+ messages: {
49
+ noArgs:
50
+ "Empty `.select()` returns all columns implicitly. Pass `joinColumns(<X_COLUMNS>)` where X_COLUMNS is a typed constant.",
51
+ literalArg:
52
+ 'Inline `.select()` argument is forbidden. Define `const X_COLUMNS = [...] as const satisfies readonly (keyof Tables<"table">)[];` and call `.select(joinColumns(X_COLUMNS))`. Use Postgres views for column-level access control.',
53
+ shapeArg:
54
+ '`.select()` argument must be `joinColumns(<X_COLUMNS>)`. Other expressions defeat type inference and column-level review.',
55
+ naming:
56
+ "Column constant `{{ name }}` must be UPPER_SNAKE_CASE ending with `_COLUMNS` (e.g. POST_LIST_COLUMNS, POST_DETAIL_COLUMNS).",
57
+ },
58
+ schema: [],
59
+ },
60
+ create(context) {
61
+ return {
62
+ CallExpression(node) {
63
+ if (node.callee.type !== "MemberExpression") return;
64
+ if (node.callee.property.type !== "Identifier") return;
65
+ if (node.callee.property.name !== "select") return;
66
+
67
+ if (node.arguments.length === 0) {
68
+ context.report({ node, messageId: "noArgs" });
69
+ return;
70
+ }
71
+
72
+ const arg = node.arguments[0];
73
+
74
+ if (arg.type === "Literal" || arg.type === "TemplateLiteral") {
75
+ context.report({ node: arg, messageId: "literalArg" });
76
+ return;
77
+ }
78
+
79
+ const id = asJoinColumnsCall(arg);
80
+ if (!id) {
81
+ context.report({ node: arg, messageId: "shapeArg" });
82
+ return;
83
+ }
84
+
85
+ if (!COLUMNS_NAME.test(id.name)) {
86
+ context.report({
87
+ node: id,
88
+ messageId: "naming",
89
+ data: { name: id.name },
90
+ });
91
+ }
92
+ },
93
+ };
94
+ },
95
+ };
@@ -118,6 +118,25 @@ export function createNamingConfigs(featureRoot, prefixLibMapping) {
118
118
  "local/queries-namespace-import": "error",
119
119
  },
120
120
  },
121
+ {
122
+ name: "naming/supabase-select",
123
+ files: featuresGlob(featureRoot, "**/queries/*.query.ts"),
124
+ plugins: { local: localPlugin },
125
+ rules: {
126
+ "local/supabase-select-typed-columns": "error",
127
+ },
128
+ },
129
+ {
130
+ name: "naming/supabase-columns-satisfies",
131
+ files: [
132
+ ...featuresGlob(featureRoot, "**/queries/*.query.ts"),
133
+ ...featuresGlob(featureRoot, "**/constants/*.constant.ts"),
134
+ ],
135
+ plugins: { local: localPlugin },
136
+ rules: {
137
+ "local/supabase-columns-satisfies": "error",
138
+ },
139
+ },
121
140
  {
122
141
  name: "naming/form-state",
123
142
  files: featuresGlob(featureRoot, "**/*.ts"),