@konvert7/klint 0.19.2 → 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,23 +127,30 @@ 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:
141
141
 
142
142
  - `arch` rules: imports, forbidden patterns, singleton locations
143
143
  - `no-unguarded-json-parse`
144
+ - `no-sync-in-async`
144
145
  - `no-nested-template-literals`
145
146
  - `no-consecutive-array-push`
146
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`
147
154
 
148
155
  These rules remain TypeScript-owned because they need TypeScript semantic information:
149
156
 
@@ -280,8 +287,8 @@ arch:
280
287
  | `no-sync-in-async` | No | Sync filesystem calls inside async functions |
281
288
  | `no-floating-promise` | Yes | Promise-returning call whose result is discarded |
282
289
  | `no-misused-promises` | Yes | Async function passed where a sync callback is expected |
283
- | `no-async-predicate` | No | Async predicate passed to array filtering/search methods |
284
- | `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 |
285
292
  | `no-optional-chain-on-non-nullable` | Yes | Optional chaining on values TypeScript knows are non-nullable |
286
293
  | `no-object-in-template` | Yes | Object values interpolated into template strings |
287
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,11 +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
+ "no-sync-in-async",
34
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",
35
42
  ]);
36
43
 
37
44
  interface CliOptions {
@@ -105,7 +112,7 @@ export async function main(opts: CliOptions = {}): Promise<void> {
105
112
  const root = resolve(configDir, raw.root ?? ".");
106
113
 
107
114
  if (engine === "rust") {
108
- runRustEngine({ configDir, fix, json, raw, rulesFile, startedAt });
115
+ runRustEngine({ fix, json, raw, root, rulesFile, startedAt });
109
116
  return;
110
117
  }
111
118
  if (
@@ -162,7 +169,14 @@ export async function main(opts: CliOptions = {}): Promise<void> {
162
169
  );
163
170
 
164
171
  if (engine === "compare") {
165
- runCompareEngine({ configDir, fix, json, raw, rulesFile, tsViolations: violations });
172
+ runCompareEngine({
173
+ fix,
174
+ json,
175
+ raw,
176
+ root,
177
+ rulesFile,
178
+ tsViolations: violations,
179
+ });
166
180
  return;
167
181
  }
168
182
 
@@ -245,14 +259,13 @@ function writeTextOutput(
245
259
  }
246
260
 
247
261
  function runRustEngine({
248
- configDir,
249
262
  fix,
250
263
  json,
251
264
  raw,
265
+ root,
252
266
  rulesFile,
253
267
  startedAt,
254
268
  }: {
255
- configDir: string;
256
269
  fix: boolean;
257
270
  json: boolean;
258
271
  raw: {
@@ -260,6 +273,7 @@ function runRustEngine({
260
273
  rules?: Record<string, RuleConfigValue>;
261
274
  arch?: unknown;
262
275
  };
276
+ root: string;
263
277
  rulesFile?: string;
264
278
  startedAt: number;
265
279
  }): void {
@@ -274,16 +288,22 @@ function runRustEngine({
274
288
  process.exit(1);
275
289
  }
276
290
 
277
- const command = resolveRustEngineCommand(configDir);
278
- if (process.argv.includes("--debug") || process.argv.includes("-debug")) {
279
- process.stderr.write(
280
- `[klint:debug] rust engine: ${command.bin} ${command.args.join(" ")}\n`
281
- );
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();
282
306
  }
283
- const result = spawnSync(command.bin, command.args, {
284
- cwd: import.meta.dir,
285
- encoding: "utf-8",
286
- });
287
307
 
288
308
  if (result.stderr) process.stderr.write(result.stderr);
289
309
  if (json) {
@@ -300,14 +320,13 @@ function runRustEngine({
300
320
  }
301
321
 
302
322
  function runCompareEngine({
303
- configDir,
304
323
  fix,
305
324
  json,
306
325
  raw,
326
+ root,
307
327
  rulesFile,
308
328
  tsViolations,
309
329
  }: {
310
- configDir: string;
311
330
  fix: boolean;
312
331
  json: boolean;
313
332
  raw: {
@@ -315,6 +334,7 @@ function runCompareEngine({
315
334
  rules?: Record<string, RuleConfigValue>;
316
335
  arch?: unknown;
317
336
  };
337
+ root: string;
318
338
  rulesFile?: string;
319
339
  tsViolations: ReturnType<typeof runKlint>;
320
340
  }): void {
@@ -335,11 +355,17 @@ function runCompareEngine({
335
355
  }
336
356
 
337
357
  const tsResult = toJsonCliResult(tsViolations);
338
- const rustCommand = resolveRustEngineCommand(configDir);
339
- const rustResult = spawnSync(rustCommand.bin, rustCommand.args, {
340
- cwd: import.meta.dir,
341
- encoding: "utf-8",
342
- });
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
+ }
343
369
 
344
370
  const rustStatus = rustResult.status ?? 1;
345
371
  if (rustResult.stderr) process.stderr.write(rustResult.stderr);
@@ -366,6 +392,35 @@ function runCompareEngine({
366
392
  process.exit(tsResult.status);
367
393
  }
368
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
+
369
424
  function runAutoEngine({
370
425
  fix,
371
426
  json,
@@ -400,12 +455,13 @@ function runAutoEngine({
400
455
  process.exit(1);
401
456
  }
402
457
 
403
- const { rustRules, tsRules } = splitRulesForAuto(raw.rules ?? {});
458
+ const effectiveRules = resolveEffectiveRules(raw.plugins, raw.rules ?? {});
459
+ const { rustRules, tsRules } = splitRulesForAuto(effectiveRules);
404
460
  const tsViolations = runKlint(
405
461
  {
406
462
  root,
407
463
  include: raw.include ?? ["."],
408
- plugins: raw.plugins,
464
+ plugins: [],
409
465
  rules: { ...customRulesMap, ...tsRules },
410
466
  },
411
467
  customRules
@@ -433,7 +489,7 @@ function splitRulesForAuto(rules: Record<string, RuleConfigValue>): {
433
489
  const tsRules: Record<string, RuleConfigValue> = {};
434
490
 
435
491
  for (const [name, value] of Object.entries(rules)) {
436
- if (RUST_SUPPORTED_TYPESCRIPT_RULES.has(name)) rustRules[name] = value;
492
+ if (RUST_SUPPORTED_RULES.has(name)) rustRules[name] = value;
437
493
  else tsRules[name] = value;
438
494
  }
439
495
 
@@ -642,31 +698,51 @@ function rustEngineUnsupportedReason({
642
698
  }): string | undefined {
643
699
  if (fix) return "KLINT_ENGINE=rust does not support --fix";
644
700
  if (rulesFile) return "KLINT_ENGINE=rust does not support --rules";
645
- if ((raw.plugins?.length ?? 0) > 0) return "KLINT_ENGINE=rust does not support plugins";
646
- 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(
647
710
  ([name, value]) =>
648
- ruleConfigSeverity(value) !== "off" && RUST_SUPPORTED_TYPESCRIPT_RULES.has(name)
711
+ ruleConfigSeverity(value) !== "off" && RUST_SUPPORTED_RULES.has(name)
649
712
  );
650
- const unsupportedRules = Object.entries(raw.rules ?? {})
713
+ const unsupportedRules = Object.entries(effectiveRules)
651
714
  .filter(
652
715
  ([name, value]) =>
653
- ruleConfigSeverity(value) !== "off" && !RUST_SUPPORTED_TYPESCRIPT_RULES.has(name)
716
+ ruleConfigSeverity(value) !== "off" && !RUST_SUPPORTED_RULES.has(name)
654
717
  )
655
718
  .map(([name]) => name);
656
719
  if (!raw.arch && activeSupportedRules.length === 0 && unsupportedRules.length === 0) {
657
- return "KLINT_ENGINE=rust requires an arch config or supported TypeScript rule";
720
+ return "KLINT_ENGINE=rust requires an arch config or supported rule";
658
721
  }
659
722
  if (unsupportedRules.length > 0) {
660
723
  return [
661
- "Rust engine currently supports arch rules and selected TypeScript rules only",
724
+ "Rust engine currently supports arch rules and selected rules only",
662
725
  "",
663
- "Unsupported TypeScript rules:",
726
+ "Unsupported rules:",
664
727
  ...unsupportedRules.map((rule) => `- ${rule}`),
665
728
  ].join("\n");
666
729
  }
667
730
  return undefined;
668
731
  }
669
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
+
670
746
  function ruleConfigSeverity(value: RuleConfigValue): string | undefined {
671
747
  if (typeof value === "string") return value;
672
748
  return value.severity;
@@ -685,7 +761,11 @@ function jsonPayloadEquals(a: unknown, b: unknown): boolean {
685
761
  }
686
762
 
687
763
  function canonicalJson(value: unknown): unknown {
688
- 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
+ }
689
769
  if (!value || typeof value !== "object") return value;
690
770
  return Object.fromEntries(
691
771
  Object.entries(value)
package/core/arch.ts CHANGED
@@ -1,10 +1,9 @@
1
- import { dirname, isAbsolute, relative, resolve } from "node:path";
1
+ import { dirname, isAbsolute, resolve } from "node:path";
2
2
  import ts from "typescript";
3
3
  import { walkAst } from "./ast";
4
+ import { relativeSlashPath, toSlashPath } from "./paths";
4
5
  import type { ArchConfig, Severity, Violation } from "./types";
5
6
 
6
- const toSlash = (p: string) => p.replaceAll("\\", "/");
7
-
8
7
  interface AliasEntry {
9
8
  /** The prefix to match (pattern with `/*` stripped, e.g. `"@"` from `"@/*"`). */
10
9
  prefix: string;
@@ -31,7 +30,7 @@ function loadPathAliases(root: string): AliasEntry[] {
31
30
  const prefix = isWildcard ? pattern.slice(0, -2) : pattern;
32
31
  const targetStr = targets[0];
33
32
  const targetBase = targetStr.endsWith("/*") ? targetStr.slice(0, -2) : targetStr;
34
- entries.push({ prefix, base: toSlash(resolve(base, targetBase)), isWildcard });
33
+ entries.push({ prefix, base: toSlashPath(resolve(base, targetBase)), isWildcard });
35
34
  }
36
35
  return entries;
37
36
  }
@@ -41,7 +40,7 @@ function resolveAlias(importPath: string, aliases: AliasEntry[]): string | undef
41
40
  if (alias.isWildcard) {
42
41
  const matchPrefix = `${alias.prefix}/`;
43
42
  if (importPath.startsWith(matchPrefix)) {
44
- return toSlash(resolve(alias.base, importPath.slice(matchPrefix.length)));
43
+ return toSlashPath(resolve(alias.base, importPath.slice(matchPrefix.length)));
45
44
  }
46
45
  } else if (importPath === alias.prefix) {
47
46
  return alias.base;
@@ -62,7 +61,7 @@ function isBareSpecifier(path: string): boolean {
62
61
  }
63
62
 
64
63
  function globToPrefix(glob: string, root: string): string {
65
- return toSlash(resolve(root, glob.split("/**")[0].split("/*")[0].split("*")[0]));
64
+ return toSlashPath(resolve(root, glob.split("/**")[0].split("/*")[0].split("*")[0]));
66
65
  }
67
66
 
68
67
  function resolveGlobs(
@@ -145,7 +144,7 @@ function scanImports(
145
144
  if (isBareSpecifier(path)) {
146
145
  resolved = resolveAlias(path, aliases) ?? path;
147
146
  } else {
148
- resolved = toSlash(resolve(fileDir, path));
147
+ resolved = toSlashPath(resolve(fileDir, path));
149
148
  }
150
149
  const { line } = src.getLineAndCharacterOfPosition(specifierNode.getStart());
151
150
  records.push({ path, resolved, isTypeOnly, line: line + 1 });
@@ -176,7 +175,7 @@ export function runArchRules(
176
175
  if (isBareSpecifier(imp.resolved)) continue;
177
176
  if (rule["type-only"] === "allow" && imp.isTypeOnly) continue;
178
177
 
179
- const relFile = relative(root, file).replaceAll("\\", "/");
178
+ const relFile = relativeSlashPath(root, file);
180
179
 
181
180
  if (rule.deny !== undefined) {
182
181
  const denyPrefixes = resolveLayerPrefixes(rule.deny, layers, root);
@@ -235,7 +234,7 @@ export function runArchRules(
235
234
 
236
235
  for (const rule of arch.singleton ?? []) {
237
236
  const severity: Severity = rule.severity ?? "error";
238
- const onlyFile = toSlash(resolve(root, rule.only));
237
+ const onlyFile = toSlashPath(resolve(root, rule.only));
239
238
  const inFiles = rule.in
240
239
  ? resolveLayerFiles(rule.in, layers, root, allFiles)
241
240
  : allFiles;
@@ -294,7 +293,7 @@ function scanJsxElements(
294
293
  if (!targets.has(tagName.text)) return;
295
294
  const { line } = src.getLineAndCharacterOfPosition(tagName.getStart());
296
295
  violations.push({
297
- file: relative(root, file).replaceAll("\\", "/"),
296
+ file: relativeSlashPath(root, file),
298
297
  line: line + 1,
299
298
  message,
300
299
  rule: ruleName,
@@ -321,7 +320,7 @@ function scanLinesForPattern(
321
320
  for (let i = 0; i < lines.length; i++) {
322
321
  if (lines[i].includes(pattern)) {
323
322
  violations.push({
324
- file: relative(root, file).replaceAll("\\", "/"),
323
+ file: relativeSlashPath(root, file),
325
324
  line: i + 1,
326
325
  message,
327
326
  rule: ruleName,
package/core/paths.ts ADDED
@@ -0,0 +1,9 @@
1
+ import { relative } from "node:path";
2
+
3
+ export function toSlashPath(path: string): string {
4
+ return path.replaceAll("\\", "/");
5
+ }
6
+
7
+ export function relativeSlashPath(from: string, to: string): string {
8
+ return toSlashPath(relative(from, to));
9
+ }
@@ -1,6 +1,6 @@
1
- import { relative } from "node:path";
2
1
  import ts from "typescript";
3
2
  import { createProgram } from "./ast";
3
+ import { relativeSlashPath } from "./paths";
4
4
  import type { KlintRule, RawViolation, RuleMeta } from "./types";
5
5
  import { defineRule } from "./types";
6
6
 
@@ -34,7 +34,7 @@ export function defineAstRule(opts: {
34
34
  report(node, message) {
35
35
  const { line } = sourceFile.getLineAndCharacterOfPosition(node.getStart());
36
36
  violations.push({
37
- file: relative(root, sourceFile.fileName),
37
+ file: relativeSlashPath(root, sourceFile.fileName),
38
38
  line: line + 1,
39
39
  message,
40
40
  });
package/core/runner.ts CHANGED
@@ -1,9 +1,10 @@
1
1
  import { readdirSync, readFileSync } from "node:fs";
2
- import { join, relative, resolve } from "node:path";
2
+ import { join, resolve } from "node:path";
3
3
  import { BUILT_IN_PLUGINS } from "../plugins/index";
4
4
  import { BUILT_IN_RULES } from "../rules/index";
5
5
  import { runArchRules } from "./arch";
6
6
  import { clearAstCache } from "./ast";
7
+ import { relativeSlashPath, toSlashPath } from "./paths";
7
8
  import type {
8
9
  KlintConfig,
9
10
  KlintRule,
@@ -29,7 +30,7 @@ function walk(dir: string, root: string, excludes: string[] = []): string[] {
29
30
  const out: string[] = [];
30
31
  for (const entry of readdirSync(dir, { withFileTypes: true })) {
31
32
  const full = join(dir, entry.name);
32
- const rel = relative(root, full).replaceAll("\\", "/");
33
+ const rel = relativeSlashPath(root, full);
33
34
  if (entry.isDirectory()) {
34
35
  if (
35
36
  excludes.some(
@@ -38,8 +39,7 @@ function walk(dir: string, root: string, excludes: string[] = []): string[] {
38
39
  )
39
40
  continue;
40
41
  out.push(...walk(full, root, excludes));
41
- } else if (/\.(tsx?|jsx?|mts|cts)$/.test(entry.name))
42
- out.push(full.replaceAll("\\", "/"));
42
+ } else if (/\.(tsx?|jsx?|mts|cts)$/.test(entry.name)) out.push(toSlashPath(full));
43
43
  }
44
44
  return out;
45
45
  }
@@ -68,8 +68,8 @@ function resolveFiles(
68
68
  }
69
69
 
70
70
  function matchPattern(relPath: string, pattern: string): boolean {
71
- const norm = relPath.replaceAll("\\", "/");
72
- const p = pattern.replaceAll("\\", "/");
71
+ const norm = toSlashPath(relPath);
72
+ const p = toSlashPath(pattern);
73
73
  if (p === "." || p === "**") return true;
74
74
  if (!p.includes("*")) return norm === p || norm.startsWith(`${p}/`);
75
75
  return globToRegExp(p).test(norm);
@@ -107,7 +107,7 @@ function applyPatterns(files: string[], patterns: string[], root: string): strin
107
107
  const includes = patterns.filter((p) => !p.startsWith("!"));
108
108
  const excludes = patterns.filter((p) => p.startsWith("!")).map((p) => p.slice(1));
109
109
  return files.filter((file) => {
110
- const rel = relative(root, file).replaceAll("\\", "/");
110
+ const rel = relativeSlashPath(root, file);
111
111
  const included = includes.length === 0 || includes.some((p) => matchPattern(rel, p));
112
112
  const excluded = excludes.some((p) => matchPattern(rel, p));
113
113
  return included && !excluded;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@konvert7/klint",
3
- "version": "0.19.2",
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.19.2",
66
- "@konvert7/klint-darwin-x64": "0.19.2",
67
- "@konvert7/klint-linux-x64": "0.19.2",
68
- "@konvert7/klint-win32-x64": "0.19.2"
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",
@@ -1,6 +1,6 @@
1
- import { relative } from "node:path";
2
1
  import ts from "typescript";
3
2
  import { walkAst } from "../core/ast";
3
+ import { relativeSlashPath } from "../core/paths";
4
4
  import type { KlintRule } from "../core/types";
5
5
 
6
6
  export const noConsecutiveArrayPush: KlintRule = {
@@ -26,7 +26,7 @@ export const noConsecutiveArrayPush: KlintRule = {
26
26
  statements[runStart].getStart()
27
27
  );
28
28
  violations.push({
29
- file: relative(root, file).replaceAll("\\", "/"),
29
+ file: relativeSlashPath(root, file),
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
  });
@@ -1,6 +1,6 @@
1
- import { relative } from "node:path";
2
1
  import ts from "typescript";
3
2
  import { walkAst } from "../core/ast";
3
+ import { relativeSlashPath } from "../core/paths";
4
4
  import type { KlintRule } from "../core/types";
5
5
 
6
6
  export const noNestedTemplateLiterals: KlintRule = {
@@ -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).replaceAll("\\", "/"),
35
+ file: relativeSlashPath(root, file),
36
36
  line: line + 1,
37
37
  message:
38
38
  "Nested template literal — extract the inner template to a variable to improve readability.",
@@ -87,7 +87,7 @@ function wouldRenderAsObjectObject(type: ts.Type, checker: ts.TypeChecker): bool
87
87
  const fileName = d.getSourceFile().fileName;
88
88
  return (
89
89
  !fileName.includes("/typescript/lib/lib") &&
90
- !fileName.includes("\\typescript\\lib\\lib")
90
+ !fileName.includes(String.raw`\typescript\lib\lib`)
91
91
  );
92
92
  });
93
93
 
@@ -1,6 +1,6 @@
1
- import { relative } from "node:path";
2
1
  import ts from "typescript";
3
2
  import { walkAst } from "../core/ast";
3
+ import { relativeSlashPath } from "../core/paths";
4
4
  import { buildNodeReplacementFix } from "../core/rule-helpers";
5
5
  import type { KlintRule } from "../core/types";
6
6
 
@@ -99,7 +99,7 @@ export const noSingleCharClass: KlintRule = {
99
99
  const fix = buildNodeReplacementFix(src, node, fixedRegex);
100
100
 
101
101
  violations.push({
102
- file: relative(root, file),
102
+ file: relativeSlashPath(root, file),
103
103
  line: fix.startLine,
104
104
  message: `Character class [${toFix[0].inner}] contains a single element — remove the brackets.`,
105
105
  fix,
@@ -1,6 +1,6 @@
1
- import { relative } from "node:path";
2
1
  import ts from "typescript";
3
2
  import { walkAst } from "../core/ast";
3
+ import { relativeSlashPath } from "../core/paths";
4
4
  import type { KlintRule } from "../core/types";
5
5
 
6
6
  export const noStringMatch: KlintRule = {
@@ -45,7 +45,7 @@ export const noStringMatch: KlintRule = {
45
45
  })()
46
46
  : undefined;
47
47
  violations.push({
48
- file: relative(root, file).replaceAll("\\", "/"),
48
+ file: relativeSlashPath(root, file),
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,
@@ -1,6 +1,6 @@
1
- import { relative } from "node:path";
2
1
  import ts from "typescript";
3
2
  import { nearestFunctionIsAsync, walkAst } from "../core/ast";
3
+ import { relativeSlashPath } from "../core/paths";
4
4
  import type { KlintRule } from "../core/types";
5
5
 
6
6
  export const noSyncInAsync: KlintRule = {
@@ -31,7 +31,7 @@ export const noSyncInAsync: KlintRule = {
31
31
  ) {
32
32
  const { line } = src.getLineAndCharacterOfPosition(node.getStart());
33
33
  violations.push({
34
- file: relative(root, file),
34
+ file: relativeSlashPath(root, file),
35
35
  line: line + 1,
36
36
  message: `${name}() blocks the event loop inside an async function — use the async equivalent from node:fs/promises.`,
37
37
  });
@@ -1,6 +1,6 @@
1
- import { relative } from "node:path";
2
1
  import ts from "typescript";
3
2
  import { isInsideTry, walkAst } from "../core/ast";
3
+ import { relativeSlashPath } from "../core/paths";
4
4
  import type { KlintRule } from "../core/types";
5
5
 
6
6
  export const noUnguardedJsonParse: KlintRule = {
@@ -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).replaceAll("\\", "/"),
26
+ file: relativeSlashPath(root, file),
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.",
@@ -1,6 +1,6 @@
1
- import { relative } from "node:path";
2
1
  import ts from "typescript";
3
2
  import { walkAst } from "../core/ast";
3
+ import { relativeSlashPath } from "../core/paths";
4
4
  import { buildNodeReplacementFix } from "../core/rule-helpers";
5
5
  import type { KlintRule } from "../core/types";
6
6
 
@@ -35,7 +35,7 @@ export const preferAt: KlintRule = {
35
35
  const fix = buildNodeReplacementFix(src, node, fixedCall);
36
36
 
37
37
  violations.push({
38
- file: relative(root, file),
38
+ file: relativeSlashPath(root, file),
39
39
  line: fix.startLine,
40
40
  message: `Prefer ${baseText}.at(-${n}) over ${baseText}[${baseText}.length - ${n}] for cleaner negative indexing.`,
41
41
  fix,
@@ -1,30 +1,25 @@
1
- import { relative } from "node:path";
2
1
  import ts from "typescript";
3
2
  import { walkAst } from "../core/ast";
3
+ import { relativeSlashPath } from "../core/paths";
4
4
  import type { KlintRule } from "../core/types";
5
5
 
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);
@@ -33,9 +28,9 @@ export const preferNullishCoalescingAssign: KlintRule = {
33
28
  const indent = getIndent(src, node);
34
29
 
35
30
  violations.push({
36
- file: relative(root, file),
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);
@@ -1,6 +1,6 @@
1
- import { relative } from "node:path";
2
1
  import ts from "typescript";
3
2
  import { walkAst } from "../core/ast";
3
+ import { relativeSlashPath } from "../core/paths";
4
4
  import type { KlintRule } from "../core/types";
5
5
 
6
6
  export const preferStringRawRegexp: KlintRule = {
@@ -47,7 +47,7 @@ export const preferStringRawRegexp: KlintRule = {
47
47
  linesText.slice(0, argOffset) + fixedArg + linesText.slice(argEndOffset);
48
48
 
49
49
  violations.push({
50
- file: relative(root, file),
50
+ file: relativeSlashPath(root, file),
51
51
  line: line + 1,
52
52
  message:
53
53
  "Use String.raw`...` for RegExp template argument to avoid double backslashes (Sonar S7780).",
@@ -1,6 +1,6 @@
1
- import { relative } from "node:path";
2
1
  import ts from "typescript";
3
2
  import { walkAst } from "../core/ast";
3
+ import { relativeSlashPath } from "../core/paths";
4
4
  import { buildNodeReplacementFix } from "../core/rule-helpers";
5
5
  import type { KlintRule } from "../core/types";
6
6
 
@@ -27,7 +27,7 @@ export const preferStringRaw: KlintRule = {
27
27
  const fix = buildNodeReplacementFix(src, node, fixedNode);
28
28
 
29
29
  violations.push({
30
- file: relative(root, file),
30
+ file: relativeSlashPath(root, file),
31
31
  line: fix.startLine,
32
32
  message:
33
33
  "String literal with escaped backslashes — use String.raw`...` for clarity (Sonar S6535).",
@@ -1,6 +1,6 @@
1
- import { relative } from "node:path";
2
1
  import ts from "typescript";
3
2
  import { walkAst } from "../core/ast";
3
+ import { relativeSlashPath } from "../core/paths";
4
4
  import { buildNodeReplacementFix } from "../core/rule-helpers";
5
5
  import type { KlintRule } from "../core/types";
6
6
 
@@ -35,13 +35,13 @@ export const preferStringReplaceall: KlintRule = {
35
35
  ).expression.getText(src);
36
36
  const replacementText = node.arguments[1].getText(src);
37
37
  const patternLit = pattern.includes('"')
38
- ? `'${pattern.replaceAll("'", "\\'")}'`
38
+ ? `'${pattern.replaceAll("'", String.raw`\'`)}'`
39
39
  : `"${pattern}"`;
40
40
  const fixedCall = `${strText}.replaceAll(${patternLit}, ${replacementText})`;
41
41
  const fix = buildNodeReplacementFix(src, node, fixedCall);
42
42
 
43
43
  violations.push({
44
- file: relative(root, file),
44
+ file: relativeSlashPath(root, file),
45
45
  line: fix.startLine,
46
46
  message: `Prefer \`${strText}.replaceAll(${patternLit}, ...)\` over \`.replace(/${pattern}/g, ...)\` — replaceAll() with a string is clearer and avoids regex escaping pitfalls.`,
47
47
  fix,
@@ -28,22 +28,31 @@ export async function prepare(_, context) {
28
28
 
29
29
  const packageJson = readPackageJson(cwd);
30
30
  packageJson.version = version;
31
- packageJson.optionalDependencies ??= {};
32
- for (const packageName of NATIVE_PACKAGE_NAMES) {
33
- packageJson.optionalDependencies[packageName] = version;
34
- }
35
31
  writePackageJson(cwd, packageJson);
36
32
  }
37
33
 
38
- export async function publish(_, context) {
34
+ export async function publish(pluginConfig = {}, context) {
39
35
  const cwd = context.cwd ?? process.cwd();
36
+ const spawn = pluginConfig.spawnSync ?? spawnSync;
37
+ const version = context.nextRelease?.version;
40
38
 
41
39
  if (context.options?.dryRun) {
42
40
  context.logger?.log("Dry run: skipping npm publish");
43
41
  return;
44
42
  }
45
43
 
46
- const result = spawnSync("npm", ["publish", "--access", "public"], {
44
+ if (!version) {
45
+ throw new Error("semantic-release did not provide nextRelease.version");
46
+ }
47
+
48
+ const packageJson = readPackageJson(cwd);
49
+ packageJson.optionalDependencies ??= {};
50
+ for (const packageName of NATIVE_PACKAGE_NAMES) {
51
+ packageJson.optionalDependencies[packageName] = version;
52
+ }
53
+ writePackageJson(cwd, packageJson);
54
+
55
+ const result = spawn("npm", ["publish", "--access", "public"], {
47
56
  cwd,
48
57
  encoding: "utf-8",
49
58
  stdio: "inherit",