@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 CHANGED
@@ -97,6 +97,8 @@ klint is for rules that are too project-specific or context-heavy for a formatte
97
97
  | Layer boundaries | `src/components/**` must not import from `src/data/**` |
98
98
  | Singleton ownership | `process.env.API_KEY` may only appear in `src/lib/auth.ts` |
99
99
  | Forbidden patterns | `console.log(` is blocked inside hook libraries |
100
+ | Raw JSX elements | `<button>` is blocked outside `src/components/ui/**` |
101
+ | File length | No file in `src/**` may exceed 300 lines |
100
102
  | Type-aware mistakes | Promise-returning calls cannot be silently discarded |
101
103
  | Repository policy | Custom `klint.rules.ts` rules run with the same severity/config system |
102
104
 
@@ -146,7 +148,7 @@ klint --engine compare --json # parity check; rejects unsupported rules
146
148
 
147
149
  Rust currently supports:
148
150
 
149
- - `arch` rules: imports, forbidden patterns, singleton locations
151
+ - `arch` rules: imports, forbidden patterns (literal + `re:` regex), singleton locations, `maxLines` file limits
150
152
  - `no-unguarded-json-parse`
151
153
  - `no-sync-in-async`
152
154
  - `no-nested-template-literals`
@@ -273,6 +275,30 @@ arch:
273
275
  message: "Libraries should return or throw, not terminate the process"
274
276
  ```
275
277
 
278
+ Match a **regular expression** instead of a literal by prefixing the pattern with `re:` — useful for value-shaped policies such as off-system utility classes. Regexes use the common JS/RE2 subset (no lookaround or backreferences) so the TS and Rust engines agree:
279
+
280
+ ```yaml
281
+ arch:
282
+ forbidden:
283
+ - pattern: 're:\b(?:p|gap)-\[' # reject Tailwind arbitrary values like p-[18px]
284
+ in: ["src/**/*.tsx"]
285
+ message: "Use the spacing scale, not arbitrary bracket values"
286
+ ```
287
+
288
+ A literal pattern that itself begins with `re:` cannot be expressed — such a value is always read as a regex.
289
+
290
+ Block raw JSX/HTML elements with `jsx-element` to push consumers onto design-system primitives. It is AST-matched on intrinsic (lowercase) tag names, so it is robust to whitespace, attributes, and lookalikes like `<buttonGroup>`:
291
+
292
+ ```yaml
293
+ arch:
294
+ forbidden:
295
+ - jsx-element: ["button", "input", "label"]
296
+ in: ["src/app/**/*.tsx", "src/components/**/*.tsx", "!src/components/ui/**"]
297
+ message: "Use the design-system primitives in @/components/ui/* instead of raw HTML elements"
298
+ ```
299
+
300
+ `jsx-element` matches intrinsic HTML elements only — not custom components like `<Button>`. Use `arch.imports` to restrict those.
301
+
276
302
  ### Singleton Locations
277
303
 
278
304
  Enforce that a pattern appears only in one designated file:
@@ -286,6 +312,36 @@ arch:
286
312
  message: "Use the auth module"
287
313
  ```
288
314
 
315
+ Like `forbidden`, a singleton `pattern` may use the `re:` regex prefix for value-shaped matches.
316
+
317
+ `singleton` accepts `jsx-element` too — pin a raw element to the one primitive file that is allowed to render it:
318
+
319
+ ```yaml
320
+ arch:
321
+ singleton:
322
+ - jsx-element: "button"
323
+ only: "src/components/ui/button.tsx"
324
+ in: ["src/**/*.tsx"]
325
+ message: "Raw <button> belongs only to the Button primitive"
326
+ ```
327
+
328
+ ### Max File Length
329
+
330
+ Cap how long any file in a layer may grow. The limit counts every physical line — blanks and comments included — and a file over it is flagged at the first line past the limit:
331
+
332
+ ```yaml
333
+ arch:
334
+ maxLines:
335
+ - limit: 300
336
+ in: ["src/**"]
337
+ message: "Split this module" # optional — defaults to a message naming the limit
338
+
339
+ - limit: 600 # different ceilings per scope
340
+ in: ["tests/**"]
341
+ ```
342
+
343
+ `maxLines` is enforced in both the TS and Rust engines and applies to every language klint scans, with no formatter dependency.
344
+
289
345
  ### Python Architecture Checks
290
346
 
291
347
  The Rust engine can apply architecture rules to Python projects:
package/cli/help.ts ADDED
@@ -0,0 +1,41 @@
1
+ import { BUILT_IN_PLUGINS } from "../plugins/index";
2
+ import { BUILT_IN_RULES } from "../rules/index";
3
+
4
+ export function printHelp(): void {
5
+ const pluginRules = new Set(
6
+ Object.values(BUILT_IN_PLUGINS).flatMap((p) => Object.keys(p.rules))
7
+ );
8
+ const standaloneRules = Object.keys(BUILT_IN_RULES).filter((r) => !pluginRules.has(r));
9
+ const pluginEntries = Object.entries(BUILT_IN_PLUGINS);
10
+
11
+ process.stdout.write(
12
+ [
13
+ "klint — agent harness for TypeScript architecture rules",
14
+ "",
15
+ "Usage: klint [--config <dir>] [--rules <file>] [--engine ts|rust|compare|auto] [--fix] [--json]",
16
+ " klint install-skill [--agents <list>] [--symlink | --copy]",
17
+ "",
18
+ " --config <dir> directory containing klint.yaml or klint.config.json (default: cwd)",
19
+ " --rules <file> custom rules file (default: <configDir>/klint.rules.ts if present)",
20
+ " --engine <name> engine to use: ts (default), rust, compare, or auto (experimental)",
21
+ " --fix apply auto-fixes for fixable violations in-place",
22
+ " --json emit structured JSON to stdout (for agent/CI consumption)",
23
+ " --debug print file resolution and rule progress to stderr",
24
+ "",
25
+ " install-skill install the rule-authoring skill into agent config directories",
26
+ " --agents <list> comma-separated: claude,opencode,cursor,codex (default: all)",
27
+ " --symlink install as symlink (stays in sync with updates)",
28
+ " --copy install as copy (default in non-TTY)",
29
+ "",
30
+ `Built-in rules (${standaloneRules.length}):`,
31
+ ...standaloneRules.map((r) => ` ${r}`),
32
+ "",
33
+ `Plugins (${pluginEntries.length}):`,
34
+ ...pluginEntries.flatMap(([name, plugin]) => [
35
+ ` ${name}`,
36
+ ...Object.keys(plugin.rules).map((r) => ` ${r}`),
37
+ ]),
38
+ "",
39
+ ].join("\n")
40
+ );
41
+ }
@@ -0,0 +1,102 @@
1
+ import { existsSync } from "node:fs";
2
+ import { cp, mkdir, rm, symlink } from "node:fs/promises";
3
+ import { dirname, join, relative, resolve } from "node:path";
4
+ import * as clack from "@clack/prompts";
5
+ import { PACKAGE_ROOT } from "./paths";
6
+
7
+ const AGENT_TARGETS = [
8
+ { value: "claude", label: "Claude Code" },
9
+ { value: "opencode", label: "opencode" },
10
+ { value: "cursor", label: "Cursor" },
11
+ { value: "codex", label: "Codex" },
12
+ ] as const;
13
+
14
+ type AgentKey = (typeof AGENT_TARGETS)[number]["value"];
15
+
16
+ const AGENT_DIRS: Record<AgentKey, string> = {
17
+ claude: ".claude/skills",
18
+ opencode: ".agents/skills",
19
+ cursor: ".cursor/skills",
20
+ codex: ".agents/skills",
21
+ };
22
+
23
+ export async function installSkill(args: string[]): Promise<void> {
24
+ const skillSrc = join(PACKAGE_ROOT, "skill", "klint-rules");
25
+ if (!existsSync(skillSrc)) {
26
+ process.stderr.write(`klint: skill source not found at ${skillSrc}\n`);
27
+ process.exit(1);
28
+ }
29
+
30
+ // Parse non-interactive flags
31
+ let flagAgents: AgentKey[] | undefined;
32
+ let flagSymlink: boolean | undefined;
33
+ for (let i = 0; i < args.length; i++) {
34
+ if (args[i] === "--agents" && args[i + 1])
35
+ flagAgents = args[++i].split(",") as AgentKey[];
36
+ else if (args[i] === "--symlink") flagSymlink = true;
37
+ else if (args[i] === "--copy") flagSymlink = false;
38
+ }
39
+
40
+ let selectedAgents: AgentKey[];
41
+ let useSymlink: boolean;
42
+
43
+ if (!process.stdin.isTTY || flagAgents !== undefined || flagSymlink !== undefined) {
44
+ selectedAgents = flagAgents ?? (AGENT_TARGETS.map((a) => a.value) as AgentKey[]);
45
+ useSymlink = flagSymlink ?? false;
46
+ } else {
47
+ clack.intro("klint install-skill");
48
+
49
+ const agents = await clack.multiselect<AgentKey>({
50
+ message: "Which agents should the skill be installed for?",
51
+ options: AGENT_TARGETS.map((a) => ({ value: a.value, label: a.label })),
52
+ initialValues: AGENT_TARGETS.map((a) => a.value) as AgentKey[],
53
+ });
54
+ if (clack.isCancel(agents)) {
55
+ clack.cancel("Cancelled.");
56
+ process.exit(0);
57
+ }
58
+ selectedAgents = agents as AgentKey[];
59
+
60
+ const mode = await clack.select<"symlink" | "copy">({
61
+ message: "Install as symlink or copy?",
62
+ options: [
63
+ {
64
+ value: "symlink",
65
+ label: "Symlink",
66
+ hint: "stays in sync when klint updates",
67
+ },
68
+ {
69
+ value: "copy",
70
+ label: "Copy",
71
+ hint: "one-time snapshot, no ongoing dependency",
72
+ },
73
+ ],
74
+ });
75
+ if (clack.isCancel(mode)) {
76
+ clack.cancel("Cancelled.");
77
+ process.exit(0);
78
+ }
79
+ useSymlink = mode === "symlink";
80
+ }
81
+
82
+ const cwd = process.cwd();
83
+ const linkType = process.platform === "win32" ? "junction" : "dir";
84
+ for (const key of selectedAgents) {
85
+ const dest = resolve(cwd, AGENT_DIRS[key], "klint-rules");
86
+ await mkdir(dirname(dest), { recursive: true });
87
+ try {
88
+ await rm(dest, { recursive: true, force: true });
89
+ } catch {
90
+ /* already gone */
91
+ }
92
+ if (useSymlink) {
93
+ await symlink(relative(dirname(dest), skillSrc), dest, linkType);
94
+ } else {
95
+ await cp(skillSrc, dest, { recursive: true });
96
+ }
97
+ }
98
+
99
+ if (process.stdin.isTTY) {
100
+ clack.outro("Done.");
101
+ }
102
+ }
package/cli/output.ts ADDED
@@ -0,0 +1,159 @@
1
+ import type { KlintDebugEvent, runKlint } from "../core/runner";
2
+ import type { Violation } from "../core/types";
3
+
4
+ export function formatDuration(startedAt: number): string {
5
+ const elapsedMs = performance.now() - startedAt;
6
+ if (elapsedMs < 1000) return `${Math.round(elapsedMs)}ms`;
7
+ return `${(elapsedMs / 1000).toFixed(elapsedMs < 10_000 ? 2 : 1)}s`;
8
+ }
9
+
10
+ export function writeTextOutput(
11
+ violations: Array<Omit<Violation, "fix">>,
12
+ startedAt: number
13
+ ): never {
14
+ const errors = violations.filter((v) => v.severity === "error");
15
+ const warns = violations.filter((v) => v.severity === "warn");
16
+
17
+ if (errors.length === 0 && warns.length === 0) {
18
+ process.stdout.write(`klint: 0 violations in ${formatDuration(startedAt)}\n`);
19
+ process.exit(0);
20
+ }
21
+
22
+ const formatBlock = (v: (typeof violations)[number]) => {
23
+ const prefix = v.severity === "warn" ? "⚠" : "×";
24
+ const header = `${v.file}:${v.line} [${v.rule}]`;
25
+ const sep = "━".repeat(Math.max(0, 80 - header.length));
26
+ return `${header} ${sep}\n\n ${prefix} ${v.message}\n`;
27
+ };
28
+
29
+ if (warns.length > 0) {
30
+ process.stderr.write(
31
+ `klint: ${warns.length} warning(s)\n\n${warns.map(formatBlock).join("\n")}`
32
+ );
33
+ }
34
+ if (errors.length > 0) {
35
+ process.stderr.write(
36
+ `klint: ${errors.length} error(s)\n\n${errors.map(formatBlock).join("\n")}`
37
+ );
38
+ process.stderr.write(`\nklint: finished in ${formatDuration(startedAt)}\n`);
39
+ process.exit(2);
40
+ }
41
+ process.stderr.write(`\nklint: finished in ${formatDuration(startedAt)}\n`);
42
+ process.exit(0);
43
+ }
44
+
45
+ export interface JsonPayload {
46
+ violations: Array<Omit<Violation, "fix"> & { fix: unknown }>;
47
+ summary: {
48
+ errors: number;
49
+ warnings: number;
50
+ };
51
+ }
52
+
53
+ export function emptyJsonPayload(): JsonPayload {
54
+ return {
55
+ violations: [],
56
+ summary: { errors: 0, warnings: 0 },
57
+ };
58
+ }
59
+
60
+ export function toJsonPayload(violations: ReturnType<typeof runKlint>): JsonPayload {
61
+ const errors = violations.filter((v) => v.severity === "error");
62
+ return {
63
+ violations: violations.map((v) => ({ ...v, fix: v.fix ?? null })),
64
+ summary: { errors: errors.length, warnings: violations.length - errors.length },
65
+ };
66
+ }
67
+
68
+ export function mergeJsonOutputs(...outputs: JsonPayload[]): JsonPayload {
69
+ const violations = outputs
70
+ .flatMap((output) => output.violations)
71
+ .sort(
72
+ (a, b) =>
73
+ a.file.localeCompare(b.file) ||
74
+ a.line - b.line ||
75
+ a.rule.localeCompare(b.rule) ||
76
+ a.message.localeCompare(b.message)
77
+ );
78
+ const errors = violations.filter((violation) => violation.severity === "error").length;
79
+ return {
80
+ violations,
81
+ summary: {
82
+ errors,
83
+ warnings: violations.length - errors,
84
+ },
85
+ };
86
+ }
87
+
88
+ export function isJsonPayload(value: unknown): value is JsonPayload {
89
+ if (!value || typeof value !== "object") return false;
90
+ const candidate = value as Partial<JsonPayload>;
91
+ return Array.isArray(candidate.violations) && typeof candidate.summary === "object";
92
+ }
93
+
94
+ export function toJsonCliResult(violations: ReturnType<typeof runKlint>): {
95
+ stdout: string;
96
+ status: number;
97
+ } {
98
+ const payload = toJsonPayload(violations);
99
+ return {
100
+ stdout: JSON.stringify(payload),
101
+ status: payload.summary.errors > 0 ? 2 : 0,
102
+ };
103
+ }
104
+
105
+ export function writeDebugEvent(event: KlintDebugEvent): void {
106
+ switch (event.type) {
107
+ case "walk:start":
108
+ process.stderr.write(`[klint:debug] walk ${event.dir}\n`);
109
+ break;
110
+ case "walk:done":
111
+ process.stderr.write(`[klint:debug] walked ${event.dir} (${event.files} files)\n`);
112
+ break;
113
+ case "files:resolved":
114
+ process.stderr.write(`[klint:debug] resolved ${event.files} files\n`);
115
+ break;
116
+ case "rule:start":
117
+ process.stderr.write(
118
+ `[klint:debug] rule ${event.rule} start (${event.files} files)\n`
119
+ );
120
+ break;
121
+ case "rule:done":
122
+ process.stderr.write(
123
+ `[klint:debug] rule ${event.rule} done (${event.violations} violations)\n`
124
+ );
125
+ break;
126
+ case "arch:start":
127
+ process.stderr.write(`[klint:debug] arch start (${event.files} files)\n`);
128
+ break;
129
+ case "arch:done":
130
+ process.stderr.write(`[klint:debug] arch done (${event.violations} violations)\n`);
131
+ break;
132
+ }
133
+ }
134
+
135
+ export function parseJsonPayload(value: string): unknown {
136
+ try {
137
+ return JSON.parse(value);
138
+ } catch {
139
+ return undefined;
140
+ }
141
+ }
142
+
143
+ export function jsonPayloadEquals(a: unknown, b: unknown): boolean {
144
+ return JSON.stringify(canonicalJson(a)) === JSON.stringify(canonicalJson(b));
145
+ }
146
+
147
+ function canonicalJson(value: unknown): unknown {
148
+ if (Array.isArray(value)) {
149
+ return value
150
+ .map(canonicalJson)
151
+ .sort((left, right) => JSON.stringify(left).localeCompare(JSON.stringify(right)));
152
+ }
153
+ if (!value || typeof value !== "object") return value;
154
+ return Object.fromEntries(
155
+ Object.entries(value)
156
+ .sort(([left], [right]) => left.localeCompare(right))
157
+ .map(([key, entry]) => [key, canonicalJson(entry)])
158
+ );
159
+ }
package/cli/paths.ts ADDED
@@ -0,0 +1,3 @@
1
+ import { resolve } from "node:path";
2
+
3
+ export const PACKAGE_ROOT = resolve(import.meta.dir, "..");
@@ -0,0 +1,144 @@
1
+ import { existsSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { resolveNativePackageBinary } from "../core/native-binary";
4
+ import type { RuleConfigValue } from "../core/types";
5
+ import { BUILT_IN_PLUGINS } from "../plugins/index";
6
+ import { PACKAGE_ROOT } from "./paths";
7
+
8
+ const RUST_SUPPORTED_RULES = new Set([
9
+ "no-consecutive-array-push",
10
+ "no-nested-template-literals",
11
+ "no-string-match",
12
+ "no-sync-in-async",
13
+ "no-unguarded-json-parse",
14
+ "sonar/no-single-char-class",
15
+ "sonar/prefer-at",
16
+ "sonar/prefer-nullish-coalescing-assign",
17
+ "sonar/prefer-string-replaceall",
18
+ "sonar/prefer-string-raw",
19
+ "sonar/prefer-string-raw-regexp",
20
+ ]);
21
+
22
+ export function splitRulesForAuto(rules: Record<string, RuleConfigValue>): {
23
+ rustRules: Record<string, RuleConfigValue>;
24
+ tsRules: Record<string, RuleConfigValue>;
25
+ } {
26
+ const rustRules: Record<string, RuleConfigValue> = {};
27
+ const tsRules: Record<string, RuleConfigValue> = {};
28
+
29
+ for (const [name, value] of Object.entries(rules)) {
30
+ if (RUST_SUPPORTED_RULES.has(name)) rustRules[name] = value;
31
+ else tsRules[name] = value;
32
+ }
33
+
34
+ return { rustRules, tsRules };
35
+ }
36
+
37
+ export interface RustEngineCommand {
38
+ bin: string;
39
+ args: string[];
40
+ }
41
+
42
+ export function resolveRustEngineCommand(configDir: string): RustEngineCommand {
43
+ const args = ["--config", configDir, "--json"];
44
+ const explicitBin = process.env.KLINT_RUST_BIN;
45
+ if (explicitBin) {
46
+ return { bin: explicitBin, args };
47
+ }
48
+
49
+ const localBin = join(
50
+ PACKAGE_ROOT,
51
+ "target",
52
+ "debug",
53
+ process.platform === "win32" ? "klint-rs.exe" : "klint-rs"
54
+ );
55
+ if (isSourceCheckout() && existsSync(localBin)) {
56
+ return { bin: localBin, args };
57
+ }
58
+
59
+ const nativePackageBin = resolveNativePackageBinary({ packageRoot: PACKAGE_ROOT });
60
+ if (nativePackageBin) {
61
+ return { bin: nativePackageBin, args };
62
+ }
63
+
64
+ if (!isSourceCheckout()) {
65
+ process.stderr.write(
66
+ "klint: native Rust engine binary is unavailable for this package version\n"
67
+ );
68
+ process.exit(1);
69
+ }
70
+
71
+ return {
72
+ bin: "cargo",
73
+ args: ["run", "--quiet", "-p", "klint-rs", "--", ...args],
74
+ };
75
+ }
76
+
77
+ function isSourceCheckout(): boolean {
78
+ return existsSync(join(PACKAGE_ROOT, "crates", "klint-rs", "Cargo.toml"));
79
+ }
80
+
81
+ export function rustEngineUnsupportedReason({
82
+ fix,
83
+ raw,
84
+ rulesFile,
85
+ }: {
86
+ fix: boolean;
87
+ raw: {
88
+ plugins?: string[];
89
+ rules?: Record<string, RuleConfigValue>;
90
+ arch?: unknown;
91
+ };
92
+ rulesFile?: string;
93
+ }): string | undefined {
94
+ if (fix) return "KLINT_ENGINE=rust does not support --fix";
95
+ if (rulesFile) return "KLINT_ENGINE=rust does not support --rules";
96
+ let effectiveRules: Record<string, RuleConfigValue>;
97
+ try {
98
+ effectiveRules = resolveEffectiveRules(raw.plugins, raw.rules ?? {});
99
+ } catch (error) {
100
+ return error instanceof Error
101
+ ? error.message
102
+ : "KLINT_ENGINE=rust failed to load plugins";
103
+ }
104
+ const activeSupportedRules = Object.entries(effectiveRules).filter(
105
+ ([name, value]) =>
106
+ ruleConfigSeverity(value) !== "off" && RUST_SUPPORTED_RULES.has(name)
107
+ );
108
+ const unsupportedRules = Object.entries(effectiveRules)
109
+ .filter(
110
+ ([name, value]) =>
111
+ ruleConfigSeverity(value) !== "off" && !RUST_SUPPORTED_RULES.has(name)
112
+ )
113
+ .map(([name]) => name);
114
+ if (!raw.arch && activeSupportedRules.length === 0 && unsupportedRules.length === 0) {
115
+ return "KLINT_ENGINE=rust requires an arch config or supported rule";
116
+ }
117
+ if (unsupportedRules.length > 0) {
118
+ return [
119
+ "Rust engine currently supports arch rules and selected rules only",
120
+ "",
121
+ "Unsupported rules:",
122
+ ...unsupportedRules.map((rule) => `- ${rule}`),
123
+ ].join("\n");
124
+ }
125
+ return undefined;
126
+ }
127
+
128
+ export function resolveEffectiveRules(
129
+ plugins: string[] | undefined,
130
+ rules: Record<string, RuleConfigValue>
131
+ ): Record<string, RuleConfigValue> {
132
+ const pluginDefaults: Record<string, RuleConfigValue> = {};
133
+ for (const pluginName of plugins ?? []) {
134
+ const plugin = BUILT_IN_PLUGINS[pluginName];
135
+ if (!plugin) throw new Error(`Unknown klint plugin: "${pluginName}"`);
136
+ Object.assign(pluginDefaults, plugin.rules);
137
+ }
138
+ return { ...pluginDefaults, ...rules };
139
+ }
140
+
141
+ export function ruleConfigSeverity(value: RuleConfigValue): string | undefined {
142
+ if (typeof value === "string") return value;
143
+ return value.severity;
144
+ }