@konvert7/klint 0.24.1 → 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 +33 -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 +29 -3
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",
|
|
@@ -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
|
|
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
|
|
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.
|