@konvert7/klint 0.1.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 ADDED
@@ -0,0 +1,286 @@
1
+ # klint
2
+
3
+ The bridge between vibe coding and agentic engineering.
4
+
5
+ ## Why
6
+
7
+ Biome and oxlint enforce syntax-level style. klint enforces architecture-level rules — the kind that require TypeScript's type graph, span multiple files, or encode constraints that an AI agent must not bypass. If a rule needs to know that `fetchUser()` returns `Promise<User>`, or that sync filesystem calls are banned inside async hooks, that's a klint rule.
8
+
9
+ Rules give your agent freedom. Without constraints, every decision is a risk. With klint, your agent knows exactly where it can move fast — and where it can't.
10
+
11
+ ## Architecture as Code
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
20
+
21
+ Define named file groups once, reference them everywhere:
22
+
23
+ ```yaml
24
+ arch:
25
+ layers:
26
+ core: ["src/hooks/lib/**", "src/tools/**"]
27
+ skills: ["assets/skills/**"]
28
+ ```
29
+
30
+ ### Import boundaries
31
+
32
+ ```yaml
33
+ arch:
34
+ imports:
35
+ # deny: block imports from one layer into another
36
+ - from: skills
37
+ deny: core
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"
51
+ ```
52
+
53
+ ### Forbidden patterns
54
+
55
+ Block literal string patterns inside a scoped layer:
56
+
57
+ ```yaml
58
+ arch:
59
+ forbidden:
60
+ - pattern: "console.log("
61
+ in: core
62
+ message: "Leaks into the agent event stream — use the hook output API instead"
63
+
64
+ - pattern: "process.exit("
65
+ in: ["src/hooks/lib/**"]
66
+ message: "Library functions should return or throw, not terminate the process"
67
+ ```
68
+
69
+ ### Singleton locations
70
+
71
+ Enforce that a pattern appears only in one designated file:
72
+
73
+ ```yaml
74
+ arch:
75
+ singleton:
76
+ - pattern: "process.env.PAL_HOME"
77
+ only: "src/hooks/lib/paths.ts"
78
+ in: ["src/**"] # optional: limit scan scope
79
+ message: "Use the paths module"
80
+
81
+ - pattern: "process.env.API_KEY"
82
+ only: "src/lib/auth.ts"
83
+ message: "Use the auth module"
84
+ ```
85
+
86
+ ### Agent integration
87
+
88
+ Wire `--json` into your Stop hook so violations are machine-readable:
89
+
90
+ ```typescript
91
+ // .agents/hooks/klint.ts
92
+ import { runHook } from "./run-hook";
93
+ const exitCode = runHook(["bun", "klint/cli.ts", "--json"]);
94
+ process.exit(exitCode);
95
+ ```
96
+
97
+ On errors the hook exits 2 (blocking) and emits:
98
+
99
+ ```json
100
+ {
101
+ "violations": [
102
+ {
103
+ "rule": "arch/imports",
104
+ "file": "assets/skills/telos/tools/update-telos.ts",
105
+ "line": 23,
106
+ "severity": "warn",
107
+ "message": "Skills must be self-contained and portable",
108
+ "fix": null
109
+ }
110
+ ],
111
+ "summary": { "errors": 0, "warnings": 1 }
112
+ }
113
+ ```
114
+
115
+ The agent reads the structured violations and fixes them before the session can close.
116
+
117
+ ---
118
+
119
+ ## Usage
120
+
121
+ ```sh
122
+ bun klint/cli.ts [--config <dir>] [--rules <file>] [--fix] [--json]
123
+ ```
124
+
125
+ | Flag | Description |
126
+ |------|-------------|
127
+ | `--config <dir>` | Directory containing `klint.yaml` or `klint.config.json` (default: cwd) |
128
+ | `--rules <file>` | Path to custom rules file (default: auto-discovered — see below) |
129
+ | `--fix` | Apply auto-fixes for fixable violations in-place |
130
+ | `--json` | Emit structured JSON to stdout (for agent/CI consumption) |
131
+
132
+ If `--rules` is omitted, klint looks for `klint.rules.ts` next to the config file. If it exists it is loaded automatically; if it doesn't, no custom rules are used.
133
+
134
+ ## Configuration
135
+
136
+ **`klint.yaml`** — lives at your project root alongside `biome.json` and `knip.json`:
137
+
138
+ ```yaml
139
+ # yaml-language-server: $schema=./klint.schema.yaml
140
+
141
+ include: ["src", "klint", "!**/node_modules/**"]
142
+ plugins: [sonar]
143
+ rules:
144
+ no-unguarded-json-parse: error
145
+ no-sync-in-async:
146
+ severity: error
147
+ include: ["src/hooks/**"]
148
+ no-floating-promise: error
149
+ my-custom-rule: warn
150
+
151
+ arch:
152
+ layers:
153
+ core: ["src/hooks/lib/**", "src/tools/**"]
154
+ imports:
155
+ - from: ["assets/skills/**"]
156
+ deny: ["src/**"]
157
+ message: "Skills must be self-contained"
158
+ severity: warn
159
+ ```
160
+
161
+ `include` — glob patterns selecting which `.ts` files to lint.
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).
165
+
166
+ A `klint.config.json` fallback is supported for backwards compatibility.
167
+
168
+ ## Built-in Rules
169
+
170
+ | Rule | Type-aware | Description |
171
+ |------|-----------|-------------|
172
+ | `no-unguarded-json-parse` | No | `JSON.parse()` called outside a try/catch |
173
+ | `no-sync-in-async` | No | Sync filesystem calls (`readFileSync` etc.) inside async functions |
174
+ | `no-floating-promise` | **Yes** | Promise-returning call whose result is discarded |
175
+ | `no-misused-promises` | **Yes** | Async function passed where a sync callback is expected |
176
+
177
+ ## Custom Rules
178
+
179
+ Create `klint.rules.ts` at your project root and export a `Record<string, KlintRule>` as default. Each key is the rule name:
180
+
181
+ ```ts
182
+ import { relative } from "node:path";
183
+ import type { KlintRule } from "./klint/core/types";
184
+
185
+ const myCustomRule: KlintRule = {
186
+ check({ files, root, fileContents }, violations) {
187
+ for (const file of files) {
188
+ const lines = (fileContents.get(file) ?? "").split("\n");
189
+ for (let i = 0; i < lines.length; i++) {
190
+ if (/forbidden-pattern/.test(lines[i])) {
191
+ violations.push({
192
+ file: relative(root, file),
193
+ line: i + 1,
194
+ message: "Explain what's wrong and how to fix it.",
195
+ });
196
+ }
197
+ }
198
+ }
199
+ },
200
+ };
201
+
202
+ export default {
203
+ "my-custom-rule": myCustomRule,
204
+ };
205
+ ```
206
+
207
+ All exported rules run at `"error"` severity by default. Override severity or scope them via `rules` in `klint.yaml` — the same mechanism as built-in rules:
208
+
209
+ ```yaml
210
+ rules:
211
+ my-custom-rule: warn
212
+ my-scoped-rule:
213
+ severity: error
214
+ include: ["src/hooks/**"]
215
+ ```
216
+
217
+ No separate registration step — everything exported from `klint.rules.ts` is picked up automatically.
218
+
219
+ ### Auto-fix support
220
+
221
+ Add a `fix` field to a violation to make it auto-fixable with `--fix`. The fix replaces a line range with new text:
222
+
223
+ ```ts
224
+ violations.push({
225
+ file: relative(root, file),
226
+ line: i + 1,
227
+ message: "Use foo() instead of bar().",
228
+ fix: {
229
+ startLine: i + 1,
230
+ endLine: i + 1,
231
+ replacement: lines[i].replace("bar()", "foo()"),
232
+ },
233
+ });
234
+ ```
235
+
236
+ ### Type-aware rules
237
+
238
+ For rules that need TypeScript's type checker, use `walkAst` from `klint/core/ast`:
239
+
240
+ ```ts
241
+ import ts from "typescript";
242
+ import { walkAst } from "./klint/core/ast";
243
+
244
+ const myTypeAwareRule: KlintRule = {
245
+ check({ files, root, fileContents }, violations) {
246
+ for (const file of files) {
247
+ const content = fileContents.get(file) ?? "";
248
+ walkAst(file, content, (node, src) => {
249
+ if (ts.isCallExpression(node)) {
250
+ // inspect node using the TypeScript AST
251
+ }
252
+ });
253
+ }
254
+ },
255
+ };
256
+ ```
257
+
258
+ ## Scoped includes
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
+ ```
267
+
268
+ ## Architecture
269
+
270
+ ```
271
+ klint/
272
+ cli.ts — CLI entry point; discovers config + rules, reports violations
273
+ core/
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
+ ```
285
+
286
+ The `klint/` directory is intentionally decoupled from the rest of the codebase — no imports cross the boundary in either direction. When it has enough rules, it ships as a standalone package.
package/cli.ts ADDED
@@ -0,0 +1,306 @@
1
+ #!/usr/bin/env bun
2
+ import {
3
+ cpSync,
4
+ existsSync,
5
+ mkdirSync,
6
+ readFileSync,
7
+ rmSync,
8
+ symlinkSync,
9
+ } from "node:fs";
10
+ import { dirname, join, relative, resolve } from "node:path";
11
+ import * as clack from "@clack/prompts";
12
+ import { parse as parseYaml } from "yaml";
13
+ import { applyFixes } from "./core/fixer";
14
+ import { runKlint } from "./core/runner";
15
+ import type { ArchConfig, KlintConfig, KlintRule, RuleConfigValue } from "./core/types";
16
+ import { BUILT_IN_PLUGINS } from "./plugins/index";
17
+ import { BUILT_IN_RULES } from "./rules/index";
18
+
19
+ interface CliOptions {
20
+ configDir?: string;
21
+ rulesFile?: string;
22
+ }
23
+
24
+ export async function main(opts: CliOptions = {}): Promise<void> {
25
+ const args = process.argv.slice(2);
26
+
27
+ if (args[0] === "install-skill") {
28
+ await installSkill(args.slice(1));
29
+ return;
30
+ }
31
+
32
+ let configDir = opts.configDir;
33
+ let rulesFile = opts.rulesFile;
34
+ let fix = false;
35
+ let json = false;
36
+
37
+ for (let i = 0; i < args.length; i++) {
38
+ if (args[i] === "--config" && args[i + 1]) configDir = resolve(args[++i]);
39
+ else if (args[i] === "--rules" && args[i + 1]) rulesFile = resolve(args[++i]);
40
+ else if (args[i] === "--fix") fix = true;
41
+ else if (args[i] === "--json") json = true;
42
+ else if (args[i] === "--help" || args[i] === "-h") {
43
+ printHelp();
44
+ process.exit(0);
45
+ }
46
+ }
47
+
48
+ configDir ??= process.cwd();
49
+
50
+ const yamlPath = resolve(configDir, "klint.yaml");
51
+ const jsonPath = resolve(configDir, "klint.config.json");
52
+ const usingYaml = existsSync(yamlPath);
53
+ const configPath = usingYaml ? yamlPath : jsonPath;
54
+
55
+ if (!existsSync(configPath)) {
56
+ process.stderr.write(
57
+ `klint: no config file found — create klint.yaml (or klint.config.json) at ${configDir}\n`
58
+ );
59
+ process.exit(1);
60
+ }
61
+
62
+ interface RawConfig {
63
+ root?: string;
64
+ include?: string[];
65
+ plugins?: string[];
66
+ rules?: Record<string, RuleConfigValue>;
67
+ arch?: unknown;
68
+ }
69
+ let raw: RawConfig;
70
+ try {
71
+ const text = readFileSync(configPath, "utf-8");
72
+ raw = (usingYaml ? parseYaml(text) : JSON.parse(text)) as RawConfig;
73
+ } catch {
74
+ process.stderr.write(`klint: failed to parse ${configPath}\n`);
75
+ process.exit(1);
76
+ }
77
+ const root = resolve(configDir, raw.root ?? ".");
78
+
79
+ let customRules: Record<string, KlintRule> = {};
80
+ const defaultRulesPath = resolve(configDir, "klint.rules.ts");
81
+ const rulesPath =
82
+ rulesFile ?? (existsSync(defaultRulesPath) ? defaultRulesPath : undefined);
83
+ if (rulesPath) {
84
+ const mod = await import(rulesPath);
85
+ customRules = (mod.default ?? {}) as Record<string, KlintRule>;
86
+ }
87
+
88
+ const customRulesMap: Record<string, RuleConfigValue> = Object.fromEntries(
89
+ Object.keys(customRules).map((name) => [name, "error" as const])
90
+ );
91
+ const allRules: KlintConfig["rules"] = { ...customRulesMap, ...(raw.rules ?? {}) };
92
+
93
+ const violations = runKlint(
94
+ {
95
+ root,
96
+ include: raw.include ?? ["."],
97
+ plugins: raw.plugins,
98
+ rules: allRules,
99
+ arch: raw.arch as ArchConfig | undefined,
100
+ },
101
+ customRules
102
+ );
103
+
104
+ if (json) {
105
+ const errors = violations.filter((v) => v.severity === "error");
106
+ process.stdout.write(
107
+ JSON.stringify({
108
+ violations: violations.map((v) => ({ ...v, fix: v.fix ?? null })),
109
+ summary: { errors: errors.length, warnings: violations.length - errors.length },
110
+ })
111
+ );
112
+ process.exit(errors.length > 0 ? 2 : 0);
113
+ }
114
+
115
+ if (fix) {
116
+ let totalApplied = 0;
117
+ let current = violations;
118
+ while (true) {
119
+ const applied = applyFixes(current, root);
120
+ totalApplied += applied;
121
+ if (applied === 0) break;
122
+ current = runKlint(
123
+ {
124
+ root,
125
+ include: raw.include ?? ["."],
126
+ plugins: raw.plugins,
127
+ rules: allRules,
128
+ arch: raw.arch as ArchConfig | undefined,
129
+ },
130
+ customRules
131
+ );
132
+ if (current.every((v) => !v.fix)) break;
133
+ }
134
+ const unfixed = current.filter((v) => !v.fix).length;
135
+ const msg =
136
+ unfixed > 0
137
+ ? `klint: applied ${totalApplied} fix(es). ${unfixed} violation(s) require manual attention.`
138
+ : `klint: applied ${totalApplied} fix(es). No remaining violations.`;
139
+ process.stdout.write(JSON.stringify({ output: msg }));
140
+ process.exit(0);
141
+ }
142
+
143
+ const errors = violations.filter((v) => v.severity === "error");
144
+ const warns = violations.filter((v) => v.severity === "warn");
145
+
146
+ if (errors.length === 0 && warns.length === 0) {
147
+ process.stdout.write(JSON.stringify({ output: "klint: 0 violations" }));
148
+ process.exit(0);
149
+ }
150
+
151
+ const formatBlock = (v: (typeof violations)[number]) => {
152
+ const prefix = v.severity === "warn" ? "⚠" : "×";
153
+ const header = `${v.file}:${v.line} [${v.rule}]`;
154
+ const sep = "━".repeat(Math.max(0, 80 - header.length));
155
+ return `${header} ${sep}\n\n ${prefix} ${v.message}\n`;
156
+ };
157
+
158
+ if (warns.length > 0) {
159
+ process.stderr.write(
160
+ `klint: ${warns.length} warning(s)\n\n${warns.map(formatBlock).join("\n")}`
161
+ );
162
+ }
163
+ if (errors.length > 0) {
164
+ process.stderr.write(
165
+ `klint: ${errors.length} error(s)\n\n${errors.map(formatBlock).join("\n")}`
166
+ );
167
+ process.exit(2);
168
+ }
169
+ process.exit(0);
170
+ }
171
+
172
+ const AGENT_TARGETS = [
173
+ { value: "claude", label: "Claude Code" },
174
+ { value: "opencode", label: "opencode" },
175
+ { value: "cursor", label: "Cursor" },
176
+ { value: "codex", label: "Codex" },
177
+ ] as const;
178
+
179
+ type AgentKey = (typeof AGENT_TARGETS)[number]["value"];
180
+
181
+ const AGENT_DIRS: Record<AgentKey, string> = {
182
+ claude: ".claude/skills",
183
+ opencode: ".agents/skills",
184
+ cursor: ".cursor/skills",
185
+ codex: ".agents/skills",
186
+ };
187
+
188
+ async function installSkill(args: string[]): Promise<void> {
189
+ const skillSrc = join(import.meta.dir, "skill", "klint-rules");
190
+ if (!existsSync(skillSrc)) {
191
+ process.stderr.write(`klint: skill source not found at ${skillSrc}\n`);
192
+ process.exit(1);
193
+ }
194
+
195
+ // Parse non-interactive flags
196
+ let flagAgents: AgentKey[] | undefined;
197
+ let flagSymlink: boolean | undefined;
198
+ for (let i = 0; i < args.length; i++) {
199
+ if (args[i] === "--agents" && args[i + 1])
200
+ flagAgents = args[++i].split(",") as AgentKey[];
201
+ else if (args[i] === "--symlink") flagSymlink = true;
202
+ else if (args[i] === "--copy") flagSymlink = false;
203
+ }
204
+
205
+ let selectedAgents: AgentKey[];
206
+ let useSymlink: boolean;
207
+
208
+ if (!process.stdin.isTTY || flagAgents !== undefined || flagSymlink !== undefined) {
209
+ selectedAgents = flagAgents ?? (AGENT_TARGETS.map((a) => a.value) as AgentKey[]);
210
+ useSymlink = flagSymlink ?? false;
211
+ } else {
212
+ clack.intro("klint install-skill");
213
+
214
+ const agents = await clack.multiselect<AgentKey>({
215
+ message: "Which agents should the skill be installed for?",
216
+ options: AGENT_TARGETS.map((a) => ({ value: a.value, label: a.label })),
217
+ initialValues: AGENT_TARGETS.map((a) => a.value) as AgentKey[],
218
+ });
219
+ if (clack.isCancel(agents)) {
220
+ clack.cancel("Cancelled.");
221
+ process.exit(0);
222
+ }
223
+ selectedAgents = agents as AgentKey[];
224
+
225
+ const mode = await clack.select<"symlink" | "copy">({
226
+ message: "Install as symlink or copy?",
227
+ options: [
228
+ {
229
+ value: "symlink",
230
+ label: "Symlink",
231
+ hint: "stays in sync when klint updates",
232
+ },
233
+ {
234
+ value: "copy",
235
+ label: "Copy",
236
+ hint: "one-time snapshot, no ongoing dependency",
237
+ },
238
+ ],
239
+ });
240
+ if (clack.isCancel(mode)) {
241
+ clack.cancel("Cancelled.");
242
+ process.exit(0);
243
+ }
244
+ useSymlink = mode === "symlink";
245
+ }
246
+
247
+ const cwd = process.cwd();
248
+ const linkType = process.platform === "win32" ? "junction" : "dir";
249
+ for (const key of selectedAgents) {
250
+ const dest = resolve(cwd, AGENT_DIRS[key], "klint-rules");
251
+ mkdirSync(dirname(dest), { recursive: true });
252
+ try {
253
+ rmSync(dest, { recursive: true, force: true });
254
+ } catch {
255
+ /* already gone */
256
+ }
257
+ if (useSymlink) {
258
+ symlinkSync(relative(dirname(dest), skillSrc), dest, linkType);
259
+ } else {
260
+ cpSync(skillSrc, dest, { recursive: true });
261
+ }
262
+ }
263
+
264
+ if (process.stdin.isTTY) {
265
+ clack.outro("Done.");
266
+ }
267
+ }
268
+
269
+ function printHelp(): void {
270
+ const pluginRules = new Set(
271
+ Object.values(BUILT_IN_PLUGINS).flatMap((p) => Object.keys(p.rules))
272
+ );
273
+ const standaloneRules = Object.keys(BUILT_IN_RULES).filter((r) => !pluginRules.has(r));
274
+ const pluginEntries = Object.entries(BUILT_IN_PLUGINS);
275
+
276
+ process.stdout.write(
277
+ [
278
+ "klint — agent harness for TypeScript architecture rules",
279
+ "",
280
+ "Usage: klint [--config <dir>] [--rules <file>] [--fix] [--json]",
281
+ " klint install-skill [--out <path>]",
282
+ "",
283
+ " --config <dir> directory containing klint.yaml or klint.config.json (default: cwd)",
284
+ " --rules <file> custom rules file (default: <configDir>/klint.rules.ts if present)",
285
+ " --fix apply auto-fixes for fixable violations in-place",
286
+ " --json emit structured JSON to stdout (for agent/CI consumption)",
287
+ "",
288
+ " install-skill install the rule-authoring skill into agent config directories",
289
+ " --agents <list> comma-separated: claude,opencode,cursor,codex (default: all)",
290
+ " --symlink install as symlink (stays in sync with updates)",
291
+ " --copy install as copy (default in non-TTY)",
292
+ "",
293
+ `Built-in rules (${standaloneRules.length}):`,
294
+ ...standaloneRules.map((r) => ` ${r}`),
295
+ "",
296
+ `Plugins (${pluginEntries.length}):`,
297
+ ...pluginEntries.flatMap(([name, plugin]) => [
298
+ ` ${name}`,
299
+ ...Object.keys(plugin.rules).map((r) => ` ${r}`),
300
+ ]),
301
+ "",
302
+ ].join("\n")
303
+ );
304
+ }
305
+
306
+ if (import.meta.main) await main();