@noctcore/eslint-plugin-code-quality 0.1.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.
- package/README.md +58 -0
- package/dist/index.cjs +725 -0
- package/dist/index.d.cts +95 -0
- package/dist/index.d.ts +95 -0
- package/dist/index.js +697 -0
- package/docs/rules/interface-prefix-i.md +44 -0
- package/docs/rules/no-bare-date-now.md +44 -0
- package/docs/rules/no-focused-tests.md +33 -0
- package/docs/rules/no-historical-comments.md +31 -0
- package/docs/rules/no-narration-comments.md +34 -0
- package/docs/rules/no-pr-reference-comments.md +36 -0
- package/docs/rules/no-process-exit.md +38 -0
- package/docs/rules/no-template-trim-empty-ternary.md +44 -0
- package/docs/rules/prefer-early-return.md +47 -0
- package/docs/rules/skipped-tests-need-tracking.md +50 -0
- package/package.json +64 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,697 @@
|
|
|
1
|
+
// src/configs/recommended.ts
|
|
2
|
+
var recommended = {
|
|
3
|
+
"noctcore-code-quality/prefer-early-return": "error",
|
|
4
|
+
"noctcore-code-quality/no-process-exit": "error",
|
|
5
|
+
"noctcore-code-quality/no-bare-date-now": "error",
|
|
6
|
+
"noctcore-code-quality/no-historical-comments": "error",
|
|
7
|
+
"noctcore-code-quality/no-narration-comments": "error",
|
|
8
|
+
"noctcore-code-quality/no-pr-reference-comments": "error",
|
|
9
|
+
"noctcore-code-quality/no-focused-tests": "error",
|
|
10
|
+
"noctcore-code-quality/skipped-tests-need-tracking": "error"
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
// src/rules/interface-prefix-i.ts
|
|
14
|
+
import { AST_NODE_TYPES } from "@typescript-eslint/utils";
|
|
15
|
+
|
|
16
|
+
// src/createRule.ts
|
|
17
|
+
import { makeCreateRule } from "@noctcore/eslint-utils";
|
|
18
|
+
var createRule = makeCreateRule("code-quality");
|
|
19
|
+
|
|
20
|
+
// src/rules/interface-prefix-i.ts
|
|
21
|
+
var RULE_NAME = "interface-prefix-i";
|
|
22
|
+
function isAlreadyPrefixed(name) {
|
|
23
|
+
return /^I[A-Z]/.test(name);
|
|
24
|
+
}
|
|
25
|
+
function isInsideAmbientModule(node) {
|
|
26
|
+
let current = node.parent;
|
|
27
|
+
while (current) {
|
|
28
|
+
if (current.type === AST_NODE_TYPES.TSModuleDeclaration) {
|
|
29
|
+
return true;
|
|
30
|
+
}
|
|
31
|
+
current = current.parent;
|
|
32
|
+
}
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
var interfacePrefixIRule = createRule({
|
|
36
|
+
name: RULE_NAME,
|
|
37
|
+
meta: {
|
|
38
|
+
type: "suggestion",
|
|
39
|
+
docs: {
|
|
40
|
+
description: "Interface names must be prefixed with `I` followed by an uppercase letter. Module/global augmentations are exempt."
|
|
41
|
+
},
|
|
42
|
+
schema: [],
|
|
43
|
+
messages: {
|
|
44
|
+
missingPrefix: "Interface `{{name}}` must be prefixed with `I` (e.g. `I{{name}}`). Rename the declaration and its references."
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
defaultOptions: [],
|
|
48
|
+
create(context) {
|
|
49
|
+
return {
|
|
50
|
+
TSInterfaceDeclaration(node) {
|
|
51
|
+
const name = node.id.name;
|
|
52
|
+
if (isAlreadyPrefixed(name) || isInsideAmbientModule(node)) {
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
context.report({ node: node.id, messageId: "missingPrefix", data: { name } });
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
// src/rules/no-bare-date-now.ts
|
|
62
|
+
import { AST_NODE_TYPES as AST_NODE_TYPES2 } from "@typescript-eslint/utils";
|
|
63
|
+
|
|
64
|
+
// src/utils/allowMatch.ts
|
|
65
|
+
var REGEX_METACHARACTERS = /[.*+?^${}()|[\]\\]/gu;
|
|
66
|
+
function escapeLiteral(text) {
|
|
67
|
+
return text.replace(REGEX_METACHARACTERS, "\\$&");
|
|
68
|
+
}
|
|
69
|
+
function globToRegExp(glob) {
|
|
70
|
+
let source = "";
|
|
71
|
+
let i = 0;
|
|
72
|
+
while (i < glob.length) {
|
|
73
|
+
const char = glob[i];
|
|
74
|
+
if (char === void 0) {
|
|
75
|
+
break;
|
|
76
|
+
}
|
|
77
|
+
if (char === "*") {
|
|
78
|
+
if (glob[i + 1] === "*") {
|
|
79
|
+
i += 2;
|
|
80
|
+
if (glob[i] === "/") {
|
|
81
|
+
source += "(?:.*/)?";
|
|
82
|
+
i += 1;
|
|
83
|
+
} else {
|
|
84
|
+
source += ".*";
|
|
85
|
+
}
|
|
86
|
+
} else {
|
|
87
|
+
source += "[^/]*";
|
|
88
|
+
i += 1;
|
|
89
|
+
}
|
|
90
|
+
} else if (char === "?") {
|
|
91
|
+
source += "[^/]";
|
|
92
|
+
i += 1;
|
|
93
|
+
} else if (char === "{") {
|
|
94
|
+
const end = glob.indexOf("}", i);
|
|
95
|
+
if (end === -1) {
|
|
96
|
+
source += "\\{";
|
|
97
|
+
i += 1;
|
|
98
|
+
} else {
|
|
99
|
+
const alternatives = glob.slice(i + 1, end).split(",").map(escapeLiteral).join("|");
|
|
100
|
+
source += `(?:${alternatives})`;
|
|
101
|
+
i = end + 1;
|
|
102
|
+
}
|
|
103
|
+
} else {
|
|
104
|
+
source += escapeLiteral(char);
|
|
105
|
+
i += 1;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return new RegExp(`^${source}$`, "u");
|
|
109
|
+
}
|
|
110
|
+
var compiledCache = /* @__PURE__ */ new Map();
|
|
111
|
+
function compile(glob) {
|
|
112
|
+
const cached = compiledCache.get(glob);
|
|
113
|
+
if (cached !== void 0) {
|
|
114
|
+
return cached;
|
|
115
|
+
}
|
|
116
|
+
const regex = globToRegExp(glob);
|
|
117
|
+
compiledCache.set(glob, regex);
|
|
118
|
+
return regex;
|
|
119
|
+
}
|
|
120
|
+
function matchesAny(filename, globs) {
|
|
121
|
+
if (globs.length === 0) {
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
124
|
+
const normalized = filename.split("\\").join("/");
|
|
125
|
+
return globs.some((glob) => compile(glob).test(normalized));
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// src/rules/no-bare-date-now.ts
|
|
129
|
+
var RULE_NAME2 = "no-bare-date-now";
|
|
130
|
+
var DEFAULT_ALLOW_IN = ["**/clock.ts", "**/clock/**"];
|
|
131
|
+
var optionSchema = {
|
|
132
|
+
type: "object",
|
|
133
|
+
additionalProperties: false,
|
|
134
|
+
properties: {
|
|
135
|
+
allowIn: {
|
|
136
|
+
type: "array",
|
|
137
|
+
items: { type: "string" },
|
|
138
|
+
uniqueItems: true
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
function isDateNowCall(node) {
|
|
143
|
+
const callee = node.callee;
|
|
144
|
+
return callee.type === AST_NODE_TYPES2.MemberExpression && !callee.computed && callee.object.type === AST_NODE_TYPES2.Identifier && callee.object.name === "Date" && callee.property.type === AST_NODE_TYPES2.Identifier && callee.property.name === "now";
|
|
145
|
+
}
|
|
146
|
+
function isBareNewDate(node) {
|
|
147
|
+
return node.callee.type === AST_NODE_TYPES2.Identifier && node.callee.name === "Date" && node.arguments.length === 0;
|
|
148
|
+
}
|
|
149
|
+
var noBareDateNowRule = createRule({
|
|
150
|
+
name: RULE_NAME2,
|
|
151
|
+
meta: {
|
|
152
|
+
type: "problem",
|
|
153
|
+
docs: {
|
|
154
|
+
description: "Disallow bare `Date.now()` / `new Date()` in business logic. Read wall-clock time through a shared `clock` util (`nowMs()` / `now()`) so time is mockable."
|
|
155
|
+
},
|
|
156
|
+
schema: [optionSchema],
|
|
157
|
+
messages: {
|
|
158
|
+
dateNow: "Use `nowMs()` from the shared `clock` util instead of bare `Date.now()`.",
|
|
159
|
+
newDate: "Use `now()` from the shared `clock` util instead of bare `new Date()`."
|
|
160
|
+
}
|
|
161
|
+
},
|
|
162
|
+
defaultOptions: [{ allowIn: [...DEFAULT_ALLOW_IN] }],
|
|
163
|
+
create(context, [options]) {
|
|
164
|
+
const allowIn = options.allowIn ?? DEFAULT_ALLOW_IN;
|
|
165
|
+
if (matchesAny(context.filename, allowIn)) {
|
|
166
|
+
return {};
|
|
167
|
+
}
|
|
168
|
+
return {
|
|
169
|
+
CallExpression(node) {
|
|
170
|
+
if (isDateNowCall(node)) {
|
|
171
|
+
context.report({ node, messageId: "dateNow" });
|
|
172
|
+
}
|
|
173
|
+
},
|
|
174
|
+
NewExpression(node) {
|
|
175
|
+
if (isBareNewDate(node)) {
|
|
176
|
+
context.report({ node, messageId: "newDate" });
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
// src/rules/no-focused-tests.ts
|
|
184
|
+
import { AST_NODE_TYPES as AST_NODE_TYPES3 } from "@typescript-eslint/utils";
|
|
185
|
+
var RULE_NAME3 = "no-focused-tests";
|
|
186
|
+
var FOCUSABLE_RUNNERS = /* @__PURE__ */ new Set(["it", "describe", "test"]);
|
|
187
|
+
var FOCUSED_CALL_NAMES = /* @__PURE__ */ new Set(["fdescribe", "fit", "ddescribe"]);
|
|
188
|
+
function rootRunnerName(node) {
|
|
189
|
+
let current = node;
|
|
190
|
+
while (current.type === AST_NODE_TYPES3.MemberExpression && !current.computed) {
|
|
191
|
+
current = current.object;
|
|
192
|
+
}
|
|
193
|
+
return current.type === AST_NODE_TYPES3.Identifier ? current.name : null;
|
|
194
|
+
}
|
|
195
|
+
var noFocusedTestsRule = createRule({
|
|
196
|
+
name: RULE_NAME3,
|
|
197
|
+
meta: {
|
|
198
|
+
type: "problem",
|
|
199
|
+
docs: {
|
|
200
|
+
description: "Ban focused tests (it.only / describe.only / test.only, fdescribe / fit / ddescribe) so a focused test never silently lands in CI."
|
|
201
|
+
},
|
|
202
|
+
schema: [],
|
|
203
|
+
messages: {
|
|
204
|
+
focused: "Focused test: remove `.only` from `{{runner}}.only(...)` so the whole suite runs in CI.",
|
|
205
|
+
focusedCall: "Focused test: replace `{{name}}(...)` with `{{base}}(...)` so the whole suite runs in CI."
|
|
206
|
+
}
|
|
207
|
+
},
|
|
208
|
+
defaultOptions: [],
|
|
209
|
+
create(context) {
|
|
210
|
+
return {
|
|
211
|
+
MemberExpression(node) {
|
|
212
|
+
if (node.property.type !== AST_NODE_TYPES3.Identifier || node.property.name !== "only") {
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
const runner = rootRunnerName(node.object);
|
|
216
|
+
if (runner !== null && FOCUSABLE_RUNNERS.has(runner)) {
|
|
217
|
+
context.report({
|
|
218
|
+
node: node.property,
|
|
219
|
+
messageId: "focused",
|
|
220
|
+
data: { runner }
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
},
|
|
224
|
+
CallExpression(node) {
|
|
225
|
+
if (node.callee.type === AST_NODE_TYPES3.Identifier && FOCUSED_CALL_NAMES.has(node.callee.name)) {
|
|
226
|
+
const name = node.callee.name;
|
|
227
|
+
context.report({
|
|
228
|
+
node: node.callee,
|
|
229
|
+
messageId: "focusedCall",
|
|
230
|
+
data: { name, base: name.startsWith("f") ? name.slice(1) : "describe" }
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
// src/utils/comments.ts
|
|
239
|
+
function commentText(comment) {
|
|
240
|
+
if (comment.type === "Line") {
|
|
241
|
+
return comment.value.trim();
|
|
242
|
+
}
|
|
243
|
+
return comment.value.split("\n").map((line) => line.replace(/^\s*\*?/u, "")).join(" ").trim();
|
|
244
|
+
}
|
|
245
|
+
function looksLikeJsDoc(comment) {
|
|
246
|
+
return comment.type === "Block" && comment.value.startsWith("*");
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// src/rules/no-historical-comments.ts
|
|
250
|
+
var RULE_NAME4 = "no-historical-comments";
|
|
251
|
+
var HISTORICAL_PATTERNS = [
|
|
252
|
+
/\bbefore\s+the\s+fix\b/iu,
|
|
253
|
+
/\bafter\s+the\s+fix\b/iu,
|
|
254
|
+
/\bbefore\s+the\s+refactor\b/iu,
|
|
255
|
+
/\bafter\s+the\s+refactor\b/iu,
|
|
256
|
+
/\bwe\s+used\s+to\b/iu,
|
|
257
|
+
/\bthis\s+used\s+to\b/iu,
|
|
258
|
+
/\bused\s+to\s+be\b/iu,
|
|
259
|
+
/\bno\s+longer\b/iu,
|
|
260
|
+
/\bkept\s+for\s+(?:backwards|backward|legacy|compat)\b/iu,
|
|
261
|
+
/\b(?:was|were)\s+a\s+(?:footgun|bug)\b/iu,
|
|
262
|
+
/\bhistorical(?:ly)?\b/iu
|
|
263
|
+
];
|
|
264
|
+
var noHistoricalCommentsRule = createRule({
|
|
265
|
+
name: RULE_NAME4,
|
|
266
|
+
meta: {
|
|
267
|
+
type: "suggestion",
|
|
268
|
+
docs: {
|
|
269
|
+
description: "Disallow comments that frame code relative to what it used to do or to a past incident ('before the fix', 'after the refactor', 'we used to', 'no longer'). Source comments describe the current invariant; history belongs in the commit message or PR description, where it does not rot when the code changes again."
|
|
270
|
+
},
|
|
271
|
+
schema: [],
|
|
272
|
+
messages: {
|
|
273
|
+
historicalComment: "Historical narration ({{snippet}}). Source comments describe the current invariant, not what the code used to do. Move the history to the commit message or delete the comment."
|
|
274
|
+
}
|
|
275
|
+
},
|
|
276
|
+
defaultOptions: [],
|
|
277
|
+
create(context) {
|
|
278
|
+
return {
|
|
279
|
+
Program() {
|
|
280
|
+
for (const comment of context.sourceCode.getAllComments()) {
|
|
281
|
+
if (looksLikeJsDoc(comment)) {
|
|
282
|
+
continue;
|
|
283
|
+
}
|
|
284
|
+
const text = commentText(comment);
|
|
285
|
+
if (text === "") {
|
|
286
|
+
continue;
|
|
287
|
+
}
|
|
288
|
+
for (const pattern of HISTORICAL_PATTERNS) {
|
|
289
|
+
const match = pattern.exec(text);
|
|
290
|
+
if (match !== null) {
|
|
291
|
+
const matched = match[0];
|
|
292
|
+
const snippet = matched.length > 40 ? `${matched.slice(0, 40)}...` : matched;
|
|
293
|
+
context.report({
|
|
294
|
+
loc: comment.loc,
|
|
295
|
+
messageId: "historicalComment",
|
|
296
|
+
data: { snippet }
|
|
297
|
+
});
|
|
298
|
+
break;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
// src/rules/no-narration-comments.ts
|
|
308
|
+
var RULE_NAME5 = "no-narration-comments";
|
|
309
|
+
var NARRATION_PATTERNS = [
|
|
310
|
+
/^here\s+we\b/iu,
|
|
311
|
+
/^now\s+we\b/iu,
|
|
312
|
+
/^first[,]?\s+we\b/iu,
|
|
313
|
+
/^then[,]?\s+we\b/iu,
|
|
314
|
+
/^next[,]?\s+we\b/iu,
|
|
315
|
+
/^finally[,]?\s+we\b/iu,
|
|
316
|
+
/^let's\b/iu,
|
|
317
|
+
/^let\s+me\b/iu
|
|
318
|
+
];
|
|
319
|
+
var noNarrationCommentsRule = createRule({
|
|
320
|
+
name: RULE_NAME5,
|
|
321
|
+
meta: {
|
|
322
|
+
type: "suggestion",
|
|
323
|
+
docs: {
|
|
324
|
+
description: "Disallow narrative comments like 'Here we...', 'Now we...', 'First, we...'. These read as step-by-step prose and add no information a future reader cannot get from the code itself. Often a tell that the comment was generated by an agent describing its own changes."
|
|
325
|
+
},
|
|
326
|
+
schema: [],
|
|
327
|
+
messages: {
|
|
328
|
+
narrationComment: "Narrative comment ({{snippet}}). Describe the WHY, not the sequence of operations, or delete the comment."
|
|
329
|
+
}
|
|
330
|
+
},
|
|
331
|
+
defaultOptions: [],
|
|
332
|
+
create(context) {
|
|
333
|
+
return {
|
|
334
|
+
Program() {
|
|
335
|
+
for (const comment of context.sourceCode.getAllComments()) {
|
|
336
|
+
if (looksLikeJsDoc(comment)) {
|
|
337
|
+
continue;
|
|
338
|
+
}
|
|
339
|
+
const text = commentText(comment);
|
|
340
|
+
if (text === "") {
|
|
341
|
+
continue;
|
|
342
|
+
}
|
|
343
|
+
for (const pattern of NARRATION_PATTERNS) {
|
|
344
|
+
if (pattern.test(text)) {
|
|
345
|
+
const snippet = text.length > 40 ? `${text.slice(0, 40)}...` : text;
|
|
346
|
+
context.report({
|
|
347
|
+
loc: comment.loc,
|
|
348
|
+
messageId: "narrationComment",
|
|
349
|
+
data: { snippet }
|
|
350
|
+
});
|
|
351
|
+
break;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
// src/rules/no-pr-reference-comments.ts
|
|
361
|
+
var RULE_NAME6 = "no-pr-reference-comments";
|
|
362
|
+
var PR_PATTERNS = [
|
|
363
|
+
{
|
|
364
|
+
pattern: /https?:\/\/github\.com\/[^\s)]+\/(?:pull|issues)\/\d+/iu,
|
|
365
|
+
label: "GitHub PR/issue URL"
|
|
366
|
+
},
|
|
367
|
+
{
|
|
368
|
+
pattern: /\b(?:see|closes?|fixes|fixed|addresses|resolves?|refs?)\s+#\d+/iu,
|
|
369
|
+
label: "issue/PR reference"
|
|
370
|
+
},
|
|
371
|
+
{
|
|
372
|
+
pattern: /\bPRs?\s+#?\d+/iu,
|
|
373
|
+
label: "PR number"
|
|
374
|
+
},
|
|
375
|
+
{
|
|
376
|
+
pattern: /(?:^|[\s(])#\d+\b/u,
|
|
377
|
+
label: "issue/PR number"
|
|
378
|
+
}
|
|
379
|
+
];
|
|
380
|
+
var noPrReferenceCommentsRule = createRule({
|
|
381
|
+
name: RULE_NAME6,
|
|
382
|
+
meta: {
|
|
383
|
+
type: "suggestion",
|
|
384
|
+
docs: {
|
|
385
|
+
description: "Disallow PR/issue references in comments. They belong in commit messages and PR descriptions, where they do not rot when the repo moves, the issue tracker migrates, or the numbering changes."
|
|
386
|
+
},
|
|
387
|
+
schema: [],
|
|
388
|
+
messages: {
|
|
389
|
+
prReferenceComment: "Comment contains {{label}} ({{snippet}}). Move it to the commit message or PR description. The git log is the canonical place for repo-history references."
|
|
390
|
+
}
|
|
391
|
+
},
|
|
392
|
+
defaultOptions: [],
|
|
393
|
+
create(context) {
|
|
394
|
+
return {
|
|
395
|
+
Program() {
|
|
396
|
+
for (const comment of context.sourceCode.getAllComments()) {
|
|
397
|
+
const text = comment.value;
|
|
398
|
+
if (text.trim() === "") {
|
|
399
|
+
continue;
|
|
400
|
+
}
|
|
401
|
+
for (const { pattern, label } of PR_PATTERNS) {
|
|
402
|
+
const match = pattern.exec(text);
|
|
403
|
+
if (match === null) {
|
|
404
|
+
continue;
|
|
405
|
+
}
|
|
406
|
+
const matched = match[0].trim();
|
|
407
|
+
const snippet = matched.length > 40 ? `${matched.slice(0, 40)}...` : matched;
|
|
408
|
+
context.report({
|
|
409
|
+
loc: comment.loc,
|
|
410
|
+
messageId: "prReferenceComment",
|
|
411
|
+
data: { label, snippet }
|
|
412
|
+
});
|
|
413
|
+
break;
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
// src/rules/no-process-exit.ts
|
|
422
|
+
import "@typescript-eslint/utils";
|
|
423
|
+
|
|
424
|
+
// src/utils/ast.ts
|
|
425
|
+
import { AST_NODE_TYPES as AST_NODE_TYPES4 } from "@typescript-eslint/utils";
|
|
426
|
+
function isEmptyStringLiteral(node) {
|
|
427
|
+
return node.type === AST_NODE_TYPES4.Literal && node.value === "";
|
|
428
|
+
}
|
|
429
|
+
function isStaticMemberAccess(node, objectName, propertyName) {
|
|
430
|
+
if (node.type !== AST_NODE_TYPES4.MemberExpression || node.object.type !== AST_NODE_TYPES4.Identifier || node.object.name !== objectName) {
|
|
431
|
+
return false;
|
|
432
|
+
}
|
|
433
|
+
if (node.computed) {
|
|
434
|
+
return node.property.type === AST_NODE_TYPES4.Literal && node.property.value === propertyName;
|
|
435
|
+
}
|
|
436
|
+
return node.property.type === AST_NODE_TYPES4.Identifier && node.property.name === propertyName;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// src/rules/no-process-exit.ts
|
|
440
|
+
var RULE_NAME7 = "no-process-exit";
|
|
441
|
+
var DEFAULT_ALLOW_IN2 = [
|
|
442
|
+
"**/scripts/**",
|
|
443
|
+
"**/bin/**",
|
|
444
|
+
"**/cli/**",
|
|
445
|
+
"**/*.config.{ts,js,mjs,cjs,cts,mts}"
|
|
446
|
+
];
|
|
447
|
+
var optionSchema2 = {
|
|
448
|
+
type: "object",
|
|
449
|
+
additionalProperties: false,
|
|
450
|
+
properties: {
|
|
451
|
+
allowIn: {
|
|
452
|
+
type: "array",
|
|
453
|
+
items: { type: "string" },
|
|
454
|
+
uniqueItems: true
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
};
|
|
458
|
+
function isProcessExit(node) {
|
|
459
|
+
return isStaticMemberAccess(node.callee, "process", "exit");
|
|
460
|
+
}
|
|
461
|
+
var noProcessExitRule = createRule({
|
|
462
|
+
name: RULE_NAME7,
|
|
463
|
+
meta: {
|
|
464
|
+
type: "problem",
|
|
465
|
+
docs: {
|
|
466
|
+
description: "Disallow `process.exit()` outside bootstrap/shutdown paths and standalone CLIs. Application and service code must throw or reject so the lifecycle can shut down gracefully."
|
|
467
|
+
},
|
|
468
|
+
schema: [optionSchema2],
|
|
469
|
+
messages: {
|
|
470
|
+
processExit: "`process.exit()` is reserved for bootstrap/shutdown and CLI entrypoints. Throw or reject and let the lifecycle handle teardown."
|
|
471
|
+
}
|
|
472
|
+
},
|
|
473
|
+
defaultOptions: [{ allowIn: [...DEFAULT_ALLOW_IN2] }],
|
|
474
|
+
create(context, [options]) {
|
|
475
|
+
const allowIn = options.allowIn ?? DEFAULT_ALLOW_IN2;
|
|
476
|
+
if (matchesAny(context.filename, allowIn)) {
|
|
477
|
+
return {};
|
|
478
|
+
}
|
|
479
|
+
return {
|
|
480
|
+
CallExpression(node) {
|
|
481
|
+
if (isProcessExit(node)) {
|
|
482
|
+
context.report({ node, messageId: "processExit" });
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
};
|
|
486
|
+
}
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
// src/rules/no-template-trim-empty-ternary.ts
|
|
490
|
+
import { AST_NODE_TYPES as AST_NODE_TYPES5 } from "@typescript-eslint/utils";
|
|
491
|
+
var RULE_NAME8 = "no-template-trim-empty-ternary";
|
|
492
|
+
function isTrimCallOnTemplate(node) {
|
|
493
|
+
return node.type === AST_NODE_TYPES5.CallExpression && node.callee.type === AST_NODE_TYPES5.MemberExpression && node.callee.property.type === AST_NODE_TYPES5.Identifier && node.callee.property.name === "trim" && node.callee.object.type === AST_NODE_TYPES5.TemplateLiteral;
|
|
494
|
+
}
|
|
495
|
+
function matchesTemplateTrimEmptyTest(test) {
|
|
496
|
+
if (test.type !== AST_NODE_TYPES5.BinaryExpression) {
|
|
497
|
+
return false;
|
|
498
|
+
}
|
|
499
|
+
if (test.operator !== "===" && test.operator !== "!==") {
|
|
500
|
+
return false;
|
|
501
|
+
}
|
|
502
|
+
return isTrimCallOnTemplate(test.left) && isEmptyStringLiteral(test.right) || isEmptyStringLiteral(test.left) && isTrimCallOnTemplate(test.right);
|
|
503
|
+
}
|
|
504
|
+
var noTemplateTrimEmptyTernaryRule = createRule({
|
|
505
|
+
name: RULE_NAME8,
|
|
506
|
+
meta: {
|
|
507
|
+
type: "suggestion",
|
|
508
|
+
docs: {
|
|
509
|
+
description: "Disallow inline `<template>.trim() === '' ? fallback : <template>.trim()` patterns. Extract to a named utility so the expression is built once and is unit-testable in one place."
|
|
510
|
+
},
|
|
511
|
+
schema: [],
|
|
512
|
+
messages: {
|
|
513
|
+
extractToUtil: "Extract this `<template>.trim() === ''` fallback pattern to a named util (e.g. `buildDisplayName(...)`). The inline ternary builds the same expression twice and is not unit-testable in one place."
|
|
514
|
+
}
|
|
515
|
+
},
|
|
516
|
+
defaultOptions: [],
|
|
517
|
+
create(context) {
|
|
518
|
+
return {
|
|
519
|
+
ConditionalExpression(node) {
|
|
520
|
+
if (matchesTemplateTrimEmptyTest(node.test)) {
|
|
521
|
+
context.report({ node, messageId: "extractToUtil" });
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
};
|
|
525
|
+
}
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
// src/utils/preferEarlyReturn.ts
|
|
529
|
+
import { AST_NODE_TYPES as AST_NODE_TYPES6 } from "@typescript-eslint/utils";
|
|
530
|
+
var MIN_CONSEQUENT_STATEMENTS = 2;
|
|
531
|
+
function getFunctionBlockBody(node) {
|
|
532
|
+
if (node.body.type !== AST_NODE_TYPES6.BlockStatement) {
|
|
533
|
+
return null;
|
|
534
|
+
}
|
|
535
|
+
return node.body;
|
|
536
|
+
}
|
|
537
|
+
function findWrappedHappyPathIf(body) {
|
|
538
|
+
if (body.body.length === 0) {
|
|
539
|
+
return null;
|
|
540
|
+
}
|
|
541
|
+
const lastStatement = body.body[body.body.length - 1];
|
|
542
|
+
if (lastStatement === void 0 || lastStatement.type !== AST_NODE_TYPES6.IfStatement) {
|
|
543
|
+
return null;
|
|
544
|
+
}
|
|
545
|
+
if (lastStatement.alternate !== null) {
|
|
546
|
+
return null;
|
|
547
|
+
}
|
|
548
|
+
if (lastStatement.consequent.type !== AST_NODE_TYPES6.BlockStatement) {
|
|
549
|
+
return null;
|
|
550
|
+
}
|
|
551
|
+
if (lastStatement.consequent.body.length < MIN_CONSEQUENT_STATEMENTS) {
|
|
552
|
+
return null;
|
|
553
|
+
}
|
|
554
|
+
return lastStatement;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
// src/rules/prefer-early-return.ts
|
|
558
|
+
var RULE_NAME9 = "prefer-early-return";
|
|
559
|
+
var preferEarlyReturnRule = createRule({
|
|
560
|
+
name: RULE_NAME9,
|
|
561
|
+
meta: {
|
|
562
|
+
type: "problem",
|
|
563
|
+
docs: {
|
|
564
|
+
description: "Prefer guard clauses (early return) over wrapping the whole function body in a multi-statement `if` without an `else`."
|
|
565
|
+
},
|
|
566
|
+
schema: [],
|
|
567
|
+
messages: {
|
|
568
|
+
preferEarlyReturn: "Use a guard clause (early return) instead of wrapping the function body in an `if`. Invert the condition and return early so the happy path stays at the top level."
|
|
569
|
+
}
|
|
570
|
+
},
|
|
571
|
+
defaultOptions: [],
|
|
572
|
+
create(context) {
|
|
573
|
+
function checkFunctionBody(node) {
|
|
574
|
+
const body = getFunctionBlockBody(node);
|
|
575
|
+
if (body === null) {
|
|
576
|
+
return;
|
|
577
|
+
}
|
|
578
|
+
const wrappedIf = findWrappedHappyPathIf(body);
|
|
579
|
+
if (wrappedIf !== null) {
|
|
580
|
+
context.report({ node: wrappedIf, messageId: "preferEarlyReturn" });
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
return {
|
|
584
|
+
FunctionDeclaration: checkFunctionBody,
|
|
585
|
+
FunctionExpression: checkFunctionBody,
|
|
586
|
+
ArrowFunctionExpression: checkFunctionBody
|
|
587
|
+
};
|
|
588
|
+
}
|
|
589
|
+
});
|
|
590
|
+
|
|
591
|
+
// src/rules/skipped-tests-need-tracking.ts
|
|
592
|
+
var RULE_NAME10 = "skipped-tests-need-tracking";
|
|
593
|
+
var SKIP_PATTERNS = [
|
|
594
|
+
{ pattern: /\b(?:it|test|describe)\.skip\s*\(/u, label: ".skip(" },
|
|
595
|
+
{ pattern: /\b(?:it|test|describe)\.fixme\s*\(/u, label: ".fixme(" },
|
|
596
|
+
{ pattern: /\bxit\s*\(/u, label: "xit(" },
|
|
597
|
+
{ pattern: /\bxdescribe\s*\(/u, label: "xdescribe(" },
|
|
598
|
+
{ pattern: /\bxtest\s*\(/u, label: "xtest(" }
|
|
599
|
+
];
|
|
600
|
+
var DEFAULT_MARKERS = ["https?://\\S+", "TODO\\(@?\\S+\\)"];
|
|
601
|
+
var DEFAULT_LOOKBACK = 30;
|
|
602
|
+
var optionSchema3 = {
|
|
603
|
+
type: "object",
|
|
604
|
+
additionalProperties: false,
|
|
605
|
+
properties: {
|
|
606
|
+
markers: {
|
|
607
|
+
type: "array",
|
|
608
|
+
items: { type: "string" },
|
|
609
|
+
uniqueItems: true,
|
|
610
|
+
minItems: 1
|
|
611
|
+
},
|
|
612
|
+
lookback: { type: "integer", minimum: 0 }
|
|
613
|
+
}
|
|
614
|
+
};
|
|
615
|
+
var skippedTestsNeedTrackingRule = createRule({
|
|
616
|
+
name: RULE_NAME10,
|
|
617
|
+
meta: {
|
|
618
|
+
type: "problem",
|
|
619
|
+
docs: {
|
|
620
|
+
description: "Skipped tests (`.skip` / `.fixme` / `xit` / `xdescribe`) must carry a tracking marker (an issue URL or `TODO(@owner)`) on or above the line, so the debt has an owner instead of rotting silently."
|
|
621
|
+
},
|
|
622
|
+
schema: [optionSchema3],
|
|
623
|
+
messages: {
|
|
624
|
+
needsTracking: "Skipped test `{{label}}` has no tracking marker. Add an issue URL or `TODO(@owner)` on the same line or above so the skip has an owner."
|
|
625
|
+
}
|
|
626
|
+
},
|
|
627
|
+
defaultOptions: [{ markers: [...DEFAULT_MARKERS], lookback: DEFAULT_LOOKBACK }],
|
|
628
|
+
create(context, [options]) {
|
|
629
|
+
const markerSources = options.markers ?? DEFAULT_MARKERS;
|
|
630
|
+
const markers = markerSources.map((source) => new RegExp(source, "u"));
|
|
631
|
+
const lookback = options.lookback ?? DEFAULT_LOOKBACK;
|
|
632
|
+
const lines = context.sourceCode.lines;
|
|
633
|
+
function hasTrackingMarker(fromLine, toLine) {
|
|
634
|
+
const window = lines.slice(fromLine, toLine + 1).join("\n");
|
|
635
|
+
return markers.some((marker) => marker.test(window));
|
|
636
|
+
}
|
|
637
|
+
return {
|
|
638
|
+
Program() {
|
|
639
|
+
for (let i = 0; i < lines.length; i++) {
|
|
640
|
+
const line = lines[i] ?? "";
|
|
641
|
+
for (const { pattern, label } of SKIP_PATTERNS) {
|
|
642
|
+
if (!pattern.test(line)) {
|
|
643
|
+
continue;
|
|
644
|
+
}
|
|
645
|
+
const start = Math.max(0, i - lookback);
|
|
646
|
+
if (hasTrackingMarker(start, i)) {
|
|
647
|
+
continue;
|
|
648
|
+
}
|
|
649
|
+
context.report({
|
|
650
|
+
loc: {
|
|
651
|
+
start: { line: i + 1, column: 0 },
|
|
652
|
+
end: { line: i + 1, column: line.length }
|
|
653
|
+
},
|
|
654
|
+
messageId: "needsTracking",
|
|
655
|
+
data: { label }
|
|
656
|
+
});
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
};
|
|
661
|
+
}
|
|
662
|
+
});
|
|
663
|
+
|
|
664
|
+
// src/rules/index.ts
|
|
665
|
+
var rules = {
|
|
666
|
+
"prefer-early-return": preferEarlyReturnRule,
|
|
667
|
+
"no-process-exit": noProcessExitRule,
|
|
668
|
+
"no-bare-date-now": noBareDateNowRule,
|
|
669
|
+
"no-historical-comments": noHistoricalCommentsRule,
|
|
670
|
+
"no-narration-comments": noNarrationCommentsRule,
|
|
671
|
+
"no-pr-reference-comments": noPrReferenceCommentsRule,
|
|
672
|
+
"no-focused-tests": noFocusedTestsRule,
|
|
673
|
+
"skipped-tests-need-tracking": skippedTestsNeedTrackingRule,
|
|
674
|
+
// Available but omitted from `recommended` (opinionated / niche).
|
|
675
|
+
"interface-prefix-i": interfacePrefixIRule,
|
|
676
|
+
"no-template-trim-empty-ternary": noTemplateTrimEmptyTernaryRule
|
|
677
|
+
};
|
|
678
|
+
|
|
679
|
+
// src/index.ts
|
|
680
|
+
var NAMESPACE = "noctcore-code-quality";
|
|
681
|
+
var VERSION = "0.1.0";
|
|
682
|
+
var plugin = {
|
|
683
|
+
meta: { name: "@noctcore/eslint-plugin-code-quality", version: VERSION },
|
|
684
|
+
rules,
|
|
685
|
+
configs: {}
|
|
686
|
+
};
|
|
687
|
+
plugin.configs.recommended = {
|
|
688
|
+
plugins: { [NAMESPACE]: plugin },
|
|
689
|
+
rules: recommended
|
|
690
|
+
};
|
|
691
|
+
var configs = plugin.configs;
|
|
692
|
+
var index_default = plugin;
|
|
693
|
+
export {
|
|
694
|
+
configs,
|
|
695
|
+
index_default as default,
|
|
696
|
+
rules
|
|
697
|
+
};
|