@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 +33 -1
- package/cli/help.ts +41 -0
- package/cli/install-skill.ts +102 -0
- package/cli/output.ts +159 -0
- package/cli/paths.ts +3 -0
- package/cli/rust-config.ts +144 -0
- package/cli/rust-engine.ts +301 -0
- package/cli.ts +15 -733
- package/core/arch.ts +44 -1
- package/core/config.schema.ts +39 -2
- package/core/native-binary.ts +5 -1
- package/core/types.ts +8 -0
- package/package.json +6 -5
- package/skill/klint-rules/SKILL.md +29 -3
package/cli.ts
CHANGED
|
@@ -1,45 +1,20 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
existsSync,
|
|
6
|
-
mkdirSync,
|
|
7
|
-
mkdtempSync,
|
|
8
|
-
readFileSync,
|
|
9
|
-
rmSync,
|
|
10
|
-
symlinkSync,
|
|
11
|
-
writeFileSync,
|
|
12
|
-
} from "node:fs";
|
|
13
|
-
import { tmpdir } from "node:os";
|
|
14
|
-
import { dirname, join, relative, resolve } from "node:path";
|
|
15
|
-
import * as clack from "@clack/prompts";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { readFile } from "node:fs/promises";
|
|
4
|
+
import { resolve } from "node:path";
|
|
16
5
|
import { parse as parseYaml } from "yaml";
|
|
6
|
+
import { printHelp } from "./cli/help";
|
|
7
|
+
import { installSkill } from "./cli/install-skill";
|
|
8
|
+
import {
|
|
9
|
+
formatDuration,
|
|
10
|
+
toJsonCliResult,
|
|
11
|
+
writeDebugEvent,
|
|
12
|
+
writeTextOutput,
|
|
13
|
+
} from "./cli/output";
|
|
14
|
+
import { runAutoEngine, runCompareEngine, runRustEngine } from "./cli/rust-engine";
|
|
17
15
|
import { applyFixes } from "./core/fixer";
|
|
18
|
-
import {
|
|
19
|
-
import {
|
|
20
|
-
import type {
|
|
21
|
-
ArchConfig,
|
|
22
|
-
KlintConfig,
|
|
23
|
-
KlintRule,
|
|
24
|
-
RuleConfigValue,
|
|
25
|
-
Violation,
|
|
26
|
-
} from "./core/types";
|
|
27
|
-
import { BUILT_IN_PLUGINS } from "./plugins/index";
|
|
28
|
-
import { BUILT_IN_RULES } from "./rules/index";
|
|
29
|
-
|
|
30
|
-
const RUST_SUPPORTED_RULES = new Set([
|
|
31
|
-
"no-consecutive-array-push",
|
|
32
|
-
"no-nested-template-literals",
|
|
33
|
-
"no-string-match",
|
|
34
|
-
"no-sync-in-async",
|
|
35
|
-
"no-unguarded-json-parse",
|
|
36
|
-
"sonar/no-single-char-class",
|
|
37
|
-
"sonar/prefer-at",
|
|
38
|
-
"sonar/prefer-nullish-coalescing-assign",
|
|
39
|
-
"sonar/prefer-string-replaceall",
|
|
40
|
-
"sonar/prefer-string-raw",
|
|
41
|
-
"sonar/prefer-string-raw-regexp",
|
|
42
|
-
]);
|
|
16
|
+
import { runKlint } from "./core/runner";
|
|
17
|
+
import type { ArchConfig, KlintConfig, KlintRule, RuleConfigValue } from "./core/types";
|
|
43
18
|
|
|
44
19
|
interface CliOptions {
|
|
45
20
|
configDir?: string;
|
|
@@ -103,7 +78,7 @@ export async function main(opts: CliOptions = {}): Promise<void> {
|
|
|
103
78
|
}
|
|
104
79
|
let raw: RawConfig;
|
|
105
80
|
try {
|
|
106
|
-
const text =
|
|
81
|
+
const text = await readFile(configPath, "utf-8");
|
|
107
82
|
raw = (usingYaml ? parseYaml(text) : JSON.parse(text)) as RawConfig;
|
|
108
83
|
} catch {
|
|
109
84
|
process.stderr.write(`klint: failed to parse ${configPath}\n`);
|
|
@@ -217,697 +192,4 @@ export async function main(opts: CliOptions = {}): Promise<void> {
|
|
|
217
192
|
writeTextOutput(violations, startedAt);
|
|
218
193
|
}
|
|
219
194
|
|
|
220
|
-
function formatDuration(startedAt: number): string {
|
|
221
|
-
const elapsedMs = performance.now() - startedAt;
|
|
222
|
-
if (elapsedMs < 1000) return `${Math.round(elapsedMs)}ms`;
|
|
223
|
-
return `${(elapsedMs / 1000).toFixed(elapsedMs < 10_000 ? 2 : 1)}s`;
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
function writeTextOutput(
|
|
227
|
-
violations: Array<Omit<Violation, "fix">>,
|
|
228
|
-
startedAt: number
|
|
229
|
-
): never {
|
|
230
|
-
const errors = violations.filter((v) => v.severity === "error");
|
|
231
|
-
const warns = violations.filter((v) => v.severity === "warn");
|
|
232
|
-
|
|
233
|
-
if (errors.length === 0 && warns.length === 0) {
|
|
234
|
-
process.stdout.write(`klint: 0 violations in ${formatDuration(startedAt)}\n`);
|
|
235
|
-
process.exit(0);
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
const formatBlock = (v: (typeof violations)[number]) => {
|
|
239
|
-
const prefix = v.severity === "warn" ? "⚠" : "×";
|
|
240
|
-
const header = `${v.file}:${v.line} [${v.rule}]`;
|
|
241
|
-
const sep = "━".repeat(Math.max(0, 80 - header.length));
|
|
242
|
-
return `${header} ${sep}\n\n ${prefix} ${v.message}\n`;
|
|
243
|
-
};
|
|
244
|
-
|
|
245
|
-
if (warns.length > 0) {
|
|
246
|
-
process.stderr.write(
|
|
247
|
-
`klint: ${warns.length} warning(s)\n\n${warns.map(formatBlock).join("\n")}`
|
|
248
|
-
);
|
|
249
|
-
}
|
|
250
|
-
if (errors.length > 0) {
|
|
251
|
-
process.stderr.write(
|
|
252
|
-
`klint: ${errors.length} error(s)\n\n${errors.map(formatBlock).join("\n")}`
|
|
253
|
-
);
|
|
254
|
-
process.stderr.write(`\nklint: finished in ${formatDuration(startedAt)}\n`);
|
|
255
|
-
process.exit(2);
|
|
256
|
-
}
|
|
257
|
-
process.stderr.write(`\nklint: finished in ${formatDuration(startedAt)}\n`);
|
|
258
|
-
process.exit(0);
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
function runRustEngine({
|
|
262
|
-
fix,
|
|
263
|
-
json,
|
|
264
|
-
raw,
|
|
265
|
-
root,
|
|
266
|
-
rulesFile,
|
|
267
|
-
startedAt,
|
|
268
|
-
}: {
|
|
269
|
-
fix: boolean;
|
|
270
|
-
json: boolean;
|
|
271
|
-
raw: {
|
|
272
|
-
plugins?: string[];
|
|
273
|
-
rules?: Record<string, RuleConfigValue>;
|
|
274
|
-
arch?: unknown;
|
|
275
|
-
};
|
|
276
|
-
root: string;
|
|
277
|
-
rulesFile?: string;
|
|
278
|
-
startedAt: number;
|
|
279
|
-
}): void {
|
|
280
|
-
const unsupportedReason = rustEngineUnsupportedReason({
|
|
281
|
-
fix,
|
|
282
|
-
raw,
|
|
283
|
-
rulesFile,
|
|
284
|
-
});
|
|
285
|
-
|
|
286
|
-
if (unsupportedReason) {
|
|
287
|
-
process.stderr.write(`klint: ${unsupportedReason}\n`);
|
|
288
|
-
process.exit(1);
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
const { configDir: rustConfigDir, cleanup } = writeRustEngineConfig({ raw, root });
|
|
292
|
-
const command = resolveRustEngineCommand(rustConfigDir);
|
|
293
|
-
let result: SpawnSyncReturns<string>;
|
|
294
|
-
try {
|
|
295
|
-
if (process.argv.includes("--debug") || process.argv.includes("-debug")) {
|
|
296
|
-
process.stderr.write(
|
|
297
|
-
`[klint:debug] rust engine: ${command.bin} ${command.args.join(" ")}\n`
|
|
298
|
-
);
|
|
299
|
-
}
|
|
300
|
-
result = spawnSync(command.bin, command.args, {
|
|
301
|
-
cwd: import.meta.dir,
|
|
302
|
-
encoding: "utf-8",
|
|
303
|
-
});
|
|
304
|
-
} finally {
|
|
305
|
-
cleanup();
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
if (result.stderr) process.stderr.write(result.stderr);
|
|
309
|
-
if (json) {
|
|
310
|
-
if (result.stdout) process.stdout.write(result.stdout);
|
|
311
|
-
process.exit(result.status ?? 1);
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
const payload = parseJsonPayload(result.stdout);
|
|
315
|
-
if (!isJsonPayload(payload)) {
|
|
316
|
-
process.stderr.write("klint: --engine rust failed to parse Rust JSON output\n");
|
|
317
|
-
process.exit(1);
|
|
318
|
-
}
|
|
319
|
-
writeTextOutput(payload.violations, startedAt);
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
function runCompareEngine({
|
|
323
|
-
fix,
|
|
324
|
-
json,
|
|
325
|
-
raw,
|
|
326
|
-
root,
|
|
327
|
-
rulesFile,
|
|
328
|
-
tsViolations,
|
|
329
|
-
}: {
|
|
330
|
-
fix: boolean;
|
|
331
|
-
json: boolean;
|
|
332
|
-
raw: {
|
|
333
|
-
plugins?: string[];
|
|
334
|
-
rules?: Record<string, RuleConfigValue>;
|
|
335
|
-
arch?: unknown;
|
|
336
|
-
};
|
|
337
|
-
root: string;
|
|
338
|
-
rulesFile?: string;
|
|
339
|
-
tsViolations: ReturnType<typeof runKlint>;
|
|
340
|
-
}): void {
|
|
341
|
-
if (!json) {
|
|
342
|
-
process.stderr.write("klint: --engine compare currently requires --json\n");
|
|
343
|
-
process.exit(1);
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
const unsupportedReason = rustEngineUnsupportedReason({
|
|
347
|
-
fix,
|
|
348
|
-
raw,
|
|
349
|
-
rulesFile,
|
|
350
|
-
});
|
|
351
|
-
|
|
352
|
-
if (unsupportedReason) {
|
|
353
|
-
process.stderr.write(`klint: ${unsupportedReason}\n`);
|
|
354
|
-
process.exit(1);
|
|
355
|
-
}
|
|
356
|
-
|
|
357
|
-
const tsResult = toJsonCliResult(tsViolations);
|
|
358
|
-
const { configDir: rustConfigDir, cleanup } = writeRustEngineConfig({ raw, root });
|
|
359
|
-
const rustCommand = resolveRustEngineCommand(rustConfigDir);
|
|
360
|
-
let rustResult: SpawnSyncReturns<string>;
|
|
361
|
-
try {
|
|
362
|
-
rustResult = spawnSync(rustCommand.bin, rustCommand.args, {
|
|
363
|
-
cwd: import.meta.dir,
|
|
364
|
-
encoding: "utf-8",
|
|
365
|
-
});
|
|
366
|
-
} finally {
|
|
367
|
-
cleanup();
|
|
368
|
-
}
|
|
369
|
-
|
|
370
|
-
const rustStatus = rustResult.status ?? 1;
|
|
371
|
-
if (rustResult.stderr) process.stderr.write(rustResult.stderr);
|
|
372
|
-
|
|
373
|
-
const tsPayload = parseJsonPayload(tsResult.stdout);
|
|
374
|
-
const rustPayload = parseJsonPayload(rustResult.stdout);
|
|
375
|
-
|
|
376
|
-
if (
|
|
377
|
-
rustStatus !== tsResult.status ||
|
|
378
|
-
tsPayload === undefined ||
|
|
379
|
-
rustPayload === undefined ||
|
|
380
|
-
!jsonPayloadEquals(tsPayload, rustPayload)
|
|
381
|
-
) {
|
|
382
|
-
const mismatchLines = [
|
|
383
|
-
"klint: compare engine mismatch",
|
|
384
|
-
`TypeScript exit: ${tsResult.status}`,
|
|
385
|
-
`Rust exit: ${rustStatus}`,
|
|
386
|
-
];
|
|
387
|
-
process.stderr.write(`${mismatchLines.join("\n")}\n`);
|
|
388
|
-
process.exit(1);
|
|
389
|
-
}
|
|
390
|
-
|
|
391
|
-
process.stdout.write(tsResult.stdout);
|
|
392
|
-
process.exit(tsResult.status);
|
|
393
|
-
}
|
|
394
|
-
|
|
395
|
-
function writeRustEngineConfig({
|
|
396
|
-
raw,
|
|
397
|
-
root,
|
|
398
|
-
}: {
|
|
399
|
-
raw: {
|
|
400
|
-
include?: string[];
|
|
401
|
-
plugins?: string[];
|
|
402
|
-
rules?: Record<string, RuleConfigValue>;
|
|
403
|
-
arch?: unknown;
|
|
404
|
-
};
|
|
405
|
-
root: string;
|
|
406
|
-
}): { configDir: string; cleanup: () => void } {
|
|
407
|
-
const tempDir = mkdtempSync(join(tmpdir(), "klint-rust-engine-"));
|
|
408
|
-
writeFileSync(
|
|
409
|
-
join(tempDir, "klint.config.json"),
|
|
410
|
-
JSON.stringify({
|
|
411
|
-
root,
|
|
412
|
-
include: raw.include ?? ["."],
|
|
413
|
-
plugins: [],
|
|
414
|
-
rules: resolveEffectiveRules(raw.plugins, raw.rules ?? {}),
|
|
415
|
-
arch: raw.arch,
|
|
416
|
-
})
|
|
417
|
-
);
|
|
418
|
-
return {
|
|
419
|
-
configDir: tempDir,
|
|
420
|
-
cleanup: () => rmSync(tempDir, { recursive: true, force: true }),
|
|
421
|
-
};
|
|
422
|
-
}
|
|
423
|
-
|
|
424
|
-
function runAutoEngine({
|
|
425
|
-
fix,
|
|
426
|
-
json,
|
|
427
|
-
raw,
|
|
428
|
-
root,
|
|
429
|
-
rulesFile,
|
|
430
|
-
startedAt,
|
|
431
|
-
customRules,
|
|
432
|
-
customRulesMap,
|
|
433
|
-
}: {
|
|
434
|
-
fix: boolean;
|
|
435
|
-
json: boolean;
|
|
436
|
-
raw: {
|
|
437
|
-
root?: string;
|
|
438
|
-
include?: string[];
|
|
439
|
-
plugins?: string[];
|
|
440
|
-
rules?: Record<string, RuleConfigValue>;
|
|
441
|
-
arch?: unknown;
|
|
442
|
-
};
|
|
443
|
-
root: string;
|
|
444
|
-
rulesFile?: string;
|
|
445
|
-
startedAt: number;
|
|
446
|
-
customRules: Record<string, KlintRule>;
|
|
447
|
-
customRulesMap: Record<string, RuleConfigValue>;
|
|
448
|
-
}): void {
|
|
449
|
-
if (fix) {
|
|
450
|
-
process.stderr.write("klint: --engine auto does not support --fix\n");
|
|
451
|
-
process.exit(1);
|
|
452
|
-
}
|
|
453
|
-
if (rulesFile) {
|
|
454
|
-
process.stderr.write("klint: --engine auto does not support --rules\n");
|
|
455
|
-
process.exit(1);
|
|
456
|
-
}
|
|
457
|
-
|
|
458
|
-
const effectiveRules = resolveEffectiveRules(raw.plugins, raw.rules ?? {});
|
|
459
|
-
const { rustRules, tsRules } = splitRulesForAuto(effectiveRules);
|
|
460
|
-
const tsViolations = runKlint(
|
|
461
|
-
{
|
|
462
|
-
root,
|
|
463
|
-
include: raw.include ?? ["."],
|
|
464
|
-
plugins: [],
|
|
465
|
-
rules: { ...customRulesMap, ...tsRules },
|
|
466
|
-
},
|
|
467
|
-
customRules
|
|
468
|
-
);
|
|
469
|
-
|
|
470
|
-
const rustOutput = runAutoRustSubset({
|
|
471
|
-
raw,
|
|
472
|
-
root,
|
|
473
|
-
rustRules,
|
|
474
|
-
});
|
|
475
|
-
const tsOutput = toJsonPayload(tsViolations);
|
|
476
|
-
const merged = mergeJsonOutputs(rustOutput, tsOutput);
|
|
477
|
-
if (json) {
|
|
478
|
-
process.stdout.write(JSON.stringify(merged));
|
|
479
|
-
process.exit(merged.summary.errors > 0 ? 2 : 0);
|
|
480
|
-
}
|
|
481
|
-
writeTextOutput(merged.violations, startedAt);
|
|
482
|
-
}
|
|
483
|
-
|
|
484
|
-
function splitRulesForAuto(rules: Record<string, RuleConfigValue>): {
|
|
485
|
-
rustRules: Record<string, RuleConfigValue>;
|
|
486
|
-
tsRules: Record<string, RuleConfigValue>;
|
|
487
|
-
} {
|
|
488
|
-
const rustRules: Record<string, RuleConfigValue> = {};
|
|
489
|
-
const tsRules: Record<string, RuleConfigValue> = {};
|
|
490
|
-
|
|
491
|
-
for (const [name, value] of Object.entries(rules)) {
|
|
492
|
-
if (RUST_SUPPORTED_RULES.has(name)) rustRules[name] = value;
|
|
493
|
-
else tsRules[name] = value;
|
|
494
|
-
}
|
|
495
|
-
|
|
496
|
-
return { rustRules, tsRules };
|
|
497
|
-
}
|
|
498
|
-
|
|
499
|
-
function runAutoRustSubset({
|
|
500
|
-
raw,
|
|
501
|
-
root,
|
|
502
|
-
rustRules,
|
|
503
|
-
}: {
|
|
504
|
-
raw: {
|
|
505
|
-
include?: string[];
|
|
506
|
-
arch?: unknown;
|
|
507
|
-
};
|
|
508
|
-
root: string;
|
|
509
|
-
rustRules: Record<string, RuleConfigValue>;
|
|
510
|
-
}): JsonPayload {
|
|
511
|
-
const hasRustRules = Object.values(rustRules).some(
|
|
512
|
-
(value) => ruleConfigSeverity(value) !== "off"
|
|
513
|
-
);
|
|
514
|
-
if (!raw.arch && !hasRustRules) {
|
|
515
|
-
return emptyJsonPayload();
|
|
516
|
-
}
|
|
517
|
-
|
|
518
|
-
const tempDir = mkdtempSync(join(tmpdir(), "klint-auto-rust-"));
|
|
519
|
-
try {
|
|
520
|
-
writeFileSync(
|
|
521
|
-
join(tempDir, "klint.config.json"),
|
|
522
|
-
JSON.stringify({
|
|
523
|
-
root,
|
|
524
|
-
include: raw.include ?? ["."],
|
|
525
|
-
rules: rustRules,
|
|
526
|
-
arch: raw.arch,
|
|
527
|
-
})
|
|
528
|
-
);
|
|
529
|
-
|
|
530
|
-
const command = resolveRustEngineCommand(tempDir);
|
|
531
|
-
const result = spawnSync(command.bin, command.args, {
|
|
532
|
-
cwd: import.meta.dir,
|
|
533
|
-
encoding: "utf-8",
|
|
534
|
-
});
|
|
535
|
-
if (result.stderr) process.stderr.write(result.stderr);
|
|
536
|
-
if ((result.status ?? 1) !== 0 && (result.status ?? 1) !== 2) {
|
|
537
|
-
process.stderr.write(result.stdout);
|
|
538
|
-
process.exit(result.status ?? 1);
|
|
539
|
-
}
|
|
540
|
-
|
|
541
|
-
const payload = parseJsonPayload(result.stdout);
|
|
542
|
-
if (!isJsonPayload(payload)) {
|
|
543
|
-
process.stderr.write("klint: --engine auto failed to parse Rust JSON output\n");
|
|
544
|
-
process.exit(1);
|
|
545
|
-
}
|
|
546
|
-
return payload;
|
|
547
|
-
} finally {
|
|
548
|
-
rmSync(tempDir, { recursive: true, force: true });
|
|
549
|
-
}
|
|
550
|
-
}
|
|
551
|
-
|
|
552
|
-
interface JsonPayload {
|
|
553
|
-
violations: Array<Omit<Violation, "fix"> & { fix: unknown }>;
|
|
554
|
-
summary: {
|
|
555
|
-
errors: number;
|
|
556
|
-
warnings: number;
|
|
557
|
-
};
|
|
558
|
-
}
|
|
559
|
-
|
|
560
|
-
function emptyJsonPayload(): JsonPayload {
|
|
561
|
-
return {
|
|
562
|
-
violations: [],
|
|
563
|
-
summary: { errors: 0, warnings: 0 },
|
|
564
|
-
};
|
|
565
|
-
}
|
|
566
|
-
|
|
567
|
-
function toJsonPayload(violations: ReturnType<typeof runKlint>): JsonPayload {
|
|
568
|
-
const errors = violations.filter((v) => v.severity === "error");
|
|
569
|
-
return {
|
|
570
|
-
violations: violations.map((v) => ({ ...v, fix: v.fix ?? null })),
|
|
571
|
-
summary: { errors: errors.length, warnings: violations.length - errors.length },
|
|
572
|
-
};
|
|
573
|
-
}
|
|
574
|
-
|
|
575
|
-
function mergeJsonOutputs(...outputs: JsonPayload[]): JsonPayload {
|
|
576
|
-
const violations = outputs
|
|
577
|
-
.flatMap((output) => output.violations)
|
|
578
|
-
.sort(
|
|
579
|
-
(a, b) =>
|
|
580
|
-
a.file.localeCompare(b.file) ||
|
|
581
|
-
a.line - b.line ||
|
|
582
|
-
a.rule.localeCompare(b.rule) ||
|
|
583
|
-
a.message.localeCompare(b.message)
|
|
584
|
-
);
|
|
585
|
-
const errors = violations.filter((violation) => violation.severity === "error").length;
|
|
586
|
-
return {
|
|
587
|
-
violations,
|
|
588
|
-
summary: {
|
|
589
|
-
errors,
|
|
590
|
-
warnings: violations.length - errors,
|
|
591
|
-
},
|
|
592
|
-
};
|
|
593
|
-
}
|
|
594
|
-
|
|
595
|
-
function isJsonPayload(value: unknown): value is JsonPayload {
|
|
596
|
-
if (!value || typeof value !== "object") return false;
|
|
597
|
-
const candidate = value as Partial<JsonPayload>;
|
|
598
|
-
return Array.isArray(candidate.violations) && typeof candidate.summary === "object";
|
|
599
|
-
}
|
|
600
|
-
|
|
601
|
-
function toJsonCliResult(violations: ReturnType<typeof runKlint>): {
|
|
602
|
-
stdout: string;
|
|
603
|
-
status: number;
|
|
604
|
-
} {
|
|
605
|
-
const payload = toJsonPayload(violations);
|
|
606
|
-
return {
|
|
607
|
-
stdout: JSON.stringify(payload),
|
|
608
|
-
status: payload.summary.errors > 0 ? 2 : 0,
|
|
609
|
-
};
|
|
610
|
-
}
|
|
611
|
-
|
|
612
|
-
function writeDebugEvent(event: KlintDebugEvent): void {
|
|
613
|
-
switch (event.type) {
|
|
614
|
-
case "walk:start":
|
|
615
|
-
process.stderr.write(`[klint:debug] walk ${event.dir}\n`);
|
|
616
|
-
break;
|
|
617
|
-
case "walk:done":
|
|
618
|
-
process.stderr.write(`[klint:debug] walked ${event.dir} (${event.files} files)\n`);
|
|
619
|
-
break;
|
|
620
|
-
case "files:resolved":
|
|
621
|
-
process.stderr.write(`[klint:debug] resolved ${event.files} files\n`);
|
|
622
|
-
break;
|
|
623
|
-
case "rule:start":
|
|
624
|
-
process.stderr.write(
|
|
625
|
-
`[klint:debug] rule ${event.rule} start (${event.files} files)\n`
|
|
626
|
-
);
|
|
627
|
-
break;
|
|
628
|
-
case "rule:done":
|
|
629
|
-
process.stderr.write(
|
|
630
|
-
`[klint:debug] rule ${event.rule} done (${event.violations} violations)\n`
|
|
631
|
-
);
|
|
632
|
-
break;
|
|
633
|
-
case "arch:start":
|
|
634
|
-
process.stderr.write(`[klint:debug] arch start (${event.files} files)\n`);
|
|
635
|
-
break;
|
|
636
|
-
case "arch:done":
|
|
637
|
-
process.stderr.write(`[klint:debug] arch done (${event.violations} violations)\n`);
|
|
638
|
-
break;
|
|
639
|
-
}
|
|
640
|
-
}
|
|
641
|
-
|
|
642
|
-
interface RustEngineCommand {
|
|
643
|
-
bin: string;
|
|
644
|
-
args: string[];
|
|
645
|
-
}
|
|
646
|
-
|
|
647
|
-
function resolveRustEngineCommand(configDir: string): RustEngineCommand {
|
|
648
|
-
const args = ["--config", configDir, "--json"];
|
|
649
|
-
const explicitBin = process.env.KLINT_RUST_BIN;
|
|
650
|
-
if (explicitBin) {
|
|
651
|
-
return { bin: explicitBin, args };
|
|
652
|
-
}
|
|
653
|
-
|
|
654
|
-
const localBin = join(
|
|
655
|
-
import.meta.dir,
|
|
656
|
-
"target",
|
|
657
|
-
"debug",
|
|
658
|
-
process.platform === "win32" ? "klint-rs.exe" : "klint-rs"
|
|
659
|
-
);
|
|
660
|
-
if (isSourceCheckout() && existsSync(localBin)) {
|
|
661
|
-
return { bin: localBin, args };
|
|
662
|
-
}
|
|
663
|
-
|
|
664
|
-
const nativePackageBin = resolveNativePackageBinary({ packageRoot: import.meta.dir });
|
|
665
|
-
if (nativePackageBin) {
|
|
666
|
-
return { bin: nativePackageBin, args };
|
|
667
|
-
}
|
|
668
|
-
|
|
669
|
-
if (!isSourceCheckout()) {
|
|
670
|
-
process.stderr.write(
|
|
671
|
-
"klint: native Rust engine binary is unavailable for this package version\n"
|
|
672
|
-
);
|
|
673
|
-
process.exit(1);
|
|
674
|
-
}
|
|
675
|
-
|
|
676
|
-
return {
|
|
677
|
-
bin: "cargo",
|
|
678
|
-
args: ["run", "--quiet", "-p", "klint-rs", "--", ...args],
|
|
679
|
-
};
|
|
680
|
-
}
|
|
681
|
-
|
|
682
|
-
function isSourceCheckout(): boolean {
|
|
683
|
-
return existsSync(join(import.meta.dir, "crates", "klint-rs", "Cargo.toml"));
|
|
684
|
-
}
|
|
685
|
-
|
|
686
|
-
function rustEngineUnsupportedReason({
|
|
687
|
-
fix,
|
|
688
|
-
raw,
|
|
689
|
-
rulesFile,
|
|
690
|
-
}: {
|
|
691
|
-
fix: boolean;
|
|
692
|
-
raw: {
|
|
693
|
-
plugins?: string[];
|
|
694
|
-
rules?: Record<string, RuleConfigValue>;
|
|
695
|
-
arch?: unknown;
|
|
696
|
-
};
|
|
697
|
-
rulesFile?: string;
|
|
698
|
-
}): string | undefined {
|
|
699
|
-
if (fix) return "KLINT_ENGINE=rust does not support --fix";
|
|
700
|
-
if (rulesFile) return "KLINT_ENGINE=rust does not support --rules";
|
|
701
|
-
let effectiveRules: Record<string, RuleConfigValue>;
|
|
702
|
-
try {
|
|
703
|
-
effectiveRules = resolveEffectiveRules(raw.plugins, raw.rules ?? {});
|
|
704
|
-
} catch (error) {
|
|
705
|
-
return error instanceof Error
|
|
706
|
-
? error.message
|
|
707
|
-
: "KLINT_ENGINE=rust failed to load plugins";
|
|
708
|
-
}
|
|
709
|
-
const activeSupportedRules = Object.entries(effectiveRules).filter(
|
|
710
|
-
([name, value]) =>
|
|
711
|
-
ruleConfigSeverity(value) !== "off" && RUST_SUPPORTED_RULES.has(name)
|
|
712
|
-
);
|
|
713
|
-
const unsupportedRules = Object.entries(effectiveRules)
|
|
714
|
-
.filter(
|
|
715
|
-
([name, value]) =>
|
|
716
|
-
ruleConfigSeverity(value) !== "off" && !RUST_SUPPORTED_RULES.has(name)
|
|
717
|
-
)
|
|
718
|
-
.map(([name]) => name);
|
|
719
|
-
if (!raw.arch && activeSupportedRules.length === 0 && unsupportedRules.length === 0) {
|
|
720
|
-
return "KLINT_ENGINE=rust requires an arch config or supported rule";
|
|
721
|
-
}
|
|
722
|
-
if (unsupportedRules.length > 0) {
|
|
723
|
-
return [
|
|
724
|
-
"Rust engine currently supports arch rules and selected rules only",
|
|
725
|
-
"",
|
|
726
|
-
"Unsupported rules:",
|
|
727
|
-
...unsupportedRules.map((rule) => `- ${rule}`),
|
|
728
|
-
].join("\n");
|
|
729
|
-
}
|
|
730
|
-
return undefined;
|
|
731
|
-
}
|
|
732
|
-
|
|
733
|
-
function resolveEffectiveRules(
|
|
734
|
-
plugins: string[] | undefined,
|
|
735
|
-
rules: Record<string, RuleConfigValue>
|
|
736
|
-
): Record<string, RuleConfigValue> {
|
|
737
|
-
const pluginDefaults: Record<string, RuleConfigValue> = {};
|
|
738
|
-
for (const pluginName of plugins ?? []) {
|
|
739
|
-
const plugin = BUILT_IN_PLUGINS[pluginName];
|
|
740
|
-
if (!plugin) throw new Error(`Unknown klint plugin: "${pluginName}"`);
|
|
741
|
-
Object.assign(pluginDefaults, plugin.rules);
|
|
742
|
-
}
|
|
743
|
-
return { ...pluginDefaults, ...rules };
|
|
744
|
-
}
|
|
745
|
-
|
|
746
|
-
function ruleConfigSeverity(value: RuleConfigValue): string | undefined {
|
|
747
|
-
if (typeof value === "string") return value;
|
|
748
|
-
return value.severity;
|
|
749
|
-
}
|
|
750
|
-
|
|
751
|
-
function parseJsonPayload(value: string): unknown {
|
|
752
|
-
try {
|
|
753
|
-
return JSON.parse(value);
|
|
754
|
-
} catch {
|
|
755
|
-
return undefined;
|
|
756
|
-
}
|
|
757
|
-
}
|
|
758
|
-
|
|
759
|
-
function jsonPayloadEquals(a: unknown, b: unknown): boolean {
|
|
760
|
-
return JSON.stringify(canonicalJson(a)) === JSON.stringify(canonicalJson(b));
|
|
761
|
-
}
|
|
762
|
-
|
|
763
|
-
function canonicalJson(value: unknown): unknown {
|
|
764
|
-
if (Array.isArray(value)) {
|
|
765
|
-
return value
|
|
766
|
-
.map(canonicalJson)
|
|
767
|
-
.sort((left, right) => JSON.stringify(left).localeCompare(JSON.stringify(right)));
|
|
768
|
-
}
|
|
769
|
-
if (!value || typeof value !== "object") return value;
|
|
770
|
-
return Object.fromEntries(
|
|
771
|
-
Object.entries(value)
|
|
772
|
-
.sort(([left], [right]) => left.localeCompare(right))
|
|
773
|
-
.map(([key, entry]) => [key, canonicalJson(entry)])
|
|
774
|
-
);
|
|
775
|
-
}
|
|
776
|
-
|
|
777
|
-
const AGENT_TARGETS = [
|
|
778
|
-
{ value: "claude", label: "Claude Code" },
|
|
779
|
-
{ value: "opencode", label: "opencode" },
|
|
780
|
-
{ value: "cursor", label: "Cursor" },
|
|
781
|
-
{ value: "codex", label: "Codex" },
|
|
782
|
-
] as const;
|
|
783
|
-
|
|
784
|
-
type AgentKey = (typeof AGENT_TARGETS)[number]["value"];
|
|
785
|
-
|
|
786
|
-
const AGENT_DIRS: Record<AgentKey, string> = {
|
|
787
|
-
claude: ".claude/skills",
|
|
788
|
-
opencode: ".agents/skills",
|
|
789
|
-
cursor: ".cursor/skills",
|
|
790
|
-
codex: ".agents/skills",
|
|
791
|
-
};
|
|
792
|
-
|
|
793
|
-
async function installSkill(args: string[]): Promise<void> {
|
|
794
|
-
const skillSrc = join(import.meta.dir, "skill", "klint-rules");
|
|
795
|
-
if (!existsSync(skillSrc)) {
|
|
796
|
-
process.stderr.write(`klint: skill source not found at ${skillSrc}\n`);
|
|
797
|
-
process.exit(1);
|
|
798
|
-
}
|
|
799
|
-
|
|
800
|
-
// Parse non-interactive flags
|
|
801
|
-
let flagAgents: AgentKey[] | undefined;
|
|
802
|
-
let flagSymlink: boolean | undefined;
|
|
803
|
-
for (let i = 0; i < args.length; i++) {
|
|
804
|
-
if (args[i] === "--agents" && args[i + 1])
|
|
805
|
-
flagAgents = args[++i].split(",") as AgentKey[];
|
|
806
|
-
else if (args[i] === "--symlink") flagSymlink = true;
|
|
807
|
-
else if (args[i] === "--copy") flagSymlink = false;
|
|
808
|
-
}
|
|
809
|
-
|
|
810
|
-
let selectedAgents: AgentKey[];
|
|
811
|
-
let useSymlink: boolean;
|
|
812
|
-
|
|
813
|
-
if (!process.stdin.isTTY || flagAgents !== undefined || flagSymlink !== undefined) {
|
|
814
|
-
selectedAgents = flagAgents ?? (AGENT_TARGETS.map((a) => a.value) as AgentKey[]);
|
|
815
|
-
useSymlink = flagSymlink ?? false;
|
|
816
|
-
} else {
|
|
817
|
-
clack.intro("klint install-skill");
|
|
818
|
-
|
|
819
|
-
const agents = await clack.multiselect<AgentKey>({
|
|
820
|
-
message: "Which agents should the skill be installed for?",
|
|
821
|
-
options: AGENT_TARGETS.map((a) => ({ value: a.value, label: a.label })),
|
|
822
|
-
initialValues: AGENT_TARGETS.map((a) => a.value) as AgentKey[],
|
|
823
|
-
});
|
|
824
|
-
if (clack.isCancel(agents)) {
|
|
825
|
-
clack.cancel("Cancelled.");
|
|
826
|
-
process.exit(0);
|
|
827
|
-
}
|
|
828
|
-
selectedAgents = agents as AgentKey[];
|
|
829
|
-
|
|
830
|
-
const mode = await clack.select<"symlink" | "copy">({
|
|
831
|
-
message: "Install as symlink or copy?",
|
|
832
|
-
options: [
|
|
833
|
-
{
|
|
834
|
-
value: "symlink",
|
|
835
|
-
label: "Symlink",
|
|
836
|
-
hint: "stays in sync when klint updates",
|
|
837
|
-
},
|
|
838
|
-
{
|
|
839
|
-
value: "copy",
|
|
840
|
-
label: "Copy",
|
|
841
|
-
hint: "one-time snapshot, no ongoing dependency",
|
|
842
|
-
},
|
|
843
|
-
],
|
|
844
|
-
});
|
|
845
|
-
if (clack.isCancel(mode)) {
|
|
846
|
-
clack.cancel("Cancelled.");
|
|
847
|
-
process.exit(0);
|
|
848
|
-
}
|
|
849
|
-
useSymlink = mode === "symlink";
|
|
850
|
-
}
|
|
851
|
-
|
|
852
|
-
const cwd = process.cwd();
|
|
853
|
-
const linkType = process.platform === "win32" ? "junction" : "dir";
|
|
854
|
-
for (const key of selectedAgents) {
|
|
855
|
-
const dest = resolve(cwd, AGENT_DIRS[key], "klint-rules");
|
|
856
|
-
mkdirSync(dirname(dest), { recursive: true });
|
|
857
|
-
try {
|
|
858
|
-
rmSync(dest, { recursive: true, force: true });
|
|
859
|
-
} catch {
|
|
860
|
-
/* already gone */
|
|
861
|
-
}
|
|
862
|
-
if (useSymlink) {
|
|
863
|
-
symlinkSync(relative(dirname(dest), skillSrc), dest, linkType);
|
|
864
|
-
} else {
|
|
865
|
-
cpSync(skillSrc, dest, { recursive: true });
|
|
866
|
-
}
|
|
867
|
-
}
|
|
868
|
-
|
|
869
|
-
if (process.stdin.isTTY) {
|
|
870
|
-
clack.outro("Done.");
|
|
871
|
-
}
|
|
872
|
-
}
|
|
873
|
-
|
|
874
|
-
function printHelp(): void {
|
|
875
|
-
const pluginRules = new Set(
|
|
876
|
-
Object.values(BUILT_IN_PLUGINS).flatMap((p) => Object.keys(p.rules))
|
|
877
|
-
);
|
|
878
|
-
const standaloneRules = Object.keys(BUILT_IN_RULES).filter((r) => !pluginRules.has(r));
|
|
879
|
-
const pluginEntries = Object.entries(BUILT_IN_PLUGINS);
|
|
880
|
-
|
|
881
|
-
process.stdout.write(
|
|
882
|
-
[
|
|
883
|
-
"klint — agent harness for TypeScript architecture rules",
|
|
884
|
-
"",
|
|
885
|
-
"Usage: klint [--config <dir>] [--rules <file>] [--engine ts|rust|compare|auto] [--fix] [--json]",
|
|
886
|
-
" klint install-skill [--agents <list>] [--symlink | --copy]",
|
|
887
|
-
"",
|
|
888
|
-
" --config <dir> directory containing klint.yaml or klint.config.json (default: cwd)",
|
|
889
|
-
" --rules <file> custom rules file (default: <configDir>/klint.rules.ts if present)",
|
|
890
|
-
" --engine <name> engine to use: ts (default), rust, compare, or auto (experimental)",
|
|
891
|
-
" --fix apply auto-fixes for fixable violations in-place",
|
|
892
|
-
" --json emit structured JSON to stdout (for agent/CI consumption)",
|
|
893
|
-
" --debug print file resolution and rule progress to stderr",
|
|
894
|
-
"",
|
|
895
|
-
" install-skill install the rule-authoring skill into agent config directories",
|
|
896
|
-
" --agents <list> comma-separated: claude,opencode,cursor,codex (default: all)",
|
|
897
|
-
" --symlink install as symlink (stays in sync with updates)",
|
|
898
|
-
" --copy install as copy (default in non-TTY)",
|
|
899
|
-
"",
|
|
900
|
-
`Built-in rules (${standaloneRules.length}):`,
|
|
901
|
-
...standaloneRules.map((r) => ` ${r}`),
|
|
902
|
-
"",
|
|
903
|
-
`Plugins (${pluginEntries.length}):`,
|
|
904
|
-
...pluginEntries.flatMap(([name, plugin]) => [
|
|
905
|
-
` ${name}`,
|
|
906
|
-
...Object.keys(plugin.rules).map((r) => ` ${r}`),
|
|
907
|
-
]),
|
|
908
|
-
"",
|
|
909
|
-
].join("\n")
|
|
910
|
-
);
|
|
911
|
-
}
|
|
912
|
-
|
|
913
195
|
if (import.meta.main) await main();
|