@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 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 files (`#` comments) are enforced by the Rust engine.
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`. External packages that do not resolve to
405
- project files, such as `requests`, are ignored. This is architecture
406
- enforcement, not full packaging or virtual-environment analysis.
482
+ as `from app.lib.auth import load_key`. Every target of a multi-target statement
483
+ is checked, so `import json, app.lib.auth` and `from . import helper, sibling`
484
+ report per target rather than only the first one. Dynamic imports count too:
485
+ `importlib.import_module("app.lib.auth")` and `__import__("app.lib.auth")` are
486
+ read off the AST, including a renamed `from importlib import import_module as
487
+ load`. A same-named call that is not bound to `importlib` — `self.import_module(…)` —
488
+ is not an import and is left alone. A package import resolves through
489
+ `<module>.py`, `<module>/__init__.py`, or a `<module>/` directory holding
490
+ scanned `.py` files, so PEP 420 namespace packages with no `__init__.py` are
491
+ covered. External packages that do not resolve to project files, such as
492
+ `requests`, are ignored by `deny`/`allow` and are the target of `deny-packages`
493
+ instead. This is architecture enforcement, not full packaging or
494
+ virtual-environment analysis.
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 parse declarations such as `import Core`,
441
- `@_exported import Core`, and `import struct Models.User`. Imported modules
442
- resolve against discovered project Swift directories and file stems. System or
443
- package modules that are not present in the project, such as `Foundation`, are
444
- ignored. This is architecture enforcement, not full SwiftPM or Xcode build graph
445
- analysis.
528
+ Swift import checks read declarations off the AST, covering `import Core`,
529
+ `@_exported import Core`, `@testable import Core`, `import struct Models.User`,
530
+ and submodule paths such as `import Core.Session`. Because the specifier comes
531
+ from the parse tree, an `import` written inside a `//` or `/* */` comment is not
532
+ an import — including nested block comments. Imported modules resolve against
533
+ discovered project Swift directories and file stems. System or package modules
534
+ that are not present in the project, such as `Foundation`, are ignored. This is
535
+ architecture enforcement, not full SwiftPM or Xcode build graph analysis.
536
+
537
+ Comment budget rules apply to Swift too, where `///` counts as a doc-comment
538
+ alongside `/** */`.
539
+
540
+ ### Rust Architecture Checks
541
+
542
+ The Rust engine can apply architecture rules to Rust crates:
543
+
544
+ ```yaml
545
+ include: ["crates/**/*.rs", "!target/**"]
546
+
547
+ rules: {}
548
+
549
+ arch:
550
+ layers:
551
+ cli: ["crates/app/src/cli/**"]
552
+ domain: ["crates/app/src/domain/**"]
553
+ storage: ["crates/app/src/storage/**"]
554
+
555
+ imports:
556
+ - from: domain
557
+ deny: storage
558
+ message: "Domain logic must not reach into the storage layer"
559
+ - from: domain
560
+ deny-packages: ["reqwest", "tokio"]
561
+ message: "Keep I/O crates out of the domain layer"
562
+
563
+ maxLines:
564
+ - limit: 500
565
+ in: [cli, domain, storage]
566
+ ```
567
+
568
+ Rust import checks read `use` declarations off the AST, and every target of a
569
+ braced list becomes its own record — `use a::{b::{c, d}, e as f}` is checked as
570
+ `a::b::c`, `a::b::d`, and `a::e`. Aliases contribute their path, `*` wildcards
571
+ contribute the path they glob, and a bare `self` in a list names its own prefix.
572
+ `pub use` re-exports are checked the same way as plain `use`.
573
+
574
+ Paths starting with `crate::`, `self::`, or `super::` resolve against the
575
+ directory tree by convention: klint walks down from the crate's `src` directory
576
+ (the one holding `lib.rs` or `main.rs`), probing `foo.rs` then `foo/mod.rs` at
577
+ each step and keeping the longest prefix that lands on a real file. Everything
578
+ after that prefix is treated as an item inside the module. External crates have
579
+ no file to resolve to and are ignored by `deny`/`allow`, which is what
580
+ `deny-packages` is for — it matches on `::` segments, so `tokio` denies
581
+ `tokio::sync::Mutex` but never `tokio_util`. This is architecture enforcement,
582
+ not a Cargo build graph or `#[path]` attribute resolver.
583
+
584
+ Comment budget rules apply to Rust too, where both `///` and `//!` count as
585
+ doc-comments.
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)) {
@@ -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
@@ -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">;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@konvert7/klint",
3
- "version": "0.27.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.27.0",
69
- "@konvert7/klint-darwin-x64": "0.27.0",
70
- "@konvert7/klint-linux-x64": "0.27.0",
71
- "@konvert7/klint-win32-x64": "0.27.0"
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 {} is permitted even when value imports are denied
82
+ # type-only: allow — import type {} and Python if TYPE_CHECKING: blocks stay permitted
82
83
  - from: core
83
84
  deny: targets
84
85
  type-only: allow
85
86
  message: "Core must not depend on agent-specific code"
87
+
88
+ # deny-packages: block npm packages and node: builtins, which deny/allow cannot reach
89
+ # because they never resolve to a project file. Composes with deny in the same rule.
90
+ - from: ["src/dao/**"]
91
+ deny-packages: ["next/headers", "node:fs"]
92
+ message: "Data access must not read request state or touch the filesystem"
86
93
  ```
87
94
 
95
+ `deny-packages` matches per segment: `next` covers `next/headers`, `nextra` is unaffected, and
96
+ `next/headers` does not match `next/navigation`. Python uses dotted segments, so `os` covers
97
+ `os.path` and blocks pip packages and stdlib modules alike. Swift uses dotted segments too, at
98
+ module granularity, which is what makes system frameworks like `UIKit` reachable. Rust splits on
99
+ `::`, so `tokio` covers `tokio::sync::Mutex` while `tokio_util` is unaffected. The specifier is read off the AST, so
100
+ static imports, dynamic `import()`, and `export … from` re-exports all count while a comment
101
+ mentioning the package does not — unlike `forbidden` + `pattern`, which is a line-based text scan.
102
+
88
103
  ### Singleton — one designated location
89
104
 
90
105
  Pin a `pattern` (literal string) **or** a `jsx-element` (AST-matched tag) to exactly one file. Every other in-scope occurrence is a violation.