@konvert7/klint 0.35.2 → 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 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 42 (hook 30, direct 12)
182
- errors prevented 17 (caught by hook 11, direct 6)
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
- prevented by rule
185
- no-floating-promise 9
186
- arch/imports 5
187
- no-string-match 3
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
- 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
+ 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
+ }
@@ -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 printHelp(): void {
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
- process.stdout.write(
12
- [
13
- "klint — agent harness for TypeScript architecture rules",
14
- "",
15
- "Usage: klint [--config <dir>] [--rules <file>] [--engine ts|rust|compare|auto] [--fix] [--json] [--hook]",
16
- " klint stats [--config <dir>]",
17
- " klint install-skill [--agents <list>] [--symlink | --copy] [--force]",
18
- "",
19
- " --config <dir> directory containing klint.yaml or klint.config.json (default: cwd)",
20
- " --rules <file> custom rules file (default: <configDir>/klint.rules.ts if present)",
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
  }
@@ -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(args: string[]): Promise<void> {
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(args);
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(args: string[]): Promise<InstallPlan> {
168
- const flags = parseFlags(args);
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(args: string[]): {
188
+ function parseFlags(values: OptionValues): {
188
189
  agents?: AgentKey[];
189
190
  shared?: boolean;
190
191
  force: boolean;
191
192
  } {
192
- let agents: AgentKey[] | undefined;
193
- let shared: boolean | undefined;
194
- let force = false;
195
-
196
- for (let i = 0; i < args.length; i++) {
197
- if (args[i] === "--agents" && args[i + 1]) agents = parseAgents(args[++i]);
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
- return { agents, force, shared };
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
- function rulesByCount(byRule: Record<string, number>): Array<[string, number]> {
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 Object.entries(byRule).sort(([a, x], [b, y]) => y - x || byCodePoint(a, b));
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 = 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));
61
+ const { runs, reported, prevented } = stats;
14
62
  const lines = [
15
63
  `klint stats (${STATS_DIR}/${STATS_FILE})`,
16
64
  "",
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})`,
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
- if (rules.length > 0) {
21
- lines.push("", " prevented by rule");
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(args: string[]): void {
28
- const configFlag = args.indexOf("--config");
88
+ export function printStats(values: OptionValues): void {
29
89
  const configDir =
30
- configFlag !== -1 && args[configFlag + 1]
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 { printHelp } from "./cli/help";
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
- interface CliOptions {
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 args = process.argv.slice(2);
33
-
34
- if (args[0] === "help" || args[0] === "h") {
35
- printHelp();
36
- process.exit(0);
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
- if (args[0] === "stats") {
40
- printStats(args.slice(1));
41
- return;
42
- }
36
+ function optionalPath(value: string | true | undefined): string | undefined {
37
+ return typeof value === "string" ? resolve(value) : undefined;
38
+ }
43
39
 
44
- const subcommand = args.indexOf("install-skill");
45
- if (subcommand !== -1) {
46
- await installSkill(args.slice(subcommand + 1));
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
- let configDir = opts.configDir;
51
- let rulesFile = opts.rulesFile;
52
- let fix = false;
53
- let json = false;
54
- let debug = false;
55
- let hook = false;
56
- let engine = process.env.KLINT_ENGINE;
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
- prevented: {
27
- byRule: Record<string, number>;
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
- prevented: { byRule: {}, bySource: { hook: 0, direct: 0 } },
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.byRule[error.rule] = (stats.prevented.byRule[error.rule] ?? 0) + 1;
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
- typeof candidate.prevented?.byRule === "object" &&
89
- typeof candidate.prevented.bySource?.hook === "number" &&
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.35.2/klint.schema.json",
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.35.2/klint.schema.json
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.35.2",
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.35.2",
74
- "@konvert7/klint-darwin-x64": "0.35.2",
75
- "@konvert7/klint-linux-x64": "0.35.2",
76
- "@konvert7/klint-win32-x64": "0.35.2"
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",
@@ -61,11 +61,11 @@ Every run is recorded in `.klint/stats.json` next to the config. The folder igno
61
61
 
62
62
  | Command or flag | Use |
63
63
  |---|---|
64
- | `klint stats [--config <dir>]` | Print runs and errors prevented, in total and per rule. Use it to answer "how much has klint caught?" |
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
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
66
  | `KLINT_NO_STATS=1` | Turn recording off for a run, e.g. in a throwaway fixture. |
67
67
 
68
- 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.
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
69
 
70
70
  ## Primitive reference
71
71