byuckchon-frontend-cli 1.9.6 → 1.9.7
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/README.md +205 -270
- package/package.json +1 -1
- package/src/commands/adopt.js +37 -2
- package/src/constants/versions.js +1 -0
- package/src/generators/createApp.js +18 -11
- package/src/generators/createBaseFiles.js +6 -4
- package/src/generators/createFolders.js +5 -5
- package/src/generators/createMonorepo.js +4 -2
- package/src/generators/createPackageJson.js +9 -2
- package/src/generators/createProject.js +22 -19
- package/src/generators/createReadme.js +4 -4
- package/src/generators/install.js +1 -0
- package/src/generators/scaffoldReviewAutomation.js +105 -0
- package/templates/review-automation/github/workflows/eslint-convention-review.monorepo.yml +86 -0
- package/templates/review-automation/github/workflows/eslint-convention-review.single.yml +44 -0
- package/templates/review-automation/tools/eslint-rules/internal-blocking-conventions.js +1242 -0
- package/templates/review-automation/tools/eslint-rules/internal-plugin.cjs +8 -0
- package/templates/review-automation/tools/eslint-rules/internal-rdjson-formatter.js +60 -0
- package/templates/review-automation/tools/eslint-rules/internal-warning-conventions.js +57 -0
- package/templates/review-automation/tools/eslint-rules/package.json +3 -0
- package/templates/review-automation/tools/eslint-rules/review.config.mjs +22 -0
- package/templates/review-automation/tools/post-eslint-review-comments.cjs +309 -0
|
@@ -0,0 +1,1242 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const path = require('path');
|
|
4
|
+
|
|
5
|
+
const BOOLEAN_PREFIX_PATTERN = /^(is|has|can|should)[A-Z0-9]/;
|
|
6
|
+
const PASCAL_CASE_PATTERN = /^[A-Z][A-Za-z0-9]*$/;
|
|
7
|
+
const UPPER_SNAKE_CASE_PATTERN = /^[A-Z][A-Z0-9_]*$/;
|
|
8
|
+
const USE_HOOK_FILE_PATTERN = /^use[A-Z0-9][A-Za-z0-9]*$/;
|
|
9
|
+
const API_FILENAME_PATTERN =
|
|
10
|
+
/^[A-Z][A-Za-z0-9]*\.(?:service|type|api|zod)$/;
|
|
11
|
+
const LOCAL_LAYER_ORDER = [
|
|
12
|
+
'assets',
|
|
13
|
+
'lib',
|
|
14
|
+
'store',
|
|
15
|
+
'api',
|
|
16
|
+
'hooks',
|
|
17
|
+
'context',
|
|
18
|
+
'components',
|
|
19
|
+
'layouts',
|
|
20
|
+
'pages',
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
function normalizeFilename(filename) {
|
|
24
|
+
return filename.split(path.sep).join('/');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function getBaseName(filename) {
|
|
28
|
+
return path.basename(filename).replace(/\.(?:tsx?|jsx?)$/, '');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function getCalleeName(callee) {
|
|
32
|
+
if (!callee) {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (callee.type === 'Identifier') {
|
|
37
|
+
return callee.name;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (callee.type === 'MemberExpression' && !callee.computed) {
|
|
41
|
+
return getCalleeName(callee.property);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function getNodeName(node) {
|
|
48
|
+
if (!node) {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (node.type === 'Identifier') {
|
|
53
|
+
return node.name;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (node.type === 'PrivateIdentifier') {
|
|
57
|
+
return node.name;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function getPropertyName(node) {
|
|
64
|
+
if (!node) {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (node.type === 'Identifier') {
|
|
69
|
+
return node.name;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (node.type === 'Literal') {
|
|
73
|
+
return String(node.value);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function hasTypeParameters(node) {
|
|
80
|
+
const parameters = node.typeParameters || node.typeArguments;
|
|
81
|
+
|
|
82
|
+
return Boolean(
|
|
83
|
+
parameters && parameters.params && parameters.params.length > 0,
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function getFirstTypeParameter(node) {
|
|
88
|
+
const parameters = node.typeParameters || node.typeArguments;
|
|
89
|
+
|
|
90
|
+
return parameters && parameters.params ? parameters.params[0] : null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function getTypeReferenceName(typeNode) {
|
|
94
|
+
if (!typeNode) {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (typeNode.type === 'TSTypeReference') {
|
|
99
|
+
return getNodeName(typeNode.typeName);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (typeNode.type === 'TSArrayType') {
|
|
103
|
+
return 'Array';
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (typeNode.type === 'TSBooleanKeyword') {
|
|
107
|
+
return 'boolean';
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (typeNode.type === 'TSStringKeyword') {
|
|
111
|
+
return 'string';
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (typeNode.type === 'TSNumberKeyword') {
|
|
115
|
+
return 'number';
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function getIdentifierTypeNode(identifier) {
|
|
122
|
+
return identifier && identifier.typeAnnotation
|
|
123
|
+
? identifier.typeAnnotation.typeAnnotation
|
|
124
|
+
: null;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function isCallNamed(node, name) {
|
|
128
|
+
return (
|
|
129
|
+
node &&
|
|
130
|
+
node.type === 'CallExpression' &&
|
|
131
|
+
getCalleeName(node.callee) === name
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function isUseStateCall(node) {
|
|
136
|
+
return isCallNamed(node, 'useState');
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function isUseRefCall(node) {
|
|
140
|
+
return isCallNamed(node, 'useRef');
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function isUseEffectCall(node) {
|
|
144
|
+
return isCallNamed(node, 'useEffect');
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function isUseParamsCall(node) {
|
|
148
|
+
const name = getCalleeName(node && node.callee);
|
|
149
|
+
|
|
150
|
+
return name === 'useParams' || name === 'useLocalSearchParams';
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function toSetterName(stateName) {
|
|
154
|
+
return `set${stateName.charAt(0).toUpperCase()}${stateName.slice(1)}`;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function isBooleanTypeNode(typeNode) {
|
|
158
|
+
return Boolean(typeNode && typeNode.type === 'TSBooleanKeyword');
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function isFunctionNode(node) {
|
|
162
|
+
return Boolean(
|
|
163
|
+
node &&
|
|
164
|
+
(node.type === 'FunctionDeclaration' ||
|
|
165
|
+
node.type === 'FunctionExpression' ||
|
|
166
|
+
node.type === 'ArrowFunctionExpression'),
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function isHookName(name) {
|
|
171
|
+
return /^use[A-Z0-9]/.test(name);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function isBooleanExpression(node) {
|
|
175
|
+
if (!node) {
|
|
176
|
+
return false;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (node.type === 'Literal' && typeof node.value === 'boolean') {
|
|
180
|
+
return true;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (node.type === 'UnaryExpression') {
|
|
184
|
+
return node.operator === '!';
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (node.type === 'BinaryExpression') {
|
|
188
|
+
return [
|
|
189
|
+
'===',
|
|
190
|
+
'!==',
|
|
191
|
+
'==',
|
|
192
|
+
'!=',
|
|
193
|
+
'<',
|
|
194
|
+
'<=',
|
|
195
|
+
'>',
|
|
196
|
+
'>=',
|
|
197
|
+
'in',
|
|
198
|
+
'instanceof',
|
|
199
|
+
].includes(node.operator);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return false;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function isLiteralOnlyExpression(node) {
|
|
206
|
+
if (!node) {
|
|
207
|
+
return false;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (node.type === 'Literal') {
|
|
211
|
+
return true;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (node.type === 'TemplateLiteral') {
|
|
215
|
+
return node.expressions.length === 0;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (node.type === 'ArrayExpression') {
|
|
219
|
+
return (
|
|
220
|
+
node.elements.length > 0 && node.elements.every(isLiteralOnlyExpression)
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (node.type === 'ObjectExpression') {
|
|
225
|
+
return (
|
|
226
|
+
node.properties.length > 0 &&
|
|
227
|
+
node.properties.every(
|
|
228
|
+
(property) =>
|
|
229
|
+
property.type === 'Property' &&
|
|
230
|
+
isLiteralOnlyExpression(property.value),
|
|
231
|
+
)
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
return false;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function isMutableRuntimeStore(node) {
|
|
239
|
+
return Boolean(
|
|
240
|
+
node &&
|
|
241
|
+
node.type === 'NewExpression' &&
|
|
242
|
+
['Map', 'Set', 'WeakMap', 'WeakSet'].includes(getCalleeName(node.callee)),
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function isPluralName(name) {
|
|
247
|
+
return /(?:s|ies)$/i.test(name);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function isLikelyArrayExpression(node) {
|
|
251
|
+
return Boolean(node && node.type === 'ArrayExpression');
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function isApiPath(filename) {
|
|
255
|
+
return /\/api\//.test(filename);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function isApiServiceFile(filename) {
|
|
259
|
+
return isApiPath(filename) && /\.service\.tsx?$/.test(filename);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function getSourceLayerFromImport(source) {
|
|
263
|
+
if (!source || !source.startsWith('@/')) {
|
|
264
|
+
return null;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
return source.slice(2).split('/')[0] || null;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function getCurrentLayer(filename) {
|
|
271
|
+
const match = filename.match(/\/src\/([^/]+)/);
|
|
272
|
+
|
|
273
|
+
return match ? match[1] : null;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function getRecordStringAnyStatus(typeNode) {
|
|
277
|
+
if (!typeNode || typeNode.type !== 'TSTypeReference') {
|
|
278
|
+
return false;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
if (getTypeReferenceName(typeNode) !== 'Record') {
|
|
282
|
+
return false;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const parameters = typeNode.typeParameters || typeNode.typeArguments;
|
|
286
|
+
|
|
287
|
+
if (!parameters || parameters.params.length !== 2) {
|
|
288
|
+
return false;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const [firstType, secondType] = parameters.params;
|
|
292
|
+
|
|
293
|
+
return (
|
|
294
|
+
firstType.type === 'TSStringKeyword' && secondType.type === 'TSAnyKeyword'
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function containsNode(node, predicate) {
|
|
299
|
+
const visited = new Set();
|
|
300
|
+
|
|
301
|
+
function visit(currentNode) {
|
|
302
|
+
if (!currentNode || typeof currentNode.type !== 'string') {
|
|
303
|
+
return false;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
if (visited.has(currentNode)) {
|
|
307
|
+
return false;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
visited.add(currentNode);
|
|
311
|
+
|
|
312
|
+
if (predicate(currentNode)) {
|
|
313
|
+
return true;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
for (const key of Object.keys(currentNode)) {
|
|
317
|
+
if (
|
|
318
|
+
key === 'parent' ||
|
|
319
|
+
key === 'loc' ||
|
|
320
|
+
key === 'range' ||
|
|
321
|
+
key === 'tokens' ||
|
|
322
|
+
key === 'comments'
|
|
323
|
+
) {
|
|
324
|
+
continue;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const value = currentNode[key];
|
|
328
|
+
|
|
329
|
+
if (Array.isArray(value)) {
|
|
330
|
+
if (value.some(visit)) {
|
|
331
|
+
return true;
|
|
332
|
+
}
|
|
333
|
+
} else if (value && typeof value.type === 'string' && visit(value)) {
|
|
334
|
+
return true;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
return false;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
return visit(node);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function getTopLevelDeclarationName(node) {
|
|
345
|
+
if (!node) {
|
|
346
|
+
return null;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const declaration =
|
|
350
|
+
node.type === 'ExportNamedDeclaration' ? node.declaration : node;
|
|
351
|
+
|
|
352
|
+
if (!declaration) {
|
|
353
|
+
return null;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
if (
|
|
357
|
+
declaration.type === 'TSInterfaceDeclaration' ||
|
|
358
|
+
declaration.type === 'TSTypeAliasDeclaration'
|
|
359
|
+
) {
|
|
360
|
+
return declaration.id.name;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
if (declaration.type === 'FunctionDeclaration') {
|
|
364
|
+
return declaration.id && declaration.id.name;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
if (declaration.type === 'VariableDeclaration') {
|
|
368
|
+
const firstDeclaration = declaration.declarations[0];
|
|
369
|
+
|
|
370
|
+
return firstDeclaration && getNodeName(firstDeclaration.id);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
return null;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function getDeclarationKind(node) {
|
|
377
|
+
const declaration =
|
|
378
|
+
node && node.type === 'ExportNamedDeclaration' ? node.declaration : node;
|
|
379
|
+
|
|
380
|
+
return declaration ? declaration.type : null;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function findTopLevelDeclaration(programNode, name) {
|
|
384
|
+
return programNode.body.find(
|
|
385
|
+
(node) => getTopLevelDeclarationName(node) === name,
|
|
386
|
+
);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function getPreviousNonImportNode(programNode, targetNode) {
|
|
390
|
+
const index = programNode.body.indexOf(targetNode);
|
|
391
|
+
|
|
392
|
+
for (let cursor = index - 1; cursor >= 0; cursor -= 1) {
|
|
393
|
+
const node = programNode.body[cursor];
|
|
394
|
+
|
|
395
|
+
if (node.type !== 'ImportDeclaration') {
|
|
396
|
+
return node;
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
return null;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function isDefaultExportFunction(exportNode) {
|
|
404
|
+
return Boolean(
|
|
405
|
+
exportNode &&
|
|
406
|
+
exportNode.type === 'ExportDefaultDeclaration' &&
|
|
407
|
+
exportNode.declaration &&
|
|
408
|
+
exportNode.declaration.type === 'FunctionDeclaration',
|
|
409
|
+
);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function getDefaultExportFunctionName(exportNode) {
|
|
413
|
+
if (!isDefaultExportFunction(exportNode)) {
|
|
414
|
+
return null;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
return exportNode.declaration.id && exportNode.declaration.id.name;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function isReactComponentName(name) {
|
|
421
|
+
return Boolean(name && /^[A-Z]/.test(name));
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function getFunctionBodyStatements(functionNode) {
|
|
425
|
+
return functionNode &&
|
|
426
|
+
functionNode.body &&
|
|
427
|
+
functionNode.body.type === 'BlockStatement'
|
|
428
|
+
? functionNode.body.body
|
|
429
|
+
: [];
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function hasPropsDestructuring(functionNode, propsName) {
|
|
433
|
+
return getFunctionBodyStatements(functionNode).some((statement) => {
|
|
434
|
+
if (statement.type !== 'VariableDeclaration') {
|
|
435
|
+
return false;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
return statement.declarations.some(
|
|
439
|
+
(declaration) =>
|
|
440
|
+
declaration.id.type === 'ObjectPattern' &&
|
|
441
|
+
declaration.init &&
|
|
442
|
+
declaration.init.type === 'Identifier' &&
|
|
443
|
+
declaration.init.name === propsName,
|
|
444
|
+
);
|
|
445
|
+
});
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function getUseQueryOptionsObject(node) {
|
|
449
|
+
if (!isCallNamed(node, 'useQuery')) {
|
|
450
|
+
return null;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
const firstArg = node.arguments[0];
|
|
454
|
+
|
|
455
|
+
return firstArg && firstArg.type === 'ObjectExpression' ? firstArg : null;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
function isCacheConfigMember(node, finalPropertyName) {
|
|
459
|
+
if (!node || node.type !== 'MemberExpression') {
|
|
460
|
+
return false;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
const propertyName = getPropertyName(node.property);
|
|
464
|
+
|
|
465
|
+
if (finalPropertyName && propertyName !== finalPropertyName) {
|
|
466
|
+
return false;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
let rootNode = node.object;
|
|
470
|
+
|
|
471
|
+
while (rootNode && rootNode.type === 'MemberExpression') {
|
|
472
|
+
rootNode = rootNode.object;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
return (
|
|
476
|
+
rootNode &&
|
|
477
|
+
rootNode.type === 'Identifier' &&
|
|
478
|
+
rootNode.name === 'cacheConfig'
|
|
479
|
+
);
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
function isQueryKeyMember(node) {
|
|
483
|
+
if (!node || node.type !== 'MemberExpression') {
|
|
484
|
+
return false;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
let rootNode = node.object;
|
|
488
|
+
|
|
489
|
+
while (rootNode && rootNode.type === 'MemberExpression') {
|
|
490
|
+
rootNode = rootNode.object;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
return (
|
|
494
|
+
rootNode && rootNode.type === 'Identifier' && rootNode.name === 'queryKey'
|
|
495
|
+
);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
function isLiteralMathExpression(node) {
|
|
499
|
+
if (!node) {
|
|
500
|
+
return false;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
if (node.type === 'Literal' && typeof node.value === 'number') {
|
|
504
|
+
return true;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
if (node.type === 'BinaryExpression') {
|
|
508
|
+
return (
|
|
509
|
+
isLiteralMathExpression(node.left) && isLiteralMathExpression(node.right)
|
|
510
|
+
);
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
return false;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
function getFunctionNameFromNode(node) {
|
|
517
|
+
if (!node) {
|
|
518
|
+
return null;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
if (node.type === 'FunctionDeclaration') {
|
|
522
|
+
return node.id && node.id.name;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
if (
|
|
526
|
+
node.type === 'VariableDeclarator' &&
|
|
527
|
+
node.id.type === 'Identifier' &&
|
|
528
|
+
isFunctionNode(node.init)
|
|
529
|
+
) {
|
|
530
|
+
return node.id.name;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
return null;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
module.exports = {
|
|
537
|
+
meta: {
|
|
538
|
+
type: 'problem',
|
|
539
|
+
docs: {
|
|
540
|
+
description: 'Internal blocking conventions from CLAUDE.md',
|
|
541
|
+
},
|
|
542
|
+
schema: [],
|
|
543
|
+
},
|
|
544
|
+
|
|
545
|
+
create(context) {
|
|
546
|
+
const sourceCode = context.getSourceCode();
|
|
547
|
+
const filename = normalizeFilename(context.getFilename());
|
|
548
|
+
const baseName = getBaseName(filename);
|
|
549
|
+
const stateSetters = new Set();
|
|
550
|
+
const apiImportNodes = [];
|
|
551
|
+
let hasJsx = false;
|
|
552
|
+
|
|
553
|
+
function report(node, message) {
|
|
554
|
+
context.report({ node, message });
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
function checkUseStateDeclarator(node) {
|
|
558
|
+
if (
|
|
559
|
+
node.id.type !== 'ArrayPattern' ||
|
|
560
|
+
!node.init ||
|
|
561
|
+
!isUseStateCall(node.init)
|
|
562
|
+
) {
|
|
563
|
+
return;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
const [stateElement, setterElement] = node.id.elements;
|
|
567
|
+
|
|
568
|
+
if (!hasTypeParameters(node.init)) {
|
|
569
|
+
report(node.init, '`useState`는 제네릭 타입을 명시해야 합니다.');
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
if (
|
|
573
|
+
stateElement &&
|
|
574
|
+
stateElement.type === 'Identifier' &&
|
|
575
|
+
setterElement &&
|
|
576
|
+
setterElement.type === 'Identifier'
|
|
577
|
+
) {
|
|
578
|
+
const expectedSetterName = toSetterName(stateElement.name);
|
|
579
|
+
|
|
580
|
+
stateSetters.add(setterElement.name);
|
|
581
|
+
|
|
582
|
+
if (setterElement.name !== expectedSetterName) {
|
|
583
|
+
report(
|
|
584
|
+
setterElement,
|
|
585
|
+
`useState setter는 \`${expectedSetterName}\` 형식이어야 합니다.`,
|
|
586
|
+
);
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
const firstTypeParameter = getFirstTypeParameter(node.init);
|
|
590
|
+
const isBooleanState =
|
|
591
|
+
isBooleanTypeNode(firstTypeParameter) ||
|
|
592
|
+
(node.init.arguments[0] &&
|
|
593
|
+
node.init.arguments[0].type === 'Literal' &&
|
|
594
|
+
typeof node.init.arguments[0].value === 'boolean');
|
|
595
|
+
|
|
596
|
+
if (isBooleanState && !BOOLEAN_PREFIX_PATTERN.test(stateElement.name)) {
|
|
597
|
+
report(
|
|
598
|
+
stateElement,
|
|
599
|
+
'boolean useState 상태명은 is/has/can/should prefix를 사용해야 합니다.',
|
|
600
|
+
);
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
function checkVariableDeclarator(node) {
|
|
606
|
+
checkUseStateDeclarator(node);
|
|
607
|
+
|
|
608
|
+
if (
|
|
609
|
+
node.init &&
|
|
610
|
+
isUseRefCall(node.init) &&
|
|
611
|
+
!hasTypeParameters(node.init)
|
|
612
|
+
) {
|
|
613
|
+
report(node.init, '`useRef`는 제네릭 타입을 명시해야 합니다.');
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
if (node.id.type !== 'Identifier') {
|
|
617
|
+
return;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
const name = node.id.name;
|
|
621
|
+
const typeNode = getIdentifierTypeNode(node.id);
|
|
622
|
+
|
|
623
|
+
if (
|
|
624
|
+
name.startsWith('set') &&
|
|
625
|
+
isFunctionNode(node.init) &&
|
|
626
|
+
!stateSetters.has(name)
|
|
627
|
+
) {
|
|
628
|
+
report(node.id, '`set` prefix는 useState setter에만 사용해야 합니다.');
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
const isBooleanVariable =
|
|
632
|
+
isBooleanTypeNode(typeNode) || isBooleanExpression(node.init);
|
|
633
|
+
|
|
634
|
+
if (isBooleanVariable && !BOOLEAN_PREFIX_PATTERN.test(name)) {
|
|
635
|
+
report(
|
|
636
|
+
node.id,
|
|
637
|
+
'boolean 변수명은 is/has/can/should prefix를 사용해야 합니다.',
|
|
638
|
+
);
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
if (
|
|
642
|
+
BOOLEAN_PREFIX_PATTERN.test(name) &&
|
|
643
|
+
typeNode &&
|
|
644
|
+
!isBooleanTypeNode(typeNode)
|
|
645
|
+
) {
|
|
646
|
+
report(
|
|
647
|
+
node.id,
|
|
648
|
+
'boolean이 아닌 변수에는 is/has/can/should prefix를 사용하지 않아야 합니다.',
|
|
649
|
+
);
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
if (isLikelyArrayExpression(node.init) && !isPluralName(name)) {
|
|
653
|
+
report(node.id, '배열 변수명은 복수형 명사를 사용해야 합니다.');
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
function checkConstNaming(node) {
|
|
658
|
+
if (node.kind !== 'const') {
|
|
659
|
+
return;
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
for (const declaration of node.declarations) {
|
|
663
|
+
if (
|
|
664
|
+
declaration.id.type === 'Identifier' &&
|
|
665
|
+
isLiteralOnlyExpression(declaration.init) &&
|
|
666
|
+
!isMutableRuntimeStore(declaration.init) &&
|
|
667
|
+
!UPPER_SNAKE_CASE_PATTERN.test(declaration.id.name)
|
|
668
|
+
) {
|
|
669
|
+
report(
|
|
670
|
+
declaration.id,
|
|
671
|
+
'불변 상수명은 UPPER_SNAKE_CASE를 사용해야 합니다.',
|
|
672
|
+
);
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
function checkClassNameAttribute(node) {
|
|
678
|
+
if (!node.name || node.name.name !== 'className') {
|
|
679
|
+
return;
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
if (!node.value || node.value.type !== 'JSXExpressionContainer') {
|
|
683
|
+
return;
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
const expression = node.value.expression;
|
|
687
|
+
|
|
688
|
+
if (!expression || expression.type !== 'TemplateLiteral') {
|
|
689
|
+
return;
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
if (expression.expressions.length === 0) {
|
|
693
|
+
report(
|
|
694
|
+
node,
|
|
695
|
+
'JavaScript 표현식이 없는 className은 문자열 리터럴로 작성해야 합니다.',
|
|
696
|
+
);
|
|
697
|
+
return;
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
const firstQuasi = expression.quasis[0];
|
|
701
|
+
const firstText = firstQuasi && firstQuasi.value.raw.trim();
|
|
702
|
+
|
|
703
|
+
if (firstText) {
|
|
704
|
+
report(
|
|
705
|
+
node,
|
|
706
|
+
'Tailwind className의 동적 표현식은 문자열 맨 앞에 위치해야 합니다.',
|
|
707
|
+
);
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
function checkEventHandlerAttribute(node) {
|
|
712
|
+
if (!node.name || typeof node.name.name !== 'string') {
|
|
713
|
+
return;
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
const openingElement = node.parent;
|
|
717
|
+
const elementName = openingElement && openingElement.name;
|
|
718
|
+
|
|
719
|
+
if (
|
|
720
|
+
!elementName ||
|
|
721
|
+
elementName.type !== 'JSXIdentifier' ||
|
|
722
|
+
!/^[a-z]/.test(elementName.name)
|
|
723
|
+
) {
|
|
724
|
+
return;
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
const eventName = node.name.name;
|
|
728
|
+
|
|
729
|
+
if (!/^on[A-Z]/.test(eventName)) {
|
|
730
|
+
return;
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
if (!node.value || node.value.type !== 'JSXExpressionContainer') {
|
|
734
|
+
return;
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
const expression = node.value.expression;
|
|
738
|
+
|
|
739
|
+
if (!expression || expression.type !== 'Identifier') {
|
|
740
|
+
return;
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
if (!expression.name.startsWith('on')) {
|
|
744
|
+
report(expression, '웹 이벤트 핸들러는 on prefix를 사용해야 합니다.');
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
function checkImportDeclaration(node) {
|
|
749
|
+
const source = node.source.value;
|
|
750
|
+
|
|
751
|
+
if (source === '@/api') {
|
|
752
|
+
apiImportNodes.push(node);
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
if (typeof source === 'string' && source.startsWith('@/api/')) {
|
|
756
|
+
report(node.source, '`@/api` 하위 경로 import는 금지됩니다.');
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
if (filename.includes('/packages/') && /^apps\//.test(source)) {
|
|
760
|
+
report(
|
|
761
|
+
node.source,
|
|
762
|
+
'`packages/*` 내부에서 `apps/*`를 import할 수 없습니다.',
|
|
763
|
+
);
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
const currentLayer = getCurrentLayer(filename);
|
|
767
|
+
const sourceLayer = getSourceLayerFromImport(source);
|
|
768
|
+
|
|
769
|
+
if (
|
|
770
|
+
currentLayer &&
|
|
771
|
+
sourceLayer &&
|
|
772
|
+
LOCAL_LAYER_ORDER.includes(currentLayer) &&
|
|
773
|
+
LOCAL_LAYER_ORDER.includes(sourceLayer) &&
|
|
774
|
+
LOCAL_LAYER_ORDER.indexOf(sourceLayer) >
|
|
775
|
+
LOCAL_LAYER_ORDER.indexOf(currentLayer)
|
|
776
|
+
) {
|
|
777
|
+
report(
|
|
778
|
+
node.source,
|
|
779
|
+
'허용된 의존성 방향을 거슬러 import할 수 없습니다.',
|
|
780
|
+
);
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
function checkApiServiceFunction(node) {
|
|
785
|
+
if (!isApiServiceFile(filename)) {
|
|
786
|
+
return;
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
const functionName = getFunctionNameFromNode(node);
|
|
790
|
+
|
|
791
|
+
if (!functionName || !functionName.startsWith('use')) {
|
|
792
|
+
return;
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
const functionNode =
|
|
796
|
+
node.type === 'VariableDeclarator' ? node.init : node;
|
|
797
|
+
const optionsParam = functionNode.params.find(
|
|
798
|
+
(param) => param.type === 'Identifier' && param.name === 'options',
|
|
799
|
+
);
|
|
800
|
+
|
|
801
|
+
if (!optionsParam) {
|
|
802
|
+
return;
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
if (!getRecordStringAnyStatus(getIdentifierTypeNode(optionsParam))) {
|
|
806
|
+
report(
|
|
807
|
+
optionsParam,
|
|
808
|
+
'service 훅의 options 매개변수 타입은 Record<string, any>여야 합니다.',
|
|
809
|
+
);
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
function checkUseQueryCall(node) {
|
|
814
|
+
if (!isApiServiceFile(filename)) {
|
|
815
|
+
return;
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
const optionsObject = getUseQueryOptionsObject(node);
|
|
819
|
+
|
|
820
|
+
if (!optionsObject) {
|
|
821
|
+
return;
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
const properties = optionsObject.properties;
|
|
825
|
+
const optionsSpreadIndex = properties.findIndex(
|
|
826
|
+
(property) =>
|
|
827
|
+
property.type === 'SpreadElement' &&
|
|
828
|
+
property.argument.type === 'Identifier' &&
|
|
829
|
+
property.argument.name === 'options',
|
|
830
|
+
);
|
|
831
|
+
|
|
832
|
+
if (
|
|
833
|
+
optionsSpreadIndex >= 0 &&
|
|
834
|
+
optionsSpreadIndex !== properties.length - 1
|
|
835
|
+
) {
|
|
836
|
+
report(
|
|
837
|
+
properties[optionsSpreadIndex],
|
|
838
|
+
'useQuery의 ...options는 캐시 설정 뒤, 객체의 마지막에 위치해야 합니다.',
|
|
839
|
+
);
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
for (const property of properties) {
|
|
843
|
+
if (property.type !== 'Property') {
|
|
844
|
+
continue;
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
const keyName = getPropertyName(property.key);
|
|
848
|
+
|
|
849
|
+
if (keyName === 'queryKey' && !isQueryKeyMember(property.value)) {
|
|
850
|
+
report(
|
|
851
|
+
property.value,
|
|
852
|
+
'queryKey는 queryKey.* 상수를 사용해야 합니다.',
|
|
853
|
+
);
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
if (
|
|
857
|
+
(keyName === 'staleTime' || keyName === 'gcTime') &&
|
|
858
|
+
!isCacheConfigMember(property.value, keyName) &&
|
|
859
|
+
isLiteralMathExpression(property.value)
|
|
860
|
+
) {
|
|
861
|
+
report(
|
|
862
|
+
property.value,
|
|
863
|
+
`${keyName}은 하드코딩하지 않고 cacheConfig를 사용해야 합니다.`,
|
|
864
|
+
);
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
if (keyName === 'queryFn') {
|
|
868
|
+
const hasSafeParse = containsNode(
|
|
869
|
+
property.value,
|
|
870
|
+
(innerNode) =>
|
|
871
|
+
innerNode.type === 'MemberExpression' &&
|
|
872
|
+
getPropertyName(innerNode.property) === 'safeParse',
|
|
873
|
+
);
|
|
874
|
+
const hasConsoleError = containsNode(
|
|
875
|
+
property.value,
|
|
876
|
+
(innerNode) =>
|
|
877
|
+
innerNode.type === 'CallExpression' &&
|
|
878
|
+
innerNode.callee.type === 'MemberExpression' &&
|
|
879
|
+
innerNode.callee.object.type === 'Identifier' &&
|
|
880
|
+
innerNode.callee.object.name === 'console' &&
|
|
881
|
+
getPropertyName(innerNode.callee.property) === 'error',
|
|
882
|
+
);
|
|
883
|
+
|
|
884
|
+
if (!hasSafeParse || !hasConsoleError) {
|
|
885
|
+
report(
|
|
886
|
+
property.value,
|
|
887
|
+
'useQuery queryFn에는 zod safeParse 실패 처리 패턴이 필요합니다.',
|
|
888
|
+
);
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
function checkUseParamsCall(node) {
|
|
895
|
+
if (!isUseParamsCall(node)) {
|
|
896
|
+
return;
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
const firstTypeParameter = getFirstTypeParameter(node);
|
|
900
|
+
|
|
901
|
+
if (getTypeReferenceName(firstTypeParameter) !== 'Params') {
|
|
902
|
+
report(
|
|
903
|
+
node,
|
|
904
|
+
'URL 라우트 params 조회에는 useParams<Params>() 형식을 사용해야 합니다.',
|
|
905
|
+
);
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
function checkFileNaming(programNode) {
|
|
910
|
+
if (baseName === 'index' || filename.endsWith('.d.ts')) {
|
|
911
|
+
return;
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
const extension = path.extname(filename);
|
|
915
|
+
const isTsx = extension === '.tsx';
|
|
916
|
+
const isTs = extension === '.ts';
|
|
917
|
+
|
|
918
|
+
if (filename.includes('/hooks/')) {
|
|
919
|
+
if (!USE_HOOK_FILE_PATTERN.test(baseName)) {
|
|
920
|
+
report(
|
|
921
|
+
programNode,
|
|
922
|
+
'hook 파일명은 use + 기능명 camelCase 형식이어야 합니다.',
|
|
923
|
+
);
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
if (isTsx && !hasJsx) {
|
|
927
|
+
report(
|
|
928
|
+
programNode,
|
|
929
|
+
'JSX를 포함하지 않는 hook 파일은 .ts 확장자를 사용해야 합니다.',
|
|
930
|
+
);
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
if (
|
|
935
|
+
isApiPath(filename) &&
|
|
936
|
+
isTs &&
|
|
937
|
+
baseName !== 'api-codegen' &&
|
|
938
|
+
baseName !== 'instance'
|
|
939
|
+
) {
|
|
940
|
+
if (!API_FILENAME_PATTERN.test(baseName)) {
|
|
941
|
+
report(
|
|
942
|
+
programNode,
|
|
943
|
+
'API 파일명은 PascalCase 리소스명 + service/type/api/zod suffix 형식이어야 합니다.',
|
|
944
|
+
);
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
if (filename.includes('/store/') && baseName !== 'index') {
|
|
949
|
+
if (!baseName.endsWith('Store')) {
|
|
950
|
+
report(
|
|
951
|
+
programNode,
|
|
952
|
+
'store 파일명은 사용 위치 + Store 형식이어야 합니다.',
|
|
953
|
+
);
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
if (isTsx && !hasJsx) {
|
|
957
|
+
report(
|
|
958
|
+
programNode,
|
|
959
|
+
'JSX를 포함하지 않는 store 파일은 .ts 확장자를 사용해야 합니다.',
|
|
960
|
+
);
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
if (
|
|
965
|
+
hasJsx &&
|
|
966
|
+
(filename.includes('/components/') ||
|
|
967
|
+
filename.includes('/layouts/') ||
|
|
968
|
+
filename.includes('/pages/'))
|
|
969
|
+
) {
|
|
970
|
+
if (!PASCAL_CASE_PATTERN.test(baseName)) {
|
|
971
|
+
report(programNode, '컴포넌트 파일명은 PascalCase여야 합니다.');
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
if (filename.includes('/layouts/') && !baseName.endsWith('Layout')) {
|
|
975
|
+
report(
|
|
976
|
+
programNode,
|
|
977
|
+
'레이아웃 파일명은 용도 + Layout 형식이어야 합니다.',
|
|
978
|
+
);
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
if (filename.includes('/context/') && baseName !== 'index') {
|
|
983
|
+
if (!baseName.endsWith('Context')) {
|
|
984
|
+
report(
|
|
985
|
+
programNode,
|
|
986
|
+
'context 파일명은 사용 위치 + Context 형식이어야 합니다.',
|
|
987
|
+
);
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
if (filename.includes('/provider/') && baseName !== 'index') {
|
|
992
|
+
if (!baseName.endsWith('Provider')) {
|
|
993
|
+
report(
|
|
994
|
+
programNode,
|
|
995
|
+
'provider 파일명은 사용 위치 + Provider 형식이어야 합니다.',
|
|
996
|
+
);
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
function checkDefaultExports(programNode) {
|
|
1002
|
+
const defaultExport = programNode.body.find(
|
|
1003
|
+
(node) => node.type === 'ExportDefaultDeclaration',
|
|
1004
|
+
);
|
|
1005
|
+
|
|
1006
|
+
const shouldBeComponent =
|
|
1007
|
+
hasJsx &&
|
|
1008
|
+
(filename.endsWith('.tsx') ||
|
|
1009
|
+
filename.includes('/components/') ||
|
|
1010
|
+
filename.includes('/layouts/') ||
|
|
1011
|
+
filename.includes('/pages/'));
|
|
1012
|
+
const shouldBeHook = filename.includes('/hooks/') && isHookName(baseName);
|
|
1013
|
+
|
|
1014
|
+
if ((shouldBeComponent || shouldBeHook) && defaultExport) {
|
|
1015
|
+
if (!isDefaultExportFunction(defaultExport)) {
|
|
1016
|
+
report(
|
|
1017
|
+
defaultExport,
|
|
1018
|
+
'default export는 export default function 형태여야 합니다.',
|
|
1019
|
+
);
|
|
1020
|
+
return;
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
if (shouldBeHook && defaultExport) {
|
|
1025
|
+
const functionName = getDefaultExportFunctionName(defaultExport);
|
|
1026
|
+
|
|
1027
|
+
if (!functionName || !isHookName(functionName)) {
|
|
1028
|
+
report(
|
|
1029
|
+
defaultExport,
|
|
1030
|
+
'커스텀 훅 default export는 export default function use... 형태여야 합니다.',
|
|
1031
|
+
);
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
if (
|
|
1036
|
+
shouldBeComponent &&
|
|
1037
|
+
defaultExport &&
|
|
1038
|
+
isDefaultExportFunction(defaultExport)
|
|
1039
|
+
) {
|
|
1040
|
+
const functionName = getDefaultExportFunctionName(defaultExport);
|
|
1041
|
+
|
|
1042
|
+
if (!isReactComponentName(functionName)) {
|
|
1043
|
+
return;
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
const functionNode = defaultExport.declaration;
|
|
1047
|
+
const propsParam = functionNode.params[0];
|
|
1048
|
+
|
|
1049
|
+
if (propsParam) {
|
|
1050
|
+
if (propsParam.type !== 'Identifier') {
|
|
1051
|
+
report(
|
|
1052
|
+
propsParam,
|
|
1053
|
+
'컴포넌트 props는 props 파라미터로 받고 내부에서 구조 분해해야 합니다.',
|
|
1054
|
+
);
|
|
1055
|
+
} else {
|
|
1056
|
+
const propsTypeName = getTypeReferenceName(
|
|
1057
|
+
getIdentifierTypeNode(propsParam),
|
|
1058
|
+
);
|
|
1059
|
+
|
|
1060
|
+
if (propsTypeName !== 'Props') {
|
|
1061
|
+
report(
|
|
1062
|
+
propsParam,
|
|
1063
|
+
'React 컴포넌트 props 타입명은 Props여야 합니다.',
|
|
1064
|
+
);
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
if (!hasPropsDestructuring(functionNode, propsParam.name)) {
|
|
1068
|
+
report(
|
|
1069
|
+
propsParam,
|
|
1070
|
+
'컴포넌트 props는 컴포넌트 내부에서 구조 분해해야 합니다.',
|
|
1071
|
+
);
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
const propsDeclaration = findTopLevelDeclaration(
|
|
1076
|
+
programNode,
|
|
1077
|
+
'Props',
|
|
1078
|
+
);
|
|
1079
|
+
|
|
1080
|
+
if (!propsDeclaration) {
|
|
1081
|
+
report(
|
|
1082
|
+
defaultExport,
|
|
1083
|
+
'React 컴포넌트 props 타입은 Props로 선언해야 합니다.',
|
|
1084
|
+
);
|
|
1085
|
+
} else {
|
|
1086
|
+
const propsDeclarationKind = getDeclarationKind(propsDeclaration);
|
|
1087
|
+
|
|
1088
|
+
if (propsDeclarationKind !== 'TSInterfaceDeclaration') {
|
|
1089
|
+
report(
|
|
1090
|
+
propsDeclaration,
|
|
1091
|
+
'React 컴포넌트 Props는 interface로 선언해야 합니다.',
|
|
1092
|
+
);
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
const previousNode = getPreviousNonImportNode(
|
|
1096
|
+
programNode,
|
|
1097
|
+
defaultExport,
|
|
1098
|
+
);
|
|
1099
|
+
|
|
1100
|
+
if (previousNode !== propsDeclaration) {
|
|
1101
|
+
report(
|
|
1102
|
+
propsDeclaration,
|
|
1103
|
+
'Props는 export default function 컴포넌트 선언 바로 위에 위치해야 합니다.',
|
|
1104
|
+
);
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
function checkParamsDeclaration(programNode) {
|
|
1112
|
+
const hasParamsUsage = containsNode(programNode, isUseParamsCall);
|
|
1113
|
+
|
|
1114
|
+
if (!hasParamsUsage) {
|
|
1115
|
+
return;
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
const paramsDeclaration = findTopLevelDeclaration(programNode, 'Params');
|
|
1119
|
+
const defaultExport = programNode.body.find(
|
|
1120
|
+
(node) => node.type === 'ExportDefaultDeclaration',
|
|
1121
|
+
);
|
|
1122
|
+
|
|
1123
|
+
if (!paramsDeclaration) {
|
|
1124
|
+
report(
|
|
1125
|
+
programNode,
|
|
1126
|
+
'URL 라우트 params 타입은 Params로 선언해야 합니다.',
|
|
1127
|
+
);
|
|
1128
|
+
return;
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
if (getDeclarationKind(paramsDeclaration) !== 'TSTypeAliasDeclaration') {
|
|
1132
|
+
report(
|
|
1133
|
+
paramsDeclaration,
|
|
1134
|
+
'URL 라우트 params는 type Params로 선언해야 합니다.',
|
|
1135
|
+
);
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
if (defaultExport) {
|
|
1139
|
+
const previousNode = getPreviousNonImportNode(
|
|
1140
|
+
programNode,
|
|
1141
|
+
defaultExport,
|
|
1142
|
+
);
|
|
1143
|
+
|
|
1144
|
+
if (previousNode !== paramsDeclaration) {
|
|
1145
|
+
report(
|
|
1146
|
+
paramsDeclaration,
|
|
1147
|
+
'Params는 export default function 컴포넌트 선언 바로 위에 위치해야 합니다.',
|
|
1148
|
+
);
|
|
1149
|
+
}
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
return {
|
|
1154
|
+
Program(programNode) {
|
|
1155
|
+
const comments = sourceCode.getAllComments();
|
|
1156
|
+
|
|
1157
|
+
for (const comment of comments) {
|
|
1158
|
+
const text = comment.value.trim();
|
|
1159
|
+
const lineText = sourceCode.lines[comment.loc.start.line - 1] || '';
|
|
1160
|
+
const beforeComment = lineText.slice(0, comment.loc.start.column);
|
|
1161
|
+
|
|
1162
|
+
if (/eslint-disable/.test(text) && !/--\s*\S+/.test(text)) {
|
|
1163
|
+
report(
|
|
1164
|
+
comment,
|
|
1165
|
+
'eslint-disable 주석에는 사유를 함께 작성해야 합니다.',
|
|
1166
|
+
);
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
if (
|
|
1170
|
+
/@ts-(?:ignore|expect-error)/.test(text) &&
|
|
1171
|
+
!/@ts-(?:ignore|expect-error)\s*[:가-힣A-Za-z0-9_-]/.test(text)
|
|
1172
|
+
) {
|
|
1173
|
+
report(
|
|
1174
|
+
comment,
|
|
1175
|
+
'@ts-ignore/@ts-expect-error 주석에는 사유를 함께 작성해야 합니다.',
|
|
1176
|
+
);
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
if (beforeComment.trim()) {
|
|
1180
|
+
report(
|
|
1181
|
+
comment,
|
|
1182
|
+
'줄 끝 인라인 주석은 금지됩니다. 코드 위 별도 줄에 작성해야 합니다.',
|
|
1183
|
+
);
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
if (/\b(?:TODO|FIXME|XXX)\b/i.test(text) && !/TODO:/.test(text)) {
|
|
1187
|
+
report(comment, '미완성 작업은 TODO: 형식으로 작성해야 합니다.');
|
|
1188
|
+
}
|
|
1189
|
+
}
|
|
1190
|
+
},
|
|
1191
|
+
|
|
1192
|
+
ImportDeclaration: checkImportDeclaration,
|
|
1193
|
+
|
|
1194
|
+
VariableDeclaration: checkConstNaming,
|
|
1195
|
+
|
|
1196
|
+
VariableDeclarator(node) {
|
|
1197
|
+
checkVariableDeclarator(node);
|
|
1198
|
+
checkApiServiceFunction(node);
|
|
1199
|
+
},
|
|
1200
|
+
|
|
1201
|
+
FunctionDeclaration: checkApiServiceFunction,
|
|
1202
|
+
|
|
1203
|
+
CallExpression(node) {
|
|
1204
|
+
if (isUseEffectCall(node) && node.arguments.length < 2) {
|
|
1205
|
+
report(node, 'useEffect 호출에는 의존성 배열 인자가 필요합니다.');
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
checkUseParamsCall(node);
|
|
1209
|
+
checkUseQueryCall(node);
|
|
1210
|
+
},
|
|
1211
|
+
|
|
1212
|
+
TSNonNullExpression(node) {
|
|
1213
|
+
report(node, 'Non-null assertion은 사용하지 않아야 합니다.');
|
|
1214
|
+
},
|
|
1215
|
+
|
|
1216
|
+
JSXElement() {
|
|
1217
|
+
hasJsx = true;
|
|
1218
|
+
},
|
|
1219
|
+
|
|
1220
|
+
JSXFragment() {
|
|
1221
|
+
hasJsx = true;
|
|
1222
|
+
},
|
|
1223
|
+
|
|
1224
|
+
JSXAttribute(node) {
|
|
1225
|
+
checkClassNameAttribute(node);
|
|
1226
|
+
checkEventHandlerAttribute(node);
|
|
1227
|
+
},
|
|
1228
|
+
|
|
1229
|
+
'Program:exit'(programNode) {
|
|
1230
|
+
if (apiImportNodes.length > 1) {
|
|
1231
|
+
for (const node of apiImportNodes.slice(1)) {
|
|
1232
|
+
report(node, '`@/api` import는 한 파일에서 하나로 합쳐야 합니다.');
|
|
1233
|
+
}
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1236
|
+
checkFileNaming(programNode);
|
|
1237
|
+
checkDefaultExports(programNode);
|
|
1238
|
+
checkParamsDeclaration(programNode);
|
|
1239
|
+
},
|
|
1240
|
+
};
|
|
1241
|
+
},
|
|
1242
|
+
};
|