@konvert7/klint 0.27.0 → 0.29.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 +162 -10
- package/core/arch-comments.ts +30 -12
- package/core/arch.ts +50 -4
- package/core/config.schema.ts +10 -1
- package/core/types.ts +3 -0
- package/package.json +5 -5
- package/skill/klint-rules/SKILL.md +73 -1
package/README.md
CHANGED
|
@@ -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:
|
|
@@ -365,7 +413,49 @@ arch:
|
|
|
365
413
|
in: ["src/**"]
|
|
366
414
|
```
|
|
367
415
|
|
|
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
|
|
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
|
+
Some comments are machinery rather than prose — markers a build or codegen step reads, region pragmas — and counting them makes both limits measure the wrong thing. `ignore` exempts them, on either rule:
|
|
419
|
+
|
|
420
|
+
```yaml
|
|
421
|
+
arch:
|
|
422
|
+
maxCommentDensity:
|
|
423
|
+
- limit: 5
|
|
424
|
+
in: ["src/**"]
|
|
425
|
+
ignore: ["@codegen:"] # literal substring, or "re:" for a regex
|
|
426
|
+
```
|
|
427
|
+
|
|
428
|
+
An ignored line still counts in the density denominator — it is a physical line like code — and still holds a comment block together, so a marker cannot be sprinkled between comment lines to split an over-tall block. Matching tests the physical source line and therefore only ever applies to lines already lexed as comments.
|
|
429
|
+
|
|
430
|
+
### Language Support
|
|
431
|
+
|
|
432
|
+
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.
|
|
433
|
+
|
|
434
|
+
| | TypeScript / JavaScript | Python | Swift | Rust |
|
|
435
|
+
|---|---|---|---|---|
|
|
436
|
+
| **Engines** | `ts`, `rust` | `rust` | `rust` | `rust` |
|
|
437
|
+
| **Parser** | TypeScript compiler API (TS engine), tree-sitter (Rust engine) | tree-sitter | tree-sitter | tree-sitter |
|
|
438
|
+
| `arch.imports` — static | ✅ | ✅ | ✅ | ✅ `use` |
|
|
439
|
+
| `arch.imports` — dynamic | ✅ `import()` | ✅ `importlib.import_module`, `__import__` | — | — |
|
|
440
|
+
| `arch.imports` — re-exports | ✅ `export … from` | — | ✅ `@_exported import` | ✅ `pub use` |
|
|
441
|
+
| `arch.imports` — multi-target | — | ✅ `import a, b` | — | ✅ `use a::{b, c}` |
|
|
442
|
+
| `type-only: allow` | ✅ `import type` | ✅ `if TYPE_CHECKING:` | — | — |
|
|
443
|
+
| Path aliases | ✅ `tsconfig.json` `paths` | — | — | — |
|
|
444
|
+
| 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 |
|
|
445
|
+
| `deny-packages` | ✅ `/` segments | ✅ `.` segments | ✅ `.` segments, module granularity | ✅ `::` segments |
|
|
446
|
+
| `arch.forbidden` / `singleton` — `pattern` | ✅ | ✅ | ✅ | ✅ |
|
|
447
|
+
| `arch.forbidden` / `singleton` — `jsx-element` | ✅ | — | — | — |
|
|
448
|
+
| `arch.maxLines` | ✅ | ✅ | ✅ | ✅ |
|
|
449
|
+
| `arch.maxCommentDensity` / `maxCommentBlock` | ✅ `//`, `/* */`, `/** */` docs | ✅ `#` | ✅ `//`, `/* */`, `///` and `/** */` docs | ✅ `//`, `/* */`, `///` and `//!` docs |
|
|
450
|
+
| Built-in rules and plugins | ✅ | ✗ | ✗ | ✗ |
|
|
451
|
+
| Custom rules (`klint.rules.ts`) | ✅ | ✗ | ✗ | ✗ |
|
|
452
|
+
| Type-aware rules | ✅ | ✗ | ✗ | ✗ |
|
|
453
|
+
|
|
454
|
+
**✅** supported · **—** the language has no equivalent construct · **✗** not supported yet
|
|
455
|
+
|
|
456
|
+
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.
|
|
457
|
+
|
|
458
|
+
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.
|
|
369
459
|
|
|
370
460
|
### Python Architecture Checks
|
|
371
461
|
|
|
@@ -401,9 +491,19 @@ arch:
|
|
|
401
491
|
|
|
402
492
|
Python import checks support relative imports such as
|
|
403
493
|
`from ..lib.auth import load_key` and project-resolvable absolute imports such
|
|
404
|
-
as `from app.lib.auth import load_key`.
|
|
405
|
-
|
|
406
|
-
|
|
494
|
+
as `from app.lib.auth import load_key`. Every target of a multi-target statement
|
|
495
|
+
is checked, so `import json, app.lib.auth` and `from . import helper, sibling`
|
|
496
|
+
report per target rather than only the first one. Dynamic imports count too:
|
|
497
|
+
`importlib.import_module("app.lib.auth")` and `__import__("app.lib.auth")` are
|
|
498
|
+
read off the AST, including a renamed `from importlib import import_module as
|
|
499
|
+
load`. A same-named call that is not bound to `importlib` — `self.import_module(…)` —
|
|
500
|
+
is not an import and is left alone. A package import resolves through
|
|
501
|
+
`<module>.py`, `<module>/__init__.py`, or a `<module>/` directory holding
|
|
502
|
+
scanned `.py` files, so PEP 420 namespace packages with no `__init__.py` are
|
|
503
|
+
covered. External packages that do not resolve to project files, such as
|
|
504
|
+
`requests`, are ignored by `deny`/`allow` and are the target of `deny-packages`
|
|
505
|
+
instead. This is architecture enforcement, not full packaging or
|
|
506
|
+
virtual-environment analysis.
|
|
407
507
|
|
|
408
508
|
### Swift Architecture Checks
|
|
409
509
|
|
|
@@ -437,12 +537,64 @@ arch:
|
|
|
437
537
|
message: "Use AppConfig"
|
|
438
538
|
```
|
|
439
539
|
|
|
440
|
-
Swift import checks
|
|
441
|
-
`@_exported import Core`,
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
540
|
+
Swift import checks read declarations off the AST, covering `import Core`,
|
|
541
|
+
`@_exported import Core`, `@testable import Core`, `import struct Models.User`,
|
|
542
|
+
and submodule paths such as `import Core.Session`. Because the specifier comes
|
|
543
|
+
from the parse tree, an `import` written inside a `//` or `/* */` comment is not
|
|
544
|
+
an import — including nested block comments. Imported modules resolve against
|
|
545
|
+
discovered project Swift directories and file stems. System or package modules
|
|
546
|
+
that are not present in the project, such as `Foundation`, are ignored. This is
|
|
547
|
+
architecture enforcement, not full SwiftPM or Xcode build graph analysis.
|
|
548
|
+
|
|
549
|
+
Comment budget rules apply to Swift too, where `///` counts as a doc-comment
|
|
550
|
+
alongside `/** */`.
|
|
551
|
+
|
|
552
|
+
### Rust Architecture Checks
|
|
553
|
+
|
|
554
|
+
The Rust engine can apply architecture rules to Rust crates:
|
|
555
|
+
|
|
556
|
+
```yaml
|
|
557
|
+
include: ["crates/**/*.rs", "!target/**"]
|
|
558
|
+
|
|
559
|
+
rules: {}
|
|
560
|
+
|
|
561
|
+
arch:
|
|
562
|
+
layers:
|
|
563
|
+
cli: ["crates/app/src/cli/**"]
|
|
564
|
+
domain: ["crates/app/src/domain/**"]
|
|
565
|
+
storage: ["crates/app/src/storage/**"]
|
|
566
|
+
|
|
567
|
+
imports:
|
|
568
|
+
- from: domain
|
|
569
|
+
deny: storage
|
|
570
|
+
message: "Domain logic must not reach into the storage layer"
|
|
571
|
+
- from: domain
|
|
572
|
+
deny-packages: ["reqwest", "tokio"]
|
|
573
|
+
message: "Keep I/O crates out of the domain layer"
|
|
574
|
+
|
|
575
|
+
maxLines:
|
|
576
|
+
- limit: 500
|
|
577
|
+
in: [cli, domain, storage]
|
|
578
|
+
```
|
|
579
|
+
|
|
580
|
+
Rust import checks read `use` declarations off the AST, and every target of a
|
|
581
|
+
braced list becomes its own record — `use a::{b::{c, d}, e as f}` is checked as
|
|
582
|
+
`a::b::c`, `a::b::d`, and `a::e`. Aliases contribute their path, `*` wildcards
|
|
583
|
+
contribute the path they glob, and a bare `self` in a list names its own prefix.
|
|
584
|
+
`pub use` re-exports are checked the same way as plain `use`.
|
|
585
|
+
|
|
586
|
+
Paths starting with `crate::`, `self::`, or `super::` resolve against the
|
|
587
|
+
directory tree by convention: klint walks down from the crate's `src` directory
|
|
588
|
+
(the one holding `lib.rs` or `main.rs`), probing `foo.rs` then `foo/mod.rs` at
|
|
589
|
+
each step and keeping the longest prefix that lands on a real file. Everything
|
|
590
|
+
after that prefix is treated as an item inside the module. External crates have
|
|
591
|
+
no file to resolve to and are ignored by `deny`/`allow`, which is what
|
|
592
|
+
`deny-packages` is for — it matches on `::` segments, so `tokio` denies
|
|
593
|
+
`tokio::sync::Mutex` but never `tokio_util`. This is architecture enforcement,
|
|
594
|
+
not a Cargo build graph or `#[path]` attribute resolver.
|
|
595
|
+
|
|
596
|
+
Comment budget rules apply to Rust too, where both `///` and `//!` count as
|
|
597
|
+
doc-comments.
|
|
446
598
|
|
|
447
599
|
## Built-in Rules
|
|
448
600
|
|
package/core/arch-comments.ts
CHANGED
|
@@ -52,22 +52,40 @@ export function commentLineSet(
|
|
|
52
52
|
return set;
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
-
/**
|
|
55
|
+
/**
|
|
56
|
+
* Comment lines whose source text matches an `ignore` pattern. They stay in the
|
|
57
|
+
* density denominator and keep a comment run contiguous, but count toward neither.
|
|
58
|
+
*/
|
|
59
|
+
export function ignoredCommentLines(
|
|
60
|
+
content: string,
|
|
61
|
+
commentLines: Iterable<number>,
|
|
62
|
+
matchesIgnore: (text: string) => boolean
|
|
63
|
+
): Set<number> {
|
|
64
|
+
const sourceLines = content.split("\n");
|
|
65
|
+
const ignored = new Set<number>();
|
|
66
|
+
for (const line of commentLines) {
|
|
67
|
+
if (matchesIgnore(sourceLines[line - 1] ?? "")) ignored.add(line);
|
|
68
|
+
}
|
|
69
|
+
return ignored;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Returns the first line at which a run of consecutive comment lines exceeds
|
|
74
|
+
* `limit`. An ignored line neither counts toward the height nor breaks the run.
|
|
75
|
+
*/
|
|
56
76
|
export function firstCommentBlockOverrun(
|
|
57
77
|
sortedLines: number[],
|
|
58
|
-
limit: number
|
|
78
|
+
limit: number,
|
|
79
|
+
ignored?: ReadonlySet<number>
|
|
59
80
|
): number | undefined {
|
|
60
|
-
let
|
|
61
|
-
let prev
|
|
62
|
-
for (
|
|
63
|
-
|
|
64
|
-
if (line === prev + 1) {
|
|
65
|
-
prev = line;
|
|
66
|
-
continue;
|
|
67
|
-
}
|
|
68
|
-
if (prev - runStart + 1 > limit) return runStart + limit;
|
|
69
|
-
runStart = line;
|
|
81
|
+
let counted = 0;
|
|
82
|
+
let prev: number | undefined;
|
|
83
|
+
for (const line of sortedLines) {
|
|
84
|
+
if (prev !== undefined && line !== prev + 1) counted = 0;
|
|
70
85
|
prev = line;
|
|
86
|
+
if (ignored?.has(line)) continue;
|
|
87
|
+
counted += 1;
|
|
88
|
+
if (counted > limit) return line;
|
|
71
89
|
}
|
|
72
90
|
return undefined;
|
|
73
91
|
}
|
package/core/arch.ts
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import { dirname, isAbsolute, resolve } from "node:path";
|
|
2
2
|
import ts from "typescript";
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
commentLineSet,
|
|
5
|
+
firstCommentBlockOverrun,
|
|
6
|
+
ignoredCommentLines,
|
|
7
|
+
} from "./arch-comments";
|
|
4
8
|
import { walkAst } from "./ast";
|
|
5
9
|
import { relativeSlashPath, toSlashPath } from "./paths";
|
|
6
10
|
import type { ArchConfig, Severity, Violation } from "./types";
|
|
@@ -103,6 +107,12 @@ function resolveLayerFiles(
|
|
|
103
107
|
);
|
|
104
108
|
}
|
|
105
109
|
|
|
110
|
+
function matchesPackage(specifier: string, entries: string[]): boolean {
|
|
111
|
+
return entries.some(
|
|
112
|
+
(entry) => specifier === entry || specifier.startsWith(`${entry}/`)
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
106
116
|
function inPrefixes(absPath: string, prefixes: string[]): boolean {
|
|
107
117
|
return prefixes.some((p) => {
|
|
108
118
|
if (absPath === p || absPath.startsWith(`${p}/`)) return true;
|
|
@@ -129,6 +139,11 @@ function scanImports(
|
|
|
129
139
|
specifierNode = node.moduleSpecifier;
|
|
130
140
|
isTypeOnly = node.importClause?.isTypeOnly ?? false;
|
|
131
141
|
}
|
|
142
|
+
} else if (ts.isExportDeclaration(node)) {
|
|
143
|
+
if (node.moduleSpecifier && ts.isStringLiteral(node.moduleSpecifier)) {
|
|
144
|
+
specifierNode = node.moduleSpecifier;
|
|
145
|
+
isTypeOnly = node.isTypeOnly;
|
|
146
|
+
}
|
|
132
147
|
} else if (
|
|
133
148
|
ts.isCallExpression(node) &&
|
|
134
149
|
node.expression.kind === ts.SyntaxKind.ImportKeyword &&
|
|
@@ -167,17 +182,30 @@ export function runArchRules(
|
|
|
167
182
|
for (const rule of arch.imports ?? []) {
|
|
168
183
|
const severity: Severity = rule.severity ?? "error";
|
|
169
184
|
const fromFiles = resolveLayerFiles(rule.from, layers, root, allFiles);
|
|
185
|
+
const denyPackages = toArray(rule["deny-packages"] ?? []);
|
|
170
186
|
|
|
171
187
|
for (const file of fromFiles) {
|
|
172
188
|
const content = fileContents.get(file);
|
|
173
189
|
if (!content) continue;
|
|
174
190
|
|
|
175
191
|
for (const imp of scanImports(file, content, aliases)) {
|
|
176
|
-
if (isBareSpecifier(imp.resolved)) continue;
|
|
177
192
|
if (rule["type-only"] === "allow" && imp.isTypeOnly) continue;
|
|
178
193
|
|
|
179
194
|
const relFile = relativeSlashPath(root, file);
|
|
180
195
|
|
|
196
|
+
if (isBareSpecifier(imp.resolved)) {
|
|
197
|
+
if (matchesPackage(imp.path, denyPackages)) {
|
|
198
|
+
violations.push({
|
|
199
|
+
file: relFile,
|
|
200
|
+
line: imp.line,
|
|
201
|
+
message: rule.message ?? "Import of a denied package",
|
|
202
|
+
rule: "arch/imports",
|
|
203
|
+
severity,
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
|
|
181
209
|
if (rule.deny !== undefined) {
|
|
182
210
|
const denyPrefixes = resolveLayerPrefixes(rule.deny, layers, root);
|
|
183
211
|
if (inPrefixes(imp.resolved, denyPrefixes)) {
|
|
@@ -285,6 +313,7 @@ export function runArchRules(
|
|
|
285
313
|
|
|
286
314
|
for (const rule of arch.maxCommentDensity ?? []) {
|
|
287
315
|
const severity: Severity = rule.severity ?? "error";
|
|
316
|
+
const ignoreMatcher = buildIgnoreMatcher(rule.ignore);
|
|
288
317
|
const inFiles = resolveLayerFiles(rule.in, layers, root, allFiles);
|
|
289
318
|
for (const file of inFiles) {
|
|
290
319
|
const content = fileContents.get(file);
|
|
@@ -292,7 +321,11 @@ export function runArchRules(
|
|
|
292
321
|
const total = countLines(content);
|
|
293
322
|
if (total === 0) continue;
|
|
294
323
|
const commentLines = commentLineSet(file, content, rule.countDocComments ?? false);
|
|
295
|
-
const
|
|
324
|
+
const ignored = ignoreMatcher
|
|
325
|
+
? ignoredCommentLines(content, commentLines, ignoreMatcher)
|
|
326
|
+
: undefined;
|
|
327
|
+
const counted = commentLines.size - (ignored?.size ?? 0);
|
|
328
|
+
const density = (counted / total) * 100;
|
|
296
329
|
if (density > rule.limit) {
|
|
297
330
|
violations.push({
|
|
298
331
|
file: relativeSlashPath(root, file),
|
|
@@ -309,6 +342,7 @@ export function runArchRules(
|
|
|
309
342
|
|
|
310
343
|
for (const rule of arch.maxCommentBlock ?? []) {
|
|
311
344
|
const severity: Severity = rule.severity ?? "error";
|
|
345
|
+
const ignoreMatcher = buildIgnoreMatcher(rule.ignore);
|
|
312
346
|
const inFiles = resolveLayerFiles(rule.in, layers, root, allFiles);
|
|
313
347
|
for (const file of inFiles) {
|
|
314
348
|
const content = fileContents.get(file);
|
|
@@ -316,7 +350,10 @@ export function runArchRules(
|
|
|
316
350
|
const commentLines = [
|
|
317
351
|
...commentLineSet(file, content, rule.countDocComments ?? false),
|
|
318
352
|
].sort((a, b) => a - b);
|
|
319
|
-
const
|
|
353
|
+
const ignored = ignoreMatcher
|
|
354
|
+
? ignoredCommentLines(content, commentLines, ignoreMatcher)
|
|
355
|
+
: undefined;
|
|
356
|
+
const overrun = firstCommentBlockOverrun(commentLines, rule.limit, ignored);
|
|
320
357
|
if (overrun !== undefined) {
|
|
321
358
|
violations.push({
|
|
322
359
|
file: relativeSlashPath(root, file),
|
|
@@ -378,6 +415,15 @@ function scanJsxElements(
|
|
|
378
415
|
|
|
379
416
|
const REGEX_PREFIX = "re:";
|
|
380
417
|
|
|
418
|
+
function buildIgnoreMatcher(
|
|
419
|
+
ignore: string | string[] | undefined
|
|
420
|
+
): ((line: string) => boolean) | undefined {
|
|
421
|
+
const patterns = toArray(ignore ?? []);
|
|
422
|
+
if (patterns.length === 0) return undefined;
|
|
423
|
+
const matchers = patterns.map(buildLineMatcher);
|
|
424
|
+
return (line) => matchers.some((matches) => matches(line));
|
|
425
|
+
}
|
|
426
|
+
|
|
381
427
|
function buildLineMatcher(pattern: string): (line: string) => boolean {
|
|
382
428
|
if (!pattern.startsWith(REGEX_PREFIX)) {
|
|
383
429
|
return (line) => line.includes(pattern);
|
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()
|
|
@@ -244,6 +247,10 @@ const CountDocCommentsSchema = z
|
|
|
244
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."
|
|
245
248
|
);
|
|
246
249
|
|
|
250
|
+
const CommentIgnoreSchema = StringOrStringArray.optional().describe(
|
|
251
|
+
"Comment lines matching these patterns are structural, not prose — marker comments a build or codegen step reads, for example — and count toward neither limit. A literal substring by default; prefix with `re:` for a regex. Ignored lines still count in the density denominator (they are physical lines) and still hold a comment block together, so a marker cannot be used to split an over-tall block."
|
|
252
|
+
);
|
|
253
|
+
|
|
247
254
|
const ArchMaxCommentDensityRuleSchema = z
|
|
248
255
|
.object({
|
|
249
256
|
limit: z
|
|
@@ -254,6 +261,7 @@ const ArchMaxCommentDensityRuleSchema = z
|
|
|
254
261
|
"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
262
|
),
|
|
256
263
|
countDocComments: CountDocCommentsSchema,
|
|
264
|
+
ignore: CommentIgnoreSchema,
|
|
257
265
|
in: StringOrStringArray.describe(
|
|
258
266
|
"Layer name(s) or glob(s) the limit applies to. Files outside the scope are not checked."
|
|
259
267
|
),
|
|
@@ -286,6 +294,7 @@ const ArchMaxCommentBlockRuleSchema = z
|
|
|
286
294
|
"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
295
|
),
|
|
288
296
|
countDocComments: CountDocCommentsSchema,
|
|
297
|
+
ignore: CommentIgnoreSchema,
|
|
289
298
|
in: StringOrStringArray.describe(
|
|
290
299
|
"Layer name(s) or glob(s) the limit applies to. Files outside the scope are not checked."
|
|
291
300
|
),
|
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">;
|
|
@@ -109,6 +110,8 @@ interface ArchCommentRuleBase {
|
|
|
109
110
|
in: string | string[];
|
|
110
111
|
/** Count doc-comments (`/** */` JSDoc, Python docstrings) toward the limit. Defaults to false. */
|
|
111
112
|
countDocComments?: boolean;
|
|
113
|
+
/** Comment lines matching these patterns count toward neither limit. Literal substring, or `re:` for a regex. */
|
|
114
|
+
ignore?: string | string[];
|
|
112
115
|
message?: string;
|
|
113
116
|
severity?: Exclude<Severity, "off">;
|
|
114
117
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@konvert7/klint",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.29.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.29.0",
|
|
69
|
+
"@konvert7/klint-darwin-x64": "0.29.0",
|
|
70
|
+
"@konvert7/klint-linux-x64": "0.29.0",
|
|
71
|
+
"@konvert7/klint-win32-x64": "0.29.0"
|
|
72
72
|
},
|
|
73
73
|
"devDependencies": {
|
|
74
74
|
"@biomejs/biome": "2.4.7",
|
|
@@ -32,12 +32,15 @@ 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` |
|
|
38
39
|
| Raw HTML/JSX element may only appear in one primitive file | `arch.singleton` + `jsx-element` |
|
|
39
40
|
| A value-shaped pattern (regex) must never appear in a scoped layer | `arch.forbidden` + `pattern: "re:…"` |
|
|
40
41
|
| No file in a layer may exceed N lines | `arch.maxLines` + `limit` |
|
|
42
|
+
| No file may be more than N% comments | `arch.maxCommentDensity` + `limit` |
|
|
43
|
+
| No comment block may stack more than N lines | `arch.maxCommentBlock` + `limit` |
|
|
41
44
|
|
|
42
45
|
4. **Read `klint.yaml`** — check existing `arch.layers` and rules before adding anything. Add a new named layer to `arch.layers` if the file group doesn't exist yet.
|
|
43
46
|
|
|
@@ -78,13 +81,27 @@ arch:
|
|
|
78
81
|
allow: ["src/dao/**", "src/prisma/**"]
|
|
79
82
|
message: "DAO may only import from dao or prisma"
|
|
80
83
|
|
|
81
|
-
# type-only: allow — import type {}
|
|
84
|
+
# type-only: allow — import type {} and Python if TYPE_CHECKING: blocks stay permitted
|
|
82
85
|
- from: core
|
|
83
86
|
deny: targets
|
|
84
87
|
type-only: allow
|
|
85
88
|
message: "Core must not depend on agent-specific code"
|
|
89
|
+
|
|
90
|
+
# deny-packages: block npm packages and node: builtins, which deny/allow cannot reach
|
|
91
|
+
# because they never resolve to a project file. Composes with deny in the same rule.
|
|
92
|
+
- from: ["src/dao/**"]
|
|
93
|
+
deny-packages: ["next/headers", "node:fs"]
|
|
94
|
+
message: "Data access must not read request state or touch the filesystem"
|
|
86
95
|
```
|
|
87
96
|
|
|
97
|
+
`deny-packages` matches per segment: `next` covers `next/headers`, `nextra` is unaffected, and
|
|
98
|
+
`next/headers` does not match `next/navigation`. Python uses dotted segments, so `os` covers
|
|
99
|
+
`os.path` and blocks pip packages and stdlib modules alike. Swift uses dotted segments too, at
|
|
100
|
+
module granularity, which is what makes system frameworks like `UIKit` reachable. Rust splits on
|
|
101
|
+
`::`, so `tokio` covers `tokio::sync::Mutex` while `tokio_util` is unaffected. The specifier is read off the AST, so
|
|
102
|
+
static imports, dynamic `import()`, and `export … from` re-exports all count while a comment
|
|
103
|
+
mentioning the package does not — unlike `forbidden` + `pattern`, which is a line-based text scan.
|
|
104
|
+
|
|
88
105
|
### Singleton — one designated location
|
|
89
106
|
|
|
90
107
|
Pin a `pattern` (literal string) **or** a `jsx-element` (AST-matched tag) to exactly one file. Every other in-scope occurrence is a violation.
|
|
@@ -154,10 +171,65 @@ arch:
|
|
|
154
171
|
|
|
155
172
|
Required: `limit` + `in`. The count is **total physical lines** — blanks and comments included, not code lines — and a file over the limit is flagged at line `limit + 1`. Enforced identically in the TS and Rust engines, across every language klint scans.
|
|
156
173
|
|
|
174
|
+
### Comment budget — cap explanatory prose
|
|
175
|
+
|
|
176
|
+
Two ceilings on how much of a file may be comments. Use them to push intent into names and small
|
|
177
|
+
functions rather than paragraphs that drift from the code they describe.
|
|
178
|
+
|
|
179
|
+
```yaml
|
|
180
|
+
arch:
|
|
181
|
+
# maxCommentDensity: what share of a file may be comment lines
|
|
182
|
+
maxCommentDensity:
|
|
183
|
+
- limit: 5 # percent; required
|
|
184
|
+
in: ["src/**"] # string OR array; supports ! negation
|
|
185
|
+
countDocComments: false # optional — default false, so JSDoc/docstrings are exempt
|
|
186
|
+
message: "Encode intent in names and small functions, not prose"
|
|
187
|
+
severity: error # optional — default error; use warn to record without blocking
|
|
188
|
+
|
|
189
|
+
# maxCommentBlock: how tall a single stack of comment lines may get
|
|
190
|
+
maxCommentBlock:
|
|
191
|
+
- limit: 2 # consecutive comment lines; required
|
|
192
|
+
in: ["src/**"]
|
|
193
|
+
ignore: ["@codegen:"] # optional — structural markers, not prose
|
|
194
|
+
message: "Extract a well-named function instead of stacking comment lines"
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
Required: `limit` + `in`. Density is a **percentage of total physical lines** (code + comments +
|
|
198
|
+
blanks — the same denominator `maxLines` uses). Block height counts **consecutive** comment lines and
|
|
199
|
+
is reported at the first offending line. Both exempt doc-comments by default — `/** … */` JSDoc and
|
|
200
|
+
Python docstrings — so documentation is not penalised; ordinary `//`, `/* */`, and `#` comments always
|
|
201
|
+
count. Set `countDocComments: true` to include them.
|
|
202
|
+
|
|
203
|
+
The two are complementary: density catches a file that is 30% commented in scattered one-liners,
|
|
204
|
+
block catches a ten-line essay above one function in an otherwise sparse file. Neither alone catches both.
|
|
205
|
+
|
|
206
|
+
**`ignore` — comments that are machinery, not prose.** Some comments are load-bearing input to a
|
|
207
|
+
build or codegen step (`// @codegen:region-start`, feature-strip markers, pragmas). They are comments to the
|
|
208
|
+
lexer but structure to the project, and counting them makes both limits measure the wrong thing:
|
|
209
|
+
|
|
210
|
+
```yaml
|
|
211
|
+
arch:
|
|
212
|
+
maxCommentDensity:
|
|
213
|
+
- limit: 5
|
|
214
|
+
in: ["src/**"]
|
|
215
|
+
ignore: ["@codegen:"] # literal substring, or "re:" for a regex
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
Two rules govern how an ignored line behaves, and both matter:
|
|
219
|
+
|
|
220
|
+
- **It stays in the density denominator.** A marker is a physical line like code, so a 20-line file
|
|
221
|
+
with 4 markers and 1 real comment measures 1/20, not 1/16.
|
|
222
|
+
- **It does not break a comment block.** `// a` / `// @marker` / `// b` / `// c` is a run of three
|
|
223
|
+
counted lines, not two runs. Otherwise sprinkling a marker every other line would defeat the cap.
|
|
224
|
+
|
|
225
|
+
`ignore` tests the **physical source line**, so it only ever applies to lines already identified as
|
|
226
|
+
comments — code containing the same text is untouched. Enforced identically in both engines.
|
|
227
|
+
|
|
157
228
|
## Pitfalls
|
|
158
229
|
|
|
159
230
|
- `pattern` in `singleton` and `forbidden` is a **literal substring** by default — it will not catch `process.env["KEY"]` (bracket notation). Either grep for both forms, or use a `re:` regex prefix to match them in one rule. Caveat: a literal pattern that itself begins with `re:` cannot be expressed (it is always read as a regex), and regexes must stay in the common JS/RE2 subset (no lookaround or backreferences) so both engines agree.
|
|
160
231
|
- `maxLines.limit` counts **total physical lines** (blanks and comments included), not code lines; a trailing newline does not add a line, and both engines count identically.
|
|
232
|
+
- `maxCommentDensity.limit` is a **percentage, not a line count** — `limit: 5` means 5% of the file, so a 40-line file is over budget at its third comment line. Measure before picking a number: land it as `severity: warn` first, read the reported densities, then tighten to `error`.
|
|
161
233
|
- `jsx-element` matches **intrinsic** (lowercase HTML) tags only — `button`, `input`, `label`. It does not match custom React components like `<Button>`; to restrict those, use `arch.imports` against the component's module instead.
|
|
162
234
|
- A `forbidden`/`singleton` stanza takes either `pattern` or `jsx-element`, never both — split into two stanzas if you need both.
|
|
163
235
|
- `only` in `singleton` is relative to the project root — use forward slashes on all platforms.
|