@konvert7/klint 0.28.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
@@ -415,6 +415,18 @@ arch:
415
415
 
416
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
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
+
418
430
  ### Language Support
419
431
 
420
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.
@@ -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";
@@ -309,6 +313,7 @@ export function runArchRules(
309
313
 
310
314
  for (const rule of arch.maxCommentDensity ?? []) {
311
315
  const severity: Severity = rule.severity ?? "error";
316
+ const ignoreMatcher = buildIgnoreMatcher(rule.ignore);
312
317
  const inFiles = resolveLayerFiles(rule.in, layers, root, allFiles);
313
318
  for (const file of inFiles) {
314
319
  const content = fileContents.get(file);
@@ -316,7 +321,11 @@ export function runArchRules(
316
321
  const total = countLines(content);
317
322
  if (total === 0) continue;
318
323
  const commentLines = commentLineSet(file, content, rule.countDocComments ?? false);
319
- 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;
320
329
  if (density > rule.limit) {
321
330
  violations.push({
322
331
  file: relativeSlashPath(root, file),
@@ -333,6 +342,7 @@ export function runArchRules(
333
342
 
334
343
  for (const rule of arch.maxCommentBlock ?? []) {
335
344
  const severity: Severity = rule.severity ?? "error";
345
+ const ignoreMatcher = buildIgnoreMatcher(rule.ignore);
336
346
  const inFiles = resolveLayerFiles(rule.in, layers, root, allFiles);
337
347
  for (const file of inFiles) {
338
348
  const content = fileContents.get(file);
@@ -340,7 +350,10 @@ export function runArchRules(
340
350
  const commentLines = [
341
351
  ...commentLineSet(file, content, rule.countDocComments ?? false),
342
352
  ].sort((a, b) => a - b);
343
- 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);
344
357
  if (overrun !== undefined) {
345
358
  violations.push({
346
359
  file: relativeSlashPath(root, file),
@@ -402,6 +415,15 @@ function scanJsxElements(
402
415
 
403
416
  const REGEX_PREFIX = "re:";
404
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
+
405
427
  function buildLineMatcher(pattern: string): (line: string) => boolean {
406
428
  if (!pattern.startsWith(REGEX_PREFIX)) {
407
429
  return (line) => line.includes(pattern);
@@ -247,6 +247,10 @@ const CountDocCommentsSchema = z
247
247
  "Count doc-comments toward the limit. Doc-comments are `/** … */` JSDoc blocks (TS/JS) and docstrings (Python); ordinary `//`, `/* */`, and `#` comments are always counted. Defaults to false, so documentation is exempt and only explanatory/inline comments are capped."
248
248
  );
249
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
+
250
254
  const ArchMaxCommentDensityRuleSchema = z
251
255
  .object({
252
256
  limit: z
@@ -257,6 +261,7 @@ const ArchMaxCommentDensityRuleSchema = z
257
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."
258
262
  ),
259
263
  countDocComments: CountDocCommentsSchema,
264
+ ignore: CommentIgnoreSchema,
260
265
  in: StringOrStringArray.describe(
261
266
  "Layer name(s) or glob(s) the limit applies to. Files outside the scope are not checked."
262
267
  ),
@@ -289,6 +294,7 @@ const ArchMaxCommentBlockRuleSchema = z
289
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."
290
295
  ),
291
296
  countDocComments: CountDocCommentsSchema,
297
+ ignore: CommentIgnoreSchema,
292
298
  in: StringOrStringArray.describe(
293
299
  "Layer name(s) or glob(s) the limit applies to. Files outside the scope are not checked."
294
300
  ),
package/core/types.ts CHANGED
@@ -110,6 +110,8 @@ interface ArchCommentRuleBase {
110
110
  in: string | string[];
111
111
  /** Count doc-comments (`/** *​/` JSDoc, Python docstrings) toward the limit. Defaults to false. */
112
112
  countDocComments?: boolean;
113
+ /** Comment lines matching these patterns count toward neither limit. Literal substring, or `re:` for a regex. */
114
+ ignore?: string | string[];
113
115
  message?: string;
114
116
  severity?: Exclude<Severity, "off">;
115
117
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@konvert7/klint",
3
- "version": "0.28.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.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"
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",
@@ -39,6 +39,8 @@ Wire it into `klint.yaml` for editor autocomplete and validation:
39
39
  | Raw HTML/JSX element may only appear in one primitive file | `arch.singleton` + `jsx-element` |
40
40
  | A value-shaped pattern (regex) must never appear in a scoped layer | `arch.forbidden` + `pattern: "re:…"` |
41
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` |
42
44
 
43
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.
44
46
 
@@ -169,10 +171,65 @@ arch:
169
171
 
170
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.
171
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
+
172
228
  ## Pitfalls
173
229
 
174
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.
175
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`.
176
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.
177
234
  - A `forbidden`/`singleton` stanza takes either `pattern` or `jsx-element`, never both — split into two stanzas if you need both.
178
235
  - `only` in `singleton` is relative to the project root — use forward slashes on all platforms.