@yasainet/eslint 0.0.63 → 0.0.64

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.63",
3
+ "version": "0.0.64",
4
4
  "description": "ESLint",
5
5
  "type": "module",
6
6
  "exports": {
@@ -0,0 +1,470 @@
1
+ /**
2
+ * Enforce the canonical entry template for `**\/entries/*.ts` exports:
3
+ *
4
+ * - body must be a single try/catch
5
+ * - try first statement: `logger.info(<obj>, "Start <funcName>")`
6
+ * - try success return preceded by: `logger.info(<obj>, "Success <funcName>")`
7
+ * - try failed branch (when present): `logger.error(<obj>, "Failed <funcName>")`
8
+ * followed by a return with the proper error shape
9
+ * - catch param: `error: unknown`
10
+ * - catch first statement: `logger.error(<obj>, "Unexpected error in <funcName>")`
11
+ * - catch return error.message must be the literal "An unexpected error occurred"
12
+ * - every log object must include the `err` key first (when applicable) and
13
+ * propagate all function input parameters as values
14
+ *
15
+ * Why one rule with many messageIds: each invariant is a small rule
16
+ * conceptually, but they share the same structural traversal and access to
17
+ * funcName / inputArgs. Splitting would duplicate the AST walk.
18
+ */
19
+
20
+ const CATCH_RETURN_MESSAGE = "An unexpected error occurred";
21
+
22
+ function getInputArgNames(params) {
23
+ const names = [];
24
+ for (const p of params) {
25
+ if (p.type === "Identifier") {
26
+ names.push(p.name);
27
+ }
28
+ }
29
+ return names;
30
+ }
31
+
32
+ function isLoggerCall(node, level) {
33
+ if (node?.type !== "CallExpression") return false;
34
+ const callee = node.callee;
35
+ return (
36
+ callee.type === "MemberExpression" &&
37
+ callee.object.type === "Identifier" &&
38
+ callee.object.name === "logger" &&
39
+ callee.property.type === "Identifier" &&
40
+ callee.property.name === level
41
+ );
42
+ }
43
+
44
+ function getStringLiteralArg(callExpr, index) {
45
+ const arg = callExpr.arguments[index];
46
+ if (arg?.type === "Literal" && typeof arg.value === "string") return arg.value;
47
+ return null;
48
+ }
49
+
50
+ function getObjectLiteralArg(callExpr, index) {
51
+ const arg = callExpr.arguments[index];
52
+ if (arg?.type === "ObjectExpression") return arg;
53
+ return null;
54
+ }
55
+
56
+ function objectContainsValue(objExpr, identifierName) {
57
+ if (!objExpr) return false;
58
+ for (const prop of objExpr.properties) {
59
+ if (prop.type !== "Property") continue;
60
+ if (
61
+ prop.shorthand &&
62
+ prop.key.type === "Identifier" &&
63
+ prop.key.name === identifierName
64
+ ) {
65
+ return true;
66
+ }
67
+ if (prop.value.type === "Identifier" && prop.value.name === identifierName) {
68
+ return true;
69
+ }
70
+ }
71
+ return false;
72
+ }
73
+
74
+ function firstPropertyIsErrKey(objExpr, errorIdentifierName) {
75
+ if (!objExpr || objExpr.properties.length === 0) return false;
76
+ const first = objExpr.properties[0];
77
+ if (first.type !== "Property") return false;
78
+ if (first.key.type !== "Identifier" || first.key.name !== "err") return false;
79
+ // value must be the catch error identifier (or any Identifier — we accept both
80
+ // `err: error` and `err: result.error` since Failed Pattern C uses MemberExpression)
81
+ if (first.value.type === "Identifier") {
82
+ return errorIdentifierName ? first.value.name === errorIdentifierName : true;
83
+ }
84
+ if (first.value.type === "MemberExpression") return true;
85
+ return false;
86
+ }
87
+
88
+ function isExpressionStatementWithLoggerCall(stmt, level) {
89
+ if (stmt?.type !== "ExpressionStatement") return false;
90
+ return isLoggerCall(stmt.expression, level);
91
+ }
92
+
93
+ function reportMissingInputArgs(context, objExpr, inputArgNames, ctx) {
94
+ for (const name of inputArgNames) {
95
+ if (!objectContainsValue(objExpr, name)) {
96
+ context.report({
97
+ node: objExpr ?? ctx.fallbackNode,
98
+ messageId: "logMissingInputArg",
99
+ data: { argName: name, where: ctx.where, funcName: ctx.funcName },
100
+ });
101
+ }
102
+ }
103
+ }
104
+
105
+ function checkLogCall({
106
+ context,
107
+ callExpr,
108
+ expectedLevel,
109
+ expectedMessage,
110
+ funcName,
111
+ inputArgNames,
112
+ requireErrFirst,
113
+ errorIdentifierName,
114
+ where,
115
+ }) {
116
+ if (!isLoggerCall(callExpr, expectedLevel)) {
117
+ context.report({
118
+ node: callExpr,
119
+ messageId: "logWrongCallShape",
120
+ data: { where, expectedLevel, funcName, expectedMessage },
121
+ });
122
+ return;
123
+ }
124
+ const msg = getStringLiteralArg(callExpr, 1);
125
+ if (msg !== expectedMessage) {
126
+ context.report({
127
+ node: callExpr,
128
+ messageId: "logWrongMessage",
129
+ data: { where, expectedMessage, actual: msg ?? "<not-a-literal>" },
130
+ });
131
+ }
132
+ const obj = getObjectLiteralArg(callExpr, 0);
133
+ if (!obj) {
134
+ context.report({
135
+ node: callExpr,
136
+ messageId: "logFirstArgNotObject",
137
+ data: { where, funcName },
138
+ });
139
+ return;
140
+ }
141
+ if (requireErrFirst && !firstPropertyIsErrKey(obj, errorIdentifierName)) {
142
+ context.report({
143
+ node: obj,
144
+ messageId: "logErrKeyNotFirst",
145
+ data: { where, funcName },
146
+ });
147
+ }
148
+ reportMissingInputArgs(context, obj, inputArgNames, {
149
+ where,
150
+ funcName,
151
+ fallbackNode: callExpr,
152
+ });
153
+ }
154
+
155
+ function isReturnDataErrorNull(ret) {
156
+ // `return { data: ..., error: null }` (data shorthand or explicit)
157
+ const arg = ret.argument;
158
+ if (arg?.type !== "ObjectExpression") return false;
159
+ let hasData = false;
160
+ let hasErrorNull = false;
161
+ for (const prop of arg.properties) {
162
+ if (prop.type !== "Property") continue;
163
+ if (prop.key.type !== "Identifier") continue;
164
+ if (prop.key.name === "data") hasData = true;
165
+ if (
166
+ prop.key.name === "error" &&
167
+ prop.value.type === "Literal" &&
168
+ prop.value.value === null
169
+ ) {
170
+ hasErrorNull = true;
171
+ }
172
+ }
173
+ return hasData && hasErrorNull;
174
+ }
175
+
176
+ function getReturnErrorMessageLiteral(ret) {
177
+ const arg = ret.argument;
178
+ if (arg?.type !== "ObjectExpression") return null;
179
+ for (const prop of arg.properties) {
180
+ if (prop.type !== "Property") continue;
181
+ if (prop.key.type !== "Identifier" || prop.key.name !== "error") continue;
182
+ if (prop.value.type !== "ObjectExpression") return null;
183
+ for (const inner of prop.value.properties) {
184
+ if (inner.type !== "Property") continue;
185
+ if (inner.key.type !== "Identifier" || inner.key.name !== "message") continue;
186
+ if (inner.value.type === "Literal" && typeof inner.value.value === "string") {
187
+ return inner.value.value;
188
+ }
189
+ return "<non-literal>";
190
+ }
191
+ }
192
+ return null;
193
+ }
194
+
195
+ function checkTryBlock(context, tryBlock, funcName, inputArgNames) {
196
+ if (tryBlock.body.length === 0) {
197
+ context.report({ node: tryBlock, messageId: "tryEmpty", data: { funcName } });
198
+ return;
199
+ }
200
+
201
+ // Start log: first statement
202
+ const first = tryBlock.body[0];
203
+ if (!isExpressionStatementWithLoggerCall(first, "info")) {
204
+ context.report({
205
+ node: first,
206
+ messageId: "tryMissingStartLog",
207
+ data: { funcName },
208
+ });
209
+ } else {
210
+ checkLogCall({
211
+ context,
212
+ callExpr: first.expression,
213
+ expectedLevel: "info",
214
+ expectedMessage: `Start ${funcName}`,
215
+ funcName,
216
+ inputArgNames,
217
+ requireErrFirst: false,
218
+ errorIdentifierName: null,
219
+ where: "Start",
220
+ });
221
+ }
222
+
223
+ // Walk body to find Success returns and Failed branches
224
+ let successFound = false;
225
+ for (let i = 0; i < tryBlock.body.length; i++) {
226
+ const stmt = tryBlock.body[i];
227
+
228
+ // Success log + return
229
+ if (stmt.type === "ReturnStatement" && isReturnDataErrorNull(stmt)) {
230
+ successFound = true;
231
+ // The previous non-ExpressionStatement non-IfStatement statement should be
232
+ // the Success log. Walk back to find it.
233
+ const prev = findPrecedingLoggerCall(tryBlock.body, i);
234
+ if (
235
+ !prev ||
236
+ !isLoggerCall(prev, "info") ||
237
+ getStringLiteralArg(prev, 1) !== `Success ${funcName}`
238
+ ) {
239
+ context.report({
240
+ node: stmt,
241
+ messageId: "trySuccessLogMissing",
242
+ data: { funcName },
243
+ });
244
+ } else {
245
+ checkLogCall({
246
+ context,
247
+ callExpr: prev,
248
+ expectedLevel: "info",
249
+ expectedMessage: `Success ${funcName}`,
250
+ funcName,
251
+ inputArgNames,
252
+ requireErrFirst: false,
253
+ errorIdentifierName: null,
254
+ where: "Success",
255
+ });
256
+ }
257
+ }
258
+
259
+ // Failed branches inside if statements
260
+ if (stmt.type === "IfStatement") {
261
+ checkFailedBranch(context, stmt, funcName, inputArgNames);
262
+ }
263
+ }
264
+
265
+ if (!successFound) {
266
+ context.report({
267
+ node: tryBlock,
268
+ messageId: "trySuccessReturnMissing",
269
+ data: { funcName },
270
+ });
271
+ }
272
+ }
273
+
274
+ function findPrecedingLoggerCall(body, returnIndex) {
275
+ for (let j = returnIndex - 1; j >= 0; j--) {
276
+ const s = body[j];
277
+ if (s.type === "IfStatement") continue;
278
+ if (
279
+ s.type === "ExpressionStatement" &&
280
+ s.expression.type === "AwaitExpression"
281
+ ) {
282
+ // `await revalidatePath(...)` etc — keep walking
283
+ continue;
284
+ }
285
+ if (s.type === "ExpressionStatement") {
286
+ return s.expression;
287
+ }
288
+ if (s.type === "VariableDeclaration") continue;
289
+ return null;
290
+ }
291
+ return null;
292
+ }
293
+
294
+ function checkFailedBranch(context, ifStmt, funcName, inputArgNames) {
295
+ // We only validate IFs that look like Failed branches: contain a return whose
296
+ // error.message is a string literal (not the catch's "An unexpected ..." literal).
297
+ const consequent = ifStmt.consequent;
298
+ if (consequent.type !== "BlockStatement") return;
299
+ const ret = consequent.body.find((s) => s.type === "ReturnStatement");
300
+ if (!ret) return;
301
+ const errMsg = getReturnErrorMessageLiteral(ret);
302
+ if (errMsg === null) return; // not a Failed-shaped return; skip
303
+
304
+ // Must have logger.error("Failed <funcName>") preceding the return
305
+ const idx = consequent.body.indexOf(ret);
306
+ let loggerCall = null;
307
+ for (let j = 0; j < idx; j++) {
308
+ const s = consequent.body[j];
309
+ if (
310
+ s.type === "ExpressionStatement" &&
311
+ isLoggerCall(s.expression, "error")
312
+ ) {
313
+ loggerCall = s.expression;
314
+ break;
315
+ }
316
+ }
317
+ if (!loggerCall) {
318
+ context.report({
319
+ node: ret,
320
+ messageId: "failedLogMissing",
321
+ data: { funcName },
322
+ });
323
+ return;
324
+ }
325
+
326
+ const isPatternC = errMsg === "<non-literal>"; // .message access
327
+ checkLogCall({
328
+ context,
329
+ callExpr: loggerCall,
330
+ expectedLevel: "error",
331
+ expectedMessage: `Failed ${funcName}`,
332
+ funcName,
333
+ inputArgNames,
334
+ requireErrFirst: isPatternC,
335
+ errorIdentifierName: null,
336
+ where: "Failed",
337
+ });
338
+ }
339
+
340
+ function checkCatchClause(context, handler, funcName, inputArgNames) {
341
+ const param = handler.param;
342
+ if (
343
+ !param ||
344
+ param.type !== "Identifier" ||
345
+ param.name !== "error" ||
346
+ !param.typeAnnotation ||
347
+ param.typeAnnotation.typeAnnotation?.type !== "TSUnknownKeyword"
348
+ ) {
349
+ context.report({
350
+ node: param ?? handler,
351
+ messageId: "catchParamWrongType",
352
+ data: { funcName },
353
+ });
354
+ }
355
+ const block = handler.body;
356
+ if (block.body.length === 0) {
357
+ context.report({ node: block, messageId: "catchEmpty", data: { funcName } });
358
+ return;
359
+ }
360
+ const first = block.body[0];
361
+ if (!isExpressionStatementWithLoggerCall(first, "error")) {
362
+ context.report({
363
+ node: first,
364
+ messageId: "catchMissingErrorLog",
365
+ data: { funcName },
366
+ });
367
+ } else {
368
+ checkLogCall({
369
+ context,
370
+ callExpr: first.expression,
371
+ expectedLevel: "error",
372
+ expectedMessage: `Unexpected error in ${funcName}`,
373
+ funcName,
374
+ inputArgNames,
375
+ requireErrFirst: true,
376
+ errorIdentifierName: "error",
377
+ where: "catch",
378
+ });
379
+ }
380
+
381
+ // Last statement must be a return whose error.message is the catch literal
382
+ const last = block.body[block.body.length - 1];
383
+ if (last?.type !== "ReturnStatement") {
384
+ context.report({
385
+ node: last ?? block,
386
+ messageId: "catchLastNotReturn",
387
+ data: { funcName },
388
+ });
389
+ return;
390
+ }
391
+ const msg = getReturnErrorMessageLiteral(last);
392
+ if (msg !== CATCH_RETURN_MESSAGE) {
393
+ context.report({
394
+ node: last,
395
+ messageId: "catchWrongReturnMessage",
396
+ data: { funcName, expected: CATCH_RETURN_MESSAGE, actual: msg ?? "<missing>" },
397
+ });
398
+ }
399
+ }
400
+
401
+ export const entryTemplateRule = {
402
+ meta: {
403
+ type: "problem",
404
+ docs: {
405
+ description:
406
+ "Enforce the canonical try/catch + Start/Failed/Success/Unexpected logger structure for entries.",
407
+ },
408
+ messages: {
409
+ bodyNotTryCatch:
410
+ "entry '{{ funcName }}' must have a single try/catch as its body.",
411
+ tryEmpty: "entry '{{ funcName }}' try block is empty.",
412
+ tryMissingStartLog:
413
+ "entry '{{ funcName }}' try block must start with `logger.info(<obj>, \"Start {{ funcName }}\")`.",
414
+ trySuccessLogMissing:
415
+ "entry '{{ funcName }}' success return must be preceded by `logger.info(<obj>, \"Success {{ funcName }}\")`.",
416
+ trySuccessReturnMissing:
417
+ "entry '{{ funcName }}' must contain a success return `return { data, error: null }`.",
418
+ failedLogMissing:
419
+ "entry '{{ funcName }}' Failed branch must call `logger.error(<obj>, \"Failed {{ funcName }}\")` before return.",
420
+ catchParamWrongType:
421
+ "entry '{{ funcName }}' catch param must be `error: unknown`.",
422
+ catchEmpty: "entry '{{ funcName }}' catch block is empty.",
423
+ catchMissingErrorLog:
424
+ "entry '{{ funcName }}' catch block must start with `logger.error(<obj>, \"Unexpected error in {{ funcName }}\")`.",
425
+ catchLastNotReturn:
426
+ "entry '{{ funcName }}' catch block must end with a return statement.",
427
+ catchWrongReturnMessage:
428
+ "entry '{{ funcName }}' catch return error.message must be the literal '{{ expected }}'. Got: '{{ actual }}'.",
429
+ logWrongCallShape:
430
+ "{{ where }} log in '{{ funcName }}' must be `logger.{{ expectedLevel }}(<obj>, \"{{ expectedMessage }}\")`.",
431
+ logWrongMessage:
432
+ "{{ where }} log message must be '{{ expectedMessage }}'. Got: '{{ actual }}'.",
433
+ logFirstArgNotObject:
434
+ "{{ where }} log in '{{ funcName }}' first argument must be an object literal.",
435
+ logErrKeyNotFirst:
436
+ "{{ where }} log in '{{ funcName }}' object must start with `err:` key.",
437
+ logMissingInputArg:
438
+ "{{ where }} log in '{{ funcName }}' is missing input arg '{{ argName }}'. All function inputs must propagate to log objects.",
439
+ },
440
+ schema: [],
441
+ },
442
+ create(context) {
443
+ return {
444
+ ExportNamedDeclaration(node) {
445
+ if (!node.declaration) return;
446
+ const decl = node.declaration;
447
+ if (decl.type !== "FunctionDeclaration") return;
448
+ if (!decl.async) return;
449
+ if (!decl.id) return;
450
+ const funcName = decl.id.name;
451
+ const inputArgNames = getInputArgNames(decl.params);
452
+
453
+ const body = decl.body.body;
454
+ if (body.length !== 1 || body[0].type !== "TryStatement") {
455
+ context.report({
456
+ node: decl.id,
457
+ messageId: "bodyNotTryCatch",
458
+ data: { funcName },
459
+ });
460
+ return;
461
+ }
462
+ const tryStmt = body[0];
463
+ checkTryBlock(context, tryStmt.block, funcName, inputArgNames);
464
+ if (tryStmt.handler) {
465
+ checkCatchClause(context, tryStmt.handler, funcName, inputArgNames);
466
+ }
467
+ },
468
+ };
469
+ },
470
+ };
@@ -1,3 +1,4 @@
1
+ import { entryTemplateRule } from "./entry-template.mjs";
1
2
  import { featureNameRule } from "./feature-name.mjs";
2
3
  import { formStateNamingRule } from "./form-state-naming.mjs";
3
4
  import { importPathStyleRule } from "./import-path-style.mjs";
@@ -13,6 +14,7 @@ import { supabaseSelectTypedColumnsRule } from "./supabase-select-typed-columns.
13
14
  /** Single plugin object to avoid ESLint "Cannot redefine plugin" errors. */
14
15
  export const localPlugin = {
15
16
  rules: {
17
+ "entry-template": entryTemplateRule,
16
18
  "feature-name": featureNameRule,
17
19
  "form-state-naming": formStateNamingRule,
18
20
  "import-path-style": importPathStyleRule,
@@ -296,6 +296,15 @@ export function createNamingConfigs(featureRoot, prefixLibMapping) {
296
296
  },
297
297
  });
298
298
 
299
+ configs.push({
300
+ name: "naming/entry-template",
301
+ files: featuresGlob(featureRoot, "**/entries/*.ts"),
302
+ plugins: { local: localPlugin },
303
+ rules: {
304
+ "local/entry-template": "error",
305
+ },
306
+ });
307
+
299
308
  configs.push(
300
309
  {
301
310
  name: "naming/entries-shared",