@flint.fyi/plugin-flint 0.2.1 → 0.5.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/lib/index.d.ts +97 -1
- package/lib/index.js +836 -1
- package/lib/plugin.d.ts +93 -0
- package/lib/rules/getStartSourceFile.d.ts +10 -0
- package/lib/rules/invalidCodeLines.d.ts +5 -2
- package/lib/rules/missingPlaceholders.d.ts +10 -0
- package/lib/rules/nodePropertyInChecks.d.ts +10 -0
- package/lib/rules/placeholderFormats.d.ts +10 -0
- package/lib/rules/pluginRuleOrdering.d.ts +10 -0
- package/lib/rules/ruleCreationMethods.d.ts +10 -0
- package/lib/rules/ruleCreator.d.ts +3 -0
- package/lib/rules/testCaseDuplicates.d.ts +5 -2
- package/lib/rules/testCaseNameDuplicates.d.ts +10 -0
- package/lib/rules/testCaseNonStaticCode.d.ts +10 -0
- package/lib/rules/testCaseOnlyFlags.d.ts +10 -0
- package/lib/rules/testShorthands.d.ts +10 -0
- package/lib/rules/unusedMessageIds.d.ts +10 -0
- package/lib/utils/findProperty.d.ts +4 -0
- package/lib/utils/getRuleTesterCaseArrays.d.ts +6 -0
- package/lib/utils/getRuleTesterDescribedCases.d.ts +6 -0
- package/lib/utils/importHelpers.d.ts +5 -0
- package/lib/utils/isTypeFromTS.d.ts +3 -0
- package/lib/utils/messageHelpers.d.ts +12 -0
- package/lib/utils/parseTestCases.d.ts +5 -0
- package/lib/utils/ruleCreatorHelpers.d.ts +5 -0
- package/lib/utils/tsAstToLiteral.d.ts +6 -0
- package/lib/utils/types.d.ts +21 -0
- package/package.json +12 -7
- package/lib/findProperty.d.ts +0 -3
- package/lib/findProperty.js +0 -8
- package/lib/flint.d.ts +0 -10
- package/lib/flint.js +0 -8
- package/lib/getRuleTesterDescribedCases.d.ts +0 -6
- package/lib/getRuleTesterDescribedCases.js +0 -32
- package/lib/parseTestCases.d.ts +0 -5
- package/lib/parseTestCases.js +0 -63
- package/lib/rules/invalidCodeLines.js +0 -77
- package/lib/rules/invalidCodeLines.test.d.ts +0 -2
- package/lib/rules/invalidCodeLines.test.js +0 -198
- package/lib/rules/ruleTester.d.ts +0 -3
- package/lib/rules/ruleTester.js +0 -8
- package/lib/rules/testCaseDuplicates.js +0 -56
- package/lib/rules/testCaseDuplicates.test.d.ts +0 -2
- package/lib/rules/testCaseDuplicates.test.js +0 -174
- package/lib/tsAstToLiteral.d.ts +0 -5
- package/lib/tsAstToLiteral.js +0 -38
- package/lib/types.d.ts +0 -18
- package/lib/types.js +0 -2
package/lib/index.js
CHANGED
|
@@ -1,2 +1,837 @@
|
|
|
1
|
-
|
|
1
|
+
import { RuleCreator, createPlugin } from "@flint.fyi/core";
|
|
2
|
+
import { getTSNodeRange, typescriptLanguage } from "@flint.fyi/typescript-language";
|
|
3
|
+
import ts, { SyntaxKind } from "typescript";
|
|
4
|
+
import { isTruthy } from "@flint.fyi/utils";
|
|
5
|
+
//#region src/utils/isTypeFromTS.ts
|
|
6
|
+
function isTypeFromTS(node, typeChecker, typeName) {
|
|
7
|
+
const type = typeChecker.getTypeAtLocation(node);
|
|
8
|
+
const visited = /* @__PURE__ */ new Set();
|
|
9
|
+
function check(type) {
|
|
10
|
+
if (visited.has(type)) return false;
|
|
11
|
+
visited.add(type);
|
|
12
|
+
if (type.isUnionOrIntersection()) return type.types.some((subType) => check(subType));
|
|
13
|
+
const symbol = type.getSymbol();
|
|
14
|
+
if (symbol?.getName() === typeName) return symbol.getDeclarations()?.some((declaration) => {
|
|
15
|
+
const sourceFile = declaration.getSourceFile().fileName;
|
|
16
|
+
return sourceFile.includes("node_modules/typescript") && sourceFile.endsWith(".d.ts");
|
|
17
|
+
}) ?? false;
|
|
18
|
+
const bases = type.getBaseTypes();
|
|
19
|
+
if (bases?.length) return bases.some((baseType) => check(baseType));
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
return check(type);
|
|
23
|
+
}
|
|
24
|
+
//#endregion
|
|
25
|
+
//#region src/rules/ruleCreator.ts
|
|
26
|
+
const ruleCreator = new RuleCreator({
|
|
27
|
+
docs: (ruleId) => `https://flint.fyi/rules/flint/${ruleId.toLowerCase()}`,
|
|
28
|
+
pluginId: "flint",
|
|
29
|
+
presets: [
|
|
30
|
+
"logical",
|
|
31
|
+
"logicalStrict",
|
|
32
|
+
"stylistic",
|
|
33
|
+
"stylisticStrict"
|
|
34
|
+
]
|
|
35
|
+
});
|
|
36
|
+
//#endregion
|
|
37
|
+
//#region src/rules/getStartSourceFile.ts
|
|
38
|
+
var getStartSourceFile_default = ruleCreator.createRule(typescriptLanguage, {
|
|
39
|
+
about: {
|
|
40
|
+
description: "Requires passing `sourceFile` to `getStart()` for better performance.",
|
|
41
|
+
id: "getStartSourceFile",
|
|
42
|
+
presets: ["logical"]
|
|
43
|
+
},
|
|
44
|
+
messages: { missingSourceFile: {
|
|
45
|
+
primary: "`getStart()` should be called with a `sourceFile` parameter for better performance.",
|
|
46
|
+
secondary: [
|
|
47
|
+
"TypeScript allows calling `node.getStart()` with or without a source file.",
|
|
48
|
+
"Providing the source file is slightly faster because TypeScript doesn't need to traverse up the AST to find it.",
|
|
49
|
+
"Consider using `getTSNodeRange()` helper function which already handles this correctly."
|
|
50
|
+
],
|
|
51
|
+
suggestions: ["Pass `sourceFile` as the argument to `getStart()`: `node.getStart(sourceFile)`.", "Or use `getTSNodeRange(node, sourceFile)` helper function instead."]
|
|
52
|
+
} },
|
|
53
|
+
setup(context) {
|
|
54
|
+
return { visitors: { CallExpression: (node, { sourceFile, typeChecker }) => {
|
|
55
|
+
if (node.expression.kind !== SyntaxKind.PropertyAccessExpression || node.expression.name.kind !== SyntaxKind.Identifier || node.expression.name.text !== "getStart" || !isTypeFromTS(node.expression.expression, typeChecker, "Node")) return;
|
|
56
|
+
if (!node.arguments.length) context.report({
|
|
57
|
+
message: "missingSourceFile",
|
|
58
|
+
range: getTSNodeRange(node, sourceFile)
|
|
59
|
+
});
|
|
60
|
+
} } };
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
//#endregion
|
|
64
|
+
//#region src/utils/findProperty.ts
|
|
65
|
+
function findProperty(properties, name, predicate) {
|
|
66
|
+
return properties.find((property) => property.kind === SyntaxKind.PropertyAssignment && property.name.kind === SyntaxKind.Identifier && property.name.text === name && predicate(property.initializer))?.initializer;
|
|
67
|
+
}
|
|
68
|
+
//#endregion
|
|
69
|
+
//#region src/utils/getRuleTesterCaseArrays.ts
|
|
70
|
+
function getRuleTesterCaseArrays(node) {
|
|
71
|
+
if (node.expression.kind !== SyntaxKind.PropertyAccessExpression || node.expression.expression.kind !== SyntaxKind.Identifier || node.expression.name.kind !== SyntaxKind.Identifier || node.expression.name.text !== "describe" || node.arguments.length !== 2) return;
|
|
72
|
+
const argument = node.arguments[1];
|
|
73
|
+
if (argument?.kind !== SyntaxKind.ObjectLiteralExpression) return;
|
|
74
|
+
const valid = findProperty(argument.properties, "valid", (node) => node.kind === SyntaxKind.ArrayLiteralExpression);
|
|
75
|
+
const invalid = findProperty(argument.properties, "invalid", (node) => node.kind === SyntaxKind.ArrayLiteralExpression);
|
|
76
|
+
if (!valid || !invalid) return;
|
|
77
|
+
return {
|
|
78
|
+
invalid,
|
|
79
|
+
valid
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
//#endregion
|
|
83
|
+
//#region src/utils/tsAstToLiteral.ts
|
|
84
|
+
function tsAstToLiteral(node) {
|
|
85
|
+
switch (node.kind) {
|
|
86
|
+
case SyntaxKind.FalseKeyword: return false;
|
|
87
|
+
case SyntaxKind.NullKeyword: return null;
|
|
88
|
+
case SyntaxKind.TrueKeyword: return true;
|
|
89
|
+
}
|
|
90
|
+
if (ts.isArrayLiteralExpression(node)) return node.elements.filter((element) => element.kind !== SyntaxKind.SpreadElement).map((element) => tsAstToLiteral(element));
|
|
91
|
+
if (ts.isNumericLiteral(node)) return parseFloat(node.text);
|
|
92
|
+
if (ts.isObjectLiteralExpression(node)) return Object.fromEntries(node.properties.filter((property) => ts.isPropertyAssignment(property) && (property.name.kind === SyntaxKind.Identifier || property.name.kind === SyntaxKind.StringLiteral)).map((property) => [property.name.escapedText || property.name.text, tsAstToLiteral(property.initializer)]));
|
|
93
|
+
if (ts.isStringLiteral(node)) return node.text;
|
|
94
|
+
}
|
|
95
|
+
//#endregion
|
|
96
|
+
//#region src/utils/parseTestCases.ts
|
|
97
|
+
function parseTestCase(node) {
|
|
98
|
+
if (node.kind === SyntaxKind.StringLiteral || node.kind === SyntaxKind.NoSubstitutionTemplateLiteral) return {
|
|
99
|
+
code: node.text,
|
|
100
|
+
nodes: {
|
|
101
|
+
case: node,
|
|
102
|
+
code: node
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
if (node.kind !== SyntaxKind.ObjectLiteralExpression) return;
|
|
106
|
+
const code = findProperty(node.properties, "code", (node) => node.kind === SyntaxKind.StringLiteral || node.kind === SyntaxKind.NoSubstitutionTemplateLiteral);
|
|
107
|
+
if (!code) return;
|
|
108
|
+
const fileName = findProperty(node.properties, "fileName", (node) => node.kind === SyntaxKind.StringLiteral || node.kind === SyntaxKind.NoSubstitutionTemplateLiteral);
|
|
109
|
+
const files = findProperty(node.properties, "files", (node) => node.kind === SyntaxKind.ObjectLiteralExpression);
|
|
110
|
+
const name = findProperty(node.properties, "name", (node) => node.kind === SyntaxKind.StringLiteral || node.kind === SyntaxKind.NoSubstitutionTemplateLiteral);
|
|
111
|
+
const options = findProperty(node.properties, "options", (node) => node.kind === SyntaxKind.ObjectLiteralExpression);
|
|
112
|
+
return {
|
|
113
|
+
code: code.text,
|
|
114
|
+
fileName: fileName?.text,
|
|
115
|
+
files: files && tsAstToLiteral(files),
|
|
116
|
+
name: name?.text,
|
|
117
|
+
nodes: {
|
|
118
|
+
case: node,
|
|
119
|
+
code,
|
|
120
|
+
fileName,
|
|
121
|
+
files,
|
|
122
|
+
name,
|
|
123
|
+
options
|
|
124
|
+
},
|
|
125
|
+
options: options && tsAstToLiteral(options)
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
function parseTestCaseInvalid(node) {
|
|
129
|
+
if (node.kind !== SyntaxKind.ObjectLiteralExpression) return;
|
|
130
|
+
const code = findProperty(node.properties, "code", (node) => node.kind === SyntaxKind.StringLiteral || node.kind === SyntaxKind.NoSubstitutionTemplateLiteral);
|
|
131
|
+
if (!code) return;
|
|
132
|
+
const fileName = findProperty(node.properties, "fileName", (node) => node.kind === SyntaxKind.StringLiteral || node.kind === SyntaxKind.NoSubstitutionTemplateLiteral);
|
|
133
|
+
const files = findProperty(node.properties, "files", (node) => node.kind === SyntaxKind.ObjectLiteralExpression);
|
|
134
|
+
const name = findProperty(node.properties, "name", (node) => node.kind === SyntaxKind.StringLiteral || node.kind === SyntaxKind.NoSubstitutionTemplateLiteral);
|
|
135
|
+
const options = findProperty(node.properties, "options", (node) => node.kind === SyntaxKind.ObjectLiteralExpression);
|
|
136
|
+
const snapshot = findProperty(node.properties, "snapshot", (node) => node.kind === SyntaxKind.StringLiteral || node.kind === SyntaxKind.NoSubstitutionTemplateLiteral);
|
|
137
|
+
if (!snapshot) return;
|
|
138
|
+
return {
|
|
139
|
+
code: code.text,
|
|
140
|
+
fileName: fileName?.text,
|
|
141
|
+
files: files && tsAstToLiteral(files),
|
|
142
|
+
name: name?.text,
|
|
143
|
+
nodes: {
|
|
144
|
+
case: node,
|
|
145
|
+
code,
|
|
146
|
+
fileName,
|
|
147
|
+
files,
|
|
148
|
+
name,
|
|
149
|
+
options,
|
|
150
|
+
snapshot
|
|
151
|
+
},
|
|
152
|
+
options: options && tsAstToLiteral(options),
|
|
153
|
+
snapshot: snapshot.text
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
//#endregion
|
|
157
|
+
//#region src/utils/getRuleTesterDescribedCases.ts
|
|
158
|
+
function getRuleTesterDescribedCases(node) {
|
|
159
|
+
const arrays = getRuleTesterCaseArrays(node);
|
|
160
|
+
if (!arrays) return;
|
|
161
|
+
return {
|
|
162
|
+
invalid: arrays.invalid.elements.map(parseTestCaseInvalid).filter(isTruthy),
|
|
163
|
+
valid: arrays.valid.elements.map(parseTestCase).filter(isTruthy)
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
//#endregion
|
|
167
|
+
//#region src/rules/invalidCodeLines.ts
|
|
168
|
+
var invalidCodeLines_default = ruleCreator.createRule(typescriptLanguage, {
|
|
169
|
+
about: {
|
|
170
|
+
description: "Reports cases for invalid code that isn't formatted across lines.",
|
|
171
|
+
id: "invalidCodeLines",
|
|
172
|
+
presets: ["logical"]
|
|
173
|
+
},
|
|
174
|
+
messages: { singleLineTest: {
|
|
175
|
+
primary: "This code block should be formatted across multiple lines for more readable reports.",
|
|
176
|
+
secondary: ["When writing `invalid` case code blocks, it's better to start code on a new line in a template literal string.", "Doing so allows the case's `snapshot` to visualize the `~` characters visually underneath reported ranges."],
|
|
177
|
+
suggestions: ["Delete this redundant test case.", "Change a property to make the test case unique."]
|
|
178
|
+
} },
|
|
179
|
+
setup(context) {
|
|
180
|
+
function checkTestCase(testCase, sourceFile) {
|
|
181
|
+
const fix = [...createNewlineFixes(testCase.code, testCase.nodes.code, sourceFile), ...createNewlineFixes(testCase.snapshot, testCase.nodes.snapshot, sourceFile)];
|
|
182
|
+
if (fix.length) context.report({
|
|
183
|
+
fix,
|
|
184
|
+
message: "singleLineTest",
|
|
185
|
+
range: getTSNodeRange(testCase.nodes.code, sourceFile)
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
function createNewlineFixes(code, node, sourceFile) {
|
|
189
|
+
if (node.kind === ts.SyntaxKind.StringLiteral) return [{
|
|
190
|
+
range: getTSNodeRange(node, sourceFile),
|
|
191
|
+
text: `\`\n${code}\n\``
|
|
192
|
+
}];
|
|
193
|
+
const changes = [];
|
|
194
|
+
if (!code.startsWith("\n")) changes.push({
|
|
195
|
+
range: {
|
|
196
|
+
begin: node.getStart(sourceFile) + 1,
|
|
197
|
+
end: node.getStart(sourceFile) + 1
|
|
198
|
+
},
|
|
199
|
+
text: "\n"
|
|
200
|
+
});
|
|
201
|
+
if (!code.endsWith("\n")) changes.push({
|
|
202
|
+
range: {
|
|
203
|
+
begin: node.getEnd() - 1,
|
|
204
|
+
end: node.getEnd() - 1
|
|
205
|
+
},
|
|
206
|
+
text: "\n"
|
|
207
|
+
});
|
|
208
|
+
return changes;
|
|
209
|
+
}
|
|
210
|
+
return { visitors: { CallExpression(node, { sourceFile }) {
|
|
211
|
+
const describedCases = getRuleTesterDescribedCases(node);
|
|
212
|
+
if (!describedCases) return;
|
|
213
|
+
for (const testCase of describedCases.invalid) checkTestCase(testCase, sourceFile);
|
|
214
|
+
} } };
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
//#endregion
|
|
218
|
+
//#region src/utils/messageHelpers.ts
|
|
219
|
+
function findMessagesProperty(node) {
|
|
220
|
+
const args = node.arguments[1];
|
|
221
|
+
if (args?.kind !== SyntaxKind.ObjectLiteralExpression) return;
|
|
222
|
+
const messagesProperty = args.properties.find((prop) => {
|
|
223
|
+
return prop.kind === SyntaxKind.PropertyAssignment && prop.name.kind === SyntaxKind.Identifier && prop.name.text === "messages";
|
|
224
|
+
});
|
|
225
|
+
if (messagesProperty?.kind === SyntaxKind.PropertyAssignment) return messagesProperty;
|
|
226
|
+
}
|
|
227
|
+
function* forEachMessageString(messagesProperty) {
|
|
228
|
+
if (messagesProperty.initializer.kind !== SyntaxKind.ObjectLiteralExpression) return;
|
|
229
|
+
for (const prop of messagesProperty.initializer.properties) {
|
|
230
|
+
if (prop.kind !== SyntaxKind.PropertyAssignment || prop.name.kind !== SyntaxKind.Identifier || prop.initializer.kind !== SyntaxKind.ObjectLiteralExpression) continue;
|
|
231
|
+
const messageId = prop.name.text;
|
|
232
|
+
for (const messageProp of prop.initializer.properties) {
|
|
233
|
+
if (messageProp.kind !== SyntaxKind.PropertyAssignment || messageProp.name.kind !== SyntaxKind.Identifier) continue;
|
|
234
|
+
const propertyName = messageProp.name.text;
|
|
235
|
+
if (messageProp.initializer.kind === SyntaxKind.StringLiteral) yield {
|
|
236
|
+
isInArray: false,
|
|
237
|
+
messageId,
|
|
238
|
+
node: messageProp.initializer,
|
|
239
|
+
propertyName
|
|
240
|
+
};
|
|
241
|
+
if (messageProp.initializer.kind === SyntaxKind.ArrayLiteralExpression) {
|
|
242
|
+
for (const el of messageProp.initializer.elements) if (el.kind === SyntaxKind.StringLiteral) yield {
|
|
243
|
+
isInArray: true,
|
|
244
|
+
messageId,
|
|
245
|
+
node: el,
|
|
246
|
+
propertyName
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
function getStringOriginalQuote(node, sourceFile) {
|
|
253
|
+
return node.getText(sourceFile)[0] ?? "\"";
|
|
254
|
+
}
|
|
255
|
+
//#endregion
|
|
256
|
+
//#region src/utils/ruleCreatorHelpers.ts
|
|
257
|
+
function isLanguageCreateRule(node, typeChecker) {
|
|
258
|
+
return node.expression.kind === SyntaxKind.PropertyAccessExpression && node.expression.name.text === "createRule" && !isRuleCreatorCreateRule(node, typeChecker);
|
|
259
|
+
}
|
|
260
|
+
function isTypedMethodCall(node, typeChecker, leftType, rightCall) {
|
|
261
|
+
if (node.expression.kind !== SyntaxKind.PropertyAccessExpression) return false;
|
|
262
|
+
const propertyAccess = node.expression;
|
|
263
|
+
return typeChecker.getTypeAtLocation(propertyAccess.expression).getSymbol()?.getName() === leftType && propertyAccess.name.text === rightCall;
|
|
264
|
+
}
|
|
265
|
+
const isRuleCreatorCreateRule = (node, typeChecker) => isTypedMethodCall(node, typeChecker, "RuleCreator", "createRule");
|
|
266
|
+
const isRuleContextReport = (node, typeChecker) => isTypedMethodCall(node, typeChecker, "RuleContext", "report");
|
|
267
|
+
//#endregion
|
|
268
|
+
//#region src/rules/missingPlaceholders.ts
|
|
269
|
+
function extractPlaceholders(text) {
|
|
270
|
+
const placeholders = /* @__PURE__ */ new Set();
|
|
271
|
+
for (const match of text.matchAll(/\{\{\s*(\w+)\s*\}\}/g)) if (match[1]) placeholders.add(match[1]);
|
|
272
|
+
return placeholders;
|
|
273
|
+
}
|
|
274
|
+
var missingPlaceholders_default = ruleCreator.createRule(typescriptLanguage, {
|
|
275
|
+
about: {
|
|
276
|
+
description: "Reports `context.report()` calls missing data for message placeholders.",
|
|
277
|
+
id: "missingPlaceholders",
|
|
278
|
+
presets: ["logical"]
|
|
279
|
+
},
|
|
280
|
+
messages: { missingPlaceholders: {
|
|
281
|
+
primary: "Message template requires placeholders in the data object.",
|
|
282
|
+
secondary: ["Message templates use `{{ placeholder }}` that must be provided via the data property.", "Each placeholder in the message template requires a corresponding key in the data object."],
|
|
283
|
+
suggestions: ["Add a data object with the required placeholder keys."]
|
|
284
|
+
} },
|
|
285
|
+
setup(context) {
|
|
286
|
+
const messagePlaceholders = /* @__PURE__ */ new Map();
|
|
287
|
+
function checkMessageInCreateRule(ruleCreatorNode) {
|
|
288
|
+
const messagesProperty = findMessagesProperty(ruleCreatorNode);
|
|
289
|
+
if (!messagesProperty) return;
|
|
290
|
+
for (const ctx of forEachMessageString(messagesProperty)) {
|
|
291
|
+
const placeholders = extractPlaceholders(ctx.node.text);
|
|
292
|
+
if (placeholders.size) {
|
|
293
|
+
const existing = messagePlaceholders.get(ctx.messageId);
|
|
294
|
+
if (existing) for (const p of placeholders) existing.add(p);
|
|
295
|
+
else messagePlaceholders.set(ctx.messageId, placeholders);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
function populateMessageInCreateRule(contextNode, sourceFile) {
|
|
300
|
+
const args = contextNode.arguments[0];
|
|
301
|
+
if (args?.kind !== SyntaxKind.ObjectLiteralExpression) return;
|
|
302
|
+
const properties = args.properties;
|
|
303
|
+
const messageProperty = findProperty(properties, "message", (node) => node.kind === SyntaxKind.StringLiteral);
|
|
304
|
+
if (!messageProperty) return;
|
|
305
|
+
const requiredPlaceholders = messagePlaceholders.get(messageProperty.text);
|
|
306
|
+
if (!requiredPlaceholders?.size) return;
|
|
307
|
+
const dataProperty = properties.find((prop) => prop.kind === SyntaxKind.PropertyAssignment && prop.name.kind === SyntaxKind.Identifier && prop.name.text === "data");
|
|
308
|
+
if (!dataProperty) {
|
|
309
|
+
context.report({
|
|
310
|
+
data: { placeholder: Array.from(requiredPlaceholders).join(", ") },
|
|
311
|
+
message: "missingPlaceholders",
|
|
312
|
+
range: getTSNodeRange(messageProperty, sourceFile)
|
|
313
|
+
});
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
if (dataProperty.initializer.kind !== SyntaxKind.ObjectLiteralExpression) return;
|
|
317
|
+
const dataKeys = /* @__PURE__ */ new Set();
|
|
318
|
+
dataProperty.initializer.properties.forEach((prop) => {
|
|
319
|
+
if (prop.kind === SyntaxKind.PropertyAssignment && prop.name.kind === SyntaxKind.Identifier) dataKeys.add(prop.name.text);
|
|
320
|
+
else if (prop.kind === SyntaxKind.ShorthandPropertyAssignment) dataKeys.add(prop.name.text);
|
|
321
|
+
});
|
|
322
|
+
const missingPlaceholders = /* @__PURE__ */ new Set();
|
|
323
|
+
for (const placeholder of requiredPlaceholders) if (!dataKeys.has(placeholder)) missingPlaceholders.add(placeholder);
|
|
324
|
+
if (missingPlaceholders.size) context.report({
|
|
325
|
+
data: { placeholder: Array.from(missingPlaceholders).join(", ") },
|
|
326
|
+
message: "missingPlaceholders",
|
|
327
|
+
range: getTSNodeRange(messageProperty, sourceFile)
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
return { visitors: {
|
|
331
|
+
CallExpression(node, { sourceFile, typeChecker }) {
|
|
332
|
+
if (isRuleCreatorCreateRule(node, typeChecker)) {
|
|
333
|
+
checkMessageInCreateRule(node);
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
if (isRuleContextReport(node, typeChecker)) {
|
|
337
|
+
populateMessageInCreateRule(node, sourceFile);
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
},
|
|
341
|
+
"SourceFile:exit"() {
|
|
342
|
+
messagePlaceholders.clear();
|
|
343
|
+
}
|
|
344
|
+
} };
|
|
345
|
+
}
|
|
346
|
+
});
|
|
347
|
+
//#endregion
|
|
348
|
+
//#region src/rules/nodePropertyInChecks.ts
|
|
349
|
+
var nodePropertyInChecks_default = ruleCreator.createRule(typescriptLanguage, {
|
|
350
|
+
about: {
|
|
351
|
+
description: "Disallows using the `in` operator to check properties on TypeScript nodes.",
|
|
352
|
+
id: "nodePropertyInChecks",
|
|
353
|
+
presets: ["logical"]
|
|
354
|
+
},
|
|
355
|
+
messages: { nodePropertyInChecks: {
|
|
356
|
+
primary: "Avoid using the `in` operator to check properties on TypeScript nodes.",
|
|
357
|
+
secondary: ["The `in` operator checks inherited properties and may not work reliably with TypeScript AST nodes.", "TypeScript AST nodes have complex prototype chains that can lead to unexpected results with `in` checks."],
|
|
358
|
+
suggestions: ["Access the property directly or use a type guard function like `ts.isXXX()` instead."]
|
|
359
|
+
} },
|
|
360
|
+
setup(context) {
|
|
361
|
+
return { visitors: { BinaryExpression(node, { sourceFile, typeChecker }) {
|
|
362
|
+
if (node.operatorToken.kind === SyntaxKind.InKeyword && isTypeFromTS(node.right, typeChecker, "Node")) context.report({
|
|
363
|
+
message: "nodePropertyInChecks",
|
|
364
|
+
range: getTSNodeRange(node, sourceFile)
|
|
365
|
+
});
|
|
366
|
+
} } };
|
|
367
|
+
}
|
|
368
|
+
});
|
|
369
|
+
//#endregion
|
|
370
|
+
//#region src/rules/placeholderFormats.ts
|
|
371
|
+
function formatPlaceholders(text) {
|
|
372
|
+
return text.replaceAll(/\{\{\s*(\w+)\s*\}\}/g, "{{ $1 }}");
|
|
373
|
+
}
|
|
374
|
+
function hasMalformedPlaceholders(text) {
|
|
375
|
+
const matches = text.match(/\{\{\s*\w+\s*\}\}/g);
|
|
376
|
+
if (!matches) return false;
|
|
377
|
+
return matches.some((match) => !/\{\{ \w+ \}\}/.test(match));
|
|
378
|
+
}
|
|
379
|
+
var placeholderFormats_default = ruleCreator.createRule(typescriptLanguage, {
|
|
380
|
+
about: {
|
|
381
|
+
description: "Reports and auto-fixes message placeholders that are not formatted as `{{ placeholder }}`.",
|
|
382
|
+
id: "placeholderFormats",
|
|
383
|
+
presets: ["stylistic", "stylisticStrict"]
|
|
384
|
+
},
|
|
385
|
+
messages: { placeholderFormats: {
|
|
386
|
+
primary: "Placeholders should be formatted with single spaces inside the braces.",
|
|
387
|
+
secondary: ["Consistent formatting improves readability of message templates.", "Use exactly one space after the opening braces and before the closing braces."],
|
|
388
|
+
suggestions: ["Format placeholder with proper spacing."]
|
|
389
|
+
} },
|
|
390
|
+
setup(context) {
|
|
391
|
+
function checkStringLiteral(node, sourceFile) {
|
|
392
|
+
const text = node.text;
|
|
393
|
+
if (!hasMalformedPlaceholders(text)) return;
|
|
394
|
+
const fixedText = formatPlaceholders(text);
|
|
395
|
+
const quote = getStringOriginalQuote(node, sourceFile);
|
|
396
|
+
context.report({
|
|
397
|
+
fix: {
|
|
398
|
+
range: getTSNodeRange(node, sourceFile),
|
|
399
|
+
text: `${quote}${fixedText}${quote}`
|
|
400
|
+
},
|
|
401
|
+
message: "placeholderFormats",
|
|
402
|
+
range: getTSNodeRange(node, sourceFile)
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
return { visitors: { CallExpression(node, { sourceFile, typeChecker }) {
|
|
406
|
+
if (!isRuleCreatorCreateRule(node, typeChecker)) return;
|
|
407
|
+
const messagesProperty = findMessagesProperty(node);
|
|
408
|
+
if (!messagesProperty) return;
|
|
409
|
+
for (const ctx of forEachMessageString(messagesProperty)) checkStringLiteral(ctx.node, sourceFile);
|
|
410
|
+
} } };
|
|
411
|
+
}
|
|
412
|
+
});
|
|
413
|
+
//#endregion
|
|
414
|
+
//#region src/rules/pluginRuleOrdering.ts
|
|
415
|
+
function compareRuleNames(left, right) {
|
|
416
|
+
const leftLower = left.toLowerCase();
|
|
417
|
+
const rightLower = right.toLowerCase();
|
|
418
|
+
if (leftLower === rightLower) {
|
|
419
|
+
if (left === right) return 0;
|
|
420
|
+
return left < right ? -1 : 1;
|
|
421
|
+
}
|
|
422
|
+
return leftLower < rightLower ? -1 : 1;
|
|
423
|
+
}
|
|
424
|
+
function hasCommentsInArray(array, sourceFile) {
|
|
425
|
+
const arrayText = sourceFile.text.slice(array.getStart(sourceFile), array.getEnd());
|
|
426
|
+
return arrayText.includes("//") || arrayText.includes("/*");
|
|
427
|
+
}
|
|
428
|
+
function isCreatePluginCall(node, typeChecker) {
|
|
429
|
+
if (node.expression.kind !== SyntaxKind.Identifier) return false;
|
|
430
|
+
const symbol = typeChecker.getSymbolAtLocation(node.expression);
|
|
431
|
+
if (!symbol) return false;
|
|
432
|
+
const resolvedSymbol = symbol.flags & ts.SymbolFlags.Alias ? typeChecker.getAliasedSymbol(symbol) : symbol;
|
|
433
|
+
if (resolvedSymbol.getName() !== "createPlugin") return false;
|
|
434
|
+
return resolvedSymbol.getDeclarations()?.some((declaration) => {
|
|
435
|
+
const fileName = declaration.getSourceFile().fileName.replaceAll("\\", "/");
|
|
436
|
+
return fileName.includes("/core/") && /plugins\/createPlugin\.(?:d\.)?[cm]?[jt]s$/.test(fileName);
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
var pluginRuleOrdering_default = ruleCreator.createRule(typescriptLanguage, {
|
|
440
|
+
about: {
|
|
441
|
+
description: "Reports Flint plugin `rules` arrays that are not sorted alphabetically.",
|
|
442
|
+
id: "pluginRuleOrdering",
|
|
443
|
+
presets: ["stylisticStrict"]
|
|
444
|
+
},
|
|
445
|
+
messages: { pluginRuleOrdering: {
|
|
446
|
+
primary: "Flint plugin rules should be listed in alphabetical order.",
|
|
447
|
+
secondary: ["Keeping plugin rule arrays sorted makes them easier to scan and update.", "This rule only checks `createPlugin({ rules: [...] })` arrays made of plain identifiers."],
|
|
448
|
+
suggestions: ["Sort the rules array alphabetically."]
|
|
449
|
+
} },
|
|
450
|
+
setup(context) {
|
|
451
|
+
function checkRulesArray(array, sourceFile) {
|
|
452
|
+
const elements = array.elements.filter((element) => element.kind === SyntaxKind.Identifier);
|
|
453
|
+
const firstElement = elements[0];
|
|
454
|
+
const lastElement = elements.at(-1);
|
|
455
|
+
if (!firstElement || !lastElement || firstElement === lastElement) return;
|
|
456
|
+
const names = elements.map((element) => element.text);
|
|
457
|
+
const sorted = [...names].toSorted(compareRuleNames);
|
|
458
|
+
const firstOutOfOrderIndex = names.findIndex((name, index) => name !== sorted[index]);
|
|
459
|
+
if (firstOutOfOrderIndex === -1) return;
|
|
460
|
+
const firstOutOfOrderElement = elements[firstOutOfOrderIndex];
|
|
461
|
+
if (!firstOutOfOrderElement) return;
|
|
462
|
+
const sortedElements = [...elements].sort((a, b) => compareRuleNames(a.text, b.text));
|
|
463
|
+
const fix = hasCommentsInArray(array, sourceFile) ? void 0 : [{
|
|
464
|
+
range: {
|
|
465
|
+
begin: firstElement.getStart(sourceFile),
|
|
466
|
+
end: lastElement.getEnd()
|
|
467
|
+
},
|
|
468
|
+
text: sortedElements.map((sorted, index) => {
|
|
469
|
+
const next = elements[index + 1];
|
|
470
|
+
const current = elements[index];
|
|
471
|
+
if (!current || !next) return sorted.text;
|
|
472
|
+
return sorted.text + sourceFile.text.slice(current.getEnd(), next.getStart(sourceFile));
|
|
473
|
+
}).join("")
|
|
474
|
+
}];
|
|
475
|
+
context.report({
|
|
476
|
+
fix,
|
|
477
|
+
message: "pluginRuleOrdering",
|
|
478
|
+
range: getTSNodeRange(firstOutOfOrderElement, sourceFile)
|
|
479
|
+
});
|
|
480
|
+
}
|
|
481
|
+
return { visitors: { CallExpression(node, { sourceFile, typeChecker }) {
|
|
482
|
+
if (!isCreatePluginCall(node, typeChecker)) return;
|
|
483
|
+
const options = node.arguments[0];
|
|
484
|
+
if (options?.kind !== SyntaxKind.ObjectLiteralExpression) return;
|
|
485
|
+
const rulesProperty = findProperty(options.properties, "rules", (node) => node.kind === SyntaxKind.ArrayLiteralExpression);
|
|
486
|
+
if (!rulesProperty) return;
|
|
487
|
+
checkRulesArray(rulesProperty, sourceFile);
|
|
488
|
+
} } };
|
|
489
|
+
}
|
|
490
|
+
});
|
|
491
|
+
//#endregion
|
|
492
|
+
//#region src/rules/ruleCreationMethods.ts
|
|
493
|
+
var ruleCreationMethods_default = ruleCreator.createRule(typescriptLanguage, {
|
|
494
|
+
about: {
|
|
495
|
+
description: "Reports plugin rules created directly with language instead of through RuleCreator.",
|
|
496
|
+
id: "ruleCreationMethods",
|
|
497
|
+
presets: ["logical"]
|
|
498
|
+
},
|
|
499
|
+
messages: { ruleCreationMethods: {
|
|
500
|
+
primary: "Plugin rules should be created through RuleCreator instead of calling language.createRule() directly.",
|
|
501
|
+
secondary: ["Direct language creation bypasses the standardized rule metadata and documentation provided by RuleCreator.", "RuleCreator adds the `docs` property and ensures consistent rule structure across plugins."],
|
|
502
|
+
suggestions: ["Create an instance of RuleCreator from `@flint.fyi/core` (e.g. `const ruleCreator = new RuleCreator({ presets: [...] })`), then use `ruleCreator.createRule(language, { ... })` instead of `language.createRule({ ... })`."]
|
|
503
|
+
} },
|
|
504
|
+
setup(context) {
|
|
505
|
+
return { visitors: { CallExpression(node, { sourceFile, typeChecker }) {
|
|
506
|
+
if (!isLanguageCreateRule(node, typeChecker)) return;
|
|
507
|
+
context.report({
|
|
508
|
+
message: "ruleCreationMethods",
|
|
509
|
+
range: getTSNodeRange(node.expression, sourceFile)
|
|
510
|
+
});
|
|
511
|
+
} } };
|
|
512
|
+
}
|
|
513
|
+
});
|
|
514
|
+
//#endregion
|
|
515
|
+
//#region src/rules/testCaseDuplicates.ts
|
|
516
|
+
var testCaseDuplicates_default = ruleCreator.createRule(typescriptLanguage, {
|
|
517
|
+
about: {
|
|
518
|
+
description: "Reports test cases that are identical to previous test cases.",
|
|
519
|
+
id: "testCaseDuplicates",
|
|
520
|
+
presets: ["logical"]
|
|
521
|
+
},
|
|
522
|
+
messages: { duplicateTest: {
|
|
523
|
+
primary: "This test code already appeared in a previous test.",
|
|
524
|
+
secondary: ["When writing tests for lint rules, it's possible to accidentally create deeply identical test cases.", "Doing so provides no added benefit for testing and is unnecessary."],
|
|
525
|
+
suggestions: ["Delete this redundant test case.", "Change a property to make the test case unique."]
|
|
526
|
+
} },
|
|
527
|
+
setup(context) {
|
|
528
|
+
function checkTestCases(testCases, sourceFile) {
|
|
529
|
+
const seen = /* @__PURE__ */ new Set();
|
|
530
|
+
for (const testCase of testCases) {
|
|
531
|
+
const key = JSON.stringify({
|
|
532
|
+
code: testCase.code,
|
|
533
|
+
fileName: testCase.fileName,
|
|
534
|
+
files: testCase.files,
|
|
535
|
+
options: testCase.options
|
|
536
|
+
});
|
|
537
|
+
if (seen.has(key)) context.report({
|
|
538
|
+
message: "duplicateTest",
|
|
539
|
+
range: getTSNodeRange(testCase.nodes.case, sourceFile)
|
|
540
|
+
});
|
|
541
|
+
else seen.add(key);
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
return { visitors: { CallExpression(node, { sourceFile }) {
|
|
545
|
+
const describedCases = getRuleTesterDescribedCases(node);
|
|
546
|
+
if (!describedCases) return;
|
|
547
|
+
checkTestCases(describedCases.invalid, sourceFile);
|
|
548
|
+
checkTestCases(describedCases.valid, sourceFile);
|
|
549
|
+
} } };
|
|
550
|
+
}
|
|
551
|
+
});
|
|
552
|
+
//#endregion
|
|
553
|
+
//#region src/rules/testCaseNameDuplicates.ts
|
|
554
|
+
var testCaseNameDuplicates_default = ruleCreator.createRule(typescriptLanguage, {
|
|
555
|
+
about: {
|
|
556
|
+
description: "Reports test cases that have the same name as a previous test case.",
|
|
557
|
+
id: "testCaseNameDuplicates",
|
|
558
|
+
presets: ["logical"]
|
|
559
|
+
},
|
|
560
|
+
messages: { duplicateTestName: {
|
|
561
|
+
primary: "This test name already appeared in a previous test.",
|
|
562
|
+
secondary: ["When writing tests for lint rules, it's possible to accidentally give the same name to multiple test cases.", "Doing so makes it harder to identify failing tests in test log output."],
|
|
563
|
+
suggestions: ["Delete this redundant test case or give it a unique name."]
|
|
564
|
+
} },
|
|
565
|
+
setup(context) {
|
|
566
|
+
function checkTestCases(testCases, sourceFile) {
|
|
567
|
+
const seen = /* @__PURE__ */ new Set();
|
|
568
|
+
for (const testCase of testCases) {
|
|
569
|
+
if (testCase.name == null || !testCase.nodes.name) continue;
|
|
570
|
+
if (seen.has(testCase.name)) context.report({
|
|
571
|
+
message: "duplicateTestName",
|
|
572
|
+
range: getTSNodeRange(testCase.nodes.name, sourceFile)
|
|
573
|
+
});
|
|
574
|
+
else seen.add(testCase.name);
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
return { visitors: { CallExpression(node, { sourceFile }) {
|
|
578
|
+
const describedCases = getRuleTesterDescribedCases(node);
|
|
579
|
+
if (!describedCases) return;
|
|
580
|
+
checkTestCases(describedCases.invalid, sourceFile);
|
|
581
|
+
checkTestCases(describedCases.valid, sourceFile);
|
|
582
|
+
} } };
|
|
583
|
+
}
|
|
584
|
+
});
|
|
585
|
+
//#endregion
|
|
586
|
+
//#region src/rules/testCaseNonStaticCode.ts
|
|
587
|
+
function getCodeProperty(node) {
|
|
588
|
+
return node.properties.find((property) => {
|
|
589
|
+
if (property.kind === SyntaxKind.PropertyAssignment) {
|
|
590
|
+
const name = property.name;
|
|
591
|
+
return (name.kind === SyntaxKind.Identifier || name.kind === SyntaxKind.StringLiteral) && name.text === "code";
|
|
592
|
+
}
|
|
593
|
+
return property.kind === SyntaxKind.ShorthandPropertyAssignment && property.name.text === "code";
|
|
594
|
+
});
|
|
595
|
+
}
|
|
596
|
+
function isStaticString(node) {
|
|
597
|
+
return node.kind === SyntaxKind.StringLiteral || node.kind === SyntaxKind.NoSubstitutionTemplateLiteral;
|
|
598
|
+
}
|
|
599
|
+
function isStringRawNoSubstitution(node) {
|
|
600
|
+
if (node.kind !== SyntaxKind.TaggedTemplateExpression) return false;
|
|
601
|
+
const tag = node.tag;
|
|
602
|
+
return tag.kind === SyntaxKind.PropertyAccessExpression && tag.expression.kind === SyntaxKind.Identifier && tag.expression.text === "String" && tag.name.kind === SyntaxKind.Identifier && tag.name.text === "raw" && node.template.kind === SyntaxKind.NoSubstitutionTemplateLiteral;
|
|
603
|
+
}
|
|
604
|
+
var testCaseNonStaticCode_default = ruleCreator.createRule(typescriptLanguage, {
|
|
605
|
+
about: {
|
|
606
|
+
description: "Test case code should be a static string literal.",
|
|
607
|
+
id: "testCaseNonStaticCode",
|
|
608
|
+
presets: ["logical"]
|
|
609
|
+
},
|
|
610
|
+
messages: { nonStaticTestCaseCode: {
|
|
611
|
+
primary: "Test case code should be a static string literal.",
|
|
612
|
+
secondary: ["Avoid generating test case code with variables, function calls, or template interpolations.", "Static strings keep test cases easy to audit and analyze with lint rules."],
|
|
613
|
+
suggestions: ["Replace the test case code with a static string literal."]
|
|
614
|
+
} },
|
|
615
|
+
setup(context) {
|
|
616
|
+
function checkTestCase(testCase, sourceFile) {
|
|
617
|
+
if (testCase.kind === SyntaxKind.OmittedExpression || isStaticString(testCase) || isStringRawNoSubstitution(testCase)) return;
|
|
618
|
+
if (testCase.kind !== SyntaxKind.ObjectLiteralExpression) {
|
|
619
|
+
const range = getTSNodeRange(testCase, sourceFile);
|
|
620
|
+
context.report({
|
|
621
|
+
message: "nonStaticTestCaseCode",
|
|
622
|
+
range
|
|
623
|
+
});
|
|
624
|
+
return;
|
|
625
|
+
}
|
|
626
|
+
const codeProperty = getCodeProperty(testCase);
|
|
627
|
+
if (!codeProperty) {
|
|
628
|
+
if (testCase.properties.some((node) => node.kind === SyntaxKind.SpreadAssignment)) {
|
|
629
|
+
const range = getTSNodeRange(testCase, sourceFile);
|
|
630
|
+
context.report({
|
|
631
|
+
message: "nonStaticTestCaseCode",
|
|
632
|
+
range
|
|
633
|
+
});
|
|
634
|
+
}
|
|
635
|
+
return;
|
|
636
|
+
}
|
|
637
|
+
if (codeProperty.kind === SyntaxKind.PropertyAssignment) {
|
|
638
|
+
if (isStaticString(codeProperty.initializer) || isStringRawNoSubstitution(codeProperty.initializer)) return;
|
|
639
|
+
const range = getTSNodeRange(codeProperty.initializer, sourceFile);
|
|
640
|
+
context.report({
|
|
641
|
+
message: "nonStaticTestCaseCode",
|
|
642
|
+
range
|
|
643
|
+
});
|
|
644
|
+
return;
|
|
645
|
+
}
|
|
646
|
+
if (codeProperty.kind === SyntaxKind.ShorthandPropertyAssignment) {
|
|
647
|
+
const range = getTSNodeRange(codeProperty.name, sourceFile);
|
|
648
|
+
context.report({
|
|
649
|
+
message: "nonStaticTestCaseCode",
|
|
650
|
+
range
|
|
651
|
+
});
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
return { visitors: { CallExpression(node, { sourceFile }) {
|
|
655
|
+
const testArrays = getRuleTesterCaseArrays(node);
|
|
656
|
+
if (!testArrays) return;
|
|
657
|
+
for (const testCase of [...testArrays.valid.elements, ...testArrays.invalid.elements]) checkTestCase(testCase, sourceFile);
|
|
658
|
+
} } };
|
|
659
|
+
}
|
|
660
|
+
});
|
|
661
|
+
//#endregion
|
|
662
|
+
//#region src/rules/testCaseOnlyFlags.ts
|
|
663
|
+
var testCaseOnlyFlags_default = ruleCreator.createRule(typescriptLanguage, {
|
|
664
|
+
about: {
|
|
665
|
+
description: "Reports test cases that are marked `only: true`.",
|
|
666
|
+
id: "testCaseOnlyFlags",
|
|
667
|
+
presets: ["logical"]
|
|
668
|
+
},
|
|
669
|
+
messages: { testCaseOnly: {
|
|
670
|
+
primary: "Do not commit test cases with `only: true`.",
|
|
671
|
+
secondary: ["The `only` flag is useful for local debugging, but it prevents the rest of the test suite from running.", "Leaving it in committed rule tests can hide failures in other test cases."],
|
|
672
|
+
suggestions: ["Remove the `only: true` property before committing."]
|
|
673
|
+
} },
|
|
674
|
+
setup(context) {
|
|
675
|
+
return { visitors: { CallExpression(node, { sourceFile }) {
|
|
676
|
+
const testArrays = getRuleTesterCaseArrays(node);
|
|
677
|
+
if (!testArrays) return;
|
|
678
|
+
for (const testCase of [...testArrays.valid.elements, ...testArrays.invalid.elements]) {
|
|
679
|
+
if (testCase.kind !== SyntaxKind.ObjectLiteralExpression) continue;
|
|
680
|
+
const onlyInitializer = findProperty(testCase.properties, "only", (node) => node.kind === SyntaxKind.TrueKeyword);
|
|
681
|
+
if (!onlyInitializer) continue;
|
|
682
|
+
context.report({
|
|
683
|
+
message: "testCaseOnly",
|
|
684
|
+
range: getTSNodeRange(onlyInitializer.parent, sourceFile)
|
|
685
|
+
});
|
|
686
|
+
}
|
|
687
|
+
} } };
|
|
688
|
+
}
|
|
689
|
+
});
|
|
690
|
+
//#endregion
|
|
691
|
+
//#region src/rules/testShorthands.ts
|
|
692
|
+
var testShorthands_default = ruleCreator.createRule(typescriptLanguage, {
|
|
693
|
+
about: {
|
|
694
|
+
description: "Test cases with only a code property can use string shorthand syntax instead of object literal syntax.",
|
|
695
|
+
id: "testShorthands",
|
|
696
|
+
presets: ["logical"]
|
|
697
|
+
},
|
|
698
|
+
messages: { testShorthands: {
|
|
699
|
+
primary: "Use string shorthand for test cases with only a code property.",
|
|
700
|
+
secondary: ["String shorthand syntax is more concise: `valid: ['code here']` instead of `valid: [{ code: 'code here' }]`.", "Object literal syntax should be reserved for test cases with additional properties like fileName or options."],
|
|
701
|
+
suggestions: ["Switch the test case to shorthand syntax."]
|
|
702
|
+
} },
|
|
703
|
+
setup(context) {
|
|
704
|
+
return { visitors: { CallExpression(node, { sourceFile }) {
|
|
705
|
+
const describedCases = getRuleTesterDescribedCases(node);
|
|
706
|
+
if (!describedCases) return;
|
|
707
|
+
for (const testCase of describedCases.valid) {
|
|
708
|
+
const caseNode = testCase.nodes.case;
|
|
709
|
+
if (ts.isObjectLiteralExpression(caseNode) && caseNode.properties.length === 1 && caseNode.properties[0]?.name && ts.isIdentifier(caseNode.properties[0].name) && caseNode.properties[0].name.text === "code") {
|
|
710
|
+
let fix;
|
|
711
|
+
if (ts.isPropertyAssignment(caseNode.properties[0])) fix = {
|
|
712
|
+
range: getTSNodeRange(caseNode, sourceFile),
|
|
713
|
+
text: caseNode.properties[0].initializer.getText(sourceFile)
|
|
714
|
+
};
|
|
715
|
+
context.report({
|
|
716
|
+
fix,
|
|
717
|
+
message: "testShorthands",
|
|
718
|
+
range: getTSNodeRange(caseNode.properties[0], sourceFile)
|
|
719
|
+
});
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
} } };
|
|
723
|
+
}
|
|
724
|
+
});
|
|
725
|
+
//#endregion
|
|
726
|
+
//#region src/utils/importHelpers.ts
|
|
727
|
+
function isImportedBindingFromModule(declaration, moduleName) {
|
|
728
|
+
if (declaration.kind !== SyntaxKind.ImportSpecifier && declaration.kind !== SyntaxKind.NamespaceImport) return false;
|
|
729
|
+
const importDeclaration = declaration.kind === SyntaxKind.ImportSpecifier ? declaration.parent.parent.parent : declaration.parent.parent;
|
|
730
|
+
return isImportDeclaration(importDeclaration) && importDeclaration.moduleSpecifier.text === moduleName;
|
|
731
|
+
}
|
|
732
|
+
function isImportedSpecifierFromModule(declaration, moduleName, importedName) {
|
|
733
|
+
if (!isImportSpecifier(declaration) || !isImportedBindingFromModule(declaration, moduleName)) return false;
|
|
734
|
+
return (declaration.propertyName?.text ?? declaration.name.text) === importedName;
|
|
735
|
+
}
|
|
736
|
+
function isImportDeclaration(node) {
|
|
737
|
+
return node.kind === SyntaxKind.ImportDeclaration && node.moduleSpecifier.kind === SyntaxKind.StringLiteral;
|
|
738
|
+
}
|
|
739
|
+
function isImportSpecifier(node) {
|
|
740
|
+
return node.kind === SyntaxKind.ImportSpecifier;
|
|
741
|
+
}
|
|
742
|
+
//#endregion
|
|
743
|
+
//#region src/rules/unusedMessageIds.ts
|
|
744
|
+
const volarLanguagePackageName = "@flint.fyi/volar-language";
|
|
745
|
+
function isVolarReportSourceCodeCall(node, typeChecker) {
|
|
746
|
+
if (node.expression.kind === SyntaxKind.Identifier) return typeChecker.getSymbolAtLocation(node.expression)?.getDeclarations()?.some((declaration) => isImportedSpecifierFromModule(declaration, volarLanguagePackageName, "reportSourceCode")) ?? false;
|
|
747
|
+
if (node.expression.kind === SyntaxKind.PropertyAccessExpression && node.expression.expression.kind === SyntaxKind.Identifier && node.expression.name.text === "reportSourceCode") return typeChecker.getSymbolAtLocation(node.expression.expression)?.getDeclarations()?.some((declaration) => declaration.kind === SyntaxKind.NamespaceImport && isImportedBindingFromModule(declaration, volarLanguagePackageName)) ?? false;
|
|
748
|
+
return false;
|
|
749
|
+
}
|
|
750
|
+
//#endregion
|
|
751
|
+
//#region src/plugin.ts
|
|
752
|
+
const flint = createPlugin({
|
|
753
|
+
name: "Flint",
|
|
754
|
+
rules: [
|
|
755
|
+
getStartSourceFile_default,
|
|
756
|
+
invalidCodeLines_default,
|
|
757
|
+
missingPlaceholders_default,
|
|
758
|
+
nodePropertyInChecks_default,
|
|
759
|
+
placeholderFormats_default,
|
|
760
|
+
pluginRuleOrdering_default,
|
|
761
|
+
ruleCreationMethods_default,
|
|
762
|
+
testCaseDuplicates_default,
|
|
763
|
+
testCaseNameDuplicates_default,
|
|
764
|
+
testCaseNonStaticCode_default,
|
|
765
|
+
testCaseOnlyFlags_default,
|
|
766
|
+
testShorthands_default,
|
|
767
|
+
ruleCreator.createRule(typescriptLanguage, {
|
|
768
|
+
about: {
|
|
769
|
+
description: "Reports message IDs defined in the messages object that are never used in recognized report calls.",
|
|
770
|
+
id: "unusedMessageIds",
|
|
771
|
+
presets: ["logical"]
|
|
772
|
+
},
|
|
773
|
+
messages: { unusedMessageIds: {
|
|
774
|
+
primary: "Message ID '{{ messageId }}' is defined but never used.",
|
|
775
|
+
secondary: ["This message ID is declared in the messages object but is not referenced in any `context.report()` or built-in Flint report helper call.", "Remove unused message IDs to keep the rule configuration clean and maintainable."],
|
|
776
|
+
suggestions: ["Remove the unused message ID from the messages object."]
|
|
777
|
+
} },
|
|
778
|
+
setup(context) {
|
|
779
|
+
const unusedMessageIds = /* @__PURE__ */ new Map();
|
|
780
|
+
function collectMessageIds(node, sourceFile) {
|
|
781
|
+
const args = node.arguments[1];
|
|
782
|
+
if (args?.kind !== SyntaxKind.ObjectLiteralExpression) return;
|
|
783
|
+
const messagesProperty = findProperty(args.properties, "messages", (node) => node.kind === SyntaxKind.ObjectLiteralExpression);
|
|
784
|
+
if (!messagesProperty) return;
|
|
785
|
+
for (const prop of messagesProperty.properties) {
|
|
786
|
+
if (prop.kind !== SyntaxKind.PropertyAssignment || prop.name.kind !== SyntaxKind.Identifier) continue;
|
|
787
|
+
const messageId = prop.name.text;
|
|
788
|
+
if (!unusedMessageIds.has(messageId)) unusedMessageIds.set(messageId, getTSNodeRange(prop.name, sourceFile));
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
function detectMessageIdUsage(node, reportArgumentIndex) {
|
|
792
|
+
if (!unusedMessageIds.size) return;
|
|
793
|
+
const args = node.arguments[reportArgumentIndex];
|
|
794
|
+
if (args?.kind !== SyntaxKind.ObjectLiteralExpression) {
|
|
795
|
+
unusedMessageIds.clear();
|
|
796
|
+
return;
|
|
797
|
+
}
|
|
798
|
+
const messageProperty = findProperty(args.properties, "message", (node) => node.kind === SyntaxKind.StringLiteral);
|
|
799
|
+
if (!messageProperty) {
|
|
800
|
+
unusedMessageIds.clear();
|
|
801
|
+
return;
|
|
802
|
+
}
|
|
803
|
+
unusedMessageIds.delete(messageProperty.text);
|
|
804
|
+
}
|
|
805
|
+
return { visitors: {
|
|
806
|
+
CallExpression(node, { sourceFile, typeChecker }) {
|
|
807
|
+
if (isRuleCreatorCreateRule(node, typeChecker)) {
|
|
808
|
+
collectMessageIds(node, sourceFile);
|
|
809
|
+
return;
|
|
810
|
+
}
|
|
811
|
+
if (isRuleContextReport(node, typeChecker)) {
|
|
812
|
+
detectMessageIdUsage(node, 0);
|
|
813
|
+
return;
|
|
814
|
+
}
|
|
815
|
+
if (isVolarReportSourceCodeCall(node, typeChecker)) {
|
|
816
|
+
detectMessageIdUsage(node, 1);
|
|
817
|
+
return;
|
|
818
|
+
}
|
|
819
|
+
},
|
|
820
|
+
"SourceFile:exit"() {
|
|
821
|
+
if (!unusedMessageIds.size) return;
|
|
822
|
+
for (const [messageId, range] of unusedMessageIds) context.report({
|
|
823
|
+
data: { messageId },
|
|
824
|
+
message: "unusedMessageIds",
|
|
825
|
+
range
|
|
826
|
+
});
|
|
827
|
+
unusedMessageIds.clear();
|
|
828
|
+
}
|
|
829
|
+
} };
|
|
830
|
+
}
|
|
831
|
+
})
|
|
832
|
+
]
|
|
833
|
+
});
|
|
834
|
+
//#endregion
|
|
835
|
+
export { flint };
|
|
836
|
+
|
|
2
837
|
//# sourceMappingURL=index.js.map
|