@konvert7/klint 0.20.0 → 0.21.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 CHANGED
@@ -127,14 +127,14 @@ Maintainer migration notes live in [`docs/rust-engine.md`](docs/rust-engine.md).
127
127
  | `ts` | Runs the full TypeScript implementation. This is the default. | Maximum compatibility |
128
128
  | `rust` | Runs only rules supported by the Rust engine. Unsupported TS-only rules are rejected. | Strict Rust smoke tests and native-engine debugging |
129
129
  | `compare` | Runs TS and Rust on the same supported config and fails if JSON output differs. Requires `--json`. | Parity verification while porting rules |
130
- | `auto` | Runs Rust-supported rules in Rust, TS-only rules in TypeScript, then merges output. | Recommended experimental migration path |
130
+ | `auto` | Runs Rust-supported rules in Rust, TS-only rules and unsupported plugin defaults in TypeScript, then merges output. | Recommended experimental migration path |
131
131
 
132
132
  Examples:
133
133
 
134
134
  ```sh
135
135
  klint --engine auto
136
- klint --engine rust --json # strict Rust-only run; rejects unsupported TS-only rules
137
- klint --engine compare --json # parity check; rejects unsupported TS-only rules
136
+ klint --engine rust --json # strict Rust-only run; rejects unsupported rules
137
+ klint --engine compare --json # parity check; rejects unsupported rules
138
138
  ```
139
139
 
140
140
  Rust currently supports:
@@ -145,6 +145,12 @@ Rust currently supports:
145
145
  - `no-nested-template-literals`
146
146
  - `no-consecutive-array-push`
147
147
  - `no-string-match`
148
+ - `sonar/no-single-char-class`
149
+ - `sonar/prefer-at`
150
+ - `sonar/prefer-nullish-coalescing-assign`
151
+ - `sonar/prefer-string-replaceall`
152
+ - `sonar/prefer-string-raw`
153
+ - `sonar/prefer-string-raw-regexp`
148
154
 
149
155
  These rules remain TypeScript-owned because they need TypeScript semantic information:
150
156
 
@@ -281,8 +287,8 @@ arch:
281
287
  | `no-sync-in-async` | No | Sync filesystem calls inside async functions |
282
288
  | `no-floating-promise` | Yes | Promise-returning call whose result is discarded |
283
289
  | `no-misused-promises` | Yes | Async function passed where a sync callback is expected |
284
- | `no-async-predicate` | No | Async predicate passed to array filtering/search methods |
285
- | `no-date-equality` | No | Date values compared by object identity |
290
+ | `no-async-predicate` | Yes | Async predicate passed to array filtering/search methods |
291
+ | `no-date-equality` | Yes | Date values compared by object identity |
286
292
  | `no-optional-chain-on-non-nullable` | Yes | Optional chaining on values TypeScript knows are non-nullable |
287
293
  | `no-object-in-template` | Yes | Object values interpolated into template strings |
288
294
  | `no-nested-template-literals` | No | Template literals nested inside template literals |
package/cli.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env bun
2
- import { spawnSync } from "node:child_process";
2
+ import { type SpawnSyncReturns, spawnSync } from "node:child_process";
3
3
  import {
4
4
  cpSync,
5
5
  existsSync,
@@ -27,12 +27,18 @@ import type {
27
27
  import { BUILT_IN_PLUGINS } from "./plugins/index";
28
28
  import { BUILT_IN_RULES } from "./rules/index";
29
29
 
30
- const RUST_SUPPORTED_TYPESCRIPT_RULES = new Set([
30
+ const RUST_SUPPORTED_RULES = new Set([
31
31
  "no-consecutive-array-push",
32
32
  "no-nested-template-literals",
33
33
  "no-string-match",
34
34
  "no-sync-in-async",
35
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",
36
42
  ]);
37
43
 
38
44
  interface CliOptions {
@@ -106,7 +112,7 @@ export async function main(opts: CliOptions = {}): Promise<void> {
106
112
  const root = resolve(configDir, raw.root ?? ".");
107
113
 
108
114
  if (engine === "rust") {
109
- runRustEngine({ configDir, fix, json, raw, rulesFile, startedAt });
115
+ runRustEngine({ fix, json, raw, root, rulesFile, startedAt });
110
116
  return;
111
117
  }
112
118
  if (
@@ -163,7 +169,14 @@ export async function main(opts: CliOptions = {}): Promise<void> {
163
169
  );
164
170
 
165
171
  if (engine === "compare") {
166
- runCompareEngine({ configDir, fix, json, raw, rulesFile, tsViolations: violations });
172
+ runCompareEngine({
173
+ fix,
174
+ json,
175
+ raw,
176
+ root,
177
+ rulesFile,
178
+ tsViolations: violations,
179
+ });
167
180
  return;
168
181
  }
169
182
 
@@ -246,14 +259,13 @@ function writeTextOutput(
246
259
  }
247
260
 
248
261
  function runRustEngine({
249
- configDir,
250
262
  fix,
251
263
  json,
252
264
  raw,
265
+ root,
253
266
  rulesFile,
254
267
  startedAt,
255
268
  }: {
256
- configDir: string;
257
269
  fix: boolean;
258
270
  json: boolean;
259
271
  raw: {
@@ -261,6 +273,7 @@ function runRustEngine({
261
273
  rules?: Record<string, RuleConfigValue>;
262
274
  arch?: unknown;
263
275
  };
276
+ root: string;
264
277
  rulesFile?: string;
265
278
  startedAt: number;
266
279
  }): void {
@@ -275,16 +288,22 @@ function runRustEngine({
275
288
  process.exit(1);
276
289
  }
277
290
 
278
- const command = resolveRustEngineCommand(configDir);
279
- if (process.argv.includes("--debug") || process.argv.includes("-debug")) {
280
- process.stderr.write(
281
- `[klint:debug] rust engine: ${command.bin} ${command.args.join(" ")}\n`
282
- );
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();
283
306
  }
284
- const result = spawnSync(command.bin, command.args, {
285
- cwd: import.meta.dir,
286
- encoding: "utf-8",
287
- });
288
307
 
289
308
  if (result.stderr) process.stderr.write(result.stderr);
290
309
  if (json) {
@@ -301,14 +320,13 @@ function runRustEngine({
301
320
  }
302
321
 
303
322
  function runCompareEngine({
304
- configDir,
305
323
  fix,
306
324
  json,
307
325
  raw,
326
+ root,
308
327
  rulesFile,
309
328
  tsViolations,
310
329
  }: {
311
- configDir: string;
312
330
  fix: boolean;
313
331
  json: boolean;
314
332
  raw: {
@@ -316,6 +334,7 @@ function runCompareEngine({
316
334
  rules?: Record<string, RuleConfigValue>;
317
335
  arch?: unknown;
318
336
  };
337
+ root: string;
319
338
  rulesFile?: string;
320
339
  tsViolations: ReturnType<typeof runKlint>;
321
340
  }): void {
@@ -336,11 +355,17 @@ function runCompareEngine({
336
355
  }
337
356
 
338
357
  const tsResult = toJsonCliResult(tsViolations);
339
- const rustCommand = resolveRustEngineCommand(configDir);
340
- const rustResult = spawnSync(rustCommand.bin, rustCommand.args, {
341
- cwd: import.meta.dir,
342
- encoding: "utf-8",
343
- });
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
+ }
344
369
 
345
370
  const rustStatus = rustResult.status ?? 1;
346
371
  if (rustResult.stderr) process.stderr.write(rustResult.stderr);
@@ -367,6 +392,35 @@ function runCompareEngine({
367
392
  process.exit(tsResult.status);
368
393
  }
369
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
+
370
424
  function runAutoEngine({
371
425
  fix,
372
426
  json,
@@ -401,12 +455,13 @@ function runAutoEngine({
401
455
  process.exit(1);
402
456
  }
403
457
 
404
- const { rustRules, tsRules } = splitRulesForAuto(raw.rules ?? {});
458
+ const effectiveRules = resolveEffectiveRules(raw.plugins, raw.rules ?? {});
459
+ const { rustRules, tsRules } = splitRulesForAuto(effectiveRules);
405
460
  const tsViolations = runKlint(
406
461
  {
407
462
  root,
408
463
  include: raw.include ?? ["."],
409
- plugins: raw.plugins,
464
+ plugins: [],
410
465
  rules: { ...customRulesMap, ...tsRules },
411
466
  },
412
467
  customRules
@@ -434,7 +489,7 @@ function splitRulesForAuto(rules: Record<string, RuleConfigValue>): {
434
489
  const tsRules: Record<string, RuleConfigValue> = {};
435
490
 
436
491
  for (const [name, value] of Object.entries(rules)) {
437
- if (RUST_SUPPORTED_TYPESCRIPT_RULES.has(name)) rustRules[name] = value;
492
+ if (RUST_SUPPORTED_RULES.has(name)) rustRules[name] = value;
438
493
  else tsRules[name] = value;
439
494
  }
440
495
 
@@ -643,31 +698,51 @@ function rustEngineUnsupportedReason({
643
698
  }): string | undefined {
644
699
  if (fix) return "KLINT_ENGINE=rust does not support --fix";
645
700
  if (rulesFile) return "KLINT_ENGINE=rust does not support --rules";
646
- if ((raw.plugins?.length ?? 0) > 0) return "KLINT_ENGINE=rust does not support plugins";
647
- const activeSupportedRules = Object.entries(raw.rules ?? {}).filter(
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(
648
710
  ([name, value]) =>
649
- ruleConfigSeverity(value) !== "off" && RUST_SUPPORTED_TYPESCRIPT_RULES.has(name)
711
+ ruleConfigSeverity(value) !== "off" && RUST_SUPPORTED_RULES.has(name)
650
712
  );
651
- const unsupportedRules = Object.entries(raw.rules ?? {})
713
+ const unsupportedRules = Object.entries(effectiveRules)
652
714
  .filter(
653
715
  ([name, value]) =>
654
- ruleConfigSeverity(value) !== "off" && !RUST_SUPPORTED_TYPESCRIPT_RULES.has(name)
716
+ ruleConfigSeverity(value) !== "off" && !RUST_SUPPORTED_RULES.has(name)
655
717
  )
656
718
  .map(([name]) => name);
657
719
  if (!raw.arch && activeSupportedRules.length === 0 && unsupportedRules.length === 0) {
658
- return "KLINT_ENGINE=rust requires an arch config or supported TypeScript rule";
720
+ return "KLINT_ENGINE=rust requires an arch config or supported rule";
659
721
  }
660
722
  if (unsupportedRules.length > 0) {
661
723
  return [
662
- "Rust engine currently supports arch rules and selected TypeScript rules only",
724
+ "Rust engine currently supports arch rules and selected rules only",
663
725
  "",
664
- "Unsupported TypeScript rules:",
726
+ "Unsupported rules:",
665
727
  ...unsupportedRules.map((rule) => `- ${rule}`),
666
728
  ].join("\n");
667
729
  }
668
730
  return undefined;
669
731
  }
670
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
+
671
746
  function ruleConfigSeverity(value: RuleConfigValue): string | undefined {
672
747
  if (typeof value === "string") return value;
673
748
  return value.severity;
@@ -686,7 +761,11 @@ function jsonPayloadEquals(a: unknown, b: unknown): boolean {
686
761
  }
687
762
 
688
763
  function canonicalJson(value: unknown): unknown {
689
- if (Array.isArray(value)) return value.map(canonicalJson);
764
+ if (Array.isArray(value)) {
765
+ return value
766
+ .map(canonicalJson)
767
+ .sort((left, right) => JSON.stringify(left).localeCompare(JSON.stringify(right)));
768
+ }
690
769
  if (!value || typeof value !== "object") return value;
691
770
  return Object.fromEntries(
692
771
  Object.entries(value)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@konvert7/klint",
3
- "version": "0.20.0",
3
+ "version": "0.21.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": {
@@ -40,6 +40,8 @@
40
40
  "pack:check": "bun tests/pack-check.ts",
41
41
  "native:stage": "bun tests/stage-native-binary.ts",
42
42
  "native:smoke": "bun tests/smoke-native-binary.ts",
43
+ "python:stage": "bun tests/stage-python-wheel.ts",
44
+ "python:smoke": "bun tests/smoke-python-wheel.ts",
43
45
  "native:release:dry-run": "bun tools/prepare-native-release.ts --version 0.0.0-dry-run",
44
46
  "check": "biome check",
45
47
  "check-write": "biome check --write",
@@ -62,10 +64,10 @@
62
64
  "zod": "^4.4.3"
63
65
  },
64
66
  "optionalDependencies": {
65
- "@konvert7/klint-darwin-arm64": "0.20.0",
66
- "@konvert7/klint-darwin-x64": "0.20.0",
67
- "@konvert7/klint-linux-x64": "0.20.0",
68
- "@konvert7/klint-win32-x64": "0.20.0"
67
+ "@konvert7/klint-darwin-arm64": "0.21.0",
68
+ "@konvert7/klint-darwin-x64": "0.21.0",
69
+ "@konvert7/klint-linux-x64": "0.21.0",
70
+ "@konvert7/klint-win32-x64": "0.21.0"
69
71
  },
70
72
  "devDependencies": {
71
73
  "@biomejs/biome": "2.4.7",
@@ -6,25 +6,20 @@ import type { KlintRule } from "../core/types";
6
6
  export const preferNullishCoalescingAssign: KlintRule = {
7
7
  meta: {
8
8
  description:
9
- "Flags `x = x ?? y` patterns — `x ??= y` is the dedicated nullish assignment operator.",
9
+ "Flags explicit nullish assignment guards — `x ??= y` is the dedicated nullish assignment operator.",
10
10
  examples: ["sonar/prefer-nullish-coalescing-assign: warn"],
11
11
  },
12
12
  check({ files, root, fileContents }, violations) {
13
13
  for (const file of files) {
14
14
  const content = fileContents.get(file) ?? "";
15
15
  walkAst(file, content, (node, src) => {
16
- if (
17
- !ts.isIfStatement(node) ||
18
- node.elseStatement !== undefined ||
19
- !ts.isPrefixUnaryExpression(node.expression) ||
20
- node.expression.operator !== ts.SyntaxKind.ExclamationToken
21
- )
22
- return;
16
+ if (!ts.isIfStatement(node) || node.elseStatement !== undefined) return;
23
17
 
24
18
  const assignExpr = extractAssignment(node.thenStatement);
25
19
  if (!assignExpr) return;
26
20
 
27
- const xText = node.expression.operand.getText(src);
21
+ const xText = nullishGuardTarget(node.expression, src);
22
+ if (!xText) return;
28
23
  if (assignExpr.left.getText(src) !== xText) return;
29
24
 
30
25
  const yText = assignExpr.right.getText(src);
@@ -35,7 +30,7 @@ export const preferNullishCoalescingAssign: KlintRule = {
35
30
  violations.push({
36
31
  file: relativeSlashPath(root, file),
37
32
  line: s + 1,
38
- message: `Prefer \`${xText} ??= ${yText}\` over \`if (!${xText}) ${xText} = ${yText}\` — ??= only assigns when null or undefined.`,
33
+ message: `Prefer \`${xText} ??= ${yText}\` over explicit nullish guard assignment — ??= only assigns when null or undefined.`,
39
34
  fix: {
40
35
  startLine: s + 1,
41
36
  endLine: e + 1,
@@ -66,6 +61,49 @@ function isAssign(node: ts.Expression): node is ts.BinaryExpression {
66
61
  );
67
62
  }
68
63
 
64
+ function nullishGuardTarget(
65
+ expression: ts.Expression,
66
+ src: ts.SourceFile
67
+ ): string | undefined {
68
+ if (!ts.isBinaryExpression(expression)) return undefined;
69
+
70
+ const direct = equalityNullishTarget(expression, src);
71
+ if (direct) return direct.target;
72
+
73
+ if (expression.operatorToken.kind !== ts.SyntaxKind.BarBarToken) return undefined;
74
+
75
+ const left = equalityNullishTarget(expression.left, src);
76
+ const right = equalityNullishTarget(expression.right, src);
77
+ if (!left || !right || left.target !== right.target) return undefined;
78
+
79
+ const values = new Set([left.value, right.value]);
80
+ if (values.has("null") && values.has("undefined")) return left.target;
81
+ return undefined;
82
+ }
83
+
84
+ function equalityNullishTarget(
85
+ expression: ts.Expression,
86
+ src: ts.SourceFile
87
+ ): { target: string; value: "null" | "undefined" } | undefined {
88
+ if (!ts.isBinaryExpression(expression)) return undefined;
89
+
90
+ const operator = expression.operatorToken.kind;
91
+ if (
92
+ operator !== ts.SyntaxKind.EqualsEqualsToken &&
93
+ operator !== ts.SyntaxKind.EqualsEqualsEqualsToken
94
+ )
95
+ return undefined;
96
+
97
+ const left = expression.left.getText(src);
98
+ const right = expression.right.getText(src);
99
+
100
+ if (right === "null") return { target: left, value: "null" };
101
+ if (right === "undefined") return { target: left, value: "undefined" };
102
+ if (left === "null") return { target: right, value: "null" };
103
+ if (left === "undefined") return { target: right, value: "undefined" };
104
+ return undefined;
105
+ }
106
+
69
107
  function getIndent(src: ts.SourceFile, node: ts.Node): string {
70
108
  const { line } = src.getLineAndCharacterOfPosition(node.getStart());
71
109
  const lineStart = src.getPositionOfLineAndCharacter(line, 0);