@konvert7/klint 0.18.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.
Files changed (2) hide show
  1. package/cli.ts +244 -25
  2. package/package.json +3 -2
package/cli.ts CHANGED
@@ -4,17 +4,26 @@ 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
 
@@ -96,12 +105,18 @@ export async function main(opts: CliOptions = {}): Promise<void> {
96
105
  const root = resolve(configDir, raw.root ?? ".");
97
106
 
98
107
  if (engine === "rust") {
99
- runRustEngine({ configDir, fix, json, raw, rulesFile });
108
+ runRustEngine({ configDir, fix, json, raw, rulesFile, startedAt });
100
109
  return;
101
110
  }
102
- 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
+ ) {
103
118
  process.stderr.write(
104
- `klint: unknown engine "${engine}" (expected "ts", "rust", or "compare")\n`
119
+ `klint: unknown engine "${engine}" (expected "ts", "rust", "compare", or "auto")\n`
105
120
  );
106
121
  process.exit(1);
107
122
  }
@@ -120,6 +135,20 @@ export async function main(opts: CliOptions = {}): Promise<void> {
120
135
  );
121
136
  const allRules: KlintConfig["rules"] = { ...customRulesMap, ...(raw.rules ?? {}) };
122
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
+
123
152
  const violations = runKlint(
124
153
  {
125
154
  root,
@@ -171,7 +200,19 @@ export async function main(opts: CliOptions = {}): Promise<void> {
171
200
  process.stdout.write(`${msg} Finished in ${formatDuration(startedAt)}.\n`);
172
201
  process.exit(0);
173
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
+ }
174
211
 
212
+ function writeTextOutput(
213
+ violations: Array<Omit<Violation, "fix">>,
214
+ startedAt: number
215
+ ): never {
175
216
  const errors = violations.filter((v) => v.severity === "error");
176
217
  const warns = violations.filter((v) => v.severity === "warn");
177
218
 
@@ -203,18 +244,13 @@ export async function main(opts: CliOptions = {}): Promise<void> {
203
244
  process.exit(0);
204
245
  }
205
246
 
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
- }
211
-
212
247
  function runRustEngine({
213
248
  configDir,
214
249
  fix,
215
250
  json,
216
251
  raw,
217
252
  rulesFile,
253
+ startedAt,
218
254
  }: {
219
255
  configDir: string;
220
256
  fix: boolean;
@@ -225,10 +261,10 @@ function runRustEngine({
225
261
  arch?: unknown;
226
262
  };
227
263
  rulesFile?: string;
264
+ startedAt: number;
228
265
  }): void {
229
266
  const unsupportedReason = rustEngineUnsupportedReason({
230
267
  fix,
231
- json,
232
268
  raw,
233
269
  rulesFile,
234
270
  });
@@ -249,9 +285,18 @@ function runRustEngine({
249
285
  encoding: "utf-8",
250
286
  });
251
287
 
252
- if (result.stdout) process.stdout.write(result.stdout);
253
288
  if (result.stderr) process.stderr.write(result.stderr);
254
- 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);
255
300
  }
256
301
 
257
302
  function runCompareEngine({
@@ -273,9 +318,13 @@ function runCompareEngine({
273
318
  rulesFile?: string;
274
319
  tsViolations: ReturnType<typeof runKlint>;
275
320
  }): void {
321
+ if (!json) {
322
+ process.stderr.write("klint: --engine compare currently requires --json\n");
323
+ process.exit(1);
324
+ }
325
+
276
326
  const unsupportedReason = rustEngineUnsupportedReason({
277
327
  fix,
278
- json,
279
328
  raw,
280
329
  rulesFile,
281
330
  });
@@ -317,17 +366,190 @@ function runCompareEngine({
317
366
  process.exit(tsResult.status);
318
367
  }
319
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
+
320
545
  function toJsonCliResult(violations: ReturnType<typeof runKlint>): {
321
546
  stdout: string;
322
547
  status: number;
323
548
  } {
324
- const errors = violations.filter((v) => v.severity === "error");
549
+ const payload = toJsonPayload(violations);
325
550
  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,
551
+ stdout: JSON.stringify(payload),
552
+ status: payload.summary.errors > 0 ? 2 : 0,
331
553
  };
332
554
  }
333
555
 
@@ -400,12 +622,10 @@ function isSourceCheckout(): boolean {
400
622
 
401
623
  function rustEngineUnsupportedReason({
402
624
  fix,
403
- json,
404
625
  raw,
405
626
  rulesFile,
406
627
  }: {
407
628
  fix: boolean;
408
- json: boolean;
409
629
  raw: {
410
630
  plugins?: string[];
411
631
  rules?: Record<string, RuleConfigValue>;
@@ -413,7 +633,6 @@ function rustEngineUnsupportedReason({
413
633
  };
414
634
  rulesFile?: string;
415
635
  }): string | undefined {
416
- if (!json) return "KLINT_ENGINE=rust currently requires --json";
417
636
  if (fix) return "KLINT_ENGINE=rust does not support --fix";
418
637
  if (rulesFile) return "KLINT_ENGINE=rust does not support --rules";
419
638
  if ((raw.plugins?.length ?? 0) > 0) return "KLINT_ENGINE=rust does not support plugins";
@@ -576,12 +795,12 @@ function printHelp(): void {
576
795
  [
577
796
  "klint — agent harness for TypeScript architecture rules",
578
797
  "",
579
- "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]",
580
799
  " klint install-skill [--agents <list>] [--symlink | --copy]",
581
800
  "",
582
801
  " --config <dir> directory containing klint.yaml or klint.config.json (default: cwd)",
583
802
  " --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)",
803
+ " --engine <name> engine to use: ts (default), rust, compare, or auto (experimental)",
585
804
  " --fix apply auto-fixes for fixable violations in-place",
586
805
  " --json emit structured JSON to stdout (for agent/CI consumption)",
587
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.18.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",