@konvert7/klint 0.27.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 +150 -10
- package/core/arch.ts +25 -1
- package/core/config.schema.ts +4 -1
- package/core/types.ts +1 -0
- package/package.json +5 -5
- package/skill/klint-rules/SKILL.md +16 -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,37 @@ 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
|
+
### 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.
|
|
369
447
|
|
|
370
448
|
### Python Architecture Checks
|
|
371
449
|
|
|
@@ -401,9 +479,19 @@ arch:
|
|
|
401
479
|
|
|
402
480
|
Python import checks support relative imports such as
|
|
403
481
|
`from ..lib.auth import load_key` and project-resolvable absolute imports such
|
|
404
|
-
as `from app.lib.auth import load_key`.
|
|
405
|
-
|
|
406
|
-
|
|
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.
|
|
407
495
|
|
|
408
496
|
### Swift Architecture Checks
|
|
409
497
|
|
|
@@ -437,12 +525,64 @@ arch:
|
|
|
437
525
|
message: "Use AppConfig"
|
|
438
526
|
```
|
|
439
527
|
|
|
440
|
-
Swift import checks
|
|
441
|
-
`@_exported import Core`,
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
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.
|
|
446
586
|
|
|
447
587
|
## Built-in Rules
|
|
448
588
|
|
package/core/arch.ts
CHANGED
|
@@ -103,6 +103,12 @@ function resolveLayerFiles(
|
|
|
103
103
|
);
|
|
104
104
|
}
|
|
105
105
|
|
|
106
|
+
function matchesPackage(specifier: string, entries: string[]): boolean {
|
|
107
|
+
return entries.some(
|
|
108
|
+
(entry) => specifier === entry || specifier.startsWith(`${entry}/`)
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
106
112
|
function inPrefixes(absPath: string, prefixes: string[]): boolean {
|
|
107
113
|
return prefixes.some((p) => {
|
|
108
114
|
if (absPath === p || absPath.startsWith(`${p}/`)) return true;
|
|
@@ -129,6 +135,11 @@ function scanImports(
|
|
|
129
135
|
specifierNode = node.moduleSpecifier;
|
|
130
136
|
isTypeOnly = node.importClause?.isTypeOnly ?? false;
|
|
131
137
|
}
|
|
138
|
+
} else if (ts.isExportDeclaration(node)) {
|
|
139
|
+
if (node.moduleSpecifier && ts.isStringLiteral(node.moduleSpecifier)) {
|
|
140
|
+
specifierNode = node.moduleSpecifier;
|
|
141
|
+
isTypeOnly = node.isTypeOnly;
|
|
142
|
+
}
|
|
132
143
|
} else if (
|
|
133
144
|
ts.isCallExpression(node) &&
|
|
134
145
|
node.expression.kind === ts.SyntaxKind.ImportKeyword &&
|
|
@@ -167,17 +178,30 @@ export function runArchRules(
|
|
|
167
178
|
for (const rule of arch.imports ?? []) {
|
|
168
179
|
const severity: Severity = rule.severity ?? "error";
|
|
169
180
|
const fromFiles = resolveLayerFiles(rule.from, layers, root, allFiles);
|
|
181
|
+
const denyPackages = toArray(rule["deny-packages"] ?? []);
|
|
170
182
|
|
|
171
183
|
for (const file of fromFiles) {
|
|
172
184
|
const content = fileContents.get(file);
|
|
173
185
|
if (!content) continue;
|
|
174
186
|
|
|
175
187
|
for (const imp of scanImports(file, content, aliases)) {
|
|
176
|
-
if (isBareSpecifier(imp.resolved)) continue;
|
|
177
188
|
if (rule["type-only"] === "allow" && imp.isTypeOnly) continue;
|
|
178
189
|
|
|
179
190
|
const relFile = relativeSlashPath(root, file);
|
|
180
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
|
+
|
|
181
205
|
if (rule.deny !== undefined) {
|
|
182
206
|
const denyPrefixes = resolveLayerPrefixes(rule.deny, layers, root);
|
|
183
207
|
if (inPrefixes(imp.resolved, denyPrefixes)) {
|
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()
|
package/core/types.ts
CHANGED
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",
|
|
@@ -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.
|