@konvert7/klint 0.24.1 → 0.26.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.1",
3
+ "version": "0.26.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.1",
68
- "@konvert7/klint-darwin-x64": "0.24.1",
69
- "@konvert7/klint-linux-x64": "0.24.1",
70
- "@konvert7/klint-win32-x64": "0.24.1"
68
+ "@konvert7/klint-darwin-arm64": "0.26.0",
69
+ "@konvert7/klint-darwin-x64": "0.26.0",
70
+ "@konvert7/klint-linux-x64": "0.26.0",
71
+ "@konvert7/klint-win32-x64": "0.26.0"
71
72
  },
72
73
  "devDependencies": {
73
74
  "@biomejs/biome": "2.4.7",
@@ -36,6 +36,8 @@ Wire it into `klint.yaml` for editor autocomplete and validation:
36
36
  | Pattern P must never appear inside a scoped layer | `arch.forbidden` + `pattern` |
37
37
  | Raw HTML/JSX element must never appear in a scoped layer | `arch.forbidden` + `jsx-element` |
38
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` |
39
41
 
40
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.
41
43
 
@@ -113,25 +115,49 @@ Block a `pattern` (literal string) **or** a `jsx-element` (AST-matched tag) insi
113
115
  ```yaml
114
116
  arch:
115
117
  forbidden:
116
- # pattern: literal substring — no regex
118
+ # pattern: literal substring by default; prefix with `re:` for a regex
117
119
  - pattern: "console.log("
118
120
  in: ["src/lib/**"] # string OR array; supports ! negation
119
121
  message: "Use the logger — console.log leaks into the agent event stream"
120
122
  severity: error # optional — default error; use warn to record without blocking
121
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
+
122
129
  # jsx-element: forbid raw HTML elements outside the design system
123
130
  - jsx-element: ["button", "input", "label"]
124
131
  in: ["src/app/**/*.tsx", "src/components/**/*.tsx", "!src/components/ui/**"]
125
132
  message: "Use the design-system primitives in @/components/ui/* instead of raw HTML elements"
126
133
  ```
127
134
 
128
- Required: `in` + `message`, plus one of `pattern` / `jsx-element` (not both in the same stanza).
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.
129
136
 
130
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.
131
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
+
132
157
  ## Pitfalls
133
158
 
134
- - `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.
135
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.
136
162
  - A `forbidden`/`singleton` stanza takes either `pattern` or `jsx-element`, never both — split into two stanzas if you need both.
137
163
  - `only` in `singleton` is relative to the project root — use forward slashes on all platforms.