@konvert7/klint 0.16.0 → 0.18.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/cli.ts CHANGED
@@ -18,6 +18,13 @@ import type { ArchConfig, KlintConfig, KlintRule, RuleConfigValue } from "./core
18
18
  import { BUILT_IN_PLUGINS } from "./plugins/index";
19
19
  import { BUILT_IN_RULES } from "./rules/index";
20
20
 
21
+ const RUST_SUPPORTED_TYPESCRIPT_RULES = new Set([
22
+ "no-consecutive-array-push",
23
+ "no-nested-template-literals",
24
+ "no-string-match",
25
+ "no-unguarded-json-parse",
26
+ ]);
27
+
21
28
  interface CliOptions {
22
29
  configDir?: string;
23
30
  rulesFile?: string;
@@ -42,10 +49,12 @@ export async function main(opts: CliOptions = {}): Promise<void> {
42
49
  let fix = false;
43
50
  let json = false;
44
51
  let debug = false;
52
+ let engine = process.env.KLINT_ENGINE;
45
53
 
46
54
  for (let i = 0; i < args.length; i++) {
47
55
  if (args[i] === "--config" && args[i + 1]) configDir = resolve(args[++i]);
48
56
  else if (args[i] === "--rules" && args[i + 1]) rulesFile = resolve(args[++i]);
57
+ else if (args[i] === "--engine" && args[i + 1]) engine = args[++i];
49
58
  else if (args[i] === "--fix") fix = true;
50
59
  else if (args[i] === "--json") json = true;
51
60
  else if (args[i] === "--debug" || args[i] === "-debug") debug = true;
@@ -85,12 +94,17 @@ export async function main(opts: CliOptions = {}): Promise<void> {
85
94
  process.exit(1);
86
95
  }
87
96
  const root = resolve(configDir, raw.root ?? ".");
88
- const engine = process.env.KLINT_ENGINE;
89
97
 
90
98
  if (engine === "rust") {
91
99
  runRustEngine({ configDir, fix, json, raw, rulesFile });
92
100
  return;
93
101
  }
102
+ if (engine !== undefined && engine !== "ts" && engine !== "compare") {
103
+ process.stderr.write(
104
+ `klint: unknown engine "${engine}" (expected "ts", "rust", or "compare")\n`
105
+ );
106
+ process.exit(1);
107
+ }
94
108
 
95
109
  let customRules: Record<string, KlintRule> = {};
96
110
  const defaultRulesPath = resolve(configDir, "klint.rules.ts");
@@ -118,15 +132,15 @@ export async function main(opts: CliOptions = {}): Promise<void> {
118
132
  { onDebug: debug ? writeDebugEvent : undefined }
119
133
  );
120
134
 
135
+ if (engine === "compare") {
136
+ runCompareEngine({ configDir, fix, json, raw, rulesFile, tsViolations: violations });
137
+ return;
138
+ }
139
+
121
140
  if (json) {
122
- const errors = violations.filter((v) => v.severity === "error");
123
- process.stdout.write(
124
- JSON.stringify({
125
- violations: violations.map((v) => ({ ...v, fix: v.fix ?? null })),
126
- summary: { errors: errors.length, warnings: violations.length - errors.length },
127
- })
128
- );
129
- process.exit(errors.length > 0 ? 2 : 0);
141
+ const tsResult = toJsonCliResult(violations);
142
+ process.stdout.write(tsResult.stdout);
143
+ process.exit(tsResult.status);
130
144
  }
131
145
 
132
146
  if (fix) {
@@ -240,6 +254,83 @@ function runRustEngine({
240
254
  process.exit(result.status ?? 1);
241
255
  }
242
256
 
257
+ function runCompareEngine({
258
+ configDir,
259
+ fix,
260
+ json,
261
+ raw,
262
+ rulesFile,
263
+ tsViolations,
264
+ }: {
265
+ configDir: string;
266
+ fix: boolean;
267
+ json: boolean;
268
+ raw: {
269
+ plugins?: string[];
270
+ rules?: Record<string, RuleConfigValue>;
271
+ arch?: unknown;
272
+ };
273
+ rulesFile?: string;
274
+ tsViolations: ReturnType<typeof runKlint>;
275
+ }): void {
276
+ const unsupportedReason = rustEngineUnsupportedReason({
277
+ fix,
278
+ json,
279
+ raw,
280
+ rulesFile,
281
+ });
282
+
283
+ if (unsupportedReason) {
284
+ process.stderr.write(`klint: ${unsupportedReason}\n`);
285
+ process.exit(1);
286
+ }
287
+
288
+ const tsResult = toJsonCliResult(tsViolations);
289
+ const rustCommand = resolveRustEngineCommand(configDir);
290
+ const rustResult = spawnSync(rustCommand.bin, rustCommand.args, {
291
+ cwd: import.meta.dir,
292
+ encoding: "utf-8",
293
+ });
294
+
295
+ const rustStatus = rustResult.status ?? 1;
296
+ if (rustResult.stderr) process.stderr.write(rustResult.stderr);
297
+
298
+ const tsPayload = parseJsonPayload(tsResult.stdout);
299
+ const rustPayload = parseJsonPayload(rustResult.stdout);
300
+
301
+ if (
302
+ rustStatus !== tsResult.status ||
303
+ tsPayload === undefined ||
304
+ rustPayload === undefined ||
305
+ !jsonPayloadEquals(tsPayload, rustPayload)
306
+ ) {
307
+ const mismatchLines = [
308
+ "klint: compare engine mismatch",
309
+ `TypeScript exit: ${tsResult.status}`,
310
+ `Rust exit: ${rustStatus}`,
311
+ ];
312
+ process.stderr.write(`${mismatchLines.join("\n")}\n`);
313
+ process.exit(1);
314
+ }
315
+
316
+ process.stdout.write(tsResult.stdout);
317
+ process.exit(tsResult.status);
318
+ }
319
+
320
+ function toJsonCliResult(violations: ReturnType<typeof runKlint>): {
321
+ stdout: string;
322
+ status: number;
323
+ } {
324
+ const errors = violations.filter((v) => v.severity === "error");
325
+ return {
326
+ stdout: JSON.stringify({
327
+ violations: violations.map((v) => ({ ...v, fix: v.fix ?? null })),
328
+ summary: { errors: errors.length, warnings: violations.length - errors.length },
329
+ }),
330
+ status: errors.length > 0 ? 2 : 0,
331
+ };
332
+ }
333
+
243
334
  function writeDebugEvent(event: KlintDebugEvent): void {
244
335
  switch (event.type) {
245
336
  case "walk:start":
@@ -282,27 +373,31 @@ function resolveRustEngineCommand(configDir: string): RustEngineCommand {
282
373
  return { bin: explicitBin, args };
283
374
  }
284
375
 
285
- const nativePackageBin = resolveNativePackageBinary({ packageRoot: import.meta.dir });
286
- if (nativePackageBin) {
287
- return { bin: nativePackageBin, args };
288
- }
289
-
290
376
  const localBin = join(
291
377
  import.meta.dir,
292
378
  "target",
293
379
  "debug",
294
380
  process.platform === "win32" ? "klint-rs.exe" : "klint-rs"
295
381
  );
296
- if (existsSync(localBin)) {
382
+ if (isSourceCheckout() && existsSync(localBin)) {
297
383
  return { bin: localBin, args };
298
384
  }
299
385
 
386
+ const nativePackageBin = resolveNativePackageBinary({ packageRoot: import.meta.dir });
387
+ if (nativePackageBin) {
388
+ return { bin: nativePackageBin, args };
389
+ }
390
+
300
391
  return {
301
392
  bin: "cargo",
302
393
  args: ["run", "--quiet", "-p", "klint-rs", "--", ...args],
303
394
  };
304
395
  }
305
396
 
397
+ function isSourceCheckout(): boolean {
398
+ return existsSync(join(import.meta.dir, "crates", "klint-rs", "Cargo.toml"));
399
+ }
400
+
306
401
  function rustEngineUnsupportedReason({
307
402
  fix,
308
403
  json,
@@ -321,14 +416,58 @@ function rustEngineUnsupportedReason({
321
416
  if (!json) return "KLINT_ENGINE=rust currently requires --json";
322
417
  if (fix) return "KLINT_ENGINE=rust does not support --fix";
323
418
  if (rulesFile) return "KLINT_ENGINE=rust does not support --rules";
324
- if (!raw.arch) return "KLINT_ENGINE=rust requires an arch config";
325
419
  if ((raw.plugins?.length ?? 0) > 0) return "KLINT_ENGINE=rust does not support plugins";
326
- if (Object.values(raw.rules ?? {}).some((value) => value !== "off")) {
327
- return "KLINT_ENGINE=rust does not support TypeScript rules";
420
+ const activeSupportedRules = Object.entries(raw.rules ?? {}).filter(
421
+ ([name, value]) =>
422
+ ruleConfigSeverity(value) !== "off" && RUST_SUPPORTED_TYPESCRIPT_RULES.has(name)
423
+ );
424
+ const unsupportedRules = Object.entries(raw.rules ?? {})
425
+ .filter(
426
+ ([name, value]) =>
427
+ ruleConfigSeverity(value) !== "off" && !RUST_SUPPORTED_TYPESCRIPT_RULES.has(name)
428
+ )
429
+ .map(([name]) => name);
430
+ if (!raw.arch && activeSupportedRules.length === 0 && unsupportedRules.length === 0) {
431
+ return "KLINT_ENGINE=rust requires an arch config or supported TypeScript rule";
432
+ }
433
+ if (unsupportedRules.length > 0) {
434
+ return [
435
+ "Rust engine currently supports arch rules and selected TypeScript rules only",
436
+ "",
437
+ "Unsupported TypeScript rules:",
438
+ ...unsupportedRules.map((rule) => `- ${rule}`),
439
+ ].join("\n");
328
440
  }
329
441
  return undefined;
330
442
  }
331
443
 
444
+ function ruleConfigSeverity(value: RuleConfigValue): string | undefined {
445
+ if (typeof value === "string") return value;
446
+ return value.severity;
447
+ }
448
+
449
+ function parseJsonPayload(value: string): unknown {
450
+ try {
451
+ return JSON.parse(value);
452
+ } catch {
453
+ return undefined;
454
+ }
455
+ }
456
+
457
+ function jsonPayloadEquals(a: unknown, b: unknown): boolean {
458
+ return JSON.stringify(canonicalJson(a)) === JSON.stringify(canonicalJson(b));
459
+ }
460
+
461
+ function canonicalJson(value: unknown): unknown {
462
+ if (Array.isArray(value)) return value.map(canonicalJson);
463
+ if (!value || typeof value !== "object") return value;
464
+ return Object.fromEntries(
465
+ Object.entries(value)
466
+ .sort(([left], [right]) => left.localeCompare(right))
467
+ .map(([key, entry]) => [key, canonicalJson(entry)])
468
+ );
469
+ }
470
+
332
471
  const AGENT_TARGETS = [
333
472
  { value: "claude", label: "Claude Code" },
334
473
  { value: "opencode", label: "opencode" },
@@ -437,11 +576,12 @@ function printHelp(): void {
437
576
  [
438
577
  "klint — agent harness for TypeScript architecture rules",
439
578
  "",
440
- "Usage: klint [--config <dir>] [--rules <file>] [--fix] [--json]",
579
+ "Usage: klint [--config <dir>] [--rules <file>] [--engine ts|rust|compare] [--fix] [--json]",
441
580
  " klint install-skill [--agents <list>] [--symlink | --copy]",
442
581
  "",
443
582
  " --config <dir> directory containing klint.yaml or klint.config.json (default: cwd)",
444
583
  " --rules <file> custom rules file (default: <configDir>/klint.rules.ts if present)",
584
+ " --engine <name> engine to use: ts (default), rust, or compare (experimental, requires --json)",
445
585
  " --fix apply auto-fixes for fixable violations in-place",
446
586
  " --json emit structured JSON to stdout (for agent/CI consumption)",
447
587
  " --debug print file resolution and rule progress to stderr",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@konvert7/klint",
3
- "version": "0.16.0",
3
+ "version": "0.18.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": {
@@ -26,7 +26,7 @@ export const noConsecutiveArrayPush: KlintRule = {
26
26
  statements[runStart].getStart()
27
27
  );
28
28
  violations.push({
29
- file: relative(root, file),
29
+ file: relative(root, file).replaceAll("\\", "/"),
30
30
  line: line + 1,
31
31
  message: `${upTo - runStart} consecutive .push() calls on \`${runReceiver}\` — combine into a single .push(a, b, …) call.`,
32
32
  });
@@ -32,7 +32,7 @@ function findNestedTemplate(
32
32
  if (ts.isTemplateExpression(node) || ts.isNoSubstitutionTemplateLiteral(node)) {
33
33
  const { line } = src.getLineAndCharacterOfPosition(node.getStart());
34
34
  violations.push({
35
- file: relative(root, file),
35
+ file: relative(root, file).replaceAll("\\", "/"),
36
36
  line: line + 1,
37
37
  message:
38
38
  "Nested template literal — extract the inner template to a variable to improve readability.",
@@ -45,7 +45,7 @@ export const noStringMatch: KlintRule = {
45
45
  })()
46
46
  : undefined;
47
47
  violations.push({
48
- file: relative(root, file),
48
+ file: relative(root, file).replaceAll("\\", "/"),
49
49
  line: s + 1,
50
50
  message: `Use RegExp.exec() instead of String.match() for non-global regexes — use new RegExp(${regexText}).exec(${strText}) instead.`,
51
51
  fix,
@@ -23,7 +23,7 @@ export const noUnguardedJsonParse: KlintRule = {
23
23
  ) {
24
24
  const { line } = src.getLineAndCharacterOfPosition(node.getStart());
25
25
  violations.push({
26
- file: relative(root, file),
26
+ file: relative(root, file).replaceAll("\\", "/"),
27
27
  line: line + 1,
28
28
  message:
29
29
  "JSON.parse() called without a surrounding try/catch — a malformed payload will throw an unhandled exception.",