@konvert7/klint 0.26.0 → 0.28.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`
@@ -248,6 +248,10 @@ arch:
248
248
  message: "Data modules may only depend on data, db, and types"
249
249
  ```
250
250
 
251
+ Every rule reads the module specifier off the AST, so `import`, dynamic
252
+ `import()`, and re-exports all count as dependencies — `export { x } from "…"`
253
+ and `export * from "…"` cross a boundary exactly like an import does.
254
+
251
255
  Allow type-only imports when runtime imports are forbidden:
252
256
 
253
257
  ```yaml
@@ -259,6 +263,50 @@ arch:
259
263
  message: "Core may reference adapter types, but not adapter values"
260
264
  ```
261
265
 
266
+ This covers `import type`, `export type … from`, and — in Python — every import
267
+ inside an `if TYPE_CHECKING:` block. The `else` and `elif` branches of such a
268
+ guard stay runtime imports and are still reported.
269
+
270
+ Block npm packages and `node:` builtins with `deny-packages`. `deny` and `allow` work on layers of
271
+ project files, so they can never reach a specifier that doesn't resolve to one — that is what
272
+ `deny-packages` is for:
273
+
274
+ ```yaml
275
+ arch:
276
+ imports:
277
+ - from: ["src/dao/**"]
278
+ deny-packages: ["next/headers", "node:fs"]
279
+ message: "Data access must not read request state or touch the filesystem"
280
+ ```
281
+
282
+ Matching is per segment, so `next` also covers `next/headers` while leaving `nextra` alone,
283
+ and `next/headers` does not match `next/navigation`. Python splits on dots instead, so the same
284
+ rule blocks pip packages and stdlib modules — `os` covers `os.path` and leaves `oscrypto` alone:
285
+
286
+ ```yaml
287
+ arch:
288
+ imports:
289
+ - from: ["src/app/jobs/**"]
290
+ deny-packages: ["requests", "os"]
291
+ message: "Jobs must go through the http client and config"
292
+ ```
293
+
294
+ Swift splits on dots as well and matches at module granularity, which makes system frameworks —
295
+ exactly the imports that never resolve to a project file — the natural target:
296
+
297
+ ```yaml
298
+ arch:
299
+ imports:
300
+ - from: ["Sources/App/Core/**"]
301
+ deny-packages: ["UIKit", "Foundation"]
302
+ message: "Core must stay free of UI and platform frameworks"
303
+ ```
304
+
305
+ The specifier comes off the AST rather than a
306
+ text scan, so static imports and dynamic `import()` are both caught while a mention inside a comment
307
+ or string is not. `type-only: allow` exempts `import type` here too. `deny-packages` composes with
308
+ `deny` in the same rule — one covers packages, the other project paths.
309
+
262
310
  ### Forbidden Patterns
263
311
 
264
312
  Block literal string patterns inside a scoped layer:
@@ -342,6 +390,61 @@ arch:
342
390
 
343
391
  `maxLines` is enforced in both the TS and Rust engines and applies to every language klint scans, with no formatter dependency.
344
392
 
393
+ ### Comment Budgets
394
+
395
+ 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.
396
+
397
+ `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:
398
+
399
+ ```yaml
400
+ arch:
401
+ maxCommentDensity:
402
+ - limit: 5 # percent — no more than 5% of the file may be comments
403
+ in: ["src/**"]
404
+ message: "Encode intent in names, not comments" # optional
405
+ ```
406
+
407
+ `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:
408
+
409
+ ```yaml
410
+ arch:
411
+ maxCommentBlock:
412
+ - limit: 2 # at most 2 comment lines in a row
413
+ in: ["src/**"]
414
+ ```
415
+
416
+ 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 (`#`) and Swift (`//`, `/* */`) files are enforced by the Rust engine.
417
+
418
+ ### Language Support
419
+
420
+ klint scans TypeScript/JavaScript, Python, Swift, and Rust. Every architecture rule is enforced from the same `klint.yaml`, but what a rule can *see* depends on what the language actually has.
421
+
422
+ | | TypeScript / JavaScript | Python | Swift | Rust |
423
+ |---|---|---|---|---|
424
+ | **Engines** | `ts`, `rust` | `rust` | `rust` | `rust` |
425
+ | **Parser** | TypeScript compiler API (TS engine), tree-sitter (Rust engine) | tree-sitter | tree-sitter | tree-sitter |
426
+ | `arch.imports` — static | ✅ | ✅ | ✅ | ✅ `use` |
427
+ | `arch.imports` — dynamic | ✅ `import()` | ✅ `importlib.import_module`, `__import__` | — | — |
428
+ | `arch.imports` — re-exports | ✅ `export … from` | — | ✅ `@_exported import` | ✅ `pub use` |
429
+ | `arch.imports` — multi-target | — | ✅ `import a, b` | — | ✅ `use a::{b, c}` |
430
+ | `type-only: allow` | ✅ `import type` | ✅ `if TYPE_CHECKING:` | — | — |
431
+ | Path aliases | ✅ `tsconfig.json` `paths` | — | — | — |
432
+ | Module resolution | relative paths + alias chains | relative, absolute, and PEP 420 namespace packages | module name against project dirs and file stems | `crate::`/`self::`/`super::` against the directory tree |
433
+ | `deny-packages` | ✅ `/` segments | ✅ `.` segments | ✅ `.` segments, module granularity | ✅ `::` segments |
434
+ | `arch.forbidden` / `singleton` — `pattern` | ✅ | ✅ | ✅ | ✅ |
435
+ | `arch.forbidden` / `singleton` — `jsx-element` | ✅ | — | — | — |
436
+ | `arch.maxLines` | ✅ | ✅ | ✅ | ✅ |
437
+ | `arch.maxCommentDensity` / `maxCommentBlock` | ✅ `//`, `/* */`, `/** */` docs | ✅ `#` | ✅ `//`, `/* */`, `///` and `/** */` docs | ✅ `//`, `/* */`, `///` and `//!` docs |
438
+ | Built-in rules and plugins | ✅ | ✗ | ✗ | ✗ |
439
+ | Custom rules (`klint.rules.ts`) | ✅ | ✗ | ✗ | ✗ |
440
+ | Type-aware rules | ✅ | ✗ | ✗ | ✗ |
441
+
442
+ **✅** supported · **—** the language has no equivalent construct · **✗** not supported yet
443
+
444
+ A dash is not a gap. Swift has no dynamic import expression, no `import type`, and one module per declaration; Rust has no dynamic import and no type-only import. Those rows can never be filled. The `✗` rows are real limits: the rule set and custom rules are written against the TypeScript AST, and type-aware rules need a type checker that only the TS engine has.
445
+
446
+ Imports are read off the AST in every language, so a specifier written inside a comment or a string is never an import — including nested Swift block comments. Only file discovery differs: the TS engine walks `.ts`/`.tsx`/`.js`/`.jsx`/`.mts`/`.cts`, so Python, Swift, and Rust files reach klint through the Rust engine, which `auto` mode selects for them automatically.
447
+
345
448
  ### Python Architecture Checks
346
449
 
347
450
  The Rust engine can apply architecture rules to Python projects:
@@ -376,9 +479,19 @@ arch:
376
479
 
377
480
  Python import checks support relative imports such as
378
481
  `from ..lib.auth import load_key` and project-resolvable absolute imports such
379
- as `from app.lib.auth import load_key`. External packages that do not resolve to
380
- project files, such as `requests`, are ignored. This is architecture
381
- enforcement, not full packaging or virtual-environment analysis.
482
+ as `from app.lib.auth import load_key`. Every target of a multi-target statement
483
+ is checked, so `import json, app.lib.auth` and `from . import helper, sibling`
484
+ report per target rather than only the first one. Dynamic imports count too:
485
+ `importlib.import_module("app.lib.auth")` and `__import__("app.lib.auth")` are
486
+ read off the AST, including a renamed `from importlib import import_module as
487
+ load`. A same-named call that is not bound to `importlib` — `self.import_module(…)` —
488
+ is not an import and is left alone. A package import resolves through
489
+ `<module>.py`, `<module>/__init__.py`, or a `<module>/` directory holding
490
+ scanned `.py` files, so PEP 420 namespace packages with no `__init__.py` are
491
+ covered. External packages that do not resolve to project files, such as
492
+ `requests`, are ignored by `deny`/`allow` and are the target of `deny-packages`
493
+ instead. This is architecture enforcement, not full packaging or
494
+ virtual-environment analysis.
382
495
 
383
496
  ### Swift Architecture Checks
384
497
 
@@ -412,12 +525,64 @@ arch:
412
525
  message: "Use AppConfig"
413
526
  ```
414
527
 
415
- Swift import checks parse declarations such as `import Core`,
416
- `@_exported import Core`, and `import struct Models.User`. Imported modules
417
- resolve against discovered project Swift directories and file stems. System or
418
- package modules that are not present in the project, such as `Foundation`, are
419
- ignored. This is architecture enforcement, not full SwiftPM or Xcode build graph
420
- analysis.
528
+ Swift import checks read declarations off the AST, covering `import Core`,
529
+ `@_exported import Core`, `@testable import Core`, `import struct Models.User`,
530
+ and submodule paths such as `import Core.Session`. Because the specifier comes
531
+ from the parse tree, an `import` written inside a `//` or `/* */` comment is not
532
+ an import — including nested block comments. Imported modules resolve against
533
+ discovered project Swift directories and file stems. System or package modules
534
+ that are not present in the project, such as `Foundation`, are ignored. This is
535
+ architecture enforcement, not full SwiftPM or Xcode build graph analysis.
536
+
537
+ Comment budget rules apply to Swift too, where `///` counts as a doc-comment
538
+ alongside `/** */`.
539
+
540
+ ### Rust Architecture Checks
541
+
542
+ The Rust engine can apply architecture rules to Rust crates:
543
+
544
+ ```yaml
545
+ include: ["crates/**/*.rs", "!target/**"]
546
+
547
+ rules: {}
548
+
549
+ arch:
550
+ layers:
551
+ cli: ["crates/app/src/cli/**"]
552
+ domain: ["crates/app/src/domain/**"]
553
+ storage: ["crates/app/src/storage/**"]
554
+
555
+ imports:
556
+ - from: domain
557
+ deny: storage
558
+ message: "Domain logic must not reach into the storage layer"
559
+ - from: domain
560
+ deny-packages: ["reqwest", "tokio"]
561
+ message: "Keep I/O crates out of the domain layer"
562
+
563
+ maxLines:
564
+ - limit: 500
565
+ in: [cli, domain, storage]
566
+ ```
567
+
568
+ Rust import checks read `use` declarations off the AST, and every target of a
569
+ braced list becomes its own record — `use a::{b::{c, d}, e as f}` is checked as
570
+ `a::b::c`, `a::b::d`, and `a::e`. Aliases contribute their path, `*` wildcards
571
+ contribute the path they glob, and a bare `self` in a list names its own prefix.
572
+ `pub use` re-exports are checked the same way as plain `use`.
573
+
574
+ Paths starting with `crate::`, `self::`, or `super::` resolve against the
575
+ directory tree by convention: klint walks down from the crate's `src` directory
576
+ (the one holding `lib.rs` or `main.rs`), probing `foo.rs` then `foo/mod.rs` at
577
+ each step and keeping the longest prefix that lands on a real file. Everything
578
+ after that prefix is treated as an item inside the module. External crates have
579
+ no file to resolve to and are ignored by `deny`/`allow`, which is what
580
+ `deny-packages` is for — it matches on `::` segments, so `tokio` denies
581
+ `tokio::sync::Mutex` but never `tokio_util`. This is architecture enforcement,
582
+ not a Cargo build graph or `#[path]` attribute resolver.
583
+
584
+ Comment budget rules apply to Rust too, where both `///` and `//!` count as
585
+ doc-comments.
421
586
 
422
587
  ## Built-in Rules
423
588
 
@@ -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";
@@ -102,6 +103,12 @@ function resolveLayerFiles(
102
103
  );
103
104
  }
104
105
 
106
+ function matchesPackage(specifier: string, entries: string[]): boolean {
107
+ return entries.some(
108
+ (entry) => specifier === entry || specifier.startsWith(`${entry}/`)
109
+ );
110
+ }
111
+
105
112
  function inPrefixes(absPath: string, prefixes: string[]): boolean {
106
113
  return prefixes.some((p) => {
107
114
  if (absPath === p || absPath.startsWith(`${p}/`)) return true;
@@ -128,6 +135,11 @@ function scanImports(
128
135
  specifierNode = node.moduleSpecifier;
129
136
  isTypeOnly = node.importClause?.isTypeOnly ?? false;
130
137
  }
138
+ } else if (ts.isExportDeclaration(node)) {
139
+ if (node.moduleSpecifier && ts.isStringLiteral(node.moduleSpecifier)) {
140
+ specifierNode = node.moduleSpecifier;
141
+ isTypeOnly = node.isTypeOnly;
142
+ }
131
143
  } else if (
132
144
  ts.isCallExpression(node) &&
133
145
  node.expression.kind === ts.SyntaxKind.ImportKeyword &&
@@ -166,17 +178,30 @@ export function runArchRules(
166
178
  for (const rule of arch.imports ?? []) {
167
179
  const severity: Severity = rule.severity ?? "error";
168
180
  const fromFiles = resolveLayerFiles(rule.from, layers, root, allFiles);
181
+ const denyPackages = toArray(rule["deny-packages"] ?? []);
169
182
 
170
183
  for (const file of fromFiles) {
171
184
  const content = fileContents.get(file);
172
185
  if (!content) continue;
173
186
 
174
187
  for (const imp of scanImports(file, content, aliases)) {
175
- if (isBareSpecifier(imp.resolved)) continue;
176
188
  if (rule["type-only"] === "allow" && imp.isTypeOnly) continue;
177
189
 
178
190
  const relFile = relativeSlashPath(root, file);
179
191
 
192
+ if (isBareSpecifier(imp.resolved)) {
193
+ if (matchesPackage(imp.path, denyPackages)) {
194
+ violations.push({
195
+ file: relFile,
196
+ line: imp.line,
197
+ message: rule.message ?? "Import of a denied package",
198
+ rule: "arch/imports",
199
+ severity,
200
+ });
201
+ }
202
+ continue;
203
+ }
204
+
180
205
  if (rule.deny !== undefined) {
181
206
  const denyPrefixes = resolveLayerPrefixes(rule.deny, layers, root);
182
207
  if (inPrefixes(imp.resolved, denyPrefixes)) {
@@ -282,6 +307,54 @@ export function runArchRules(
282
307
  }
283
308
  }
284
309
 
310
+ for (const rule of arch.maxCommentDensity ?? []) {
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 total = countLines(content);
317
+ if (total === 0) continue;
318
+ const commentLines = commentLineSet(file, content, rule.countDocComments ?? false);
319
+ const density = (commentLines.size / total) * 100;
320
+ if (density > rule.limit) {
321
+ violations.push({
322
+ file: relativeSlashPath(root, file),
323
+ line: 1,
324
+ message:
325
+ rule.message ??
326
+ `Comment density ${density.toFixed(1)}% exceeds the maximum of ${rule.limit}%`,
327
+ rule: "arch/max-comment-density",
328
+ severity,
329
+ });
330
+ }
331
+ }
332
+ }
333
+
334
+ for (const rule of arch.maxCommentBlock ?? []) {
335
+ const severity: Severity = rule.severity ?? "error";
336
+ const inFiles = resolveLayerFiles(rule.in, layers, root, allFiles);
337
+ for (const file of inFiles) {
338
+ const content = fileContents.get(file);
339
+ if (content === undefined) continue;
340
+ const commentLines = [
341
+ ...commentLineSet(file, content, rule.countDocComments ?? false),
342
+ ].sort((a, b) => a - b);
343
+ const overrun = firstCommentBlockOverrun(commentLines, rule.limit);
344
+ if (overrun !== undefined) {
345
+ violations.push({
346
+ file: relativeSlashPath(root, file),
347
+ line: overrun,
348
+ message:
349
+ rule.message ??
350
+ `Comment block exceeds the maximum of ${rule.limit} consecutive lines`,
351
+ rule: "arch/max-comment-block",
352
+ severity,
353
+ });
354
+ }
355
+ }
356
+ }
357
+
285
358
  return violations;
286
359
  }
287
360
 
@@ -85,11 +85,14 @@ const ArchImportRuleSchema = z
85
85
  allow: StringOrStringArray.optional().describe(
86
86
  "Allowlist of layer name(s) / glob(s) the source may import from. Anything not matching `allow` is denied."
87
87
  ),
88
+ "deny-packages": StringOrStringArray.optional().describe(
89
+ "Package specifier(s) the source is not allowed to import — npm packages, `node:` builtins, Python pip packages and stdlib modules, Swift system frameworks, and external Rust crates, which `deny`/`allow` cannot reach because they never resolve to a project file. Matching is per segment: `/` in TypeScript/JavaScript, so `next` also covers `next/headers` while `nextra` is unaffected; `.` in Python and Swift, so `os` also covers `os.path` while `oscrypto` is unaffected; and `::` in Rust, so `tokio` also covers `tokio::sync::Mutex` while `tokio_util` is unaffected. Python, Swift, and Rust sources are checked by the Rust engine."
90
+ ),
88
91
  "type-only": z
89
92
  .literal("allow")
90
93
  .optional()
91
94
  .describe(
92
- 'When set to "allow", `import type` statements that would otherwise be denied are permitted. Useful for type-only references across layers.'
95
+ 'When set to "allow", `import type` statements that would otherwise be denied are permitted. In Python, imports inside an `if TYPE_CHECKING:` block are exempted the same way. Useful for type-only references across layers.'
93
96
  ),
94
97
  message: z
95
98
  .string()
@@ -237,6 +240,76 @@ const ArchMaxLinesRuleSchema = z
237
240
  ],
238
241
  });
239
242
 
243
+ const CountDocCommentsSchema = z
244
+ .boolean()
245
+ .optional()
246
+ .describe(
247
+ "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."
248
+ );
249
+
250
+ const ArchMaxCommentDensityRuleSchema = z
251
+ .object({
252
+ limit: z
253
+ .number()
254
+ .positive()
255
+ .max(100)
256
+ .describe(
257
+ "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."
258
+ ),
259
+ countDocComments: CountDocCommentsSchema,
260
+ in: StringOrStringArray.describe(
261
+ "Layer name(s) or glob(s) the limit applies to. Files outside the scope are not checked."
262
+ ),
263
+ message: z
264
+ .string()
265
+ .optional()
266
+ .describe(
267
+ "Optional error message. Defaults to a message naming the measured density and the limit."
268
+ ),
269
+ severity: ArchSeveritySchema.optional(),
270
+ })
271
+ .strict()
272
+ .describe(
273
+ "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."
274
+ )
275
+ .meta({
276
+ examples: [
277
+ 'arch:\n maxCommentDensity:\n - limit: 5\n in: src/**\n message: "Encode intent in names, not comments"',
278
+ "arch:\n maxCommentDensity:\n - limit: 10\n countDocComments: true\n in: src/**",
279
+ ],
280
+ });
281
+
282
+ const ArchMaxCommentBlockRuleSchema = z
283
+ .object({
284
+ limit: z
285
+ .number()
286
+ .int()
287
+ .positive()
288
+ .describe(
289
+ "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."
290
+ ),
291
+ countDocComments: CountDocCommentsSchema,
292
+ in: StringOrStringArray.describe(
293
+ "Layer name(s) or glob(s) the limit applies to. Files outside the scope are not checked."
294
+ ),
295
+ message: z
296
+ .string()
297
+ .optional()
298
+ .describe(
299
+ "Optional error message. Defaults to a message naming the consecutive-line limit."
300
+ ),
301
+ severity: ArchSeveritySchema.optional(),
302
+ })
303
+ .strict()
304
+ .describe(
305
+ "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."
306
+ )
307
+ .meta({
308
+ examples: [
309
+ 'arch:\n maxCommentBlock:\n - limit: 2\n in: src/**\n message: "Extract a named function instead of a comment block"',
310
+ ],
311
+ });
312
+
240
313
  const ArchSchema = z
241
314
  .object({
242
315
  layers: ArchLayersSchema.optional(),
@@ -262,10 +335,22 @@ const ArchSchema = z
262
335
  .describe(
263
336
  "Per-file line-count limits. Cap how long files in a layer may grow, with different ceilings per scope."
264
337
  ),
338
+ maxCommentDensity: z
339
+ .array(ArchMaxCommentDensityRuleSchema)
340
+ .optional()
341
+ .describe(
342
+ "Per-file comment-density ceilings. Cap the share of a file that may be explanatory comments; doc-comments are exempt by default."
343
+ ),
344
+ maxCommentBlock: z
345
+ .array(ArchMaxCommentBlockRuleSchema)
346
+ .optional()
347
+ .describe(
348
+ "Per-block comment-height caps. Limit how many comment lines may stack together; doc-comments are exempt by default."
349
+ ),
265
350
  })
266
351
  .strict()
267
352
  .describe(
268
- "Architecture-as-Code constraints — layers, import boundaries, forbidden patterns, singleton locations. Enforced by the arch engine alongside the rule set."
353
+ "Architecture-as-Code constraints — layers, import boundaries, forbidden patterns, singleton locations, comment budgets. Enforced by the arch engine alongside the rule set."
269
354
  );
270
355
 
271
356
  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
@@ -58,6 +58,7 @@ interface ArchImportRule {
58
58
  from: string | string[];
59
59
  deny?: string | string[];
60
60
  allow?: string | string[];
61
+ "deny-packages"?: string | string[];
61
62
  "type-only"?: "allow";
62
63
  message?: string;
63
64
  severity?: Exclude<Severity, "off">;
@@ -104,12 +105,29 @@ interface ArchMaxLinesRule {
104
105
  severity?: Exclude<Severity, "off">;
105
106
  }
106
107
 
108
+ interface ArchCommentRuleBase {
109
+ limit: number;
110
+ in: string | string[];
111
+ /** Count doc-comments (`/** *​/` JSDoc, Python docstrings) toward the limit. Defaults to false. */
112
+ countDocComments?: boolean;
113
+ message?: string;
114
+ severity?: Exclude<Severity, "off">;
115
+ }
116
+
117
+ /** Caps the share of a file's physical lines that are non-doc comments. */
118
+ interface ArchMaxCommentDensityRule extends ArchCommentRuleBase {}
119
+
120
+ /** Caps how many consecutive comment lines may stack into a single block. */
121
+ interface ArchMaxCommentBlockRule extends ArchCommentRuleBase {}
122
+
107
123
  export interface ArchConfig {
108
124
  layers?: Record<string, string[]>;
109
125
  imports?: ArchImportRule[];
110
126
  forbidden?: ArchForbiddenRule[];
111
127
  singleton?: ArchSingletonRule[];
112
128
  maxLines?: ArchMaxLinesRule[];
129
+ maxCommentDensity?: ArchMaxCommentDensityRule[];
130
+ maxCommentBlock?: ArchMaxCommentBlockRule[];
113
131
  }
114
132
 
115
133
  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.28.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.28.0",
69
+ "@konvert7/klint-darwin-x64": "0.28.0",
70
+ "@konvert7/klint-linux-x64": "0.28.0",
71
+ "@konvert7/klint-win32-x64": "0.28.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
  });
@@ -32,6 +32,7 @@ Wire it into `klint.yaml` for editor autocomplete and validation:
32
32
  |---|---|
33
33
  | Layer X must not import from layer Y | `arch.imports` + `deny` |
34
34
  | Layer X may only import from Y and Z | `arch.imports` + `allow` |
35
+ | Layer X must not import an npm package or `node:` builtin | `arch.imports` + `deny-packages` |
35
36
  | Pattern P must only appear in one designated file | `arch.singleton` + `pattern` |
36
37
  | Pattern P must never appear inside a scoped layer | `arch.forbidden` + `pattern` |
37
38
  | Raw HTML/JSX element must never appear in a scoped layer | `arch.forbidden` + `jsx-element` |
@@ -78,13 +79,27 @@ arch:
78
79
  allow: ["src/dao/**", "src/prisma/**"]
79
80
  message: "DAO may only import from dao or prisma"
80
81
 
81
- # type-only: allow — import type {} is permitted even when value imports are denied
82
+ # type-only: allow — import type {} and Python if TYPE_CHECKING: blocks stay permitted
82
83
  - from: core
83
84
  deny: targets
84
85
  type-only: allow
85
86
  message: "Core must not depend on agent-specific code"
87
+
88
+ # deny-packages: block npm packages and node: builtins, which deny/allow cannot reach
89
+ # because they never resolve to a project file. Composes with deny in the same rule.
90
+ - from: ["src/dao/**"]
91
+ deny-packages: ["next/headers", "node:fs"]
92
+ message: "Data access must not read request state or touch the filesystem"
86
93
  ```
87
94
 
95
+ `deny-packages` matches per segment: `next` covers `next/headers`, `nextra` is unaffected, and
96
+ `next/headers` does not match `next/navigation`. Python uses dotted segments, so `os` covers
97
+ `os.path` and blocks pip packages and stdlib modules alike. Swift uses dotted segments too, at
98
+ module granularity, which is what makes system frameworks like `UIKit` reachable. Rust splits on
99
+ `::`, so `tokio` covers `tokio::sync::Mutex` while `tokio_util` is unaffected. The specifier is read off the AST, so
100
+ static imports, dynamic `import()`, and `export … from` re-exports all count while a comment
101
+ mentioning the package does not — unlike `forbidden` + `pattern`, which is a line-based text scan.
102
+
88
103
  ### Singleton — one designated location
89
104
 
90
105
  Pin a `pattern` (literal string) **or** a `jsx-element` (AST-matched tag) to exactly one file. Every other in-scope occurrence is a violation.