@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 +175 -10
- package/core/arch-comments.ts +93 -0
- package/core/arch.ts +74 -1
- package/core/config.schema.ts +87 -2
- package/core/fixer.ts +9 -6
- package/core/runner.ts +8 -1
- package/core/types.ts +18 -0
- package/package.json +5 -5
- package/rules/no-single-char-class.ts +16 -13
- package/skill/klint-rules/SKILL.md +16 -1
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`.
|
|
380
|
-
|
|
381
|
-
|
|
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
|
|
416
|
-
`@_exported import Core`,
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
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
|
|
package/core/config.schema.ts
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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.
|
|
69
|
-
"@konvert7/klint-darwin-x64": "0.
|
|
70
|
-
"@konvert7/klint-linux-x64": "0.
|
|
71
|
-
"@konvert7/klint-win32-x64": "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
|
-
|
|
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
|
});
|
|
@@ -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 {}
|
|
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.
|