@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 CHANGED
@@ -98,6 +98,7 @@ klint is for rules that are too project-specific or context-heavy for a formatte
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
100
  | Raw JSX elements | `<button>` is blocked outside `src/components/ui/**` |
101
+ | File length | No file in `src/**` may exceed 300 lines |
101
102
  | Type-aware mistakes | Promise-returning calls cannot be silently discarded |
102
103
  | Repository policy | Custom `klint.rules.ts` rules run with the same severity/config system |
103
104
 
@@ -147,7 +148,7 @@ klint --engine compare --json # parity check; rejects unsupported rules
147
148
 
148
149
  Rust currently supports:
149
150
 
150
- - `arch` rules: imports, forbidden patterns, singleton locations
151
+ - `arch` rules: imports, forbidden patterns (literal + `re:` regex), singleton locations, `maxLines` file limits
151
152
  - `no-unguarded-json-parse`
152
153
  - `no-sync-in-async`
153
154
  - `no-nested-template-literals`
@@ -274,6 +275,18 @@ arch:
274
275
  message: "Libraries should return or throw, not terminate the process"
275
276
  ```
276
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
+
277
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>`:
278
291
 
279
292
  ```yaml
@@ -299,6 +312,8 @@ arch:
299
312
  message: "Use the auth module"
300
313
  ```
301
314
 
315
+ Like `forbidden`, a singleton `pattern` may use the `re:` regex prefix for value-shaped matches.
316
+
302
317
  `singleton` accepts `jsx-element` too — pin a raw element to the one primitive file that is allowed to render it:
303
318
 
304
319
  ```yaml
@@ -310,6 +325,23 @@ arch:
310
325
  message: "Raw <button> belongs only to the Button primitive"
311
326
  ```
312
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
+
313
345
  ### Python Architecture Checks
314
346
 
315
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
+ }