@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 {
|
|
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([
|
|
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 (
|
|
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 "
|
|
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
|
-
|
|
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
|
|
549
|
+
const payload = toJsonPayload(violations);
|
|
320
550
|
return {
|
|
321
|
-
stdout: JSON.stringify(
|
|
322
|
-
|
|
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
|
|
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.
|
|
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.",
|