@konvert7/klint 0.26.0 → 0.27.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 +26 -1
- package/core/arch-comments.ts +93 -0
- package/core/arch.ts +49 -0
- package/core/config.schema.ts +83 -1
- package/core/fixer.ts +9 -6
- package/core/runner.ts +8 -1
- package/core/types.ts +17 -0
- package/package.json +5 -5
- package/rules/no-single-char-class.ts +16 -13
package/README.md
CHANGED
|
@@ -148,7 +148,7 @@ klint --engine compare --json # parity check; rejects unsupported rules
|
|
|
148
148
|
|
|
149
149
|
Rust currently supports:
|
|
150
150
|
|
|
151
|
-
- `arch` rules: imports, forbidden patterns (literal + `re:` regex), singleton locations, `maxLines` file limits
|
|
151
|
+
- `arch` rules: imports, forbidden patterns (literal + `re:` regex), singleton locations, `maxLines` file limits, comment budgets (`maxCommentDensity`, `maxCommentBlock`)
|
|
152
152
|
- `no-unguarded-json-parse`
|
|
153
153
|
- `no-sync-in-async`
|
|
154
154
|
- `no-nested-template-literals`
|
|
@@ -342,6 +342,31 @@ arch:
|
|
|
342
342
|
|
|
343
343
|
`maxLines` is enforced in both the TS and Rust engines and applies to every language klint scans, with no formatter dependency.
|
|
344
344
|
|
|
345
|
+
### Comment Budgets
|
|
346
|
+
|
|
347
|
+
Push intent into names and code rather than prose — and keep token cost down — by capping how much of a file may be comments. Two independent limits, both counting only ordinary `//`, `/* */`, and `#` comments; doc-comments (`/** */` JSDoc and Python docstrings) are exempt by default so documentation stays free.
|
|
348
|
+
|
|
349
|
+
`maxCommentDensity` caps the share of a file's physical lines that are comment lines. The denominator is total physical lines (the same count `maxLines` uses), so blank lines dilute the ratio:
|
|
350
|
+
|
|
351
|
+
```yaml
|
|
352
|
+
arch:
|
|
353
|
+
maxCommentDensity:
|
|
354
|
+
- limit: 5 # percent — no more than 5% of the file may be comments
|
|
355
|
+
in: ["src/**"]
|
|
356
|
+
message: "Encode intent in names, not comments" # optional
|
|
357
|
+
```
|
|
358
|
+
|
|
359
|
+
`maxCommentBlock` caps how many consecutive comment lines may stack into one block, flagged at the first line past the limit. A tall explanation should become a well-named function instead:
|
|
360
|
+
|
|
361
|
+
```yaml
|
|
362
|
+
arch:
|
|
363
|
+
maxCommentBlock:
|
|
364
|
+
- limit: 2 # at most 2 comment lines in a row
|
|
365
|
+
in: ["src/**"]
|
|
366
|
+
```
|
|
367
|
+
|
|
368
|
+
Set `countDocComments: true` on either rule to count doc-comments toward the limit as well. Both rules run in the TS and Rust engines; Python files (`#` comments) are enforced by the Rust engine.
|
|
369
|
+
|
|
345
370
|
### Python Architecture Checks
|
|
346
371
|
|
|
347
372
|
The Rust engine can apply architecture rules to Python projects:
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import ts from "typescript";
|
|
2
|
+
|
|
3
|
+
interface CommentSpan {
|
|
4
|
+
startLine: number;
|
|
5
|
+
endLine: number;
|
|
6
|
+
isDoc: boolean;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** JS-like sources are lexed for comments; Python/Swift comment rules run in the native engine. */
|
|
10
|
+
function collectComments(file: string, content: string): CommentSpan[] {
|
|
11
|
+
if (!/\.(tsx?|jsx?|mts|cts)$/.test(file)) return [];
|
|
12
|
+
const variant = /\.(tsx|jsx)$/.test(file)
|
|
13
|
+
? ts.LanguageVariant.JSX
|
|
14
|
+
: ts.LanguageVariant.Standard;
|
|
15
|
+
const scanner = ts.createScanner(ts.ScriptTarget.Latest, false, variant, content);
|
|
16
|
+
const starts = lineStarts(content);
|
|
17
|
+
const spans: CommentSpan[] = [];
|
|
18
|
+
let token = scanner.scan();
|
|
19
|
+
while (token !== ts.SyntaxKind.EndOfFileToken) {
|
|
20
|
+
if (
|
|
21
|
+
token === ts.SyntaxKind.SingleLineCommentTrivia ||
|
|
22
|
+
token === ts.SyntaxKind.MultiLineCommentTrivia
|
|
23
|
+
) {
|
|
24
|
+
const start = scanner.getTokenStart();
|
|
25
|
+
const end = scanner.getTokenEnd();
|
|
26
|
+
spans.push({
|
|
27
|
+
startLine: lineAt(starts, start),
|
|
28
|
+
endLine: lineAt(starts, Math.max(start, end - 1)),
|
|
29
|
+
isDoc: isDocComment(content.slice(start, end)),
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
token = scanner.scan();
|
|
33
|
+
}
|
|
34
|
+
return spans;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** A `/** */` JSDoc block, but not the empty `/**/` comment. Mirrors the Rust engine. */
|
|
38
|
+
function isDocComment(text: string): boolean {
|
|
39
|
+
return text.startsWith("/**") && text !== "/**/";
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function commentLineSet(
|
|
43
|
+
file: string,
|
|
44
|
+
content: string,
|
|
45
|
+
countDocComments: boolean
|
|
46
|
+
): Set<number> {
|
|
47
|
+
const set = new Set<number>();
|
|
48
|
+
for (const span of collectComments(file, content)) {
|
|
49
|
+
if (!countDocComments && span.isDoc) continue;
|
|
50
|
+
for (let line = span.startLine; line <= span.endLine; line++) set.add(line);
|
|
51
|
+
}
|
|
52
|
+
return set;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Returns the first line at which a run of consecutive comment lines exceeds `limit`. */
|
|
56
|
+
export function firstCommentBlockOverrun(
|
|
57
|
+
sortedLines: number[],
|
|
58
|
+
limit: number
|
|
59
|
+
): number | undefined {
|
|
60
|
+
let runStart = sortedLines[0];
|
|
61
|
+
let prev = sortedLines[0];
|
|
62
|
+
for (let i = 1; i <= sortedLines.length; i++) {
|
|
63
|
+
const line = sortedLines[i];
|
|
64
|
+
if (line === prev + 1) {
|
|
65
|
+
prev = line;
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
if (prev - runStart + 1 > limit) return runStart + limit;
|
|
69
|
+
runStart = line;
|
|
70
|
+
prev = line;
|
|
71
|
+
}
|
|
72
|
+
return undefined;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function lineStarts(content: string): number[] {
|
|
76
|
+
const starts = [0];
|
|
77
|
+
for (let i = 0; i < content.length; i++) {
|
|
78
|
+
if (content[i] === "\n") starts.push(i + 1);
|
|
79
|
+
}
|
|
80
|
+
return starts;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** 1-based line for a character offset, via binary search over line starts. */
|
|
84
|
+
function lineAt(starts: number[], offset: number): number {
|
|
85
|
+
let lo = 0;
|
|
86
|
+
let hi = starts.length - 1;
|
|
87
|
+
while (lo < hi) {
|
|
88
|
+
const mid = (lo + hi + 1) >> 1;
|
|
89
|
+
if (starts[mid] <= offset) lo = mid;
|
|
90
|
+
else hi = mid - 1;
|
|
91
|
+
}
|
|
92
|
+
return lo + 1;
|
|
93
|
+
}
|
package/core/arch.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { dirname, isAbsolute, resolve } from "node:path";
|
|
2
2
|
import ts from "typescript";
|
|
3
|
+
import { commentLineSet, firstCommentBlockOverrun } from "./arch-comments";
|
|
3
4
|
import { walkAst } from "./ast";
|
|
4
5
|
import { relativeSlashPath, toSlashPath } from "./paths";
|
|
5
6
|
import type { ArchConfig, Severity, Violation } from "./types";
|
|
@@ -282,6 +283,54 @@ export function runArchRules(
|
|
|
282
283
|
}
|
|
283
284
|
}
|
|
284
285
|
|
|
286
|
+
for (const rule of arch.maxCommentDensity ?? []) {
|
|
287
|
+
const severity: Severity = rule.severity ?? "error";
|
|
288
|
+
const inFiles = resolveLayerFiles(rule.in, layers, root, allFiles);
|
|
289
|
+
for (const file of inFiles) {
|
|
290
|
+
const content = fileContents.get(file);
|
|
291
|
+
if (content === undefined) continue;
|
|
292
|
+
const total = countLines(content);
|
|
293
|
+
if (total === 0) continue;
|
|
294
|
+
const commentLines = commentLineSet(file, content, rule.countDocComments ?? false);
|
|
295
|
+
const density = (commentLines.size / total) * 100;
|
|
296
|
+
if (density > rule.limit) {
|
|
297
|
+
violations.push({
|
|
298
|
+
file: relativeSlashPath(root, file),
|
|
299
|
+
line: 1,
|
|
300
|
+
message:
|
|
301
|
+
rule.message ??
|
|
302
|
+
`Comment density ${density.toFixed(1)}% exceeds the maximum of ${rule.limit}%`,
|
|
303
|
+
rule: "arch/max-comment-density",
|
|
304
|
+
severity,
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
for (const rule of arch.maxCommentBlock ?? []) {
|
|
311
|
+
const severity: Severity = rule.severity ?? "error";
|
|
312
|
+
const inFiles = resolveLayerFiles(rule.in, layers, root, allFiles);
|
|
313
|
+
for (const file of inFiles) {
|
|
314
|
+
const content = fileContents.get(file);
|
|
315
|
+
if (content === undefined) continue;
|
|
316
|
+
const commentLines = [
|
|
317
|
+
...commentLineSet(file, content, rule.countDocComments ?? false),
|
|
318
|
+
].sort((a, b) => a - b);
|
|
319
|
+
const overrun = firstCommentBlockOverrun(commentLines, rule.limit);
|
|
320
|
+
if (overrun !== undefined) {
|
|
321
|
+
violations.push({
|
|
322
|
+
file: relativeSlashPath(root, file),
|
|
323
|
+
line: overrun,
|
|
324
|
+
message:
|
|
325
|
+
rule.message ??
|
|
326
|
+
`Comment block exceeds the maximum of ${rule.limit} consecutive lines`,
|
|
327
|
+
rule: "arch/max-comment-block",
|
|
328
|
+
severity,
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
285
334
|
return violations;
|
|
286
335
|
}
|
|
287
336
|
|
package/core/config.schema.ts
CHANGED
|
@@ -237,6 +237,76 @@ const ArchMaxLinesRuleSchema = z
|
|
|
237
237
|
],
|
|
238
238
|
});
|
|
239
239
|
|
|
240
|
+
const CountDocCommentsSchema = z
|
|
241
|
+
.boolean()
|
|
242
|
+
.optional()
|
|
243
|
+
.describe(
|
|
244
|
+
"Count doc-comments toward the limit. Doc-comments are `/** … */` JSDoc blocks (TS/JS) and docstrings (Python); ordinary `//`, `/* */`, and `#` comments are always counted. Defaults to false, so documentation is exempt and only explanatory/inline comments are capped."
|
|
245
|
+
);
|
|
246
|
+
|
|
247
|
+
const ArchMaxCommentDensityRuleSchema = z
|
|
248
|
+
.object({
|
|
249
|
+
limit: z
|
|
250
|
+
.number()
|
|
251
|
+
.positive()
|
|
252
|
+
.max(100)
|
|
253
|
+
.describe(
|
|
254
|
+
"Maximum percentage of a file's physical lines that may be comment lines. Denominator is total physical lines (code + comments + blanks), the same count `maxLines` uses. A file above this ratio is a violation."
|
|
255
|
+
),
|
|
256
|
+
countDocComments: CountDocCommentsSchema,
|
|
257
|
+
in: StringOrStringArray.describe(
|
|
258
|
+
"Layer name(s) or glob(s) the limit applies to. Files outside the scope are not checked."
|
|
259
|
+
),
|
|
260
|
+
message: z
|
|
261
|
+
.string()
|
|
262
|
+
.optional()
|
|
263
|
+
.describe(
|
|
264
|
+
"Optional error message. Defaults to a message naming the measured density and the limit."
|
|
265
|
+
),
|
|
266
|
+
severity: ArchSeveritySchema.optional(),
|
|
267
|
+
})
|
|
268
|
+
.strict()
|
|
269
|
+
.describe(
|
|
270
|
+
"A per-file comment-density ceiling. Caps how much of a file may be explanatory comments — the honest way to push intent into code and names rather than prose (and to keep token cost down), while leaving doc-comments free by default."
|
|
271
|
+
)
|
|
272
|
+
.meta({
|
|
273
|
+
examples: [
|
|
274
|
+
'arch:\n maxCommentDensity:\n - limit: 5\n in: src/**\n message: "Encode intent in names, not comments"',
|
|
275
|
+
"arch:\n maxCommentDensity:\n - limit: 10\n countDocComments: true\n in: src/**",
|
|
276
|
+
],
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
const ArchMaxCommentBlockRuleSchema = z
|
|
280
|
+
.object({
|
|
281
|
+
limit: z
|
|
282
|
+
.number()
|
|
283
|
+
.int()
|
|
284
|
+
.positive()
|
|
285
|
+
.describe(
|
|
286
|
+
"Maximum number of consecutive comment lines allowed in a single block. A run of more comment lines than this — e.g. a tall comment stacked above a statement — is a violation, reported at the first offending line."
|
|
287
|
+
),
|
|
288
|
+
countDocComments: CountDocCommentsSchema,
|
|
289
|
+
in: StringOrStringArray.describe(
|
|
290
|
+
"Layer name(s) or glob(s) the limit applies to. Files outside the scope are not checked."
|
|
291
|
+
),
|
|
292
|
+
message: z
|
|
293
|
+
.string()
|
|
294
|
+
.optional()
|
|
295
|
+
.describe(
|
|
296
|
+
"Optional error message. Defaults to a message naming the consecutive-line limit."
|
|
297
|
+
),
|
|
298
|
+
severity: ArchSeveritySchema.optional(),
|
|
299
|
+
})
|
|
300
|
+
.strict()
|
|
301
|
+
.describe(
|
|
302
|
+
"A per-block comment-height cap. Limits how many comment lines may stack together, so comments stay terse and a long explanation becomes a well-named function instead. Doc-comments are exempt by default."
|
|
303
|
+
)
|
|
304
|
+
.meta({
|
|
305
|
+
examples: [
|
|
306
|
+
'arch:\n maxCommentBlock:\n - limit: 2\n in: src/**\n message: "Extract a named function instead of a comment block"',
|
|
307
|
+
],
|
|
308
|
+
});
|
|
309
|
+
|
|
240
310
|
const ArchSchema = z
|
|
241
311
|
.object({
|
|
242
312
|
layers: ArchLayersSchema.optional(),
|
|
@@ -262,10 +332,22 @@ const ArchSchema = z
|
|
|
262
332
|
.describe(
|
|
263
333
|
"Per-file line-count limits. Cap how long files in a layer may grow, with different ceilings per scope."
|
|
264
334
|
),
|
|
335
|
+
maxCommentDensity: z
|
|
336
|
+
.array(ArchMaxCommentDensityRuleSchema)
|
|
337
|
+
.optional()
|
|
338
|
+
.describe(
|
|
339
|
+
"Per-file comment-density ceilings. Cap the share of a file that may be explanatory comments; doc-comments are exempt by default."
|
|
340
|
+
),
|
|
341
|
+
maxCommentBlock: z
|
|
342
|
+
.array(ArchMaxCommentBlockRuleSchema)
|
|
343
|
+
.optional()
|
|
344
|
+
.describe(
|
|
345
|
+
"Per-block comment-height caps. Limit how many comment lines may stack together; doc-comments are exempt by default."
|
|
346
|
+
),
|
|
265
347
|
})
|
|
266
348
|
.strict()
|
|
267
349
|
.describe(
|
|
268
|
-
"Architecture-as-Code constraints — layers, import boundaries, forbidden patterns, singleton locations. Enforced by the arch engine alongside the rule set."
|
|
350
|
+
"Architecture-as-Code constraints — layers, import boundaries, forbidden patterns, singleton locations, comment budgets. Enforced by the arch engine alongside the rule set."
|
|
269
351
|
);
|
|
270
352
|
|
|
271
353
|
function buildRulesSchema() {
|
package/core/fixer.ts
CHANGED
|
@@ -20,12 +20,7 @@ export function applyFixes(violations: Violation[], root: string): number {
|
|
|
20
20
|
let applied = 0;
|
|
21
21
|
for (const [absPath, fileViolations] of byFile) {
|
|
22
22
|
const lines = readFileSync(absPath, "utf-8").split("\n");
|
|
23
|
-
|
|
24
|
-
// This handles bottom-to-top ordering (higher lines first) AND ensures outer
|
|
25
|
-
// fixes beat inner/overlapping fixes when chained calls share a start position.
|
|
26
|
-
const sorted = [...fileViolations].sort(
|
|
27
|
-
(a, b) => (b.fix?.endLine ?? 0) - (a.fix?.endLine ?? 0)
|
|
28
|
-
);
|
|
23
|
+
const sorted = [...fileViolations].sort(byLargestFixRangeFirst);
|
|
29
24
|
|
|
30
25
|
const used: Array<{ start: number; end: number }> = [];
|
|
31
26
|
for (const v of sorted) {
|
|
@@ -42,3 +37,11 @@ export function applyFixes(violations: Violation[], root: string): number {
|
|
|
42
37
|
|
|
43
38
|
return applied;
|
|
44
39
|
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Largest-range fix wins: sorting by `endLine` descending applies fixes bottom-to-top
|
|
43
|
+
* and lets an outer or overlapping fix beat inner ones that share a start position.
|
|
44
|
+
*/
|
|
45
|
+
function byLargestFixRangeFirst(a: Violation, b: Violation): number {
|
|
46
|
+
return (b.fix?.endLine ?? 0) - (a.fix?.endLine ?? 0);
|
|
47
|
+
}
|
package/core/runner.ts
CHANGED
|
@@ -64,7 +64,14 @@ function resolveFiles(
|
|
|
64
64
|
// directory doesn't exist — skip
|
|
65
65
|
}
|
|
66
66
|
}
|
|
67
|
-
return [...files];
|
|
67
|
+
return [...files].sort(byLexicographicOrder);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Not localeCompare — must match the Rust engine's locale-independent Vec<PathBuf>::sort().
|
|
71
|
+
function byLexicographicOrder(a: string, b: string): number {
|
|
72
|
+
if (a < b) return -1;
|
|
73
|
+
if (a > b) return 1;
|
|
74
|
+
return 0;
|
|
68
75
|
}
|
|
69
76
|
|
|
70
77
|
function matchPattern(relPath: string, pattern: string): boolean {
|
package/core/types.ts
CHANGED
|
@@ -104,12 +104,29 @@ interface ArchMaxLinesRule {
|
|
|
104
104
|
severity?: Exclude<Severity, "off">;
|
|
105
105
|
}
|
|
106
106
|
|
|
107
|
+
interface ArchCommentRuleBase {
|
|
108
|
+
limit: number;
|
|
109
|
+
in: string | string[];
|
|
110
|
+
/** Count doc-comments (`/** */` JSDoc, Python docstrings) toward the limit. Defaults to false. */
|
|
111
|
+
countDocComments?: boolean;
|
|
112
|
+
message?: string;
|
|
113
|
+
severity?: Exclude<Severity, "off">;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Caps the share of a file's physical lines that are non-doc comments. */
|
|
117
|
+
interface ArchMaxCommentDensityRule extends ArchCommentRuleBase {}
|
|
118
|
+
|
|
119
|
+
/** Caps how many consecutive comment lines may stack into a single block. */
|
|
120
|
+
interface ArchMaxCommentBlockRule extends ArchCommentRuleBase {}
|
|
121
|
+
|
|
107
122
|
export interface ArchConfig {
|
|
108
123
|
layers?: Record<string, string[]>;
|
|
109
124
|
imports?: ArchImportRule[];
|
|
110
125
|
forbidden?: ArchForbiddenRule[];
|
|
111
126
|
singleton?: ArchSingletonRule[];
|
|
112
127
|
maxLines?: ArchMaxLinesRule[];
|
|
128
|
+
maxCommentDensity?: ArchMaxCommentDensityRule[];
|
|
129
|
+
maxCommentBlock?: ArchMaxCommentBlockRule[];
|
|
113
130
|
}
|
|
114
131
|
|
|
115
132
|
export interface KlintConfig {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@konvert7/klint",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.27.0",
|
|
4
4
|
"description": "Architecture-as-Code linter for TypeScript. Enforce layer boundaries, import rules, and singleton patterns in YAML.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -65,10 +65,10 @@
|
|
|
65
65
|
"zod": "^4.4.3"
|
|
66
66
|
},
|
|
67
67
|
"optionalDependencies": {
|
|
68
|
-
"@konvert7/klint-darwin-arm64": "0.
|
|
69
|
-
"@konvert7/klint-darwin-x64": "0.
|
|
70
|
-
"@konvert7/klint-linux-x64": "0.
|
|
71
|
-
"@konvert7/klint-win32-x64": "0.
|
|
68
|
+
"@konvert7/klint-darwin-arm64": "0.27.0",
|
|
69
|
+
"@konvert7/klint-darwin-x64": "0.27.0",
|
|
70
|
+
"@konvert7/klint-linux-x64": "0.27.0",
|
|
71
|
+
"@konvert7/klint-win32-x64": "0.27.0"
|
|
72
72
|
},
|
|
73
73
|
"devDependencies": {
|
|
74
74
|
"@biomejs/biome": "2.4.7",
|
|
@@ -4,14 +4,16 @@ import { relativeSlashPath } from "../core/paths";
|
|
|
4
4
|
import { buildNodeReplacementFix } from "../core/rule-helpers";
|
|
5
5
|
import type { KlintRule } from "../core/types";
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
7
|
+
/**
|
|
8
|
+
* Metacharacters that lose their special meaning inside a character class, so `[.]`
|
|
9
|
+
* is a valid shorthand for a literal dot and must not be unwrapped to a bare `.`.
|
|
10
|
+
*/
|
|
9
11
|
const METACHAR_EXCEPTIONS = new Set([".", "*", "+", "?", "{", "}", "(", ")", "|", "$"]);
|
|
10
12
|
|
|
11
13
|
interface CharClass {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
14
|
+
bracketStart: number;
|
|
15
|
+
bracketEnd: number;
|
|
16
|
+
innerToken: string;
|
|
15
17
|
}
|
|
16
18
|
|
|
17
19
|
function parseCharClasses(pattern: string): CharClass[] {
|
|
@@ -58,7 +60,9 @@ function parseCharClasses(pattern: string): CharClass[] {
|
|
|
58
60
|
}
|
|
59
61
|
if (i >= pattern.length) break;
|
|
60
62
|
const end = i++;
|
|
61
|
-
if (tokens.length === 1)
|
|
63
|
+
if (tokens.length === 1) {
|
|
64
|
+
results.push({ bracketStart: start, bracketEnd: end, innerToken: tokens[0] });
|
|
65
|
+
}
|
|
62
66
|
}
|
|
63
67
|
return results;
|
|
64
68
|
}
|
|
@@ -81,17 +85,16 @@ export const noSingleCharClass: KlintRule = {
|
|
|
81
85
|
const flags = regexSrc.slice(lastSlash + 1);
|
|
82
86
|
|
|
83
87
|
const allClasses = parseCharClasses(pattern);
|
|
84
|
-
const toFix = allClasses.filter((c) => !METACHAR_EXCEPTIONS.has(c.
|
|
88
|
+
const toFix = allClasses.filter((c) => !METACHAR_EXCEPTIONS.has(c.innerToken));
|
|
85
89
|
if (toFix.length === 0) return;
|
|
86
90
|
|
|
87
|
-
// Build fixed pattern: replace [token] with token for non-exceptions
|
|
88
91
|
let fixedPattern = "";
|
|
89
92
|
let prev = 0;
|
|
90
93
|
for (const cls of allClasses) {
|
|
91
|
-
if (METACHAR_EXCEPTIONS.has(cls.
|
|
92
|
-
fixedPattern += pattern.slice(prev, cls.
|
|
93
|
-
fixedPattern += cls.
|
|
94
|
-
prev = cls.
|
|
94
|
+
if (METACHAR_EXCEPTIONS.has(cls.innerToken)) continue;
|
|
95
|
+
fixedPattern += pattern.slice(prev, cls.bracketStart);
|
|
96
|
+
fixedPattern += cls.innerToken;
|
|
97
|
+
prev = cls.bracketEnd + 1;
|
|
95
98
|
}
|
|
96
99
|
fixedPattern += pattern.slice(prev);
|
|
97
100
|
|
|
@@ -101,7 +104,7 @@ export const noSingleCharClass: KlintRule = {
|
|
|
101
104
|
violations.push({
|
|
102
105
|
file: relativeSlashPath(root, file),
|
|
103
106
|
line: fix.startLine,
|
|
104
|
-
message: `Character class [${toFix[0].
|
|
107
|
+
message: `Character class [${toFix[0].innerToken}] contains a single element — remove the brackets.`,
|
|
105
108
|
fix,
|
|
106
109
|
});
|
|
107
110
|
});
|