@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/README.md +57 -1
- package/cli/help.ts +41 -0
- package/cli/install-skill.ts +102 -0
- package/cli/output.ts +159 -0
- package/cli/paths.ts +3 -0
- package/cli/rust-config.ts +144 -0
- package/cli/rust-engine.ts +301 -0
- package/cli.ts +15 -733
- package/core/arch.ts +44 -1
- package/core/config.schema.ts +39 -2
- package/core/native-binary.ts +5 -1
- package/core/types.ts +8 -0
- package/package.json +6 -5
- package/skill/klint-rules/SKILL.md +74 -6
- package/tools/generate-homebrew-formula.ts +6 -10
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]
|
|
364
|
+
if (matchesLine(lines[i])) {
|
|
322
365
|
violations.push({
|
|
323
366
|
file: relativeSlashPath(root, file),
|
|
324
367
|
line: i + 1,
|
package/core/config.schema.ts
CHANGED
|
@@ -115,7 +115,7 @@ const ArchForbiddenPatternRuleSchema = z
|
|
|
115
115
|
pattern: z
|
|
116
116
|
.string()
|
|
117
117
|
.describe(
|
|
118
|
-
"
|
|
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
|
-
"
|
|
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(
|
package/core/native-binary.ts
CHANGED
|
@@ -100,7 +100,11 @@ function nativePackageVersionMatches(
|
|
|
100
100
|
}
|
|
101
101
|
|
|
102
102
|
function readJsonFile(path: string): { version?: string } {
|
|
103
|
-
|
|
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.
|
|
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.
|
|
68
|
-
"@konvert7/klint-darwin-x64": "0.
|
|
69
|
-
"@konvert7/klint-linux-x64": "0.
|
|
70
|
-
"@konvert7/klint-win32-x64": "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
|
-
`
|
|
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
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|