@konvert7/klint 0.10.0 → 0.12.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
@@ -13,7 +13,7 @@ import * as clack from "@clack/prompts";
13
13
  import { parse as parseYaml } from "yaml";
14
14
  import { applyFixes } from "./core/fixer";
15
15
  import { resolveNativePackageBinary } from "./core/native-binary";
16
- import { runKlint } from "./core/runner";
16
+ import { type KlintDebugEvent, runKlint } from "./core/runner";
17
17
  import type { ArchConfig, KlintConfig, KlintRule, RuleConfigValue } from "./core/types";
18
18
  import { BUILT_IN_PLUGINS } from "./plugins/index";
19
19
  import { BUILT_IN_RULES } from "./rules/index";
@@ -40,12 +40,14 @@ export async function main(opts: CliOptions = {}): Promise<void> {
40
40
  let rulesFile = opts.rulesFile;
41
41
  let fix = false;
42
42
  let json = false;
43
+ let debug = false;
43
44
 
44
45
  for (let i = 0; i < args.length; i++) {
45
46
  if (args[i] === "--config" && args[i + 1]) configDir = resolve(args[++i]);
46
47
  else if (args[i] === "--rules" && args[i + 1]) rulesFile = resolve(args[++i]);
47
48
  else if (args[i] === "--fix") fix = true;
48
49
  else if (args[i] === "--json") json = true;
50
+ else if (args[i] === "--debug" || args[i] === "-debug") debug = true;
49
51
  else if (args[i] === "--help" || args[i] === "-h") {
50
52
  printHelp();
51
53
  process.exit(0);
@@ -111,7 +113,8 @@ export async function main(opts: CliOptions = {}): Promise<void> {
111
113
  rules: allRules,
112
114
  arch: raw.arch as ArchConfig | undefined,
113
115
  },
114
- customRules
116
+ customRules,
117
+ { onDebug: debug ? writeDebugEvent : undefined }
115
118
  );
116
119
 
117
120
  if (json) {
@@ -140,7 +143,8 @@ export async function main(opts: CliOptions = {}): Promise<void> {
140
143
  rules: allRules,
141
144
  arch: raw.arch as ArchConfig | undefined,
142
145
  },
143
- customRules
146
+ customRules,
147
+ { onDebug: debug ? writeDebugEvent : undefined }
144
148
  );
145
149
  if (current.every((v) => !v.fix)) break;
146
150
  }
@@ -212,6 +216,11 @@ function runRustEngine({
212
216
  }
213
217
 
214
218
  const command = resolveRustEngineCommand(configDir);
219
+ if (process.argv.includes("--debug") || process.argv.includes("-debug")) {
220
+ process.stderr.write(
221
+ `[klint:debug] rust engine: ${command.bin} ${command.args.join(" ")}\n`
222
+ );
223
+ }
215
224
  const result = spawnSync(command.bin, command.args, {
216
225
  cwd: import.meta.dir,
217
226
  encoding: "utf-8",
@@ -222,6 +231,36 @@ function runRustEngine({
222
231
  process.exit(result.status ?? 1);
223
232
  }
224
233
 
234
+ function writeDebugEvent(event: KlintDebugEvent): void {
235
+ switch (event.type) {
236
+ case "walk:start":
237
+ process.stderr.write(`[klint:debug] walk ${event.dir}\n`);
238
+ break;
239
+ case "walk:done":
240
+ process.stderr.write(`[klint:debug] walked ${event.dir} (${event.files} files)\n`);
241
+ break;
242
+ case "files:resolved":
243
+ process.stderr.write(`[klint:debug] resolved ${event.files} files\n`);
244
+ break;
245
+ case "rule:start":
246
+ process.stderr.write(
247
+ `[klint:debug] rule ${event.rule} start (${event.files} files)\n`
248
+ );
249
+ break;
250
+ case "rule:done":
251
+ process.stderr.write(
252
+ `[klint:debug] rule ${event.rule} done (${event.violations} violations)\n`
253
+ );
254
+ break;
255
+ case "arch:start":
256
+ process.stderr.write(`[klint:debug] arch start (${event.files} files)\n`);
257
+ break;
258
+ case "arch:done":
259
+ process.stderr.write(`[klint:debug] arch done (${event.violations} violations)\n`);
260
+ break;
261
+ }
262
+ }
263
+
225
264
  interface RustEngineCommand {
226
265
  bin: string;
227
266
  args: string[];
@@ -396,6 +435,7 @@ function printHelp(): void {
396
435
  " --rules <file> custom rules file (default: <configDir>/klint.rules.ts if present)",
397
436
  " --fix apply auto-fixes for fixable violations in-place",
398
437
  " --json emit structured JSON to stdout (for agent/CI consumption)",
438
+ " --debug print file resolution and rule progress to stderr",
399
439
  "",
400
440
  " install-skill install the rule-authoring skill into agent config directories",
401
441
  " --agents <list> comma-separated: claude,opencode,cursor,codex (default: all)",
@@ -115,7 +115,7 @@ const ArchForbiddenPatternRuleSchema = z
115
115
  pattern: z
116
116
  .string()
117
117
  .describe(
118
- "Literal substring to search for in source. No regex — exact substring match (e.g. `console.log(`)."
118
+ "Literal substring to search for in source. No regex — exact substring match (for example, a console logging call)."
119
119
  ),
120
120
  in: StringOrStringArray.describe(
121
121
  "Layer name(s) or glob(s) the pattern is forbidden in. Files outside the scope are not checked."
@@ -153,7 +153,7 @@ const ArchForbiddenRuleSchema = z
153
153
  )
154
154
  .meta({
155
155
  examples: [
156
- 'arch:\n forbidden:\n - pattern: "console.log("\n in: core\n message: "Leaks into the agent event stream"',
156
+ 'arch:\n forbidden:\n - pattern: "debugLog("\n in: core\n message: "Leaks into the agent event stream"',
157
157
  'arch:\n forbidden:\n - jsx-element: ["button", "input", "label"]\n in: ["src/app/**/*.tsx", "src/components/**/*.tsx", "!src/components/ui/**"]\n message: "Use the design-system primitives in @/components/ui/* instead of raw HTML elements."',
158
158
  ],
159
159
  });
package/core/runner.ts CHANGED
@@ -12,23 +12,49 @@ import type {
12
12
  Violation,
13
13
  } from "./types";
14
14
 
15
- function walk(dir: string): string[] {
15
+ export type KlintDebugEvent =
16
+ | { type: "walk:start"; dir: string }
17
+ | { type: "walk:done"; dir: string; files: number }
18
+ | { type: "files:resolved"; files: number }
19
+ | { type: "rule:start"; rule: string; files: number }
20
+ | { type: "rule:done"; rule: string; violations: number }
21
+ | { type: "arch:start"; files: number }
22
+ | { type: "arch:done"; violations: number };
23
+
24
+ export interface RunKlintOptions {
25
+ onDebug?: (event: KlintDebugEvent) => void;
26
+ }
27
+
28
+ function walk(dir: string, root: string, excludes: string[] = []): string[] {
16
29
  const out: string[] = [];
17
30
  for (const entry of readdirSync(dir, { withFileTypes: true })) {
18
31
  const full = join(dir, entry.name);
19
- if (entry.isDirectory()) out.push(...walk(full));
20
- else if (/\.(tsx?|jsx?|mts|cts)$/.test(entry.name))
32
+ const rel = relative(root, full).replaceAll("\\", "/");
33
+ if (entry.isDirectory()) {
34
+ if (excludes.some((pattern) => matchPattern(rel, pattern))) continue;
35
+ out.push(...walk(full, root, excludes));
36
+ } else if (/\.(tsx?|jsx?|mts|cts)$/.test(entry.name))
21
37
  out.push(full.replaceAll("\\", "/"));
22
38
  }
23
39
  return out;
24
40
  }
25
41
 
26
- function resolveFiles(include: string[], root: string): string[] {
42
+ function resolveFiles(
43
+ include: string[],
44
+ root: string,
45
+ onDebug?: (event: KlintDebugEvent) => void
46
+ ): string[] {
27
47
  const files = new Set<string>();
48
+ const excludes = include.filter((p) => p.startsWith("!")).map((p) => p.slice(1));
28
49
  for (const pattern of include) {
29
- const dir = resolve(root, pattern.split("/**")[0].split("/*")[0]);
50
+ if (pattern.startsWith("!")) continue;
51
+ const base = pattern.split("/**")[0].split("/*")[0];
52
+ const dir = resolve(root, base === "**" ? "." : base);
30
53
  try {
31
- for (const file of walk(dir)) files.add(file);
54
+ onDebug?.({ type: "walk:start", dir });
55
+ const before = files.size;
56
+ for (const file of walk(dir, root, excludes)) files.add(file);
57
+ onDebug?.({ type: "walk:done", dir, files: files.size - before });
32
58
  } catch {
33
59
  // directory doesn't exist — skip
34
60
  }
@@ -41,8 +67,17 @@ function matchPattern(relPath: string, pattern: string): boolean {
41
67
  const p = pattern.replaceAll("\\", "/");
42
68
  if (p === "." || p === "**") return true;
43
69
  if (p.endsWith("/**")) return norm.startsWith(`${p.slice(0, -3)}/`);
44
- if (p.startsWith("**/")) return norm.endsWith(p.slice(2));
45
- return norm === p || norm.startsWith(`${p}/`);
70
+ if (!p.includes("*")) return norm === p || norm.startsWith(`${p}/`);
71
+ return globToRegExp(p).test(norm);
72
+ }
73
+
74
+ function globToRegExp(pattern: string): RegExp {
75
+ const escaped = pattern
76
+ .replace(/[.+?^${}()|[\]\\]/g, "\\$&")
77
+ .replaceAll("**/", "(?:.*/)?")
78
+ .replaceAll("**", ".*")
79
+ .replaceAll("*", "[^/]*");
80
+ return new RegExp(`^${escaped}$`);
46
81
  }
47
82
 
48
83
  function applyPatterns(files: string[], patterns: string[], root: string): string[] {
@@ -66,7 +101,8 @@ function resolveInclude(value: RuleConfigValue): string[] | undefined {
66
101
 
67
102
  export function runKlint(
68
103
  config: KlintConfig,
69
- customRules: Record<string, KlintRule> = {}
104
+ customRules: Record<string, KlintRule> = {},
105
+ options: RunKlintOptions = {}
70
106
  ): Violation[] {
71
107
  clearAstCache();
72
108
 
@@ -94,10 +130,11 @@ export function runKlint(
94
130
  };
95
131
 
96
132
  const allFiles = applyPatterns(
97
- resolveFiles(config.include, config.root),
133
+ resolveFiles(config.include, config.root, options.onDebug),
98
134
  config.include,
99
135
  config.root
100
136
  );
137
+ options.onDebug?.({ type: "files:resolved", files: allFiles.length });
101
138
  const fileContents = new Map(allFiles.map((f) => [f, readFileSync(f, "utf-8")]));
102
139
  const violations: Violation[] = [];
103
140
 
@@ -112,12 +149,17 @@ export function runKlint(
112
149
  const files = include ? applyPatterns(allFiles, include, config.root) : allFiles;
113
150
 
114
151
  const batch: Omit<Violation, "severity">[] = [];
152
+ options.onDebug?.({ type: "rule:start", rule: ruleName, files: files.length });
115
153
  rule.check({ files, root: config.root, fileContents }, batch);
154
+ options.onDebug?.({ type: "rule:done", rule: ruleName, violations: batch.length });
116
155
  for (const v of batch) violations.push({ ...v, rule: ruleName, severity });
117
156
  }
118
157
 
119
158
  if (config.arch) {
120
- violations.push(...runArchRules(config.arch, allFiles, fileContents, config.root));
159
+ options.onDebug?.({ type: "arch:start", files: allFiles.length });
160
+ const archViolations = runArchRules(config.arch, allFiles, fileContents, config.root);
161
+ options.onDebug?.({ type: "arch:done", violations: archViolations.length });
162
+ violations.push(...archViolations);
121
163
  }
122
164
 
123
165
  return violations;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@konvert7/klint",
3
- "version": "0.10.0",
3
+ "version": "0.12.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": {
@@ -39,6 +39,7 @@
39
39
  "pack:check": "bun tests/pack-check.ts",
40
40
  "native:stage": "bun tests/stage-native-binary.ts",
41
41
  "native:smoke": "bun tests/smoke-native-binary.ts",
42
+ "native:release:dry-run": "bun tools/prepare-native-release.ts --version 0.0.0-dry-run",
42
43
  "check": "biome check",
43
44
  "check-write": "biome check --write",
44
45
  "knip": "knip-bun",
@@ -59,6 +60,12 @@
59
60
  "yaml": "^2.9.0",
60
61
  "zod": "^4.4.3"
61
62
  },
63
+ "optionalDependencies": {
64
+ "@konvert7/klint-darwin-arm64": "*",
65
+ "@konvert7/klint-darwin-x64": "*",
66
+ "@konvert7/klint-linux-x64": "*",
67
+ "@konvert7/klint-win32-x64": "*"
68
+ },
62
69
  "devDependencies": {
63
70
  "@biomejs/biome": "2.4.7",
64
71
  "@commitlint/cli": "^20.5.3",
@@ -0,0 +1,168 @@
1
+ import {
2
+ copyFileSync,
3
+ existsSync,
4
+ mkdirSync,
5
+ mkdtempSync,
6
+ readFileSync,
7
+ rmSync,
8
+ writeFileSync,
9
+ } from "node:fs";
10
+ import { tmpdir } from "node:os";
11
+ import { dirname, join, resolve } from "node:path";
12
+ import { fileURLToPath } from "node:url";
13
+ import { nativePackages } from "../core/native-binary";
14
+
15
+ interface PackageJson {
16
+ name: string;
17
+ version: string;
18
+ private?: boolean;
19
+ optionalDependencies?: Record<string, string>;
20
+ }
21
+
22
+ interface Options {
23
+ version: string;
24
+ outDir: string;
25
+ requireBinaries: boolean;
26
+ keep: boolean;
27
+ }
28
+
29
+ const root = fileURLToPath(new URL("..", import.meta.url));
30
+ const options = parseArgs();
31
+
32
+ if (existsSync(options.outDir)) {
33
+ rmSync(options.outDir, { recursive: true, force: true });
34
+ }
35
+ mkdirSync(options.outDir, { recursive: true });
36
+
37
+ try {
38
+ const releaseRootPackageJson = prepareRootPackageJson();
39
+ const nativeResults = nativePackages().map(prepareNativePackage);
40
+
41
+ validateRootPackageJson(releaseRootPackageJson);
42
+ for (const result of nativeResults) validateNativePackage(result.packageJson);
43
+
44
+ const missing = nativeResults
45
+ .filter((result) => !result.binaryCopied)
46
+ .map((result) => result.packageJson.name);
47
+
48
+ if (options.requireBinaries && missing.length > 0) {
49
+ fail(`Missing native release binaries:\n${missing.join("\n")}`);
50
+ }
51
+
52
+ const missingMessage =
53
+ missing.length > 0
54
+ ? `missing binaries: ${missing.join(", ")}`
55
+ : "missing binaries: none";
56
+ const outputLines = [
57
+ `native release dry-run ok (${options.version})`,
58
+ `output: ${options.outDir}`,
59
+ `native packages: ${nativeResults.length}`,
60
+ missingMessage,
61
+ ];
62
+ process.stdout.write(`${outputLines.join("\n")}\n`);
63
+ } finally {
64
+ if (!options.keep) {
65
+ rmSync(options.outDir, { recursive: true, force: true });
66
+ }
67
+ }
68
+
69
+ function prepareRootPackageJson(): PackageJson {
70
+ const packageJson = readPackageJson(join(root, "package.json"));
71
+ packageJson.version = options.version;
72
+ packageJson.optionalDependencies = Object.fromEntries(
73
+ nativePackages().map((nativePackage) => [nativePackage.packageName, options.version])
74
+ );
75
+
76
+ const outPath = join(options.outDir, "package.json");
77
+ writeJson(outPath, packageJson);
78
+ return packageJson;
79
+ }
80
+
81
+ function prepareNativePackage(nativePackage: ReturnType<typeof nativePackages>[number]): {
82
+ binaryCopied: boolean;
83
+ packageJson: PackageJson;
84
+ } {
85
+ const packageDirName = `${nativePackage.platform}-${nativePackage.arch}`;
86
+ const sourceDir = join(root, "npm", "native", packageDirName);
87
+ const outDir = join(options.outDir, "npm", "native", packageDirName);
88
+ mkdirSync(outDir, { recursive: true });
89
+
90
+ const packageJson = readPackageJson(join(sourceDir, "package.json"));
91
+ packageJson.version = options.version;
92
+ packageJson.private = false;
93
+ writeJson(join(outDir, "package.json"), packageJson);
94
+
95
+ const sourceBinary = join(sourceDir, nativePackage.binaryPath);
96
+ const outBinary = join(outDir, nativePackage.binaryPath);
97
+ const binaryCopied = existsSync(sourceBinary);
98
+ if (binaryCopied) {
99
+ mkdirSync(dirname(outBinary), { recursive: true });
100
+ copyFileSync(sourceBinary, outBinary);
101
+ }
102
+
103
+ return { binaryCopied, packageJson };
104
+ }
105
+
106
+ function validateRootPackageJson(packageJson: PackageJson): void {
107
+ const optionalDependencies = packageJson.optionalDependencies ?? {};
108
+ for (const nativePackage of nativePackages()) {
109
+ if (optionalDependencies[nativePackage.packageName] !== options.version) {
110
+ fail(
111
+ `Root optional dependency ${nativePackage.packageName} is not pinned to ${options.version}`
112
+ );
113
+ }
114
+ }
115
+ }
116
+
117
+ function validateNativePackage(packageJson: PackageJson): void {
118
+ if (packageJson.version !== options.version) {
119
+ fail(
120
+ `${packageJson.name} version is ${packageJson.version}, expected ${options.version}`
121
+ );
122
+ }
123
+ if (packageJson.private !== false) {
124
+ fail(`${packageJson.name} private must be false in release prep output`);
125
+ }
126
+ }
127
+
128
+ function readPackageJson(path: string): PackageJson {
129
+ try {
130
+ return JSON.parse(readFileSync(path, "utf-8")) as PackageJson;
131
+ } catch (error) {
132
+ const message = error instanceof Error ? error.message : String(error);
133
+ fail(`Failed to read package JSON at ${path}: ${message}`);
134
+ }
135
+ }
136
+
137
+ function writeJson(path: string, value: unknown): void {
138
+ writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`);
139
+ }
140
+
141
+ function parseArgs(): Options {
142
+ const args = process.argv.slice(2);
143
+ const version = valueAfter(args, "--version");
144
+ if (!version) {
145
+ fail(
146
+ "Usage: bun tools/prepare-native-release.ts --version <version> [--out <dir>] [--require-binaries] [--keep]"
147
+ );
148
+ }
149
+
150
+ return {
151
+ version,
152
+ outDir: resolve(
153
+ valueAfter(args, "--out") ?? mkdtempSync(join(tmpdir(), "klint-native-release-"))
154
+ ),
155
+ requireBinaries: args.includes("--require-binaries"),
156
+ keep: args.includes("--keep"),
157
+ };
158
+ }
159
+
160
+ function valueAfter(args: string[], name: string): string | undefined {
161
+ const index = args.indexOf(name);
162
+ return index >= 0 ? args[index + 1] : undefined;
163
+ }
164
+
165
+ function fail(message: string): never {
166
+ process.stderr.write(`${message}\n`);
167
+ process.exit(1);
168
+ }
@@ -0,0 +1,52 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { readFileSync, writeFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+
5
+ export async function verifyConditions(_, context) {
6
+ const cwd = context.cwd ?? process.cwd();
7
+ const packageJson = readPackageJson(cwd);
8
+
9
+ if (!packageJson.name) {
10
+ throw new Error("Root package.json must have a name before npm publish");
11
+ }
12
+ }
13
+
14
+ export async function prepare(_, context) {
15
+ const cwd = context.cwd ?? process.cwd();
16
+ const version = context.nextRelease?.version;
17
+
18
+ if (!version) {
19
+ throw new Error("semantic-release did not provide nextRelease.version");
20
+ }
21
+
22
+ const packageJson = readPackageJson(cwd);
23
+ packageJson.version = version;
24
+ writePackageJson(cwd, packageJson);
25
+ }
26
+
27
+ export async function publish(_, context) {
28
+ const cwd = context.cwd ?? process.cwd();
29
+
30
+ if (context.options?.dryRun) {
31
+ context.logger?.log("Dry run: skipping npm publish");
32
+ return;
33
+ }
34
+
35
+ const result = spawnSync("npm", ["publish", "--access", "public"], {
36
+ cwd,
37
+ encoding: "utf-8",
38
+ stdio: "inherit",
39
+ });
40
+
41
+ if ((result.status ?? -1) !== 0) {
42
+ throw new Error(`npm publish failed with exit code ${result.status ?? -1}`);
43
+ }
44
+ }
45
+
46
+ function readPackageJson(cwd) {
47
+ return JSON.parse(readFileSync(join(cwd, "package.json"), "utf-8"));
48
+ }
49
+
50
+ function writePackageJson(cwd, packageJson) {
51
+ writeFileSync(join(cwd, "package.json"), `${JSON.stringify(packageJson, null, 2)}\n`);
52
+ }