@konvert7/klint 0.24.0 → 0.25.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/core/arch.ts CHANGED
@@ -264,9 +264,33 @@ export function runArchRules(
264
264
  }
265
265
  }
266
266
 
267
+ for (const rule of arch.maxLines ?? []) {
268
+ const severity: Severity = rule.severity ?? "error";
269
+ const inFiles = resolveLayerFiles(rule.in, layers, root, allFiles);
270
+ for (const file of inFiles) {
271
+ const content = fileContents.get(file);
272
+ if (content === undefined) continue;
273
+ if (countLines(content) > rule.limit) {
274
+ violations.push({
275
+ file: relativeSlashPath(root, file),
276
+ line: rule.limit + 1,
277
+ message: rule.message ?? `File exceeds the maximum of ${rule.limit} lines`,
278
+ rule: "arch/max-lines",
279
+ severity,
280
+ });
281
+ }
282
+ }
283
+ }
284
+
267
285
  return violations;
268
286
  }
269
287
 
288
+ /** Counts physical lines the same way Rust's `str::lines()` does — a trailing newline does not add a line. */
289
+ function countLines(content: string): number {
290
+ if (content.length === 0) return 0;
291
+ return content.split("\n").length - (content.endsWith("\n") ? 1 : 0);
292
+ }
293
+
270
294
  function toArray<T>(v: T | T[]): T[] {
271
295
  return Array.isArray(v) ? v : [v];
272
296
  }
@@ -303,6 +327,24 @@ function scanJsxElements(
303
327
  }
304
328
  }
305
329
 
330
+ const REGEX_PREFIX = "re:";
331
+
332
+ function buildLineMatcher(pattern: string): (line: string) => boolean {
333
+ if (!pattern.startsWith(REGEX_PREFIX)) {
334
+ return (line) => line.includes(pattern);
335
+ }
336
+ const source = pattern.slice(REGEX_PREFIX.length);
337
+ let regex: RegExp;
338
+ try {
339
+ regex = new RegExp(source);
340
+ } catch (cause) {
341
+ throw new Error(
342
+ `klint: invalid regex in arch pattern "${pattern}": ${(cause as Error).message}`
343
+ );
344
+ }
345
+ return (line) => regex.test(line);
346
+ }
347
+
306
348
  function scanLinesForPattern(
307
349
  files: string[],
308
350
  pattern: string,
@@ -313,12 +355,13 @@ function scanLinesForPattern(
313
355
  severity: Severity,
314
356
  violations: Violation[]
315
357
  ): void {
358
+ const matchesLine = buildLineMatcher(pattern);
316
359
  for (const file of files) {
317
360
  const content = fileContents.get(file);
318
361
  if (!content) continue;
319
362
  const lines = content.split("\n");
320
363
  for (let i = 0; i < lines.length; i++) {
321
- if (lines[i].includes(pattern)) {
364
+ if (matchesLine(lines[i])) {
322
365
  violations.push({
323
366
  file: relativeSlashPath(root, file),
324
367
  line: i + 1,
@@ -115,7 +115,7 @@ const ArchForbiddenPatternRuleSchema = z
115
115
  pattern: z
116
116
  .string()
117
117
  .describe(
118
- "Literal substring to search for in source. No regex — exact substring match (for example, a console logging call)."
118
+ "Pattern to search for, scanned per line. A literal substring by default (e.g. a console logging call). Prefix with `re:` to match a regular expression instead (e.g. `re:\\bp-\\[\\d+px\\]`). Regexes must use the common JS/RE2 subset — no lookaround or backreferences — so the TS and Rust engines agree. Note: a literal pattern that itself begins with `re:` cannot be expressed; such a value is always read as a regex."
119
119
  ),
120
120
  in: StringOrStringArray.describe(
121
121
  "Layer name(s) or glob(s) the pattern is forbidden in. Files outside the scope are not checked."
@@ -163,7 +163,7 @@ const ArchSingletonPatternRuleSchema = z
163
163
  pattern: z
164
164
  .string()
165
165
  .describe(
166
- "Literal substring whose appearance is allowed only at one location. Exact substring match (e.g. `process.env.API_KEY`)."
166
+ "Pattern whose appearance is allowed only at one location, scanned per line. A literal substring by default (e.g. `process.env.API_KEY`). Prefix with `re:` to match a regular expression instead. Regexes must use the common JS/RE2 subset — no lookaround or backreferences — so the TS and Rust engines agree. Note: a literal pattern that itself begins with `re:` cannot be expressed; such a value is always read as a regex."
167
167
  ),
168
168
  only: z
169
169
  .string()
@@ -206,6 +206,37 @@ const ArchSingletonRuleSchema = z
206
206
  ],
207
207
  });
208
208
 
209
+ const ArchMaxLinesRuleSchema = z
210
+ .object({
211
+ limit: z
212
+ .number()
213
+ .int()
214
+ .positive()
215
+ .describe(
216
+ "Maximum number of physical lines a file may contain. Counts every line including blanks and comments. A file with more lines than this is a violation."
217
+ ),
218
+ in: StringOrStringArray.describe(
219
+ "Layer name(s) or glob(s) the limit applies to. Files outside the scope are not checked."
220
+ ),
221
+ message: z
222
+ .string()
223
+ .optional()
224
+ .describe(
225
+ "Optional error message. Defaults to a message naming the limit (e.g. 'File exceeds the maximum of 300 lines')."
226
+ ),
227
+ severity: ArchSeveritySchema.optional(),
228
+ })
229
+ .strict()
230
+ .describe(
231
+ "A per-file line-count limit. Caps how long any file in scope may grow — the honest way to keep modules small without a formatter dependency."
232
+ )
233
+ .meta({
234
+ examples: [
235
+ 'arch:\n maxLines:\n - limit: 300\n in: src/**\n message: "Split this module"',
236
+ "arch:\n maxLines:\n - limit: 300\n in: src/**\n - limit: 600\n in: tests/**",
237
+ ],
238
+ });
239
+
209
240
  const ArchSchema = z
210
241
  .object({
211
242
  layers: ArchLayersSchema.optional(),
@@ -225,6 +256,12 @@ const ArchSchema = z
225
256
  .describe(
226
257
  "Singleton-location rules. Pin a pattern to one file; every other touch is a violation."
227
258
  ),
259
+ maxLines: z
260
+ .array(ArchMaxLinesRuleSchema)
261
+ .optional()
262
+ .describe(
263
+ "Per-file line-count limits. Cap how long files in a layer may grow, with different ceilings per scope."
264
+ ),
228
265
  })
229
266
  .strict()
230
267
  .describe(
@@ -100,7 +100,11 @@ function nativePackageVersionMatches(
100
100
  }
101
101
 
102
102
  function readJsonFile(path: string): { version?: string } {
103
- return JSON.parse(readFileSync(path, "utf-8")) as { version?: string };
103
+ try {
104
+ return JSON.parse(readFileSync(path, "utf-8")) as { version?: string };
105
+ } catch {
106
+ return {};
107
+ }
104
108
  }
105
109
 
106
110
  function resolveOptionalPackageJson(
package/core/types.ts CHANGED
@@ -97,11 +97,19 @@ interface ArchSingletonJsxRule extends ArchSingletonRuleBase {
97
97
 
98
98
  type ArchSingletonRule = ArchSingletonPatternRule | ArchSingletonJsxRule;
99
99
 
100
+ interface ArchMaxLinesRule {
101
+ limit: number;
102
+ in: string | string[];
103
+ message?: string;
104
+ severity?: Exclude<Severity, "off">;
105
+ }
106
+
100
107
  export interface ArchConfig {
101
108
  layers?: Record<string, string[]>;
102
109
  imports?: ArchImportRule[];
103
110
  forbidden?: ArchForbiddenRule[];
104
111
  singleton?: ArchSingletonRule[];
112
+ maxLines?: ArchMaxLinesRule[];
105
113
  }
106
114
 
107
115
  export interface KlintConfig {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@konvert7/klint",
3
- "version": "0.24.0",
3
+ "version": "0.25.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": {
@@ -8,6 +8,7 @@
8
8
  },
9
9
  "files": [
10
10
  "cli.ts",
11
+ "cli",
11
12
  "core",
12
13
  "rules",
13
14
  "plugins",
@@ -64,10 +65,10 @@
64
65
  "zod": "^4.4.3"
65
66
  },
66
67
  "optionalDependencies": {
67
- "@konvert7/klint-darwin-arm64": "0.24.0",
68
- "@konvert7/klint-darwin-x64": "0.24.0",
69
- "@konvert7/klint-linux-x64": "0.24.0",
70
- "@konvert7/klint-win32-x64": "0.24.0"
68
+ "@konvert7/klint-darwin-arm64": "0.25.0",
69
+ "@konvert7/klint-darwin-x64": "0.25.0",
70
+ "@konvert7/klint-linux-x64": "0.25.0",
71
+ "@konvert7/klint-win32-x64": "0.25.0"
71
72
  },
72
73
  "devDependencies": {
73
74
  "@biomejs/biome": "2.4.7",
@@ -4,6 +4,19 @@ description: Add, modify, or explain klint architecture rules for this repo. Use
4
4
  argument-hint: <constraint to enforce>
5
5
  ---
6
6
 
7
+ ## Schema
8
+
9
+ The full, authoritative config schema lives in the repo and is the source of truth for every field below:
10
+
11
+ - YAML: <https://github.com/konvert7/klint/blob/main/klint.schema.yaml>
12
+ - JSON: <https://github.com/konvert7/klint/blob/main/klint.schema.json>
13
+
14
+ Wire it into `klint.yaml` for editor autocomplete and validation:
15
+
16
+ ```yaml
17
+ # yaml-language-server: $schema=./klint.schema.yaml
18
+ ```
19
+
7
20
  ## Workflow
8
21
 
9
22
  1. **Understand the constraint** — if scope is ambiguous, ask: which files/layers are involved? should it block or warn? are there legitimate exceptions?
@@ -19,8 +32,12 @@ argument-hint: <constraint to enforce>
19
32
  |---|---|
20
33
  | Layer X must not import from layer Y | `arch.imports` + `deny` |
21
34
  | Layer X may only import from Y and Z | `arch.imports` + `allow` |
22
- | Pattern P must only appear in one designated file | `arch.singleton` |
23
- | Pattern P must never appear inside a scoped layer | `arch.forbidden` |
35
+ | Pattern P must only appear in one designated file | `arch.singleton` + `pattern` |
36
+ | Pattern P must never appear inside a scoped layer | `arch.forbidden` + `pattern` |
37
+ | Raw HTML/JSX element must never appear in a scoped layer | `arch.forbidden` + `jsx-element` |
38
+ | Raw HTML/JSX element may only appear in one primitive file | `arch.singleton` + `jsx-element` |
39
+ | A value-shaped pattern (regex) must never appear in a scoped layer | `arch.forbidden` + `pattern: "re:…"` |
40
+ | No file in a layer may exceed N lines | `arch.maxLines` + `limit` |
24
41
 
25
42
  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.
26
43
 
@@ -69,29 +86,80 @@ arch:
69
86
  ```
70
87
 
71
88
  ### Singleton — one designated location
89
+
90
+ Pin a `pattern` (literal string) **or** a `jsx-element` (AST-matched tag) to exactly one file. Every other in-scope occurrence is a violation.
91
+
72
92
  ```yaml
73
93
  arch:
74
94
  singleton:
95
+ # pattern: literal string match — fires if it appears anywhere except `only`
75
96
  - pattern: "process.env.API_KEY"
76
97
  only: "src/lib/auth.ts"
77
- in: ["src/**"] # optional: limit scan scope (default: all files)
98
+ in: ["src/**"] # optional: limit scan scope (default: all files). string OR array
78
99
  message: "Use the auth module instead of reading API_KEY directly"
100
+ severity: error # optional — default error; use warn to record without blocking
101
+
102
+ # jsx-element: pin a raw element to its design-system primitive
103
+ - jsx-element: "button" # one tag, or a list: ["button", "input"]
104
+ only: "src/components/ui/button.tsx"
105
+ in: ["src/**/*.tsx"]
106
+ message: "Raw <button> belongs only to the Button primitive"
79
107
  ```
80
108
 
81
- `pattern` is a **literal string match**. It fires if the pattern appears in any in-scope file except the `only` file.
109
+ Required: `only` + `message`, plus one of `pattern` / `jsx-element` (not both in the same stanza).
82
110
 
83
111
  ### Forbidden — banned pattern in scope
112
+
113
+ Block a `pattern` (literal string) **or** a `jsx-element` (AST-matched tag) inside a scoped layer.
114
+
84
115
  ```yaml
85
116
  arch:
86
117
  forbidden:
118
+ # pattern: literal substring by default; prefix with `re:` for a regex
87
119
  - pattern: "console.log("
88
- in: ["src/lib/**"]
120
+ in: ["src/lib/**"] # string OR array; supports ! negation
89
121
  message: "Use the logger — console.log leaks into the agent event stream"
122
+ severity: error # optional — default error; use warn to record without blocking
123
+
124
+ # re: prefix — match a regular expression (common JS/RE2 subset; no lookaround/backrefs)
125
+ - pattern: 're:\b(?:p|gap)-\['
126
+ in: ["src/**/*.tsx"]
127
+ message: "Use the spacing scale, not arbitrary bracket values like p-[18px]"
128
+
129
+ # jsx-element: forbid raw HTML elements outside the design system
130
+ - jsx-element: ["button", "input", "label"]
131
+ in: ["src/app/**/*.tsx", "src/components/**/*.tsx", "!src/components/ui/**"]
132
+ message: "Use the design-system primitives in @/components/ui/* instead of raw HTML elements"
90
133
  ```
91
134
 
135
+ Required: `in` + `message`, plus one of `pattern` / `jsx-element` (not both in the same stanza). A `pattern` may be a literal substring or, with the `re:` prefix, a regex; `singleton` patterns accept `re:` too.
136
+
137
+ `jsx-element` matches intrinsic element names on the AST (opening and self-closing tags), so it is robust to whitespace, attributes, and naming collisions like `<buttonGroup>` — unlike a literal `pattern: "<button"` scan. It works on `.tsx`/`.jsx` files in both the TS and Rust engines.
138
+
139
+ ### Max lines — cap file length
140
+
141
+ Limit how many physical lines a file in scope may have. Use a separate stanza per scope to give different ceilings to source and tests.
142
+
143
+ ```yaml
144
+ arch:
145
+ maxLines:
146
+ - limit: 300 # positive integer; required
147
+ in: ["src/**"] # string OR array; supports ! negation
148
+ message: "Split this module" # optional — defaults to "File exceeds the maximum of N lines"
149
+ severity: error # optional — default error; use warn to record without blocking
150
+
151
+ - limit: 600 # tests may run longer
152
+ in: ["tests/**"]
153
+ ```
154
+
155
+ 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
+
92
157
  ## Pitfalls
93
158
 
94
- - `pattern` in `singleton` and `forbidden` is a **literal string** — will not catch `process.env["KEY"]` (bracket notation). Grep for both forms if both are possible.
159
+ - `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
+ - `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.
161
+ - `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
+ - A `forbidden`/`singleton` stanza takes either `pattern` or `jsx-element`, never both — split into two stanzas if you need both.
95
163
  - `only` in `singleton` is relative to the project root — use forward slashes on all platforms.
96
164
  - `severity: warn` records the violation but does not block the session. Use `error` (the default) to block.
97
165
  - Path aliases (`@/*`) resolve via `tsconfig.json` `compilerOptions.paths`, including `extends` chains.
@@ -19,16 +19,12 @@ class Klint < Formula
19
19
  version "${version}"
20
20
  license "MIT"
21
21
 
22
- on_macos do
23
- on_arm do
24
- url "https://github.com/konvert7/klint/releases/download/native-v${version}/klint-${version}-darwin-arm64.tar.gz"
25
- sha256 "${options.darwinArm64Sha}"
26
- end
27
-
28
- on_intel do
29
- url "https://github.com/konvert7/klint/releases/download/native-v${version}/klint-${version}-darwin-x64.tar.gz"
30
- sha256 "${options.darwinX64Sha}"
31
- end
22
+ if Hardware::CPU.arm?
23
+ url "https://github.com/konvert7/klint/releases/download/native-v${version}/klint-${version}-darwin-arm64.tar.gz"
24
+ sha256 "${options.darwinArm64Sha}"
25
+ else
26
+ url "https://github.com/konvert7/klint/releases/download/native-v${version}/klint-${version}-darwin-x64.tar.gz"
27
+ sha256 "${options.darwinX64Sha}"
32
28
  end
33
29
 
34
30
  def install