@konvert7/klint 0.17.0 → 0.19.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
@@ -4,21 +4,35 @@ import {
4
4
  cpSync,
5
5
  existsSync,
6
6
  mkdirSync,
7
+ mkdtempSync,
7
8
  readFileSync,
8
9
  rmSync,
9
10
  symlinkSync,
11
+ writeFileSync,
10
12
  } from "node:fs";
13
+ import { tmpdir } from "node:os";
11
14
  import { dirname, join, relative, resolve } from "node:path";
12
15
  import * as clack from "@clack/prompts";
13
16
  import { parse as parseYaml } from "yaml";
14
17
  import { applyFixes } from "./core/fixer";
15
18
  import { resolveNativePackageBinary } from "./core/native-binary";
16
19
  import { type KlintDebugEvent, runKlint } from "./core/runner";
17
- import type { ArchConfig, KlintConfig, KlintRule, RuleConfigValue } from "./core/types";
20
+ import type {
21
+ ArchConfig,
22
+ KlintConfig,
23
+ KlintRule,
24
+ RuleConfigValue,
25
+ Violation,
26
+ } from "./core/types";
18
27
  import { BUILT_IN_PLUGINS } from "./plugins/index";
19
28
  import { BUILT_IN_RULES } from "./rules/index";
20
29
 
21
- const RUST_SUPPORTED_TYPESCRIPT_RULES = new Set(["no-string-match"]);
30
+ const RUST_SUPPORTED_TYPESCRIPT_RULES = new Set([
31
+ "no-consecutive-array-push",
32
+ "no-nested-template-literals",
33
+ "no-string-match",
34
+ "no-unguarded-json-parse",
35
+ ]);
22
36
 
23
37
  interface CliOptions {
24
38
  configDir?: string;
@@ -91,12 +105,18 @@ export async function main(opts: CliOptions = {}): Promise<void> {
91
105
  const root = resolve(configDir, raw.root ?? ".");
92
106
 
93
107
  if (engine === "rust") {
94
- runRustEngine({ configDir, fix, json, raw, rulesFile });
108
+ runRustEngine({ configDir, fix, json, raw, rulesFile, startedAt });
95
109
  return;
96
110
  }
97
- if (engine !== undefined && engine !== "ts" && engine !== "compare") {
111
+ if (
112
+ engine !== undefined &&
113
+ engine !== "ts" &&
114
+ engine !== "rust" &&
115
+ engine !== "compare" &&
116
+ engine !== "auto"
117
+ ) {
98
118
  process.stderr.write(
99
- `klint: unknown engine "${engine}" (expected "ts", "rust", or "compare")\n`
119
+ `klint: unknown engine "${engine}" (expected "ts", "rust", "compare", or "auto")\n`
100
120
  );
101
121
  process.exit(1);
102
122
  }
@@ -115,6 +135,20 @@ export async function main(opts: CliOptions = {}): Promise<void> {
115
135
  );
116
136
  const allRules: KlintConfig["rules"] = { ...customRulesMap, ...(raw.rules ?? {}) };
117
137
 
138
+ if (engine === "auto") {
139
+ runAutoEngine({
140
+ fix,
141
+ json,
142
+ raw,
143
+ root,
144
+ rulesFile,
145
+ startedAt,
146
+ customRules,
147
+ customRulesMap,
148
+ });
149
+ return;
150
+ }
151
+
118
152
  const violations = runKlint(
119
153
  {
120
154
  root,
@@ -166,7 +200,19 @@ export async function main(opts: CliOptions = {}): Promise<void> {
166
200
  process.stdout.write(`${msg} Finished in ${formatDuration(startedAt)}.\n`);
167
201
  process.exit(0);
168
202
  }
203
+ writeTextOutput(violations, startedAt);
204
+ }
205
+
206
+ function formatDuration(startedAt: number): string {
207
+ const elapsedMs = performance.now() - startedAt;
208
+ if (elapsedMs < 1000) return `${Math.round(elapsedMs)}ms`;
209
+ return `${(elapsedMs / 1000).toFixed(elapsedMs < 10_000 ? 2 : 1)}s`;
210
+ }
169
211
 
212
+ function writeTextOutput(
213
+ violations: Array<Omit<Violation, "fix">>,
214
+ startedAt: number
215
+ ): never {
170
216
  const errors = violations.filter((v) => v.severity === "error");
171
217
  const warns = violations.filter((v) => v.severity === "warn");
172
218
 
@@ -198,18 +244,13 @@ export async function main(opts: CliOptions = {}): Promise<void> {
198
244
  process.exit(0);
199
245
  }
200
246
 
201
- function formatDuration(startedAt: number): string {
202
- const elapsedMs = performance.now() - startedAt;
203
- if (elapsedMs < 1000) return `${Math.round(elapsedMs)}ms`;
204
- return `${(elapsedMs / 1000).toFixed(elapsedMs < 10_000 ? 2 : 1)}s`;
205
- }
206
-
207
247
  function runRustEngine({
208
248
  configDir,
209
249
  fix,
210
250
  json,
211
251
  raw,
212
252
  rulesFile,
253
+ startedAt,
213
254
  }: {
214
255
  configDir: string;
215
256
  fix: boolean;
@@ -220,10 +261,10 @@ function runRustEngine({
220
261
  arch?: unknown;
221
262
  };
222
263
  rulesFile?: string;
264
+ startedAt: number;
223
265
  }): void {
224
266
  const unsupportedReason = rustEngineUnsupportedReason({
225
267
  fix,
226
- json,
227
268
  raw,
228
269
  rulesFile,
229
270
  });
@@ -244,9 +285,18 @@ function runRustEngine({
244
285
  encoding: "utf-8",
245
286
  });
246
287
 
247
- if (result.stdout) process.stdout.write(result.stdout);
248
288
  if (result.stderr) process.stderr.write(result.stderr);
249
- process.exit(result.status ?? 1);
289
+ if (json) {
290
+ if (result.stdout) process.stdout.write(result.stdout);
291
+ process.exit(result.status ?? 1);
292
+ }
293
+
294
+ const payload = parseJsonPayload(result.stdout);
295
+ if (!isJsonPayload(payload)) {
296
+ process.stderr.write("klint: --engine rust failed to parse Rust JSON output\n");
297
+ process.exit(1);
298
+ }
299
+ writeTextOutput(payload.violations, startedAt);
250
300
  }
251
301
 
252
302
  function runCompareEngine({
@@ -268,9 +318,13 @@ function runCompareEngine({
268
318
  rulesFile?: string;
269
319
  tsViolations: ReturnType<typeof runKlint>;
270
320
  }): void {
321
+ if (!json) {
322
+ process.stderr.write("klint: --engine compare currently requires --json\n");
323
+ process.exit(1);
324
+ }
325
+
271
326
  const unsupportedReason = rustEngineUnsupportedReason({
272
327
  fix,
273
- json,
274
328
  raw,
275
329
  rulesFile,
276
330
  });
@@ -312,17 +366,190 @@ function runCompareEngine({
312
366
  process.exit(tsResult.status);
313
367
  }
314
368
 
369
+ function runAutoEngine({
370
+ fix,
371
+ json,
372
+ raw,
373
+ root,
374
+ rulesFile,
375
+ startedAt,
376
+ customRules,
377
+ customRulesMap,
378
+ }: {
379
+ fix: boolean;
380
+ json: boolean;
381
+ raw: {
382
+ root?: string;
383
+ include?: string[];
384
+ plugins?: string[];
385
+ rules?: Record<string, RuleConfigValue>;
386
+ arch?: unknown;
387
+ };
388
+ root: string;
389
+ rulesFile?: string;
390
+ startedAt: number;
391
+ customRules: Record<string, KlintRule>;
392
+ customRulesMap: Record<string, RuleConfigValue>;
393
+ }): void {
394
+ if (fix) {
395
+ process.stderr.write("klint: --engine auto does not support --fix\n");
396
+ process.exit(1);
397
+ }
398
+ if (rulesFile) {
399
+ process.stderr.write("klint: --engine auto does not support --rules\n");
400
+ process.exit(1);
401
+ }
402
+
403
+ const { rustRules, tsRules } = splitRulesForAuto(raw.rules ?? {});
404
+ const tsViolations = runKlint(
405
+ {
406
+ root,
407
+ include: raw.include ?? ["."],
408
+ plugins: raw.plugins,
409
+ rules: { ...customRulesMap, ...tsRules },
410
+ },
411
+ customRules
412
+ );
413
+
414
+ const rustOutput = runAutoRustSubset({
415
+ raw,
416
+ root,
417
+ rustRules,
418
+ });
419
+ const tsOutput = toJsonPayload(tsViolations);
420
+ const merged = mergeJsonOutputs(rustOutput, tsOutput);
421
+ if (json) {
422
+ process.stdout.write(JSON.stringify(merged));
423
+ process.exit(merged.summary.errors > 0 ? 2 : 0);
424
+ }
425
+ writeTextOutput(merged.violations, startedAt);
426
+ }
427
+
428
+ function splitRulesForAuto(rules: Record<string, RuleConfigValue>): {
429
+ rustRules: Record<string, RuleConfigValue>;
430
+ tsRules: Record<string, RuleConfigValue>;
431
+ } {
432
+ const rustRules: Record<string, RuleConfigValue> = {};
433
+ const tsRules: Record<string, RuleConfigValue> = {};
434
+
435
+ for (const [name, value] of Object.entries(rules)) {
436
+ if (RUST_SUPPORTED_TYPESCRIPT_RULES.has(name)) rustRules[name] = value;
437
+ else tsRules[name] = value;
438
+ }
439
+
440
+ return { rustRules, tsRules };
441
+ }
442
+
443
+ function runAutoRustSubset({
444
+ raw,
445
+ root,
446
+ rustRules,
447
+ }: {
448
+ raw: {
449
+ include?: string[];
450
+ arch?: unknown;
451
+ };
452
+ root: string;
453
+ rustRules: Record<string, RuleConfigValue>;
454
+ }): JsonPayload {
455
+ const hasRustRules = Object.values(rustRules).some(
456
+ (value) => ruleConfigSeverity(value) !== "off"
457
+ );
458
+ if (!raw.arch && !hasRustRules) {
459
+ return emptyJsonPayload();
460
+ }
461
+
462
+ const tempDir = mkdtempSync(join(tmpdir(), "klint-auto-rust-"));
463
+ try {
464
+ writeFileSync(
465
+ join(tempDir, "klint.config.json"),
466
+ JSON.stringify({
467
+ root,
468
+ include: raw.include ?? ["."],
469
+ rules: rustRules,
470
+ arch: raw.arch,
471
+ })
472
+ );
473
+
474
+ const command = resolveRustEngineCommand(tempDir);
475
+ const result = spawnSync(command.bin, command.args, {
476
+ cwd: import.meta.dir,
477
+ encoding: "utf-8",
478
+ });
479
+ if (result.stderr) process.stderr.write(result.stderr);
480
+ if ((result.status ?? 1) !== 0 && (result.status ?? 1) !== 2) {
481
+ process.stderr.write(result.stdout);
482
+ process.exit(result.status ?? 1);
483
+ }
484
+
485
+ const payload = parseJsonPayload(result.stdout);
486
+ if (!isJsonPayload(payload)) {
487
+ process.stderr.write("klint: --engine auto failed to parse Rust JSON output\n");
488
+ process.exit(1);
489
+ }
490
+ return payload;
491
+ } finally {
492
+ rmSync(tempDir, { recursive: true, force: true });
493
+ }
494
+ }
495
+
496
+ interface JsonPayload {
497
+ violations: Array<Omit<Violation, "fix"> & { fix: unknown }>;
498
+ summary: {
499
+ errors: number;
500
+ warnings: number;
501
+ };
502
+ }
503
+
504
+ function emptyJsonPayload(): JsonPayload {
505
+ return {
506
+ violations: [],
507
+ summary: { errors: 0, warnings: 0 },
508
+ };
509
+ }
510
+
511
+ function toJsonPayload(violations: ReturnType<typeof runKlint>): JsonPayload {
512
+ const errors = violations.filter((v) => v.severity === "error");
513
+ return {
514
+ violations: violations.map((v) => ({ ...v, fix: v.fix ?? null })),
515
+ summary: { errors: errors.length, warnings: violations.length - errors.length },
516
+ };
517
+ }
518
+
519
+ function mergeJsonOutputs(...outputs: JsonPayload[]): JsonPayload {
520
+ const violations = outputs
521
+ .flatMap((output) => output.violations)
522
+ .sort(
523
+ (a, b) =>
524
+ a.file.localeCompare(b.file) ||
525
+ a.line - b.line ||
526
+ a.rule.localeCompare(b.rule) ||
527
+ a.message.localeCompare(b.message)
528
+ );
529
+ const errors = violations.filter((violation) => violation.severity === "error").length;
530
+ return {
531
+ violations,
532
+ summary: {
533
+ errors,
534
+ warnings: violations.length - errors,
535
+ },
536
+ };
537
+ }
538
+
539
+ function isJsonPayload(value: unknown): value is JsonPayload {
540
+ if (!value || typeof value !== "object") return false;
541
+ const candidate = value as Partial<JsonPayload>;
542
+ return Array.isArray(candidate.violations) && typeof candidate.summary === "object";
543
+ }
544
+
315
545
  function toJsonCliResult(violations: ReturnType<typeof runKlint>): {
316
546
  stdout: string;
317
547
  status: number;
318
548
  } {
319
- const errors = violations.filter((v) => v.severity === "error");
549
+ const payload = toJsonPayload(violations);
320
550
  return {
321
- stdout: JSON.stringify({
322
- violations: violations.map((v) => ({ ...v, fix: v.fix ?? null })),
323
- summary: { errors: errors.length, warnings: violations.length - errors.length },
324
- }),
325
- status: errors.length > 0 ? 2 : 0,
551
+ stdout: JSON.stringify(payload),
552
+ status: payload.summary.errors > 0 ? 2 : 0,
326
553
  };
327
554
  }
328
555
 
@@ -395,12 +622,10 @@ function isSourceCheckout(): boolean {
395
622
 
396
623
  function rustEngineUnsupportedReason({
397
624
  fix,
398
- json,
399
625
  raw,
400
626
  rulesFile,
401
627
  }: {
402
628
  fix: boolean;
403
- json: boolean;
404
629
  raw: {
405
630
  plugins?: string[];
406
631
  rules?: Record<string, RuleConfigValue>;
@@ -408,7 +633,6 @@ function rustEngineUnsupportedReason({
408
633
  };
409
634
  rulesFile?: string;
410
635
  }): string | undefined {
411
- if (!json) return "KLINT_ENGINE=rust currently requires --json";
412
636
  if (fix) return "KLINT_ENGINE=rust does not support --fix";
413
637
  if (rulesFile) return "KLINT_ENGINE=rust does not support --rules";
414
638
  if ((raw.plugins?.length ?? 0) > 0) return "KLINT_ENGINE=rust does not support plugins";
@@ -571,12 +795,12 @@ function printHelp(): void {
571
795
  [
572
796
  "klint — agent harness for TypeScript architecture rules",
573
797
  "",
574
- "Usage: klint [--config <dir>] [--rules <file>] [--engine ts|rust|compare] [--fix] [--json]",
798
+ "Usage: klint [--config <dir>] [--rules <file>] [--engine ts|rust|compare|auto] [--fix] [--json]",
575
799
  " klint install-skill [--agents <list>] [--symlink | --copy]",
576
800
  "",
577
801
  " --config <dir> directory containing klint.yaml or klint.config.json (default: cwd)",
578
802
  " --rules <file> custom rules file (default: <configDir>/klint.rules.ts if present)",
579
- " --engine <name> engine to use: ts (default), rust, or compare (experimental, requires --json)",
803
+ " --engine <name> engine to use: ts (default), rust, compare, or auto (experimental)",
580
804
  " --fix apply auto-fixes for fixable violations in-place",
581
805
  " --json emit structured JSON to stdout (for agent/CI consumption)",
582
806
  " --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.17.0",
3
+ "version": "0.19.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": {
@@ -36,6 +36,7 @@
36
36
  "type-check": "tsc --noEmit",
37
37
  "test": "bun test",
38
38
  "test:rust-engine": "bun test tests/rust-engine-cli.test.ts",
39
+ "test:auto-engine": "bun cli.ts --engine auto",
39
40
  "pack:check": "bun tests/pack-check.ts",
40
41
  "native:stage": "bun tests/stage-native-binary.ts",
41
42
  "native:smoke": "bun tests/smoke-native-binary.ts",
@@ -43,7 +44,7 @@
43
44
  "check": "biome check",
44
45
  "check-write": "biome check --write",
45
46
  "knip": "knip-bun",
46
- "klint": "bun cli.ts",
47
+ "klint": "bun cli.ts --engine auto",
47
48
  "jscpd": "jscpd --noTips",
48
49
  "schema": "bun tools/generate-schema.ts",
49
50
  "lint-staged": "lint-staged",
@@ -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.",
@@ -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.",