@konvert7/klint 0.16.0 → 0.17.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,8 @@ 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(["no-string-match"]);
22
+
21
23
  interface CliOptions {
22
24
  configDir?: string;
23
25
  rulesFile?: string;
@@ -42,10 +44,12 @@ export async function main(opts: CliOptions = {}): Promise<void> {
42
44
  let fix = false;
43
45
  let json = false;
44
46
  let debug = false;
47
+ let engine = process.env.KLINT_ENGINE;
45
48
 
46
49
  for (let i = 0; i < args.length; i++) {
47
50
  if (args[i] === "--config" && args[i + 1]) configDir = resolve(args[++i]);
48
51
  else if (args[i] === "--rules" && args[i + 1]) rulesFile = resolve(args[++i]);
52
+ else if (args[i] === "--engine" && args[i + 1]) engine = args[++i];
49
53
  else if (args[i] === "--fix") fix = true;
50
54
  else if (args[i] === "--json") json = true;
51
55
  else if (args[i] === "--debug" || args[i] === "-debug") debug = true;
@@ -85,12 +89,17 @@ export async function main(opts: CliOptions = {}): Promise<void> {
85
89
  process.exit(1);
86
90
  }
87
91
  const root = resolve(configDir, raw.root ?? ".");
88
- const engine = process.env.KLINT_ENGINE;
89
92
 
90
93
  if (engine === "rust") {
91
94
  runRustEngine({ configDir, fix, json, raw, rulesFile });
92
95
  return;
93
96
  }
97
+ if (engine !== undefined && engine !== "ts" && engine !== "compare") {
98
+ process.stderr.write(
99
+ `klint: unknown engine "${engine}" (expected "ts", "rust", or "compare")\n`
100
+ );
101
+ process.exit(1);
102
+ }
94
103
 
95
104
  let customRules: Record<string, KlintRule> = {};
96
105
  const defaultRulesPath = resolve(configDir, "klint.rules.ts");
@@ -118,15 +127,15 @@ export async function main(opts: CliOptions = {}): Promise<void> {
118
127
  { onDebug: debug ? writeDebugEvent : undefined }
119
128
  );
120
129
 
130
+ if (engine === "compare") {
131
+ runCompareEngine({ configDir, fix, json, raw, rulesFile, tsViolations: violations });
132
+ return;
133
+ }
134
+
121
135
  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);
136
+ const tsResult = toJsonCliResult(violations);
137
+ process.stdout.write(tsResult.stdout);
138
+ process.exit(tsResult.status);
130
139
  }
131
140
 
132
141
  if (fix) {
@@ -240,6 +249,83 @@ function runRustEngine({
240
249
  process.exit(result.status ?? 1);
241
250
  }
242
251
 
252
+ function runCompareEngine({
253
+ configDir,
254
+ fix,
255
+ json,
256
+ raw,
257
+ rulesFile,
258
+ tsViolations,
259
+ }: {
260
+ configDir: string;
261
+ fix: boolean;
262
+ json: boolean;
263
+ raw: {
264
+ plugins?: string[];
265
+ rules?: Record<string, RuleConfigValue>;
266
+ arch?: unknown;
267
+ };
268
+ rulesFile?: string;
269
+ tsViolations: ReturnType<typeof runKlint>;
270
+ }): void {
271
+ const unsupportedReason = rustEngineUnsupportedReason({
272
+ fix,
273
+ json,
274
+ raw,
275
+ rulesFile,
276
+ });
277
+
278
+ if (unsupportedReason) {
279
+ process.stderr.write(`klint: ${unsupportedReason}\n`);
280
+ process.exit(1);
281
+ }
282
+
283
+ const tsResult = toJsonCliResult(tsViolations);
284
+ const rustCommand = resolveRustEngineCommand(configDir);
285
+ const rustResult = spawnSync(rustCommand.bin, rustCommand.args, {
286
+ cwd: import.meta.dir,
287
+ encoding: "utf-8",
288
+ });
289
+
290
+ const rustStatus = rustResult.status ?? 1;
291
+ if (rustResult.stderr) process.stderr.write(rustResult.stderr);
292
+
293
+ const tsPayload = parseJsonPayload(tsResult.stdout);
294
+ const rustPayload = parseJsonPayload(rustResult.stdout);
295
+
296
+ if (
297
+ rustStatus !== tsResult.status ||
298
+ tsPayload === undefined ||
299
+ rustPayload === undefined ||
300
+ !jsonPayloadEquals(tsPayload, rustPayload)
301
+ ) {
302
+ const mismatchLines = [
303
+ "klint: compare engine mismatch",
304
+ `TypeScript exit: ${tsResult.status}`,
305
+ `Rust exit: ${rustStatus}`,
306
+ ];
307
+ process.stderr.write(`${mismatchLines.join("\n")}\n`);
308
+ process.exit(1);
309
+ }
310
+
311
+ process.stdout.write(tsResult.stdout);
312
+ process.exit(tsResult.status);
313
+ }
314
+
315
+ function toJsonCliResult(violations: ReturnType<typeof runKlint>): {
316
+ stdout: string;
317
+ status: number;
318
+ } {
319
+ const errors = violations.filter((v) => v.severity === "error");
320
+ 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,
326
+ };
327
+ }
328
+
243
329
  function writeDebugEvent(event: KlintDebugEvent): void {
244
330
  switch (event.type) {
245
331
  case "walk:start":
@@ -282,27 +368,31 @@ function resolveRustEngineCommand(configDir: string): RustEngineCommand {
282
368
  return { bin: explicitBin, args };
283
369
  }
284
370
 
285
- const nativePackageBin = resolveNativePackageBinary({ packageRoot: import.meta.dir });
286
- if (nativePackageBin) {
287
- return { bin: nativePackageBin, args };
288
- }
289
-
290
371
  const localBin = join(
291
372
  import.meta.dir,
292
373
  "target",
293
374
  "debug",
294
375
  process.platform === "win32" ? "klint-rs.exe" : "klint-rs"
295
376
  );
296
- if (existsSync(localBin)) {
377
+ if (isSourceCheckout() && existsSync(localBin)) {
297
378
  return { bin: localBin, args };
298
379
  }
299
380
 
381
+ const nativePackageBin = resolveNativePackageBinary({ packageRoot: import.meta.dir });
382
+ if (nativePackageBin) {
383
+ return { bin: nativePackageBin, args };
384
+ }
385
+
300
386
  return {
301
387
  bin: "cargo",
302
388
  args: ["run", "--quiet", "-p", "klint-rs", "--", ...args],
303
389
  };
304
390
  }
305
391
 
392
+ function isSourceCheckout(): boolean {
393
+ return existsSync(join(import.meta.dir, "crates", "klint-rs", "Cargo.toml"));
394
+ }
395
+
306
396
  function rustEngineUnsupportedReason({
307
397
  fix,
308
398
  json,
@@ -321,14 +411,58 @@ function rustEngineUnsupportedReason({
321
411
  if (!json) return "KLINT_ENGINE=rust currently requires --json";
322
412
  if (fix) return "KLINT_ENGINE=rust does not support --fix";
323
413
  if (rulesFile) return "KLINT_ENGINE=rust does not support --rules";
324
- if (!raw.arch) return "KLINT_ENGINE=rust requires an arch config";
325
414
  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";
415
+ const activeSupportedRules = Object.entries(raw.rules ?? {}).filter(
416
+ ([name, value]) =>
417
+ ruleConfigSeverity(value) !== "off" && RUST_SUPPORTED_TYPESCRIPT_RULES.has(name)
418
+ );
419
+ const unsupportedRules = Object.entries(raw.rules ?? {})
420
+ .filter(
421
+ ([name, value]) =>
422
+ ruleConfigSeverity(value) !== "off" && !RUST_SUPPORTED_TYPESCRIPT_RULES.has(name)
423
+ )
424
+ .map(([name]) => name);
425
+ if (!raw.arch && activeSupportedRules.length === 0 && unsupportedRules.length === 0) {
426
+ return "KLINT_ENGINE=rust requires an arch config or supported TypeScript rule";
427
+ }
428
+ if (unsupportedRules.length > 0) {
429
+ return [
430
+ "Rust engine currently supports arch rules and selected TypeScript rules only",
431
+ "",
432
+ "Unsupported TypeScript rules:",
433
+ ...unsupportedRules.map((rule) => `- ${rule}`),
434
+ ].join("\n");
328
435
  }
329
436
  return undefined;
330
437
  }
331
438
 
439
+ function ruleConfigSeverity(value: RuleConfigValue): string | undefined {
440
+ if (typeof value === "string") return value;
441
+ return value.severity;
442
+ }
443
+
444
+ function parseJsonPayload(value: string): unknown {
445
+ try {
446
+ return JSON.parse(value);
447
+ } catch {
448
+ return undefined;
449
+ }
450
+ }
451
+
452
+ function jsonPayloadEquals(a: unknown, b: unknown): boolean {
453
+ return JSON.stringify(canonicalJson(a)) === JSON.stringify(canonicalJson(b));
454
+ }
455
+
456
+ function canonicalJson(value: unknown): unknown {
457
+ if (Array.isArray(value)) return value.map(canonicalJson);
458
+ if (!value || typeof value !== "object") return value;
459
+ return Object.fromEntries(
460
+ Object.entries(value)
461
+ .sort(([left], [right]) => left.localeCompare(right))
462
+ .map(([key, entry]) => [key, canonicalJson(entry)])
463
+ );
464
+ }
465
+
332
466
  const AGENT_TARGETS = [
333
467
  { value: "claude", label: "Claude Code" },
334
468
  { value: "opencode", label: "opencode" },
@@ -437,11 +571,12 @@ function printHelp(): void {
437
571
  [
438
572
  "klint — agent harness for TypeScript architecture rules",
439
573
  "",
440
- "Usage: klint [--config <dir>] [--rules <file>] [--fix] [--json]",
574
+ "Usage: klint [--config <dir>] [--rules <file>] [--engine ts|rust|compare] [--fix] [--json]",
441
575
  " klint install-skill [--agents <list>] [--symlink | --copy]",
442
576
  "",
443
577
  " --config <dir> directory containing klint.yaml or klint.config.json (default: cwd)",
444
578
  " --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)",
445
580
  " --fix apply auto-fixes for fixable violations in-place",
446
581
  " --json emit structured JSON to stdout (for agent/CI consumption)",
447
582
  " --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.17.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": {
@@ -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,