@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 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
 
@@ -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
- // Sort by endLine descending so the largest-range fix at any position wins.
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.26.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.26.0",
69
- "@konvert7/klint-darwin-x64": "0.26.0",
70
- "@konvert7/klint-linux-x64": "0.26.0",
71
- "@konvert7/klint-win32-x64": "0.26.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
- // Unescaped metacharacters that lose their special meaning inside a character class,
8
- // making [.] a valid shorthand for a literal dot without needing \.
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
- start: number; // index of '[' in pattern
13
- end: number; // index of ']' in pattern
14
- inner: string; // the single token inside
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) results.push({ start, end, inner: tokens[0] });
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.inner));
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.inner)) continue;
92
- fixedPattern += pattern.slice(prev, cls.start);
93
- fixedPattern += cls.inner;
94
- prev = cls.end + 1;
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].inner}] contains a single element — remove the brackets.`,
107
+ message: `Character class [${toFix[0].innerToken}] contains a single element — remove the brackets.`,
105
108
  fix,
106
109
  });
107
110
  });