@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 +31 -3
- package/cli/help.ts +6 -1
- package/cli/rust-engine.ts +16 -1
- package/cli/stats.ts +34 -0
- package/cli.ts +16 -1
- package/core/stats.ts +128 -0
- package/klint.schema.json +1 -1
- package/klint.schema.yaml +1 -1
- package/package.json +5 -5
- package/tools/generate-homebrew-formula.ts +2 -2
- package/tools/semantic-release-crates.js +96 -0
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)",
|
package/cli/rust-engine.ts
CHANGED
|
@@ -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.
|
|
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.
|
|
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.
|
|
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.
|
|
74
|
-
"@konvert7/klint-darwin-x64": "0.
|
|
75
|
-
"@konvert7/klint-linux-x64": "0.
|
|
76
|
-
"@konvert7/klint-win32-x64": "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/
|
|
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/
|
|
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
|
+
}
|