@konvert7/klint 0.7.1 → 0.9.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 +241 -150
- package/cli.ts +1 -1
- package/core/arch.ts +97 -23
- package/core/config.schema.ts +45 -8
- package/core/runner.ts +2 -1
- package/core/types.ts +21 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,188 +1,298 @@
|
|
|
1
1
|
# klint
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Architecture and type-aware linting for TypeScript projects.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
Biome and oxlint are excellent at syntax, style, and fast local correctness. klint handles the rules that need project context: layer boundaries, singleton ownership, forbidden patterns, custom repository policies, and type-aware checks that should block an AI agent or CI run before code lands.
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
Use klint when "please follow AGENTS.md" is not strong enough. Put the rule in `klint.yaml`, run `klint`, and make the constraint executable.
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
## Install
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
klint's YAML config supports an `arch:` section that lets you define architectural rules declaratively — no code required.
|
|
14
|
-
|
|
15
|
-
> **AGENTS.md tells the model what to do. Klint ensures it actually did.**
|
|
16
|
-
>
|
|
17
|
-
> Instructions in a prompt are a contract with no enforcement. A model that's drifting, context-starved, or just wrong will violate AGENTS.md silently and ship anyway. Klint makes the violation structurally impossible to land — the gate blocks it regardless of what the model thought it understood.
|
|
18
|
-
|
|
19
|
-
### Layers
|
|
11
|
+
klint currently runs on Bun.
|
|
20
12
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
```yaml
|
|
24
|
-
arch:
|
|
25
|
-
layers:
|
|
26
|
-
core: ["src/hooks/lib/**", "src/tools/**"]
|
|
27
|
-
skills: ["assets/skills/**"]
|
|
13
|
+
```sh
|
|
14
|
+
bun add -d @konvert7/klint
|
|
28
15
|
```
|
|
29
16
|
|
|
30
|
-
|
|
17
|
+
Add a script:
|
|
31
18
|
|
|
32
|
-
```
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
message: "Skills must be self-contained and portable"
|
|
39
|
-
severity: warn # optional, default: error
|
|
40
|
-
|
|
41
|
-
# allow: whitelist mode — anything not listed is denied (npm/node: builtins always pass)
|
|
42
|
-
- from: ["src/dao/**"]
|
|
43
|
-
allow: ["src/dao/**", "src/prisma/**", "src/types/**"]
|
|
44
|
-
message: "DAO may only import from dao, prisma, or types"
|
|
45
|
-
|
|
46
|
-
# type-only: allow — import type {} is permitted even when value imports are denied
|
|
47
|
-
- from: core
|
|
48
|
-
deny: ["src/targets/**"]
|
|
49
|
-
type-only: allow
|
|
50
|
-
message: "Core must not depend on agent-specific code"
|
|
19
|
+
```json
|
|
20
|
+
{
|
|
21
|
+
"scripts": {
|
|
22
|
+
"klint": "klint"
|
|
23
|
+
}
|
|
24
|
+
}
|
|
51
25
|
```
|
|
52
26
|
|
|
53
|
-
|
|
27
|
+
Then create `klint.yaml` in your project root.
|
|
54
28
|
|
|
55
|
-
|
|
29
|
+
## Quickstart
|
|
56
30
|
|
|
57
31
|
```yaml
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
- pattern: "console.log("
|
|
61
|
-
in: core
|
|
62
|
-
message: "Leaks into the agent event stream — use the hook output API instead"
|
|
32
|
+
# klint.yaml
|
|
33
|
+
include: ["src", "!**/node_modules/**"]
|
|
63
34
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
Enforce that a pattern appears only in one designated file:
|
|
35
|
+
rules:
|
|
36
|
+
no-floating-promise: error
|
|
37
|
+
no-unguarded-json-parse: error
|
|
38
|
+
no-sync-in-async:
|
|
39
|
+
severity: error
|
|
40
|
+
include: ["src/hooks/**"]
|
|
72
41
|
|
|
73
|
-
```yaml
|
|
74
42
|
arch:
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
message: "Use the paths module"
|
|
43
|
+
layers:
|
|
44
|
+
app: ["src/app/**"]
|
|
45
|
+
data: ["src/data/**"]
|
|
46
|
+
ui: ["src/components/**"]
|
|
80
47
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
48
|
+
imports:
|
|
49
|
+
- from: ui
|
|
50
|
+
deny: data
|
|
51
|
+
message: "UI components must go through app services, not data modules"
|
|
84
52
|
```
|
|
85
53
|
|
|
86
|
-
|
|
54
|
+
Run it:
|
|
87
55
|
|
|
88
|
-
|
|
56
|
+
```sh
|
|
57
|
+
bun run klint
|
|
58
|
+
```
|
|
89
59
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
process.exit(exitCode);
|
|
60
|
+
For machines and hooks:
|
|
61
|
+
|
|
62
|
+
```sh
|
|
63
|
+
bun run klint -- --json
|
|
95
64
|
```
|
|
96
65
|
|
|
97
|
-
|
|
66
|
+
`--json` emits structured violations and exits `2` when errors are present.
|
|
98
67
|
|
|
99
68
|
```json
|
|
100
69
|
{
|
|
101
70
|
"violations": [
|
|
102
71
|
{
|
|
103
72
|
"rule": "arch/imports",
|
|
104
|
-
"file": "
|
|
105
|
-
"line":
|
|
106
|
-
"severity": "
|
|
107
|
-
"message": "
|
|
73
|
+
"file": "src/components/Profile.tsx",
|
|
74
|
+
"line": 3,
|
|
75
|
+
"severity": "error",
|
|
76
|
+
"message": "UI components must go through app services, not data modules",
|
|
108
77
|
"fix": null
|
|
109
78
|
}
|
|
110
79
|
],
|
|
111
|
-
"summary": { "errors":
|
|
80
|
+
"summary": { "errors": 1, "warnings": 0 }
|
|
112
81
|
}
|
|
113
82
|
```
|
|
114
83
|
|
|
115
|
-
|
|
84
|
+
## What Klint Catches
|
|
85
|
+
|
|
86
|
+
klint is for rules that are too project-specific or context-heavy for a formatter.
|
|
87
|
+
|
|
88
|
+
| Problem | Example |
|
|
89
|
+
|---------|---------|
|
|
90
|
+
| Layer boundaries | `src/components/**` must not import from `src/data/**` |
|
|
91
|
+
| Singleton ownership | `process.env.API_KEY` may only appear in `src/lib/auth.ts` |
|
|
92
|
+
| Forbidden patterns | `console.log(` is blocked inside hook libraries |
|
|
93
|
+
| Type-aware mistakes | Promise-returning calls cannot be silently discarded |
|
|
94
|
+
| Repository policy | Custom `klint.rules.ts` rules run with the same severity/config system |
|
|
116
95
|
|
|
117
|
-
|
|
96
|
+
Rules give agents room to move quickly because the unsafe moves are blocked structurally.
|
|
118
97
|
|
|
119
|
-
##
|
|
98
|
+
## CLI
|
|
120
99
|
|
|
121
100
|
```sh
|
|
122
|
-
|
|
101
|
+
klint [--config <dir>] [--rules <file>] [--fix] [--json]
|
|
102
|
+
klint install-skill [--agents <list>] [--symlink | --copy]
|
|
123
103
|
```
|
|
124
104
|
|
|
125
105
|
| Flag | Description |
|
|
126
106
|
|------|-------------|
|
|
127
107
|
| `--config <dir>` | Directory containing `klint.yaml` or `klint.config.json` (default: cwd) |
|
|
128
|
-
| `--rules <file>` | Path to custom rules file (default:
|
|
108
|
+
| `--rules <file>` | Path to custom rules file (default: `<configDir>/klint.rules.ts` when present) |
|
|
129
109
|
| `--fix` | Apply auto-fixes for fixable violations in-place |
|
|
130
|
-
| `--json` | Emit structured JSON to stdout
|
|
110
|
+
| `--json` | Emit structured JSON to stdout for agents and CI |
|
|
131
111
|
|
|
132
|
-
|
|
112
|
+
`klint install-skill` installs the bundled rule-authoring skill for agent environments:
|
|
113
|
+
|
|
114
|
+
```sh
|
|
115
|
+
klint install-skill --agents claude,opencode,cursor,codex --copy
|
|
116
|
+
```
|
|
133
117
|
|
|
134
118
|
## Configuration
|
|
135
119
|
|
|
136
|
-
|
|
120
|
+
`klint.yaml` lives at your project root alongside tools like `biome.json`, `tsconfig.json`, and `knip.json`.
|
|
137
121
|
|
|
138
122
|
```yaml
|
|
139
123
|
# yaml-language-server: $schema=./klint.schema.yaml
|
|
140
124
|
|
|
141
|
-
include: ["src", "
|
|
125
|
+
include: ["src", "!**/node_modules/**"]
|
|
142
126
|
plugins: [sonar]
|
|
127
|
+
|
|
143
128
|
rules:
|
|
144
129
|
no-unguarded-json-parse: error
|
|
145
|
-
no-sync-in-async:
|
|
146
|
-
severity: error
|
|
147
|
-
include: ["src/hooks/**"]
|
|
148
130
|
no-floating-promise: error
|
|
149
|
-
|
|
131
|
+
no-misused-promises: error
|
|
132
|
+
sonar/prefer-string-replaceall: warn
|
|
133
|
+
my-custom-rule:
|
|
134
|
+
severity: error
|
|
135
|
+
include: ["src/server/**"]
|
|
150
136
|
|
|
151
137
|
arch:
|
|
152
138
|
layers:
|
|
153
|
-
|
|
139
|
+
server: ["src/server/**"]
|
|
140
|
+
client: ["src/client/**"]
|
|
141
|
+
|
|
154
142
|
imports:
|
|
155
|
-
- from:
|
|
156
|
-
deny:
|
|
157
|
-
message: "
|
|
158
|
-
severity: warn
|
|
143
|
+
- from: client
|
|
144
|
+
deny: server
|
|
145
|
+
message: "Client code must not import server-only modules"
|
|
159
146
|
```
|
|
160
147
|
|
|
161
|
-
`include`
|
|
162
|
-
`plugins` — named rule bundles (`"sonar"`) that apply a default set of rules.
|
|
163
|
-
`rules` — map of rule name → `"error" | "warn" | "off"` or an options object with `severity` and/or `include`.
|
|
164
|
-
`arch` — declarative architecture constraints (see Architecture as Code above).
|
|
148
|
+
`include` selects `.ts` files to lint. Patterns support `**` globs and `!` negation.
|
|
165
149
|
|
|
166
|
-
|
|
150
|
+
`plugins` enables bundled rule groups. Today, `sonar` provides focused code-quality rules.
|
|
151
|
+
|
|
152
|
+
`rules` maps rule names to `"error"`, `"warn"`, `"off"`, or an options object with `severity` and/or `include`.
|
|
153
|
+
|
|
154
|
+
`arch` declares architecture constraints without writing a custom rule.
|
|
155
|
+
|
|
156
|
+
`klint.config.json` is still supported for backwards compatibility.
|
|
157
|
+
|
|
158
|
+
## Architecture as Code
|
|
159
|
+
|
|
160
|
+
The `arch:` section turns project boundaries into executable policy.
|
|
161
|
+
|
|
162
|
+
### Layers
|
|
163
|
+
|
|
164
|
+
Define named file groups once, then reference them from import rules:
|
|
165
|
+
|
|
166
|
+
```yaml
|
|
167
|
+
arch:
|
|
168
|
+
layers:
|
|
169
|
+
core: ["src/core/**", "src/lib/**"]
|
|
170
|
+
features: ["src/features/**"]
|
|
171
|
+
adapters: ["src/adapters/**"]
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
### Import Boundaries
|
|
175
|
+
|
|
176
|
+
Use `deny` to block one layer from importing another:
|
|
177
|
+
|
|
178
|
+
```yaml
|
|
179
|
+
arch:
|
|
180
|
+
imports:
|
|
181
|
+
- from: features
|
|
182
|
+
deny: adapters
|
|
183
|
+
message: "Features must use core ports, not adapter implementations"
|
|
184
|
+
severity: error
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
Use `allow` for whitelist mode. Anything not listed is denied:
|
|
188
|
+
|
|
189
|
+
```yaml
|
|
190
|
+
arch:
|
|
191
|
+
imports:
|
|
192
|
+
- from: ["src/data/**"]
|
|
193
|
+
allow: ["src/data/**", "src/db/**", "src/types/**"]
|
|
194
|
+
message: "Data modules may only depend on data, db, and types"
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
Allow type-only imports when runtime imports are forbidden:
|
|
198
|
+
|
|
199
|
+
```yaml
|
|
200
|
+
arch:
|
|
201
|
+
imports:
|
|
202
|
+
- from: core
|
|
203
|
+
deny: adapters
|
|
204
|
+
type-only: allow
|
|
205
|
+
message: "Core may reference adapter types, but not adapter values"
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
### Forbidden Patterns
|
|
209
|
+
|
|
210
|
+
Block literal string patterns inside a scoped layer:
|
|
211
|
+
|
|
212
|
+
```yaml
|
|
213
|
+
arch:
|
|
214
|
+
forbidden:
|
|
215
|
+
- pattern: "console.log("
|
|
216
|
+
in: ["src/hooks/**"]
|
|
217
|
+
message: "Hook output must go through the structured output API"
|
|
218
|
+
|
|
219
|
+
- pattern: "process.exit("
|
|
220
|
+
in: ["src/lib/**"]
|
|
221
|
+
message: "Libraries should return or throw, not terminate the process"
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
### Singleton Locations
|
|
225
|
+
|
|
226
|
+
Enforce that a pattern appears only in one designated file:
|
|
227
|
+
|
|
228
|
+
```yaml
|
|
229
|
+
arch:
|
|
230
|
+
singleton:
|
|
231
|
+
- pattern: "process.env.API_KEY"
|
|
232
|
+
only: "src/lib/auth.ts"
|
|
233
|
+
in: ["src/**"]
|
|
234
|
+
message: "Use the auth module"
|
|
235
|
+
```
|
|
167
236
|
|
|
168
237
|
## Built-in Rules
|
|
169
238
|
|
|
170
239
|
| Rule | Type-aware | Description |
|
|
171
|
-
|
|
240
|
+
|------|------------|-------------|
|
|
172
241
|
| `no-unguarded-json-parse` | No | `JSON.parse()` called outside a try/catch |
|
|
173
|
-
| `no-sync-in-async` | No | Sync filesystem calls
|
|
174
|
-
| `no-floating-promise` |
|
|
175
|
-
| `no-misused-promises` |
|
|
242
|
+
| `no-sync-in-async` | No | Sync filesystem calls inside async functions |
|
|
243
|
+
| `no-floating-promise` | Yes | Promise-returning call whose result is discarded |
|
|
244
|
+
| `no-misused-promises` | Yes | Async function passed where a sync callback is expected |
|
|
245
|
+
| `no-async-predicate` | No | Async predicate passed to array filtering/search methods |
|
|
246
|
+
| `no-date-equality` | No | Date values compared by object identity |
|
|
247
|
+
| `no-optional-chain-on-non-nullable` | Yes | Optional chaining on values TypeScript knows are non-nullable |
|
|
248
|
+
| `no-object-in-template` | Yes | Object values interpolated into template strings |
|
|
249
|
+
| `no-nested-template-literals` | No | Template literals nested inside template literals |
|
|
250
|
+
| `no-consecutive-array-push` | No | Multiple consecutive `push()` calls that should be grouped |
|
|
251
|
+
| `no-string-match` | No | `String#match()` usage better expressed with clearer APIs |
|
|
252
|
+
|
|
253
|
+
## Plugins
|
|
254
|
+
|
|
255
|
+
Enable plugin rules with `plugins: [sonar]`, then override individual severities in `rules` when needed.
|
|
256
|
+
|
|
257
|
+
| Rule | Description |
|
|
258
|
+
|------|-------------|
|
|
259
|
+
| `sonar/prefer-string-replaceall` | Prefer `replaceAll()` when replacing every string occurrence |
|
|
260
|
+
| `sonar/prefer-string-raw-regexp` | Prefer `String.raw` for regex strings where escaping is easy to misread |
|
|
261
|
+
| `sonar/prefer-string-raw` | Prefer `String.raw` for escape-heavy strings |
|
|
262
|
+
| `sonar/prefer-nullish-coalescing-assign` | Prefer `??=` for nullish assignment patterns |
|
|
263
|
+
| `sonar/no-single-char-class` | Avoid regex character classes with a single character |
|
|
264
|
+
| `sonar/prefer-at` | Prefer `.at()` for relative index access |
|
|
265
|
+
|
|
266
|
+
## Agent and CI Integration
|
|
267
|
+
|
|
268
|
+
Agent hooks should run klint with `--json` so violations are machine-readable.
|
|
269
|
+
|
|
270
|
+
```ts
|
|
271
|
+
// .agents/hooks/klint.ts
|
|
272
|
+
import { runHook } from "./run-hook";
|
|
273
|
+
|
|
274
|
+
const exitCode = runHook(["bun", "run", "klint", "--", "--json"]);
|
|
275
|
+
process.exit(exitCode);
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
In CI, run the same command you use locally:
|
|
279
|
+
|
|
280
|
+
```sh
|
|
281
|
+
bun install
|
|
282
|
+
bun run klint
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
The important contract is simple: klint exits `0` when the project is clean, `2` when blocking errors exist, and can emit JSON for agents that need structured feedback.
|
|
176
286
|
|
|
177
287
|
## Custom Rules
|
|
178
288
|
|
|
179
|
-
Create `klint.rules.ts`
|
|
289
|
+
Create `klint.rules.ts` next to `klint.yaml` and export a `Record<string, KlintRule>` as default. Each key is the rule name.
|
|
180
290
|
|
|
181
291
|
```ts
|
|
182
292
|
import { relative } from "node:path";
|
|
183
|
-
import type { KlintRule } from "
|
|
293
|
+
import type { KlintRule } from "@konvert7/klint/core/types";
|
|
184
294
|
|
|
185
|
-
const
|
|
295
|
+
const noForbiddenPattern: KlintRule = {
|
|
186
296
|
check({ files, root, fileContents }, violations) {
|
|
187
297
|
for (const file of files) {
|
|
188
298
|
const lines = (fileContents.get(file) ?? "").split("\n");
|
|
@@ -191,7 +301,7 @@ const myCustomRule: KlintRule = {
|
|
|
191
301
|
violations.push({
|
|
192
302
|
file: relative(root, file),
|
|
193
303
|
line: i + 1,
|
|
194
|
-
message: "Explain what
|
|
304
|
+
message: "Explain what is wrong and how to fix it.",
|
|
195
305
|
});
|
|
196
306
|
}
|
|
197
307
|
}
|
|
@@ -200,25 +310,24 @@ const myCustomRule: KlintRule = {
|
|
|
200
310
|
};
|
|
201
311
|
|
|
202
312
|
export default {
|
|
203
|
-
"
|
|
313
|
+
"no-forbidden-pattern": noForbiddenPattern,
|
|
204
314
|
};
|
|
205
315
|
```
|
|
206
316
|
|
|
207
|
-
All exported rules run at `"error"` severity by default. Override severity or scope
|
|
317
|
+
All exported custom rules run at `"error"` severity by default. Override severity or scope through `rules`:
|
|
208
318
|
|
|
209
319
|
```yaml
|
|
210
320
|
rules:
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
include: ["src/hooks/**"]
|
|
321
|
+
no-forbidden-pattern:
|
|
322
|
+
severity: warn
|
|
323
|
+
include: ["src/server/**"]
|
|
215
324
|
```
|
|
216
325
|
|
|
217
|
-
No separate registration step
|
|
326
|
+
No separate registration step is required.
|
|
218
327
|
|
|
219
|
-
### Auto-fix
|
|
328
|
+
### Auto-fix Support
|
|
220
329
|
|
|
221
|
-
Add a `fix` field to a violation to make it auto-fixable with `--fix`.
|
|
330
|
+
Add a `fix` field to a violation to make it auto-fixable with `--fix`. Fixes replace a line range with new text:
|
|
222
331
|
|
|
223
332
|
```ts
|
|
224
333
|
violations.push({
|
|
@@ -233,21 +342,22 @@ violations.push({
|
|
|
233
342
|
});
|
|
234
343
|
```
|
|
235
344
|
|
|
236
|
-
### Type-aware
|
|
345
|
+
### Type-aware Rules
|
|
237
346
|
|
|
238
|
-
For rules that need TypeScript
|
|
347
|
+
For rules that need the TypeScript AST, use `walkAst`:
|
|
239
348
|
|
|
240
349
|
```ts
|
|
241
350
|
import ts from "typescript";
|
|
242
|
-
import { walkAst } from "
|
|
351
|
+
import { walkAst } from "@konvert7/klint/core/ast";
|
|
352
|
+
import type { KlintRule } from "@konvert7/klint/core/types";
|
|
243
353
|
|
|
244
|
-
const
|
|
245
|
-
check({ files,
|
|
354
|
+
const noSpecificCall: KlintRule = {
|
|
355
|
+
check({ files, fileContents }, violations) {
|
|
246
356
|
for (const file of files) {
|
|
247
357
|
const content = fileContents.get(file) ?? "";
|
|
248
|
-
walkAst(file, content, (node
|
|
358
|
+
walkAst(file, content, (node) => {
|
|
249
359
|
if (ts.isCallExpression(node)) {
|
|
250
|
-
//
|
|
360
|
+
// Inspect node with the TypeScript AST.
|
|
251
361
|
}
|
|
252
362
|
});
|
|
253
363
|
}
|
|
@@ -255,32 +365,13 @@ const myTypeAwareRule: KlintRule = {
|
|
|
255
365
|
};
|
|
256
366
|
```
|
|
257
367
|
|
|
258
|
-
##
|
|
259
|
-
|
|
260
|
-
Any rule can be restricted to a file subset via the `include` option. Patterns support `**` globs and negation with `!`:
|
|
261
|
-
|
|
262
|
-
```yaml
|
|
263
|
-
no-sync-in-async:
|
|
264
|
-
severity: error
|
|
265
|
-
include: ["src/hooks/**", "!src/hooks/scripts/**"]
|
|
266
|
-
```
|
|
368
|
+
## How It Fits
|
|
267
369
|
|
|
268
|
-
|
|
370
|
+
klint is not a replacement for Biome, oxlint, ESLint, knip, or TypeScript itself.
|
|
269
371
|
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
types.ts — KlintRule, KlintConfig, ArchConfig, Violation, RuleEntry
|
|
275
|
-
runner.ts — runKlint(); resolves files, dispatches rules, calls arch engine
|
|
276
|
-
arch.ts — runArchRules(); AST import scanner, layers/imports/forbidden/singleton
|
|
277
|
-
ast.ts — walkAst(), createProgram(), nearestFunctionIsAsync(), isInsideTry()
|
|
278
|
-
fixer.ts — applyFixes(); bottom-up line-range patch with overlap detection
|
|
279
|
-
rules/
|
|
280
|
-
index.ts — BUILT_IN_RULES registry
|
|
281
|
-
...
|
|
282
|
-
tests/
|
|
283
|
-
...
|
|
284
|
-
```
|
|
372
|
+
- Use TypeScript for type correctness.
|
|
373
|
+
- Use Biome or oxlint for fast syntax and style checks.
|
|
374
|
+
- Use knip for unused files and exports.
|
|
375
|
+
- Use klint for architecture, agent constraints, project policy, and custom type-aware checks.
|
|
285
376
|
|
|
286
|
-
The
|
|
377
|
+
The goal is a small, sharp gate: the rules your project cannot afford to leave as prose.
|
package/cli.ts
CHANGED
|
@@ -283,7 +283,7 @@ function printHelp(): void {
|
|
|
283
283
|
"klint — agent harness for TypeScript architecture rules",
|
|
284
284
|
"",
|
|
285
285
|
"Usage: klint [--config <dir>] [--rules <file>] [--fix] [--json]",
|
|
286
|
-
" klint install-skill [--
|
|
286
|
+
" klint install-skill [--agents <list>] [--symlink | --copy]",
|
|
287
287
|
"",
|
|
288
288
|
" --config <dir> directory containing klint.yaml or klint.config.json (default: cwd)",
|
|
289
289
|
" --rules <file> custom rules file (default: <configDir>/klint.rules.ts if present)",
|
package/core/arch.ts
CHANGED
|
@@ -78,7 +78,9 @@ function resolveLayerPrefixes(
|
|
|
78
78
|
layers: Record<string, string[]> | undefined,
|
|
79
79
|
root: string
|
|
80
80
|
): string[] {
|
|
81
|
-
return resolveGlobs(ref, layers)
|
|
81
|
+
return resolveGlobs(ref, layers)
|
|
82
|
+
.filter((g) => !g.startsWith("!"))
|
|
83
|
+
.map((g) => globToPrefix(g, root));
|
|
82
84
|
}
|
|
83
85
|
|
|
84
86
|
function resolveLayerFiles(
|
|
@@ -87,8 +89,18 @@ function resolveLayerFiles(
|
|
|
87
89
|
root: string,
|
|
88
90
|
allFiles: string[]
|
|
89
91
|
): string[] {
|
|
90
|
-
const
|
|
91
|
-
|
|
92
|
+
const globs = resolveGlobs(ref, layers);
|
|
93
|
+
const includePrefixes = globs
|
|
94
|
+
.filter((g) => !g.startsWith("!"))
|
|
95
|
+
.map((g) => globToPrefix(g, root));
|
|
96
|
+
const excludePrefixes = globs
|
|
97
|
+
.filter((g) => g.startsWith("!"))
|
|
98
|
+
.map((g) => globToPrefix(g.slice(1), root));
|
|
99
|
+
return allFiles.filter(
|
|
100
|
+
(f) =>
|
|
101
|
+
includePrefixes.some((p) => f === p || f.startsWith(`${p}/`)) &&
|
|
102
|
+
!excludePrefixes.some((p) => f === p || f.startsWith(`${p}/`))
|
|
103
|
+
);
|
|
92
104
|
}
|
|
93
105
|
|
|
94
106
|
function inPrefixes(absPath: string, prefixes: string[]): boolean {
|
|
@@ -196,16 +208,29 @@ export function runArchRules(
|
|
|
196
208
|
for (const rule of arch.forbidden ?? []) {
|
|
197
209
|
const severity: Severity = rule.severity ?? "error";
|
|
198
210
|
const inFiles = resolveLayerFiles(rule.in, layers, root, allFiles);
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
211
|
+
if ("jsx-element" in rule) {
|
|
212
|
+
scanJsxElements(
|
|
213
|
+
inFiles,
|
|
214
|
+
toArray(rule["jsx-element"]),
|
|
215
|
+
fileContents,
|
|
216
|
+
root,
|
|
217
|
+
"arch/forbidden",
|
|
218
|
+
rule.message,
|
|
219
|
+
severity,
|
|
220
|
+
violations
|
|
221
|
+
);
|
|
222
|
+
} else {
|
|
223
|
+
scanLinesForPattern(
|
|
224
|
+
inFiles,
|
|
225
|
+
rule.pattern,
|
|
226
|
+
fileContents,
|
|
227
|
+
root,
|
|
228
|
+
"arch/forbidden",
|
|
229
|
+
rule.message,
|
|
230
|
+
severity,
|
|
231
|
+
violations
|
|
232
|
+
);
|
|
233
|
+
}
|
|
209
234
|
}
|
|
210
235
|
|
|
211
236
|
for (const rule of arch.singleton ?? []) {
|
|
@@ -215,21 +240,70 @@ export function runArchRules(
|
|
|
215
240
|
? resolveLayerFiles(rule.in, layers, root, allFiles)
|
|
216
241
|
: allFiles;
|
|
217
242
|
const scope = inFiles.filter((f) => f !== onlyFile);
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
243
|
+
if ("jsx-element" in rule) {
|
|
244
|
+
scanJsxElements(
|
|
245
|
+
scope,
|
|
246
|
+
toArray(rule["jsx-element"]),
|
|
247
|
+
fileContents,
|
|
248
|
+
root,
|
|
249
|
+
"arch/singleton",
|
|
250
|
+
rule.message,
|
|
251
|
+
severity,
|
|
252
|
+
violations
|
|
253
|
+
);
|
|
254
|
+
} else {
|
|
255
|
+
scanLinesForPattern(
|
|
256
|
+
scope,
|
|
257
|
+
rule.pattern,
|
|
258
|
+
fileContents,
|
|
259
|
+
root,
|
|
260
|
+
"arch/singleton",
|
|
261
|
+
rule.message,
|
|
262
|
+
severity,
|
|
263
|
+
violations
|
|
264
|
+
);
|
|
265
|
+
}
|
|
228
266
|
}
|
|
229
267
|
|
|
230
268
|
return violations;
|
|
231
269
|
}
|
|
232
270
|
|
|
271
|
+
function toArray<T>(v: T | T[]): T[] {
|
|
272
|
+
return Array.isArray(v) ? v : [v];
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function scanJsxElements(
|
|
276
|
+
files: string[],
|
|
277
|
+
tagNames: string[],
|
|
278
|
+
fileContents: Map<string, string>,
|
|
279
|
+
root: string,
|
|
280
|
+
ruleName: string,
|
|
281
|
+
message: string,
|
|
282
|
+
severity: Severity,
|
|
283
|
+
violations: Violation[]
|
|
284
|
+
): void {
|
|
285
|
+
const targets = new Set(tagNames);
|
|
286
|
+
for (const file of files) {
|
|
287
|
+
if (!/\.(tsx|jsx)$/.test(file)) continue;
|
|
288
|
+
const content = fileContents.get(file);
|
|
289
|
+
if (!content) continue;
|
|
290
|
+
walkAst(file, content, (node, src) => {
|
|
291
|
+
if (!ts.isJsxOpeningElement(node) && !ts.isJsxSelfClosingElement(node)) return;
|
|
292
|
+
const tagName = node.tagName;
|
|
293
|
+
if (!ts.isIdentifier(tagName)) return;
|
|
294
|
+
if (!targets.has(tagName.text)) return;
|
|
295
|
+
const { line } = src.getLineAndCharacterOfPosition(tagName.getStart());
|
|
296
|
+
violations.push({
|
|
297
|
+
file: relative(root, file).replaceAll("\\", "/"),
|
|
298
|
+
line: line + 1,
|
|
299
|
+
message,
|
|
300
|
+
rule: ruleName,
|
|
301
|
+
severity,
|
|
302
|
+
});
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
233
307
|
function scanLinesForPattern(
|
|
234
308
|
files: string[],
|
|
235
309
|
pattern: string,
|
package/core/config.schema.ts
CHANGED
|
@@ -110,7 +110,7 @@ const ArchImportRuleSchema = z
|
|
|
110
110
|
],
|
|
111
111
|
});
|
|
112
112
|
|
|
113
|
-
const
|
|
113
|
+
const ArchForbiddenPatternRuleSchema = z
|
|
114
114
|
.object({
|
|
115
115
|
pattern: z
|
|
116
116
|
.string()
|
|
@@ -127,17 +127,38 @@ const ArchForbiddenRuleSchema = z
|
|
|
127
127
|
),
|
|
128
128
|
severity: ArchSeveritySchema.optional(),
|
|
129
129
|
})
|
|
130
|
-
.strict()
|
|
130
|
+
.strict();
|
|
131
|
+
|
|
132
|
+
const ArchForbiddenJsxRuleSchema = z
|
|
133
|
+
.object({
|
|
134
|
+
"jsx-element": StringOrStringArray.describe(
|
|
135
|
+
"JSX intrinsic element name(s) to forbid (e.g. `button`, `input`, `label`). AST-matched on opening and self-closing tag names — robust to whitespace, attributes, and naming collisions like `<buttonGroup>`."
|
|
136
|
+
),
|
|
137
|
+
in: StringOrStringArray.describe(
|
|
138
|
+
"Layer name(s) or glob(s) the element is forbidden in. Files outside the scope are not checked."
|
|
139
|
+
),
|
|
140
|
+
message: z
|
|
141
|
+
.string()
|
|
142
|
+
.describe(
|
|
143
|
+
"Required error message explaining why the raw element is forbidden. Typically points at the design-system replacement (e.g. `Use <Button /> from @/components/ui/button`)."
|
|
144
|
+
),
|
|
145
|
+
severity: ArchSeveritySchema.optional(),
|
|
146
|
+
})
|
|
147
|
+
.strict();
|
|
148
|
+
|
|
149
|
+
const ArchForbiddenRuleSchema = z
|
|
150
|
+
.union([ArchForbiddenPatternRuleSchema, ArchForbiddenJsxRuleSchema])
|
|
131
151
|
.describe(
|
|
132
|
-
"A forbidden-pattern rule. Blocks literal
|
|
152
|
+
"A forbidden-pattern rule. Blocks either a literal substring (`pattern:`) or a JSX element (`jsx-element:`) inside specific layers."
|
|
133
153
|
)
|
|
134
154
|
.meta({
|
|
135
155
|
examples: [
|
|
136
156
|
'arch:\n forbidden:\n - pattern: "console.log("\n in: core\n message: "Leaks into the agent event stream"',
|
|
157
|
+
'arch:\n forbidden:\n - jsx-element: ["button", "input", "label"]\n in: ["src/app/**/*.tsx", "src/components/**/*.tsx", "!src/components/ui/**"]\n message: "Use the design-system primitives in @/components/ui/* instead of raw HTML elements."',
|
|
137
158
|
],
|
|
138
159
|
});
|
|
139
160
|
|
|
140
|
-
const
|
|
161
|
+
const ArchSingletonPatternRuleSchema = z
|
|
141
162
|
.object({
|
|
142
163
|
pattern: z
|
|
143
164
|
.string()
|
|
@@ -152,20 +173,36 @@ const ArchSingletonRuleSchema = z
|
|
|
152
173
|
in: StringOrStringArray.optional().describe(
|
|
153
174
|
"Optional scope. Limit the scan to these layers/globs; defaults to all files in `include`."
|
|
154
175
|
),
|
|
155
|
-
message: z
|
|
176
|
+
message: z.string(),
|
|
177
|
+
severity: ArchSeveritySchema.optional(),
|
|
178
|
+
})
|
|
179
|
+
.strict();
|
|
180
|
+
|
|
181
|
+
const ArchSingletonJsxRuleSchema = z
|
|
182
|
+
.object({
|
|
183
|
+
"jsx-element": StringOrStringArray.describe(
|
|
184
|
+
"JSX intrinsic element name(s) allowed only inside the `only` file. AST-matched on tag names."
|
|
185
|
+
),
|
|
186
|
+
only: z
|
|
156
187
|
.string()
|
|
157
188
|
.describe(
|
|
158
|
-
"
|
|
189
|
+
"The single file path where the element is allowed (typically the design-system primitive that wraps it)."
|
|
159
190
|
),
|
|
191
|
+
in: StringOrStringArray.optional(),
|
|
192
|
+
message: z.string(),
|
|
160
193
|
severity: ArchSeveritySchema.optional(),
|
|
161
194
|
})
|
|
162
|
-
.strict()
|
|
195
|
+
.strict();
|
|
196
|
+
|
|
197
|
+
const ArchSingletonRuleSchema = z
|
|
198
|
+
.union([ArchSingletonPatternRuleSchema, ArchSingletonJsxRuleSchema])
|
|
163
199
|
.describe(
|
|
164
|
-
"A singleton-location rule. Pins a pattern to exactly one file — the only honest way to enforce a module of record."
|
|
200
|
+
"A singleton-location rule. Pins a pattern or JSX element to exactly one file — the only honest way to enforce a module of record or a design-system primitive."
|
|
165
201
|
)
|
|
166
202
|
.meta({
|
|
167
203
|
examples: [
|
|
168
204
|
'arch:\n singleton:\n - pattern: "process.env.API_KEY"\n only: "src/lib/auth.ts"\n message: "API key access must funnel through the auth module."',
|
|
205
|
+
'arch:\n singleton:\n - jsx-element: "button"\n only: "src/components/ui/button.tsx"\n in: ["src/**/*.tsx"]\n message: "Raw <button> belongs only to the Button primitive."',
|
|
169
206
|
],
|
|
170
207
|
});
|
|
171
208
|
|
package/core/runner.ts
CHANGED
|
@@ -17,7 +17,8 @@ function walk(dir: string): string[] {
|
|
|
17
17
|
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
18
18
|
const full = join(dir, entry.name);
|
|
19
19
|
if (entry.isDirectory()) out.push(...walk(full));
|
|
20
|
-
else if (
|
|
20
|
+
else if (/\.(tsx?|jsx?|mts|cts)$/.test(entry.name))
|
|
21
|
+
out.push(full.replaceAll("\\", "/"));
|
|
21
22
|
}
|
|
22
23
|
return out;
|
|
23
24
|
}
|
package/core/types.ts
CHANGED
|
@@ -63,15 +63,23 @@ interface ArchImportRule {
|
|
|
63
63
|
severity?: Exclude<Severity, "off">;
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
-
interface
|
|
67
|
-
pattern: string;
|
|
66
|
+
interface ArchForbiddenRuleBase {
|
|
68
67
|
in: string | string[];
|
|
69
68
|
message: string;
|
|
70
69
|
severity?: Exclude<Severity, "off">;
|
|
71
70
|
}
|
|
72
71
|
|
|
73
|
-
interface
|
|
72
|
+
interface ArchForbiddenPatternRule extends ArchForbiddenRuleBase {
|
|
74
73
|
pattern: string;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
interface ArchForbiddenJsxRule extends ArchForbiddenRuleBase {
|
|
77
|
+
"jsx-element": string | string[];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
type ArchForbiddenRule = ArchForbiddenPatternRule | ArchForbiddenJsxRule;
|
|
81
|
+
|
|
82
|
+
interface ArchSingletonRuleBase {
|
|
75
83
|
only: string;
|
|
76
84
|
message: string;
|
|
77
85
|
/** Limit scan to these files/layers. Defaults to all files. */
|
|
@@ -79,6 +87,16 @@ interface ArchSingletonRule {
|
|
|
79
87
|
severity?: Exclude<Severity, "off">;
|
|
80
88
|
}
|
|
81
89
|
|
|
90
|
+
interface ArchSingletonPatternRule extends ArchSingletonRuleBase {
|
|
91
|
+
pattern: string;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
interface ArchSingletonJsxRule extends ArchSingletonRuleBase {
|
|
95
|
+
"jsx-element": string | string[];
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
type ArchSingletonRule = ArchSingletonPatternRule | ArchSingletonJsxRule;
|
|
99
|
+
|
|
82
100
|
export interface ArchConfig {
|
|
83
101
|
layers?: Record<string, string[]>;
|
|
84
102
|
imports?: ArchImportRule[];
|
package/package.json
CHANGED