@konvert7/klint 0.35.1 → 0.36.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 +10 -7
- package/cli/command.ts +189 -0
- package/cli/commands.ts +90 -0
- package/cli/help.ts +11 -42
- package/cli/install-skill.ts +15 -20
- package/cli/stats.ts +75 -17
- package/cli.ts +25 -51
- package/core/stats.ts +43 -11
- package/klint.schema.json +1 -1
- package/klint.schema.yaml +1 -1
- package/package.json +5 -5
- package/skill/klint-rules/SKILL.md +12 -0
package/README.md
CHANGED
|
@@ -178,16 +178,19 @@ Every lint run is recorded in `.klint/stats.json` next to your config. `klint st
|
|
|
178
178
|
```
|
|
179
179
|
klint stats (.klint/stats.json)
|
|
180
180
|
|
|
181
|
-
runs
|
|
182
|
-
|
|
181
|
+
runs 42 (hook 30, direct 12)
|
|
182
|
+
violations reported 61 (hook 48, direct 13)
|
|
183
|
+
errors prevented 17 (caught by hook 11, direct 6)
|
|
183
184
|
|
|
184
|
-
|
|
185
|
-
no-floating-promise
|
|
186
|
-
arch/imports
|
|
187
|
-
no-string-match
|
|
185
|
+
by rule reported prevented
|
|
186
|
+
no-floating-promise 29 9
|
|
187
|
+
arch/imports 20 5
|
|
188
|
+
no-string-match 12 3
|
|
188
189
|
```
|
|
189
190
|
|
|
190
|
-
|
|
191
|
+
Violations reported counts every error and warning each run shows, so an unfixed error reported by three runs counts three times. That is the measure of how often klint stepped in. klint's own `klint/*` advisories, such as a stale skill, aren't counted.
|
|
192
|
+
|
|
193
|
+
Errors prevented is stricter: an error counts 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 as prevented. 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
194
|
|
|
192
195
|
`.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
196
|
|
package/cli/command.ts
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
interface OptionSpec {
|
|
2
|
+
description: string;
|
|
3
|
+
value?: string;
|
|
4
|
+
short?: string;
|
|
5
|
+
aliases?: string[];
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
type OptionSpecs = Record<string, OptionSpec>;
|
|
9
|
+
export type OptionValues = Record<string, string | true | undefined>;
|
|
10
|
+
|
|
11
|
+
interface Leaf {
|
|
12
|
+
kind: "leaf";
|
|
13
|
+
summary: string;
|
|
14
|
+
options: OptionSpecs;
|
|
15
|
+
details?: () => string[];
|
|
16
|
+
run(values: OptionValues): unknown;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface Group {
|
|
20
|
+
kind: "group";
|
|
21
|
+
summary: string;
|
|
22
|
+
commands: Record<string, Leaf>;
|
|
23
|
+
aliases?: Record<string, string>;
|
|
24
|
+
defaultCommand?: Leaf;
|
|
25
|
+
details?: () => string[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type Command = Leaf | Group;
|
|
29
|
+
|
|
30
|
+
const HELP_FLAGS = new Set(["-h", "--help"]);
|
|
31
|
+
const HELP_WORD = "help";
|
|
32
|
+
const HELP_OPTION: OptionSpec = { short: "h", description: "Show this help" };
|
|
33
|
+
|
|
34
|
+
interface ParsedOptions {
|
|
35
|
+
values: OptionValues;
|
|
36
|
+
positionals: string[];
|
|
37
|
+
help: boolean;
|
|
38
|
+
error?: string;
|
|
39
|
+
rest: string[];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function findOption(
|
|
43
|
+
options: OptionSpecs,
|
|
44
|
+
token: string
|
|
45
|
+
): [string, OptionSpec] | undefined {
|
|
46
|
+
return Object.entries(options).find(
|
|
47
|
+
([name, spec]) =>
|
|
48
|
+
token === `--${name}` ||
|
|
49
|
+
(spec.short !== undefined && token === `-${spec.short}`) ||
|
|
50
|
+
(spec.aliases?.includes(token) ?? false)
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function parseOptions(
|
|
55
|
+
args: string[],
|
|
56
|
+
options: OptionSpecs,
|
|
57
|
+
stopAtPositional: boolean
|
|
58
|
+
): ParsedOptions {
|
|
59
|
+
const parsed: ParsedOptions = { values: {}, positionals: [], help: false, rest: [] };
|
|
60
|
+
for (let i = 0; i < args.length; i++) {
|
|
61
|
+
const token = args[i];
|
|
62
|
+
const match = findOption(options, token);
|
|
63
|
+
if (HELP_FLAGS.has(token)) {
|
|
64
|
+
parsed.help = true;
|
|
65
|
+
} else if (match?.[1].value !== undefined) {
|
|
66
|
+
const [name, spec] = match;
|
|
67
|
+
const value = args[i + 1];
|
|
68
|
+
if (value === undefined) parsed.error ??= `option '--${name}' needs ${spec.value}`;
|
|
69
|
+
else parsed.values[name] = value;
|
|
70
|
+
i++;
|
|
71
|
+
} else if (match) {
|
|
72
|
+
parsed.values[match[0]] = true;
|
|
73
|
+
} else if (token.startsWith("-")) {
|
|
74
|
+
parsed.error ??= `unknown option '${token}'`;
|
|
75
|
+
} else if (stopAtPositional) {
|
|
76
|
+
parsed.positionals.push(token);
|
|
77
|
+
parsed.rest = args.slice(i + 1);
|
|
78
|
+
return parsed;
|
|
79
|
+
} else {
|
|
80
|
+
parsed.positionals.push(token);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return parsed;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export async function runCommand(
|
|
87
|
+
command: Command,
|
|
88
|
+
args: string[],
|
|
89
|
+
path: string[]
|
|
90
|
+
): Promise<number> {
|
|
91
|
+
return command.kind === "group"
|
|
92
|
+
? runGroup(command, args, path)
|
|
93
|
+
: runLeaf(command, args, helpText(command, path));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function runGroup(group: Group, args: string[], path: string[]): Promise<number> {
|
|
97
|
+
const help = helpText(group, path);
|
|
98
|
+
const parsed = parseOptions(args, group.defaultCommand?.options ?? {}, true);
|
|
99
|
+
const [name] = parsed.positionals;
|
|
100
|
+
if (parsed.help || name === HELP_WORD || group.aliases?.[name] === HELP_WORD) {
|
|
101
|
+
return showHelp(help);
|
|
102
|
+
}
|
|
103
|
+
if (name !== undefined) {
|
|
104
|
+
const sub = group.commands[group.aliases?.[name] ?? name];
|
|
105
|
+
if (!sub) return usageFailure(`unknown command '${name}'`, help);
|
|
106
|
+
if (parsed.error) return usageFailure(parsed.error, help);
|
|
107
|
+
return runCommand(sub, parsed.rest, [...path, name]);
|
|
108
|
+
}
|
|
109
|
+
if (!group.defaultCommand) return showHelp(help);
|
|
110
|
+
return runLeaf(group.defaultCommand, args, help);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function runLeaf(leaf: Leaf, args: string[], help: string): Promise<number> {
|
|
114
|
+
const parsed = parseOptions(args, leaf.options, false);
|
|
115
|
+
if (parsed.help || parsed.positionals.includes(HELP_WORD)) return showHelp(help);
|
|
116
|
+
if (parsed.error) return usageFailure(parsed.error, help);
|
|
117
|
+
const [extra] = parsed.positionals;
|
|
118
|
+
if (extra !== undefined) return usageFailure(`unexpected argument '${extra}'`, help);
|
|
119
|
+
const result = await leaf.run(parsed.values);
|
|
120
|
+
return typeof result === "number" ? result : 0;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function showHelp(help: string): number {
|
|
124
|
+
process.stdout.write(`${help}\n`);
|
|
125
|
+
return 0;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function usageFailure(message: string, help: string): number {
|
|
129
|
+
process.stderr.write(`error: ${message}\n\n${help}\n`);
|
|
130
|
+
return 1;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function helpText(command: Command, path: string[]): string {
|
|
134
|
+
return command.kind === "group" ? groupHelp(command, path) : leafHelp(command, path);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function optionLabel(name: string, spec: OptionSpec): string {
|
|
138
|
+
const short = spec.short ? `-${spec.short}, ` : "";
|
|
139
|
+
const value = spec.value ? ` ${spec.value}` : "";
|
|
140
|
+
return `${short}--${name}${value}`;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function optionRows(options: OptionSpecs): string[] {
|
|
144
|
+
return table(
|
|
145
|
+
Object.entries({ ...options, help: HELP_OPTION }).map(([name, spec]) => [
|
|
146
|
+
optionLabel(name, spec),
|
|
147
|
+
spec.description,
|
|
148
|
+
])
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function leafHelp(leaf: Leaf, path: string[]): string {
|
|
153
|
+
return [
|
|
154
|
+
`Usage: ${path.join(" ")} [options]`,
|
|
155
|
+
"",
|
|
156
|
+
` ${leaf.summary}`,
|
|
157
|
+
"",
|
|
158
|
+
"Options:",
|
|
159
|
+
...optionRows(leaf.options),
|
|
160
|
+
...detailLines(leaf.details),
|
|
161
|
+
].join("\n");
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function groupHelp(group: Group, path: string[]): string {
|
|
165
|
+
const fallback = group.defaultCommand;
|
|
166
|
+
const synopsis = fallback ? `[<command>] [options]` : "<command>";
|
|
167
|
+
return [
|
|
168
|
+
`Usage: ${path.join(" ")} ${synopsis}`,
|
|
169
|
+
"",
|
|
170
|
+
` ${group.summary}`,
|
|
171
|
+
"",
|
|
172
|
+
"Commands:",
|
|
173
|
+
...table(Object.entries(group.commands).map(([name, sub]) => [name, sub.summary])),
|
|
174
|
+
...(fallback ? ["", "Options:", ...optionRows(fallback.options)] : []),
|
|
175
|
+
"",
|
|
176
|
+
...(fallback ? [`With no command, ${fallback.summary.toLowerCase()}.`] : []),
|
|
177
|
+
`Run '${path.join(" ")} <command> --help' for its options.`,
|
|
178
|
+
...detailLines(group.details),
|
|
179
|
+
].join("\n");
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function detailLines(details: (() => string[]) | undefined): string[] {
|
|
183
|
+
return details ? ["", ...details()] : [];
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function table(rows: string[][]): string[] {
|
|
187
|
+
const width = Math.max(...rows.map(([label]) => label.length));
|
|
188
|
+
return rows.map(([label, text]) => ` ${label.padEnd(width)} ${text}`);
|
|
189
|
+
}
|
package/cli/commands.ts
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { AGENT_SKILL_DIRS } from "../core/skill";
|
|
2
|
+
import type { Group, OptionValues } from "./command";
|
|
3
|
+
import { builtInRuleLines } from "./help";
|
|
4
|
+
|
|
5
|
+
interface CommandHandlers {
|
|
6
|
+
lint(values: OptionValues): unknown;
|
|
7
|
+
stats(values: OptionValues): unknown;
|
|
8
|
+
installSkill(values: OptionValues): unknown;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function klintCommand(handlers: CommandHandlers): Group {
|
|
12
|
+
return {
|
|
13
|
+
kind: "group",
|
|
14
|
+
summary:
|
|
15
|
+
"Architecture-as-Code linter: layer boundaries, import rules and forbidden patterns",
|
|
16
|
+
aliases: { h: "help" },
|
|
17
|
+
defaultCommand: {
|
|
18
|
+
kind: "leaf",
|
|
19
|
+
summary: "Lints the project",
|
|
20
|
+
options: {
|
|
21
|
+
config: {
|
|
22
|
+
value: "<dir>",
|
|
23
|
+
description:
|
|
24
|
+
"Directory containing klint.yaml or klint.config.json (default: cwd)",
|
|
25
|
+
},
|
|
26
|
+
rules: {
|
|
27
|
+
value: "<file>",
|
|
28
|
+
description:
|
|
29
|
+
"Custom rules file (default: <configDir>/klint.rules.ts if present)",
|
|
30
|
+
},
|
|
31
|
+
engine: {
|
|
32
|
+
value: "<name>",
|
|
33
|
+
description:
|
|
34
|
+
"Engine to use: ts (default), rust, compare, or auto (experimental)",
|
|
35
|
+
},
|
|
36
|
+
fix: { description: "Apply auto-fixes for fixable violations in place" },
|
|
37
|
+
json: { description: "Emit structured JSON to stdout (for agent and CI use)" },
|
|
38
|
+
debug: {
|
|
39
|
+
aliases: ["-debug"],
|
|
40
|
+
description: "Print file resolution and rule progress to stderr",
|
|
41
|
+
},
|
|
42
|
+
hook: { description: "Count this run as an agent hook run in klint stats" },
|
|
43
|
+
version: { short: "V", description: "Print the installed klint version" },
|
|
44
|
+
},
|
|
45
|
+
run: handlers.lint,
|
|
46
|
+
},
|
|
47
|
+
commands: {
|
|
48
|
+
stats: {
|
|
49
|
+
kind: "leaf",
|
|
50
|
+
summary:
|
|
51
|
+
"Print runs, violations reported and errors prevented, in total and per rule",
|
|
52
|
+
options: {
|
|
53
|
+
config: {
|
|
54
|
+
value: "<dir>",
|
|
55
|
+
description: "Directory holding .klint/stats.json (default: cwd)",
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
details: () => ["Set KLINT_NO_STATS=1 to stop recording."],
|
|
59
|
+
run: handlers.stats,
|
|
60
|
+
},
|
|
61
|
+
"install-skill": {
|
|
62
|
+
kind: "leaf",
|
|
63
|
+
summary: "Install the rule-authoring skill into agent config directories",
|
|
64
|
+
options: {
|
|
65
|
+
agents: {
|
|
66
|
+
value: "<list>",
|
|
67
|
+
description: `Comma-separated: ${Object.keys(AGENT_SKILL_DIRS).join(",")} (default: all)`,
|
|
68
|
+
},
|
|
69
|
+
symlink: {
|
|
70
|
+
aliases: ["--shared"],
|
|
71
|
+
description:
|
|
72
|
+
"Keep one skill in .agents/skills and symlink the others to it (default)",
|
|
73
|
+
},
|
|
74
|
+
copy: { description: "Give every agent directory its own copy" },
|
|
75
|
+
force: { description: "Replace a skill directory klint did not install" },
|
|
76
|
+
},
|
|
77
|
+
details: () => [
|
|
78
|
+
"Asks which agents and how to store the skill when run on a terminal without",
|
|
79
|
+
"--agents, --symlink or --copy.",
|
|
80
|
+
"",
|
|
81
|
+
"Installed skills record the version and hash they came from. klint warns with",
|
|
82
|
+
"klint/skill-stale once the shipped skill moves on, and with",
|
|
83
|
+
"klint/skill-legacy-link for old symlinks that point into node_modules.",
|
|
84
|
+
],
|
|
85
|
+
run: handlers.installSkill,
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
details: builtInRuleLines,
|
|
89
|
+
};
|
|
90
|
+
}
|
package/cli/help.ts
CHANGED
|
@@ -1,52 +1,21 @@
|
|
|
1
1
|
import { BUILT_IN_PLUGINS } from "../plugins/index";
|
|
2
2
|
import { BUILT_IN_RULES } from "../rules/index";
|
|
3
3
|
|
|
4
|
-
export function
|
|
4
|
+
export function builtInRuleLines(): string[] {
|
|
5
5
|
const pluginRules = new Set(
|
|
6
6
|
Object.values(BUILT_IN_PLUGINS).flatMap((p) => Object.keys(p.rules))
|
|
7
7
|
);
|
|
8
8
|
const standaloneRules = Object.keys(BUILT_IN_RULES).filter((r) => !pluginRules.has(r));
|
|
9
9
|
const pluginEntries = Object.entries(BUILT_IN_PLUGINS);
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
" --engine <name> engine to use: ts (default), rust, compare, or auto (experimental)",
|
|
22
|
-
" --fix apply auto-fixes for fixable violations in-place",
|
|
23
|
-
" --json emit structured JSON to stdout (for agent/CI consumption)",
|
|
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)",
|
|
29
|
-
"",
|
|
30
|
-
" install-skill install the rule-authoring skill into agent config directories",
|
|
31
|
-
" --agents <list> comma-separated: claude,opencode,cursor,codex,copilot (default: all)",
|
|
32
|
-
" --symlink keep one skill in .agents/skills and symlink the",
|
|
33
|
-
" other agents to it (default)",
|
|
34
|
-
" --copy give every agent directory its own copy",
|
|
35
|
-
" --force replace a skill directory klint did not install",
|
|
36
|
-
"",
|
|
37
|
-
" Installed skills record the version and hash they came from. klint warns with",
|
|
38
|
-
" klint/skill-stale once the shipped skill moves on, and with",
|
|
39
|
-
" klint/skill-legacy-link for old symlinks that point into node_modules.",
|
|
40
|
-
"",
|
|
41
|
-
`Built-in rules (${standaloneRules.length}):`,
|
|
42
|
-
...standaloneRules.map((r) => ` ${r}`),
|
|
43
|
-
"",
|
|
44
|
-
`Plugins (${pluginEntries.length}):`,
|
|
45
|
-
...pluginEntries.flatMap(([name, plugin]) => [
|
|
46
|
-
` ${name}`,
|
|
47
|
-
...Object.keys(plugin.rules).map((r) => ` ${r}`),
|
|
48
|
-
]),
|
|
49
|
-
"",
|
|
50
|
-
].join("\n")
|
|
51
|
-
);
|
|
11
|
+
return [
|
|
12
|
+
`Built-in rules (${standaloneRules.length}):`,
|
|
13
|
+
...standaloneRules.map((r) => ` ${r}`),
|
|
14
|
+
"",
|
|
15
|
+
`Plugins (${pluginEntries.length}):`,
|
|
16
|
+
...pluginEntries.flatMap(([name, plugin]) => [
|
|
17
|
+
` ${name}`,
|
|
18
|
+
...Object.keys(plugin.rules).map((r) => ` ${r}`),
|
|
19
|
+
]),
|
|
20
|
+
];
|
|
52
21
|
}
|
package/cli/install-skill.ts
CHANGED
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
type SkillReceipt,
|
|
14
14
|
skillHash,
|
|
15
15
|
} from "../core/skill";
|
|
16
|
+
import type { OptionValues } from "./command";
|
|
16
17
|
import { installedVersion, PACKAGE_ROOT } from "./paths";
|
|
17
18
|
|
|
18
19
|
const AGENT_TARGETS = [
|
|
@@ -29,14 +30,14 @@ interface InstallPlan {
|
|
|
29
30
|
force: boolean;
|
|
30
31
|
}
|
|
31
32
|
|
|
32
|
-
export async function installSkill(
|
|
33
|
+
export async function installSkill(values: OptionValues): Promise<void> {
|
|
33
34
|
const skillSrc = join(PACKAGE_ROOT, "skill", SKILL_DIR_NAME);
|
|
34
35
|
if (!existsSync(skillSrc)) {
|
|
35
36
|
process.stderr.write(`klint: skill source not found at ${skillSrc}\n`);
|
|
36
37
|
process.exit(1);
|
|
37
38
|
}
|
|
38
39
|
|
|
39
|
-
const plan = await resolvePlan(
|
|
40
|
+
const plan = await resolvePlan(values);
|
|
40
41
|
const dirs = agentSkillDirs(plan.agents);
|
|
41
42
|
|
|
42
43
|
try {
|
|
@@ -164,8 +165,8 @@ async function writeReceipt(dest: string, skillSrc: string): Promise<void> {
|
|
|
164
165
|
await writeFile(join(dest, RECEIPT_FILE_NAME), `${JSON.stringify(receipt, null, 2)}\n`);
|
|
165
166
|
}
|
|
166
167
|
|
|
167
|
-
async function resolvePlan(
|
|
168
|
-
const flags = parseFlags(
|
|
168
|
+
async function resolvePlan(values: OptionValues): Promise<InstallPlan> {
|
|
169
|
+
const flags = parseFlags(values);
|
|
169
170
|
const decided = flags.agents !== undefined || flags.shared !== undefined;
|
|
170
171
|
|
|
171
172
|
if (!process.stdin.isTTY || decided) {
|
|
@@ -184,27 +185,21 @@ async function resolvePlan(args: string[]): Promise<InstallPlan> {
|
|
|
184
185
|
};
|
|
185
186
|
}
|
|
186
187
|
|
|
187
|
-
function parseFlags(
|
|
188
|
+
function parseFlags(values: OptionValues): {
|
|
188
189
|
agents?: AgentKey[];
|
|
189
190
|
shared?: boolean;
|
|
190
191
|
force: boolean;
|
|
191
192
|
} {
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
else if (args[i] === "--symlink" || args[i] === "--shared") shared = true;
|
|
199
|
-
else if (args[i] === "--copy") shared = false;
|
|
200
|
-
else if (args[i] === "--force") force = true;
|
|
201
|
-
else {
|
|
202
|
-
process.stderr.write(`klint: unknown install-skill argument "${args[i]}"\n`);
|
|
203
|
-
process.exit(1);
|
|
204
|
-
}
|
|
205
|
-
}
|
|
193
|
+
return {
|
|
194
|
+
agents: typeof values.agents === "string" ? parseAgents(values.agents) : undefined,
|
|
195
|
+
shared: sharedChoice(values),
|
|
196
|
+
force: values.force === true,
|
|
197
|
+
};
|
|
198
|
+
}
|
|
206
199
|
|
|
207
|
-
|
|
200
|
+
function sharedChoice(values: OptionValues): boolean | undefined {
|
|
201
|
+
if (values.copy) return false;
|
|
202
|
+
return values.symlink ? true : undefined;
|
|
208
203
|
}
|
|
209
204
|
|
|
210
205
|
function parseAgents(value: string): AgentKey[] {
|
package/cli/stats.ts
CHANGED
|
@@ -1,34 +1,92 @@
|
|
|
1
1
|
import { resolve } from "node:path";
|
|
2
2
|
import { type KlintStats, readStats, STATS_DIR, STATS_FILE } from "../core/stats";
|
|
3
|
+
import type { OptionValues } from "./command";
|
|
3
4
|
|
|
4
|
-
|
|
5
|
+
const RULE_HEADING = "by rule";
|
|
6
|
+
const REPORTED_HEADING = "reported";
|
|
7
|
+
const PREVENTED_HEADING = "prevented";
|
|
8
|
+
const LABEL_WIDTH = "violations reported".length;
|
|
9
|
+
|
|
10
|
+
interface RuleRow {
|
|
11
|
+
rule: string;
|
|
12
|
+
reported: number;
|
|
13
|
+
prevented: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function ruleRows(stats: KlintStats): RuleRow[] {
|
|
17
|
+
const rules = new Set([
|
|
18
|
+
...Object.keys(stats.reported.byRule),
|
|
19
|
+
...Object.keys(stats.prevented.byRule),
|
|
20
|
+
]);
|
|
5
21
|
const byCodePoint = (a: string, b: string) => Number(a > b) - Number(a < b);
|
|
6
|
-
return
|
|
22
|
+
return [...rules]
|
|
23
|
+
.map((rule) => ({
|
|
24
|
+
rule,
|
|
25
|
+
reported: stats.reported.byRule[rule] ?? 0,
|
|
26
|
+
prevented: stats.prevented.byRule[rule] ?? 0,
|
|
27
|
+
}))
|
|
28
|
+
.sort(
|
|
29
|
+
(a, b) =>
|
|
30
|
+
b.reported - a.reported ||
|
|
31
|
+
b.prevented - a.prevented ||
|
|
32
|
+
byCodePoint(a.rule, b.rule)
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface Summary {
|
|
37
|
+
label: string;
|
|
38
|
+
total: number;
|
|
39
|
+
detail: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function summaryLines(summaries: Summary[]): string[] {
|
|
43
|
+
const width = Math.max(...summaries.map(({ total }) => String(total).length));
|
|
44
|
+
return summaries.map(
|
|
45
|
+
({ label, total, detail }) =>
|
|
46
|
+
` ${label.padEnd(LABEL_WIDTH)} ${String(total).padStart(width)} (${detail})`
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function ruleTable(rows: RuleRow[]): string[] {
|
|
51
|
+
const width = Math.max(RULE_HEADING.length - 2, ...rows.map(({ rule }) => rule.length));
|
|
52
|
+
const heading = ` ${RULE_HEADING.padEnd(width + 2)} ${REPORTED_HEADING} ${PREVENTED_HEADING}`;
|
|
53
|
+
const lines = rows.map(
|
|
54
|
+
({ rule, reported, prevented }) =>
|
|
55
|
+
` ${rule.padEnd(width)} ${String(reported).padStart(REPORTED_HEADING.length)} ${String(prevented).padStart(PREVENTED_HEADING.length)}`
|
|
56
|
+
);
|
|
57
|
+
return [heading, ...lines];
|
|
7
58
|
}
|
|
8
59
|
|
|
9
60
|
export function formatStats(stats: KlintStats): string {
|
|
10
|
-
const runs
|
|
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));
|
|
61
|
+
const { runs, reported, prevented } = stats;
|
|
14
62
|
const lines = [
|
|
15
63
|
`klint stats (${STATS_DIR}/${STATS_FILE})`,
|
|
16
64
|
"",
|
|
17
|
-
|
|
18
|
-
|
|
65
|
+
...summaryLines([
|
|
66
|
+
{
|
|
67
|
+
label: "runs",
|
|
68
|
+
total: runs.hook + runs.direct,
|
|
69
|
+
detail: `hook ${runs.hook}, direct ${runs.direct}`,
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
label: "violations reported",
|
|
73
|
+
total: reported.bySource.hook + reported.bySource.direct,
|
|
74
|
+
detail: `hook ${reported.bySource.hook}, direct ${reported.bySource.direct}`,
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
label: "errors prevented",
|
|
78
|
+
total: prevented.bySource.hook + prevented.bySource.direct,
|
|
79
|
+
detail: `caught by hook ${prevented.bySource.hook}, direct ${prevented.bySource.direct}`,
|
|
80
|
+
},
|
|
81
|
+
]),
|
|
19
82
|
];
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
for (const [rule, count] of rules) lines.push(` ${rule.padEnd(width)} ${count}`);
|
|
23
|
-
}
|
|
83
|
+
const rows = ruleRows(stats);
|
|
84
|
+
if (rows.length > 0) lines.push("", ...ruleTable(rows));
|
|
24
85
|
return `${lines.join("\n")}\n`;
|
|
25
86
|
}
|
|
26
87
|
|
|
27
|
-
export function printStats(
|
|
28
|
-
const configFlag = args.indexOf("--config");
|
|
88
|
+
export function printStats(values: OptionValues): void {
|
|
29
89
|
const configDir =
|
|
30
|
-
|
|
31
|
-
? resolve(args[configFlag + 1])
|
|
32
|
-
: process.cwd();
|
|
90
|
+
typeof values.config === "string" ? resolve(values.config) : process.cwd();
|
|
33
91
|
process.stdout.write(formatStats(readStats(configDir)));
|
|
34
92
|
}
|
package/cli.ts
CHANGED
|
@@ -3,7 +3,8 @@ import { existsSync } from "node:fs";
|
|
|
3
3
|
import { readFile } from "node:fs/promises";
|
|
4
4
|
import { resolve } from "node:path";
|
|
5
5
|
import { parse as parseYaml } from "yaml";
|
|
6
|
-
import {
|
|
6
|
+
import { type OptionValues, runCommand } from "./cli/command";
|
|
7
|
+
import { klintCommand } from "./cli/commands";
|
|
7
8
|
import { installSkill } from "./cli/install-skill";
|
|
8
9
|
import {
|
|
9
10
|
formatDuration,
|
|
@@ -22,60 +23,33 @@ import { skillAdvisories } from "./core/skill";
|
|
|
22
23
|
import { runRecorder } from "./core/stats";
|
|
23
24
|
import type { ArchConfig, KlintConfig, KlintRule, RuleConfigValue } from "./core/types";
|
|
24
25
|
|
|
25
|
-
|
|
26
|
-
configDir?: string;
|
|
27
|
-
rulesFile?: string;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
export async function main(opts: CliOptions = {}): Promise<void> {
|
|
26
|
+
export async function main(): Promise<number> {
|
|
31
27
|
const startedAt = performance.now();
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
28
|
+
const command = klintCommand({
|
|
29
|
+
lint: (values) => lint(values, startedAt),
|
|
30
|
+
stats: printStats,
|
|
31
|
+
installSkill,
|
|
32
|
+
});
|
|
33
|
+
return runCommand(command, process.argv.slice(2), ["klint"]);
|
|
34
|
+
}
|
|
38
35
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
}
|
|
36
|
+
function optionalPath(value: string | true | undefined): string | undefined {
|
|
37
|
+
return typeof value === "string" ? resolve(value) : undefined;
|
|
38
|
+
}
|
|
43
39
|
|
|
44
|
-
|
|
45
|
-
if (
|
|
46
|
-
|
|
40
|
+
async function lint(values: OptionValues, startedAt: number): Promise<void> {
|
|
41
|
+
if (values.version) {
|
|
42
|
+
process.stdout.write(`klint ${installedVersion()}\n`);
|
|
47
43
|
return;
|
|
48
44
|
}
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
for (let i = 0; i < args.length; i++) {
|
|
59
|
-
if (args[i] === "--config" && args[i + 1]) configDir = resolve(args[++i]);
|
|
60
|
-
else if (args[i] === "--rules" && args[i + 1]) rulesFile = resolve(args[++i]);
|
|
61
|
-
else if (args[i] === "--engine" && args[i + 1]) engine = args[++i];
|
|
62
|
-
else if (args[i] === "--fix") fix = true;
|
|
63
|
-
else if (args[i] === "--json") json = true;
|
|
64
|
-
else if (args[i] === "--hook") hook = true;
|
|
65
|
-
else if (args[i] === "--debug" || args[i] === "-debug") debug = true;
|
|
66
|
-
else if (args[i] === "--help" || args[i] === "-h") {
|
|
67
|
-
printHelp();
|
|
68
|
-
process.exit(0);
|
|
69
|
-
} else if (args[i] === "--version" || args[i] === "-V") {
|
|
70
|
-
process.stdout.write(`klint ${installedVersion()}\n`);
|
|
71
|
-
process.exit(0);
|
|
72
|
-
} else {
|
|
73
|
-
process.stderr.write(`klint: unknown argument "${args[i]}"\n`);
|
|
74
|
-
process.exit(1);
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
configDir ??= process.cwd();
|
|
45
|
+
const rulesFile = optionalPath(values.rules);
|
|
46
|
+
const fix = values.fix === true;
|
|
47
|
+
const json = values.json === true;
|
|
48
|
+
const debug = values.debug === true;
|
|
49
|
+
const hook = values.hook === true;
|
|
50
|
+
const engine =
|
|
51
|
+
typeof values.engine === "string" ? values.engine : process.env.KLINT_ENGINE;
|
|
52
|
+
const configDir = optionalPath(values.config) ?? process.cwd();
|
|
79
53
|
const record = runRecorder(configDir, hook ? "hook" : "direct");
|
|
80
54
|
|
|
81
55
|
const yamlPath = resolve(configDir, "klint.yaml");
|
|
@@ -237,4 +211,4 @@ export async function main(opts: CliOptions = {}): Promise<void> {
|
|
|
237
211
|
writeTextOutput([...violations, ...advisories], startedAt);
|
|
238
212
|
}
|
|
239
213
|
|
|
240
|
-
if (import.meta.main) await main();
|
|
214
|
+
if (import.meta.main) process.exitCode = await main();
|
package/core/stats.ts
CHANGED
|
@@ -6,6 +6,7 @@ export const STATS_DIR = ".klint";
|
|
|
6
6
|
export const STATS_FILE = "stats.json";
|
|
7
7
|
const SELF_IGNORE = "# Created by klint automatically.\n*\n";
|
|
8
8
|
const STATS_VERSION = 1;
|
|
9
|
+
const KLINT_ADVISORY_PREFIX = "klint/";
|
|
9
10
|
|
|
10
11
|
export type RunSource = "hook" | "direct";
|
|
11
12
|
|
|
@@ -23,18 +24,31 @@ interface OpenError {
|
|
|
23
24
|
export interface KlintStats {
|
|
24
25
|
version: typeof STATS_VERSION;
|
|
25
26
|
runs: Record<RunSource, number>;
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
bySource: Record<RunSource, number>;
|
|
29
|
-
};
|
|
27
|
+
reported: RuleCounts;
|
|
28
|
+
prevented: RuleCounts;
|
|
30
29
|
open: OpenError[];
|
|
31
30
|
}
|
|
32
31
|
|
|
32
|
+
interface RuleCounts {
|
|
33
|
+
byRule: Record<string, number>;
|
|
34
|
+
bySource: Record<RunSource, number>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function emptyCounts(): RuleCounts {
|
|
38
|
+
return { byRule: {}, bySource: { hook: 0, direct: 0 } };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function bump(counts: RuleCounts, rule: string, source: RunSource): void {
|
|
42
|
+
counts.byRule[rule] = (counts.byRule[rule] ?? 0) + 1;
|
|
43
|
+
counts.bySource[source] += 1;
|
|
44
|
+
}
|
|
45
|
+
|
|
33
46
|
export function emptyStats(): KlintStats {
|
|
34
47
|
return {
|
|
35
48
|
version: STATS_VERSION,
|
|
36
49
|
runs: { hook: 0, direct: 0 },
|
|
37
|
-
|
|
50
|
+
reported: emptyCounts(),
|
|
51
|
+
prevented: emptyCounts(),
|
|
38
52
|
open: [],
|
|
39
53
|
};
|
|
40
54
|
}
|
|
@@ -43,6 +57,10 @@ function sameError(a: Omit<OpenError, "caughtBy">, b: Omit<OpenError, "caughtBy"
|
|
|
43
57
|
return a.rule === b.rule && a.file === b.file && a.message === b.message;
|
|
44
58
|
}
|
|
45
59
|
|
|
60
|
+
function isCodeViolation(violation: CountedViolation): boolean {
|
|
61
|
+
return !violation.rule.startsWith(KLINT_ADVISORY_PREFIX);
|
|
62
|
+
}
|
|
63
|
+
|
|
46
64
|
function errorsOf(
|
|
47
65
|
violations: readonly CountedViolation[]
|
|
48
66
|
): Omit<OpenError, "caughtBy">[] {
|
|
@@ -58,14 +76,16 @@ export function nextStats(
|
|
|
58
76
|
): KlintStats {
|
|
59
77
|
const stats = structuredClone(previous);
|
|
60
78
|
stats.runs[source] += 1;
|
|
79
|
+
for (const violation of violations.filter(isCodeViolation)) {
|
|
80
|
+
bump(stats.reported, violation.rule, source);
|
|
81
|
+
}
|
|
61
82
|
|
|
62
83
|
const unmatched = errorsOf(violations);
|
|
63
84
|
const stillOpen: OpenError[] = [];
|
|
64
85
|
for (const error of previous.open) {
|
|
65
86
|
const match = unmatched.findIndex((current) => sameError(current, error));
|
|
66
87
|
if (match === -1) {
|
|
67
|
-
stats.prevented
|
|
68
|
-
stats.prevented.bySource[error.caughtBy] += 1;
|
|
88
|
+
bump(stats.prevented, error.rule, error.caughtBy);
|
|
69
89
|
continue;
|
|
70
90
|
}
|
|
71
91
|
unmatched.splice(match, 1);
|
|
@@ -78,6 +98,15 @@ export function nextStats(
|
|
|
78
98
|
return stats;
|
|
79
99
|
}
|
|
80
100
|
|
|
101
|
+
function isRuleCounts(value: unknown): value is RuleCounts {
|
|
102
|
+
const candidate = value as Partial<RuleCounts> | undefined;
|
|
103
|
+
return (
|
|
104
|
+
typeof candidate?.byRule === "object" &&
|
|
105
|
+
typeof candidate.bySource?.hook === "number" &&
|
|
106
|
+
typeof candidate.bySource.direct === "number"
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
81
110
|
function isStats(value: unknown): value is KlintStats {
|
|
82
111
|
if (!value || typeof value !== "object") return false;
|
|
83
112
|
const candidate = value as Partial<KlintStats>;
|
|
@@ -85,19 +114,22 @@ function isStats(value: unknown): value is KlintStats {
|
|
|
85
114
|
candidate.version === STATS_VERSION &&
|
|
86
115
|
typeof candidate.runs?.hook === "number" &&
|
|
87
116
|
typeof candidate.runs.direct === "number" &&
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
typeof candidate.prevented.bySource.direct === "number" &&
|
|
117
|
+
isRuleCounts(candidate.prevented) &&
|
|
118
|
+
(candidate.reported === undefined || isRuleCounts(candidate.reported)) &&
|
|
91
119
|
Array.isArray(candidate.open)
|
|
92
120
|
);
|
|
93
121
|
}
|
|
94
122
|
|
|
123
|
+
function withReportedCounts(stats: KlintStats): KlintStats {
|
|
124
|
+
return { ...stats, reported: stats.reported ?? emptyCounts() };
|
|
125
|
+
}
|
|
126
|
+
|
|
95
127
|
export function readStats(configDir: string): KlintStats {
|
|
96
128
|
try {
|
|
97
129
|
const parsed: unknown = JSON.parse(
|
|
98
130
|
readFileSync(join(configDir, STATS_DIR, STATS_FILE), "utf-8")
|
|
99
131
|
);
|
|
100
|
-
return isStats(parsed) ? parsed : emptyStats();
|
|
132
|
+
return isStats(parsed) ? withReportedCounts(parsed) : emptyStats();
|
|
101
133
|
} catch {
|
|
102
134
|
return emptyStats();
|
|
103
135
|
}
|
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.36.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.36.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.36.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.36.0",
|
|
74
|
+
"@konvert7/klint-darwin-x64": "0.36.0",
|
|
75
|
+
"@konvert7/klint-linux-x64": "0.36.0",
|
|
76
|
+
"@konvert7/klint-win32-x64": "0.36.0"
|
|
77
77
|
},
|
|
78
78
|
"devDependencies": {
|
|
79
79
|
"@biomejs/biome": "2.4.7",
|
|
@@ -55,6 +55,18 @@ Wire it into `klint.yaml` for editor autocomplete and validation:
|
|
|
55
55
|
|
|
56
56
|
7. **Write a break test** — prove the rule fires on a deliberate violation, and doesn't fire on clean code. Run the test suite to confirm.
|
|
57
57
|
|
|
58
|
+
## Run tracking
|
|
59
|
+
|
|
60
|
+
Every run is recorded in `.klint/stats.json` next to the config. The folder ignores itself, so never add it to `.gitignore`.
|
|
61
|
+
|
|
62
|
+
| Command or flag | Use |
|
|
63
|
+
|---|---|
|
|
64
|
+
| `klint stats [--config <dir>]` | Print runs, violations reported and errors prevented, in total and per rule. Use it to answer "how much has klint caught?" |
|
|
65
|
+
| `--hook` | Put it only in agent hook commands, e.g. `klint --json --hook`. Leave it off when you run klint yourself, so your runs count as direct. |
|
|
66
|
+
| `KLINT_NO_STATS=1` | Turn recording off for a run, e.g. in a throwaway fixture. |
|
|
67
|
+
|
|
68
|
+
Every violation a run reports counts as reported, repeats included; klint's own `klint/*` advisories do not. An error counts as prevented once, when a later run no longer reports it. Errors match by rule, file and message, so moving code to another line is not a fix. Warnings are not tracked.
|
|
69
|
+
|
|
58
70
|
## Primitive reference
|
|
59
71
|
|
|
60
72
|
### Layers
|