@konvert7/klint 0.33.1 → 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 +34 -4
- package/cli/help.ts +7 -2
- package/cli/install-skill.ts +1 -0
- package/cli/rust-engine.ts +16 -1
- package/cli/stats.ts +34 -0
- package/cli.ts +16 -1
- package/core/skill.ts +1 -0
- 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
|
|
|
@@ -127,6 +135,8 @@ klint install-skill
|
|
|
127
135
|
|
|
128
136
|
The skill is embedded in the native engine, so this works from every distribution — the npm package, the PyPI wheel, the NuGet tool, and the standalone binary — not just Node projects.
|
|
129
137
|
|
|
138
|
+
Run on a terminal without `--agents`, `--symlink`, or `--copy`, it asks which agents to install for and how to store the skill. That is true of every distribution: the npm CLI prompts through `@clack/prompts`, and the native engine — which the wheel, the NuGet tool, and the standalone binary all wrap — prompts through `inquire`. Passing any of those flags, or piping stdin (CI, scripts, hooks), skips the questions and uses the defaults.
|
|
139
|
+
|
|
130
140
|
### Shared layout (default)
|
|
131
141
|
|
|
132
142
|
By default the skill lives in exactly one place and every other agent directory points at it:
|
|
@@ -137,7 +147,7 @@ By default the skill lives in exactly one place and every other agent directory
|
|
|
137
147
|
.cursor/skills/klint-rules -> ../../.agents/skills/klint-rules
|
|
138
148
|
```
|
|
139
149
|
|
|
140
|
-
`.agents/skills` is where opencode and
|
|
150
|
+
`.agents/skills` is where opencode, Codex, and GitHub Copilot already read from, so it doubles as the hub. One source of truth means one file to diff, one receipt, and one warning when it drifts. The links are relative and stay inside the repo — nothing points into `node_modules`, so reinstalling dependencies can never break them.
|
|
141
151
|
|
|
142
152
|
Pass `--copy` instead to give every agent directory its own independent copy and receipt.
|
|
143
153
|
|
|
@@ -161,6 +171,26 @@ Earlier versions symlinked into `node_modules/@konvert7/klint`, which breaks the
|
|
|
161
171
|
|
|
162
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.
|
|
163
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
|
+
|
|
164
194
|
## Engines
|
|
165
195
|
|
|
166
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.
|
|
@@ -743,13 +773,13 @@ Enable plugin rules with `plugins: [sonar]`, then override individual severities
|
|
|
743
773
|
|
|
744
774
|
## Agent and CI Integration
|
|
745
775
|
|
|
746
|
-
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.
|
|
747
777
|
|
|
748
778
|
```ts
|
|
749
779
|
// .agents/hooks/klint.ts
|
|
750
780
|
import { runHook } from "./run-hook";
|
|
751
781
|
|
|
752
|
-
const exitCode = runHook(["bun", "run", "klint", "--", "--json"]);
|
|
782
|
+
const exitCode = runHook(["bun", "run", "klint", "--", "--json", "--hook"]);
|
|
753
783
|
process.exit(exitCode);
|
|
754
784
|
```
|
|
755
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,9 +22,13 @@ 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
|
-
" --agents <list> comma-separated: claude,opencode,cursor,codex (default: all)",
|
|
31
|
+
" --agents <list> comma-separated: claude,opencode,cursor,codex,copilot (default: all)",
|
|
27
32
|
" --symlink keep one skill in .agents/skills and symlink the",
|
|
28
33
|
" other agents to it (default)",
|
|
29
34
|
" --copy give every agent directory its own copy",
|
package/cli/install-skill.ts
CHANGED
|
@@ -20,6 +20,7 @@ const AGENT_TARGETS = [
|
|
|
20
20
|
{ value: "opencode", label: "opencode" },
|
|
21
21
|
{ value: "cursor", label: "Cursor" },
|
|
22
22
|
{ value: "codex", label: "Codex" },
|
|
23
|
+
{ value: "copilot", label: "GitHub Copilot" },
|
|
23
24
|
] as const satisfies ReadonlyArray<{ value: AgentKey; label: string }>;
|
|
24
25
|
|
|
25
26
|
interface InstallPlan {
|
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/skill.ts
CHANGED
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
|
+
}
|