@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 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 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
+ 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`. 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.
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 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.
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
 
@@ -52,22 +52,40 @@ export function commentLineSet(
52
52
  return set;
53
53
  }
54
54
 
55
- /** Returns the first line at which a run of consecutive comment lines exceeds `limit`. */
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 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;
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 { commentLineSet, firstCommentBlockOverrun } from "./arch-comments";
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 density = (commentLines.size / total) * 100;
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 overrun = firstCommentBlockOverrun(commentLines, rule.limit);
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);
@@ -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.27.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.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.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 {} is permitted even when value imports are denied
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.