@konvert7/klint 0.34.0 → 0.35.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
@@ -15,6 +15,12 @@ brew tap konvert7/tap
15
15
  brew install klint
16
16
  ```
17
17
 
18
+ With Cargo (installs the `klint-rs` binary):
19
+
20
+ ```sh
21
+ cargo install klint-rs
22
+ ```
23
+
18
24
  For TypeScript projects using Bun:
19
25
 
20
26
  ```sh
@@ -107,7 +113,8 @@ Rules give agents room to move quickly because the unsafe moves are blocked stru
107
113
  ## CLI
108
114
 
109
115
  ```sh
110
- klint [--config <dir>] [--rules <file>] [--engine ts|rust|compare|auto] [--fix] [--json]
116
+ klint [--config <dir>] [--rules <file>] [--engine ts|rust|compare|auto] [--fix] [--json] [--hook]
117
+ klint stats [--config <dir>]
111
118
  klint install-skill [--agents <list>] [--symlink | --copy] [--force]
112
119
  ```
113
120
 
@@ -118,6 +125,7 @@ klint install-skill [--agents <list>] [--symlink | --copy] [--force]
118
125
  | `--engine <name>` | Engine mode: `ts` (default), `rust`, `compare`, or `auto` |
119
126
  | `--fix` | Apply auto-fixes for fixable violations in-place |
120
127
  | `--json` | Emit structured JSON to stdout for agents and CI |
128
+ | `--hook` | Count this run as an agent hook run in `klint stats` |
121
129
 
122
130
  `klint install-skill` installs the bundled rule-authoring skill for agent environments:
123
131
 
@@ -163,6 +171,26 @@ Earlier versions symlinked into `node_modules/@konvert7/klint`, which breaks the
163
171
 
164
172
  Running `klint install-skill` replaces those links with the shared layout, including links whose target is already gone. No `--force` needed — a symlink holds no content to lose.
165
173
 
174
+ ### Stats
175
+
176
+ Every lint run is recorded in `.klint/stats.json` next to your config. `klint stats` prints the totals:
177
+
178
+ ```
179
+ klint stats (.klint/stats.json)
180
+
181
+ runs 42 (hook 30, direct 12)
182
+ errors prevented 17 (caught by hook 11, direct 6)
183
+
184
+ prevented by rule
185
+ no-floating-promise 9
186
+ arch/imports 5
187
+ no-string-match 3
188
+ ```
189
+
190
+ An error counts as prevented once: when a run reports it and a later run no longer does. Errors match by rule, file, and message, so moving code to another line doesn't count as a fix, and running klint again on an unfixed error doesn't count twice. Warnings aren't tracked. A prevented error is credited to the run that first caught it: pass `--hook` from agent hooks, and every other run (an agent's own shell, a person, CI) counts as direct.
191
+
192
+ `.klint/` contains a `.gitignore` with `*`, so git ignores the whole folder and nothing needs adding to your own `.gitignore`. Set `KLINT_NO_STATS=1` to stop recording. Stats are best effort: a read-only checkout never fails a lint run.
193
+
166
194
  ## Engines
167
195
 
168
196
  klint currently ships a TypeScript engine and an experimental Rust engine. The Rust path is built for portable architecture checks and syntax-local rules; type-aware checks stay in TypeScript for now.
@@ -745,13 +773,13 @@ Enable plugin rules with `plugins: [sonar]`, then override individual severities
745
773
 
746
774
  ## Agent and CI Integration
747
775
 
748
- Agent hooks should run klint with `--json` so violations are machine-readable.
776
+ Agent hooks should run klint with `--json` so violations are machine-readable, and with `--hook` so [`klint stats`](#stats) can tell hook runs apart from direct ones.
749
777
 
750
778
  ```ts
751
779
  // .agents/hooks/klint.ts
752
780
  import { runHook } from "./run-hook";
753
781
 
754
- const exitCode = runHook(["bun", "run", "klint", "--", "--json"]);
782
+ const exitCode = runHook(["bun", "run", "klint", "--", "--json", "--hook"]);
755
783
  process.exit(exitCode);
756
784
  ```
757
785
 
package/cli/help.ts CHANGED
@@ -12,7 +12,8 @@ export function printHelp(): void {
12
12
  [
13
13
  "klint — agent harness for TypeScript architecture rules",
14
14
  "",
15
- "Usage: klint [--config <dir>] [--rules <file>] [--engine ts|rust|compare|auto] [--fix] [--json]",
15
+ "Usage: klint [--config <dir>] [--rules <file>] [--engine ts|rust|compare|auto] [--fix] [--json] [--hook]",
16
+ " klint stats [--config <dir>]",
16
17
  " klint install-skill [--agents <list>] [--symlink | --copy] [--force]",
17
18
  "",
18
19
  " --config <dir> directory containing klint.yaml or klint.config.json (default: cwd)",
@@ -21,6 +22,10 @@ export function printHelp(): void {
21
22
  " --fix apply auto-fixes for fixable violations in-place",
22
23
  " --json emit structured JSON to stdout (for agent/CI consumption)",
23
24
  " --debug print file resolution and rule progress to stderr",
25
+ " --hook count this run as an agent hook run in klint stats",
26
+ "",
27
+ " stats print runs and errors prevented, in total and per rule, from",
28
+ " .klint/stats.json (set KLINT_NO_STATS=1 to stop recording)",
24
29
  "",
25
30
  " install-skill install the rule-authoring skill into agent config directories",
26
31
  " --agents <list> comma-separated: claude,opencode,cursor,codex,copilot (default: all)",
@@ -3,6 +3,7 @@ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { runKlint } from "../core/runner";
6
+ import type { RunRecorder } from "../core/stats";
6
7
  import type { KlintRule, RuleConfigValue, Violation } from "../core/types";
7
8
  import {
8
9
  emptyJsonPayload,
@@ -25,11 +26,14 @@ import {
25
26
  splitRulesForAuto,
26
27
  } from "./rust-config";
27
28
 
29
+ const RUST_CHILD_ENV = { ...process.env, KLINT_NO_STATS: "1" };
30
+
28
31
  export function runRustEngine({
29
32
  advisories,
30
33
  fix,
31
34
  json,
32
35
  raw,
36
+ record,
33
37
  root,
34
38
  rulesFile,
35
39
  startedAt,
@@ -42,6 +46,7 @@ export function runRustEngine({
42
46
  rules?: Record<string, RuleConfigValue>;
43
47
  arch?: unknown;
44
48
  };
49
+ record: RunRecorder;
45
50
  root: string;
46
51
  rulesFile?: string;
47
52
  startedAt: number;
@@ -69,18 +74,20 @@ export function runRustEngine({
69
74
  result = spawnSync(command.bin, command.args, {
70
75
  cwd: PACKAGE_ROOT,
71
76
  encoding: "utf-8",
77
+ env: RUST_CHILD_ENV,
72
78
  });
73
79
  } finally {
74
80
  cleanup();
75
81
  }
76
82
 
77
83
  if (result.stderr) process.stderr.write(result.stderr);
84
+ const payload = parseJsonPayload(result.stdout);
85
+ if (isJsonPayload(payload)) record(payload.violations);
78
86
  if (json) {
79
87
  if (result.stdout) process.stdout.write(withAdvisories(result.stdout, advisories));
80
88
  process.exit(result.status ?? 1);
81
89
  }
82
90
 
83
- const payload = parseJsonPayload(result.stdout);
84
91
  if (!isJsonPayload(payload)) {
85
92
  process.stderr.write("klint: --engine rust failed to parse Rust JSON output\n");
86
93
  process.exit(1);
@@ -93,6 +100,7 @@ export function runCompareEngine({
93
100
  fix,
94
101
  json,
95
102
  raw,
103
+ record,
96
104
  root,
97
105
  rulesFile,
98
106
  tsViolations,
@@ -105,6 +113,7 @@ export function runCompareEngine({
105
113
  rules?: Record<string, RuleConfigValue>;
106
114
  arch?: unknown;
107
115
  };
116
+ record: RunRecorder;
108
117
  root: string;
109
118
  rulesFile?: string;
110
119
  tsViolations: ReturnType<typeof runKlint>;
@@ -133,6 +142,7 @@ export function runCompareEngine({
133
142
  rustResult = spawnSync(rustCommand.bin, rustCommand.args, {
134
143
  cwd: PACKAGE_ROOT,
135
144
  encoding: "utf-8",
145
+ env: RUST_CHILD_ENV,
136
146
  });
137
147
  } finally {
138
148
  cleanup();
@@ -159,6 +169,7 @@ export function runCompareEngine({
159
169
  process.exit(1);
160
170
  }
161
171
 
172
+ record(tsViolations);
162
173
  process.stdout.write(withAdvisories(tsResult.stdout, advisories));
163
174
  process.exit(tsResult.status);
164
175
  }
@@ -197,6 +208,7 @@ export function runAutoEngine({
197
208
  fix,
198
209
  json,
199
210
  raw,
211
+ record,
200
212
  root,
201
213
  rulesFile,
202
214
  startedAt,
@@ -213,6 +225,7 @@ export function runAutoEngine({
213
225
  rules?: Record<string, RuleConfigValue>;
214
226
  arch?: unknown;
215
227
  };
228
+ record: RunRecorder;
216
229
  root: string;
217
230
  rulesFile?: string;
218
231
  startedAt: number;
@@ -247,6 +260,7 @@ export function runAutoEngine({
247
260
  });
248
261
  const tsOutput = toJsonPayload(tsViolations);
249
262
  const merged = mergeJsonOutputs(rustOutput, tsOutput);
263
+ record(merged.violations);
250
264
  if (json) {
251
265
  process.stdout.write(withAdvisories(JSON.stringify(merged), advisories));
252
266
  process.exit(merged.summary.errors > 0 ? 2 : 0);
@@ -289,6 +303,7 @@ function runAutoRustSubset({
289
303
  const result = spawnSync(command.bin, command.args, {
290
304
  cwd: PACKAGE_ROOT,
291
305
  encoding: "utf-8",
306
+ env: RUST_CHILD_ENV,
292
307
  });
293
308
  if (result.stderr) process.stderr.write(result.stderr);
294
309
  if ((result.status ?? 1) !== 0 && (result.status ?? 1) !== 2) {
package/cli/stats.ts ADDED
@@ -0,0 +1,34 @@
1
+ import { resolve } from "node:path";
2
+ import { type KlintStats, readStats, STATS_DIR, STATS_FILE } from "../core/stats";
3
+
4
+ function rulesByCount(byRule: Record<string, number>): Array<[string, number]> {
5
+ const byCodePoint = (a: string, b: string) => Number(a > b) - Number(a < b);
6
+ return Object.entries(byRule).sort(([a, x], [b, y]) => y - x || byCodePoint(a, b));
7
+ }
8
+
9
+ export function formatStats(stats: KlintStats): string {
10
+ const runs = stats.runs.hook + stats.runs.direct;
11
+ const prevented = stats.prevented.bySource.hook + stats.prevented.bySource.direct;
12
+ const rules = rulesByCount(stats.prevented.byRule);
13
+ const width = Math.max(0, ...rules.map(([rule]) => rule.length));
14
+ const lines = [
15
+ `klint stats (${STATS_DIR}/${STATS_FILE})`,
16
+ "",
17
+ ` runs ${runs} (hook ${stats.runs.hook}, direct ${stats.runs.direct})`,
18
+ ` errors prevented ${prevented} (caught by hook ${stats.prevented.bySource.hook}, direct ${stats.prevented.bySource.direct})`,
19
+ ];
20
+ if (rules.length > 0) {
21
+ lines.push("", " prevented by rule");
22
+ for (const [rule, count] of rules) lines.push(` ${rule.padEnd(width)} ${count}`);
23
+ }
24
+ return `${lines.join("\n")}\n`;
25
+ }
26
+
27
+ export function printStats(args: string[]): void {
28
+ const configFlag = args.indexOf("--config");
29
+ const configDir =
30
+ configFlag !== -1 && args[configFlag + 1]
31
+ ? resolve(args[configFlag + 1])
32
+ : process.cwd();
33
+ process.stdout.write(formatStats(readStats(configDir)));
34
+ }
package/cli.ts CHANGED
@@ -14,10 +14,12 @@ import {
14
14
  } from "./cli/output";
15
15
  import { installedVersion, SHIPPED_SKILL_PATH } from "./cli/paths";
16
16
  import { runAutoEngine, runCompareEngine, runRustEngine } from "./cli/rust-engine";
17
+ import { printStats } from "./cli/stats";
17
18
  import { applyFixes } from "./core/fixer";
18
19
  import { runKlint } from "./core/runner";
19
20
  import { schemaVersionAdvisory } from "./core/schema-version";
20
21
  import { skillAdvisories } from "./core/skill";
22
+ import { runRecorder } from "./core/stats";
21
23
  import type { ArchConfig, KlintConfig, KlintRule, RuleConfigValue } from "./core/types";
22
24
 
23
25
  interface CliOptions {
@@ -34,6 +36,11 @@ export async function main(opts: CliOptions = {}): Promise<void> {
34
36
  process.exit(0);
35
37
  }
36
38
 
39
+ if (args[0] === "stats") {
40
+ printStats(args.slice(1));
41
+ return;
42
+ }
43
+
37
44
  const subcommand = args.indexOf("install-skill");
38
45
  if (subcommand !== -1) {
39
46
  await installSkill(args.slice(subcommand + 1));
@@ -45,6 +52,7 @@ export async function main(opts: CliOptions = {}): Promise<void> {
45
52
  let fix = false;
46
53
  let json = false;
47
54
  let debug = false;
55
+ let hook = false;
48
56
  let engine = process.env.KLINT_ENGINE;
49
57
 
50
58
  for (let i = 0; i < args.length; i++) {
@@ -53,6 +61,7 @@ export async function main(opts: CliOptions = {}): Promise<void> {
53
61
  else if (args[i] === "--engine" && args[i + 1]) engine = args[++i];
54
62
  else if (args[i] === "--fix") fix = true;
55
63
  else if (args[i] === "--json") json = true;
64
+ else if (args[i] === "--hook") hook = true;
56
65
  else if (args[i] === "--debug" || args[i] === "-debug") debug = true;
57
66
  else if (args[i] === "--help" || args[i] === "-h") {
58
67
  printHelp();
@@ -67,6 +76,7 @@ export async function main(opts: CliOptions = {}): Promise<void> {
67
76
  }
68
77
 
69
78
  configDir ??= process.cwd();
79
+ const record = runRecorder(configDir, hook ? "hook" : "direct");
70
80
 
71
81
  const yamlPath = resolve(configDir, "klint.yaml");
72
82
  const jsonPath = resolve(configDir, "klint.config.json");
@@ -115,7 +125,7 @@ export async function main(opts: CliOptions = {}): Promise<void> {
115
125
  ];
116
126
 
117
127
  if (engine === "rust") {
118
- runRustEngine({ advisories, fix, json, raw, root, rulesFile, startedAt });
128
+ runRustEngine({ advisories, fix, json, raw, record, root, rulesFile, startedAt });
119
129
  return;
120
130
  }
121
131
  if (
@@ -151,6 +161,7 @@ export async function main(opts: CliOptions = {}): Promise<void> {
151
161
  fix,
152
162
  json,
153
163
  raw,
164
+ record,
154
165
  root,
155
166
  rulesFile,
156
167
  startedAt,
@@ -178,6 +189,7 @@ export async function main(opts: CliOptions = {}): Promise<void> {
178
189
  fix,
179
190
  json,
180
191
  raw,
192
+ record,
181
193
  root,
182
194
  rulesFile,
183
195
  tsViolations: violations,
@@ -186,6 +198,7 @@ export async function main(opts: CliOptions = {}): Promise<void> {
186
198
  }
187
199
 
188
200
  if (json) {
201
+ record(violations);
189
202
  const tsResult = toJsonCliResult(violations);
190
203
  process.stdout.write(withAdvisories(tsResult.stdout, advisories));
191
204
  process.exit(tsResult.status);
@@ -211,6 +224,7 @@ export async function main(opts: CliOptions = {}): Promise<void> {
211
224
  );
212
225
  if (current.every((v) => !v.fix)) break;
213
226
  }
227
+ record(current);
214
228
  const unfixed = current.filter((v) => !v.fix).length;
215
229
  const msg =
216
230
  unfixed > 0
@@ -219,6 +233,7 @@ export async function main(opts: CliOptions = {}): Promise<void> {
219
233
  process.stdout.write(`${msg} Finished in ${formatDuration(startedAt)}.\n`);
220
234
  process.exit(0);
221
235
  }
236
+ record(violations);
222
237
  writeTextOutput([...violations, ...advisories], startedAt);
223
238
  }
224
239
 
package/core/stats.ts ADDED
@@ -0,0 +1,128 @@
1
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import type { Violation } from "./types";
4
+
5
+ export const STATS_DIR = ".klint";
6
+ export const STATS_FILE = "stats.json";
7
+ const SELF_IGNORE = "# Created by klint automatically.\n*\n";
8
+ const STATS_VERSION = 1;
9
+
10
+ export type RunSource = "hook" | "direct";
11
+
12
+ type CountedViolation = Pick<Violation, "file" | "rule" | "message" | "severity">;
13
+
14
+ export type RunRecorder = (violations: readonly CountedViolation[]) => void;
15
+
16
+ interface OpenError {
17
+ rule: string;
18
+ file: string;
19
+ message: string;
20
+ caughtBy: RunSource;
21
+ }
22
+
23
+ export interface KlintStats {
24
+ version: typeof STATS_VERSION;
25
+ runs: Record<RunSource, number>;
26
+ prevented: {
27
+ byRule: Record<string, number>;
28
+ bySource: Record<RunSource, number>;
29
+ };
30
+ open: OpenError[];
31
+ }
32
+
33
+ export function emptyStats(): KlintStats {
34
+ return {
35
+ version: STATS_VERSION,
36
+ runs: { hook: 0, direct: 0 },
37
+ prevented: { byRule: {}, bySource: { hook: 0, direct: 0 } },
38
+ open: [],
39
+ };
40
+ }
41
+
42
+ function sameError(a: Omit<OpenError, "caughtBy">, b: Omit<OpenError, "caughtBy">) {
43
+ return a.rule === b.rule && a.file === b.file && a.message === b.message;
44
+ }
45
+
46
+ function errorsOf(
47
+ violations: readonly CountedViolation[]
48
+ ): Omit<OpenError, "caughtBy">[] {
49
+ return violations
50
+ .filter((v) => v.severity === "error")
51
+ .map(({ rule, file, message }) => ({ rule, file, message }));
52
+ }
53
+
54
+ export function nextStats(
55
+ previous: KlintStats,
56
+ violations: readonly CountedViolation[],
57
+ source: RunSource
58
+ ): KlintStats {
59
+ const stats = structuredClone(previous);
60
+ stats.runs[source] += 1;
61
+
62
+ const unmatched = errorsOf(violations);
63
+ const stillOpen: OpenError[] = [];
64
+ for (const error of previous.open) {
65
+ const match = unmatched.findIndex((current) => sameError(current, error));
66
+ if (match === -1) {
67
+ stats.prevented.byRule[error.rule] = (stats.prevented.byRule[error.rule] ?? 0) + 1;
68
+ stats.prevented.bySource[error.caughtBy] += 1;
69
+ continue;
70
+ }
71
+ unmatched.splice(match, 1);
72
+ stillOpen.push(error);
73
+ }
74
+ stats.open = [
75
+ ...stillOpen,
76
+ ...unmatched.map((error) => ({ ...error, caughtBy: source })),
77
+ ];
78
+ return stats;
79
+ }
80
+
81
+ function isStats(value: unknown): value is KlintStats {
82
+ if (!value || typeof value !== "object") return false;
83
+ const candidate = value as Partial<KlintStats>;
84
+ return (
85
+ candidate.version === STATS_VERSION &&
86
+ typeof candidate.runs?.hook === "number" &&
87
+ typeof candidate.runs.direct === "number" &&
88
+ typeof candidate.prevented?.byRule === "object" &&
89
+ typeof candidate.prevented.bySource?.hook === "number" &&
90
+ typeof candidate.prevented.bySource.direct === "number" &&
91
+ Array.isArray(candidate.open)
92
+ );
93
+ }
94
+
95
+ export function readStats(configDir: string): KlintStats {
96
+ try {
97
+ const parsed: unknown = JSON.parse(
98
+ readFileSync(join(configDir, STATS_DIR, STATS_FILE), "utf-8")
99
+ );
100
+ return isStats(parsed) ? parsed : emptyStats();
101
+ } catch {
102
+ return emptyStats();
103
+ }
104
+ }
105
+
106
+ function writeStats(configDir: string, stats: KlintStats): void {
107
+ const dir = join(configDir, STATS_DIR);
108
+ mkdirSync(dir, { recursive: true });
109
+ const ignoreFile = join(dir, ".gitignore");
110
+ if (!existsSync(ignoreFile)) writeFileSync(ignoreFile, SELF_IGNORE);
111
+ const target = join(dir, STATS_FILE);
112
+ const staging = `${target}.${process.pid}.tmp`;
113
+ writeFileSync(staging, `${JSON.stringify(stats, null, 2)}\n`);
114
+ renameSync(staging, target);
115
+ }
116
+
117
+ function statsDisabled(): boolean {
118
+ return Boolean(process.env.KLINT_NO_STATS);
119
+ }
120
+
121
+ export function runRecorder(configDir: string, source: RunSource): RunRecorder {
122
+ return (violations) => {
123
+ if (statsDisabled()) return;
124
+ try {
125
+ writeStats(configDir, nextStats(readStats(configDir), violations, source));
126
+ } catch {}
127
+ };
128
+ }
package/klint.schema.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "$schema": "http://json-schema.org/draft-07/schema#",
3
- "$id": "https://raw.githubusercontent.com/konvert7/klint/refs/tags/v0.34.0/klint.schema.json",
3
+ "$id": "https://raw.githubusercontent.com/konvert7/klint/refs/tags/v0.35.0/klint.schema.json",
4
4
  "type": "object",
5
5
  "properties": {
6
6
  "$schema": {
package/klint.schema.yaml CHANGED
@@ -1,5 +1,5 @@
1
1
  $schema: http://json-schema.org/draft-07/schema#
2
- $id: https://raw.githubusercontent.com/konvert7/klint/refs/tags/v0.34.0/klint.schema.json
2
+ $id: https://raw.githubusercontent.com/konvert7/klint/refs/tags/v0.35.0/klint.schema.json
3
3
  type: object
4
4
  properties:
5
5
  $schema:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@konvert7/klint",
3
- "version": "0.34.0",
3
+ "version": "0.35.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": {
@@ -70,10 +70,10 @@
70
70
  "zod": "^4.4.3"
71
71
  },
72
72
  "optionalDependencies": {
73
- "@konvert7/klint-darwin-arm64": "0.34.0",
74
- "@konvert7/klint-darwin-x64": "0.34.0",
75
- "@konvert7/klint-linux-x64": "0.34.0",
76
- "@konvert7/klint-win32-x64": "0.34.0"
73
+ "@konvert7/klint-darwin-arm64": "0.35.0",
74
+ "@konvert7/klint-darwin-x64": "0.35.0",
75
+ "@konvert7/klint-linux-x64": "0.35.0",
76
+ "@konvert7/klint-win32-x64": "0.35.0"
77
77
  },
78
78
  "devDependencies": {
79
79
  "@biomejs/biome": "2.4.7",
@@ -20,10 +20,10 @@ class Klint < Formula
20
20
  license "MIT"
21
21
 
22
22
  if Hardware::CPU.arm?
23
- url "https://github.com/konvert7/klint/releases/download/native-v${version}/klint-${version}-darwin-arm64.tar.gz"
23
+ url "https://github.com/konvert7/klint/releases/download/v${version}/klint-${version}-darwin-arm64.tar.gz"
24
24
  sha256 "${options.darwinArm64Sha}"
25
25
  else
26
- url "https://github.com/konvert7/klint/releases/download/native-v${version}/klint-${version}-darwin-x64.tar.gz"
26
+ url "https://github.com/konvert7/klint/releases/download/v${version}/klint-${version}-darwin-x64.tar.gz"
27
27
  sha256 "${options.darwinX64Sha}"
28
28
  end
29
29
 
@@ -0,0 +1,96 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { copyFileSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { dirname, join } from "node:path";
4
+
5
+ const CRATE_NAME = "klint-rs";
6
+ const MANIFEST = "crates/klint-rs/Cargo.toml";
7
+ const WORKSPACE_SKILL = "skill/klint-rules/SKILL.md";
8
+ const PACKAGED_SKILL = "crates/klint-rs/skill/SKILL.md";
9
+
10
+ export async function verifyConditions(_, context) {
11
+ const manifest = readManifest(cwdOf(context));
12
+ if (crateNameOf(manifest) !== CRATE_NAME) {
13
+ throw new Error(`${MANIFEST} declares ${crateNameOf(manifest)}, expected ${CRATE_NAME}`);
14
+ }
15
+ if (context.options?.dryRun) return;
16
+ if (!process.env.CARGO_REGISTRY_TOKEN) {
17
+ throw new Error(
18
+ "CARGO_REGISTRY_TOKEN is unset; crates.io trusted publishing issued no token"
19
+ );
20
+ }
21
+ }
22
+
23
+ export async function prepare(pluginConfig = {}, context) {
24
+ const cwd = cwdOf(context);
25
+ const version = versionOf(context);
26
+
27
+ writeManifest(cwd, stampVersion(readManifest(cwd), version));
28
+ refreshLockfile(cwd, pluginConfig.spawnSync ?? spawnSync);
29
+ bundleSkill(cwd);
30
+
31
+ context.logger?.log(`Stamped ${CRATE_NAME} at ${version} and bundled its skill`);
32
+ }
33
+
34
+ export async function publish(pluginConfig = {}, context) {
35
+ if (context.options?.dryRun) {
36
+ context.logger?.log("Dry run: skipping the crates.io publish");
37
+ return;
38
+ }
39
+
40
+ const spawn = pluginConfig.spawnSync ?? spawnSync;
41
+ const result = spawn(
42
+ "cargo",
43
+ ["publish", "-p", CRATE_NAME, "--locked", "--allow-dirty"],
44
+ { cwd: cwdOf(context), encoding: "utf-8", stdio: "inherit" }
45
+ );
46
+ if ((result.status ?? -1) !== 0) {
47
+ throw new Error(`cargo publish failed with exit code ${result.status ?? -1}`);
48
+ }
49
+
50
+ context.logger?.log(`Published ${CRATE_NAME} ${versionOf(context)} to crates.io`);
51
+ }
52
+
53
+ function stampVersion(manifest, version) {
54
+ return manifest.replace(/^version = ".*"$/m, `version = "${version}"`);
55
+ }
56
+
57
+ function refreshLockfile(cwd, spawn) {
58
+ const result = spawn("cargo", ["update", "--workspace"], {
59
+ cwd,
60
+ encoding: "utf-8",
61
+ stdio: ["ignore", "ignore", "pipe"],
62
+ });
63
+ if ((result.status ?? -1) !== 0) {
64
+ throw new Error(
65
+ `cargo update failed with exit code ${result.status ?? -1}\n${result.stderr ?? ""}`
66
+ );
67
+ }
68
+ }
69
+
70
+ function bundleSkill(cwd) {
71
+ const target = join(cwd, PACKAGED_SKILL);
72
+ mkdirSync(dirname(target), { recursive: true });
73
+ copyFileSync(join(cwd, WORKSPACE_SKILL), target);
74
+ }
75
+
76
+ function crateNameOf(manifest) {
77
+ return manifest.match(/^name = "(.*)"$/m)?.[1];
78
+ }
79
+
80
+ function readManifest(cwd) {
81
+ return readFileSync(join(cwd, MANIFEST), "utf-8");
82
+ }
83
+
84
+ function writeManifest(cwd, manifest) {
85
+ writeFileSync(join(cwd, MANIFEST), manifest);
86
+ }
87
+
88
+ function cwdOf(context) {
89
+ return context.cwd ?? process.cwd();
90
+ }
91
+
92
+ function versionOf(context) {
93
+ const version = context.nextRelease?.version;
94
+ if (!version) throw new Error("semantic-release did not provide nextRelease.version");
95
+ return version;
96
+ }