@konvert7/klint 0.28.0 → 0.30.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 +29 -0
- package/cli/output.ts +19 -0
- package/cli/rust-engine.ts +13 -6
- package/cli.ts +31 -5
- package/core/arch-comments.ts +30 -12
- package/core/arch.ts +25 -3
- package/core/config.schema.ts +8 -2
- package/core/schema-version.ts +46 -0
- package/core/types.ts +3 -1
- package/package.json +5 -5
- package/skill/klint-rules/SKILL.md +57 -0
- package/tools/generate-schema.ts +12 -2
- package/tools/semantic-release-native-npm.js +3 -1
- package/tools/semantic-release-root-npm.js +15 -1
package/README.md
CHANGED
|
@@ -209,6 +209,23 @@ arch:
|
|
|
209
209
|
|
|
210
210
|
`klint.config.json` is still supported for backwards compatibility.
|
|
211
211
|
|
|
212
|
+
### Pinning the schema version
|
|
213
|
+
|
|
214
|
+
The `# yaml-language-server:` comment above points your editor at the schema shipped
|
|
215
|
+
inside the package, which always matches the installed klint. To pin a specific
|
|
216
|
+
published schema instead, set `$schema` to a version-tagged URL:
|
|
217
|
+
|
|
218
|
+
```yaml
|
|
219
|
+
$schema: https://raw.githubusercontent.com/konvert7/klint/refs/tags/v0.29.0/klint.schema.json
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
When the tagged version differs from the installed klint, the run reports a
|
|
223
|
+
`klint/schema-version` warning naming both versions and the URL to move to. It is a
|
|
224
|
+
warning, not an error — the exit code is unaffected. Local paths such as
|
|
225
|
+
`./klint.schema.json` carry no version and are never checked.
|
|
226
|
+
|
|
227
|
+
`klint --version` prints the installed version.
|
|
228
|
+
|
|
212
229
|
## Architecture as Code
|
|
213
230
|
|
|
214
231
|
The `arch:` section turns project boundaries into executable policy.
|
|
@@ -415,6 +432,18 @@ arch:
|
|
|
415
432
|
|
|
416
433
|
Set `countDocComments: true` on either rule to count doc-comments toward the limit as well. Both rules run in the TS and Rust engines; Python (`#`) and Swift (`//`, `/* */`) files are enforced by the Rust engine.
|
|
417
434
|
|
|
435
|
+
Some comments are machinery rather than prose — markers a build or codegen step reads, region pragmas — and counting them makes both limits measure the wrong thing. `ignore` exempts them, on either rule:
|
|
436
|
+
|
|
437
|
+
```yaml
|
|
438
|
+
arch:
|
|
439
|
+
maxCommentDensity:
|
|
440
|
+
- limit: 5
|
|
441
|
+
in: ["src/**"]
|
|
442
|
+
ignore: ["@codegen:"] # literal substring, or "re:" for a regex
|
|
443
|
+
```
|
|
444
|
+
|
|
445
|
+
An ignored line still counts in the density denominator — it is a physical line like code — and still holds a comment block together, so a marker cannot be sprinkled between comment lines to split an over-tall block. Matching tests the physical source line and therefore only ever applies to lines already lexed as comments.
|
|
446
|
+
|
|
418
447
|
### Language Support
|
|
419
448
|
|
|
420
449
|
klint scans TypeScript/JavaScript, Python, Swift, and Rust. Every architecture rule is enforced from the same `klint.yaml`, but what a rule can *see* depends on what the language actually has.
|
package/cli/output.ts
CHANGED
|
@@ -85,6 +85,25 @@ export function mergeJsonOutputs(...outputs: JsonPayload[]): JsonPayload {
|
|
|
85
85
|
};
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
+
export function withAdvisories(
|
|
89
|
+
stdout: string,
|
|
90
|
+
advisories: Array<Omit<Violation, "fix">>
|
|
91
|
+
): string {
|
|
92
|
+
if (advisories.length === 0) return stdout;
|
|
93
|
+
const payload = parseJsonPayload(stdout);
|
|
94
|
+
if (!isJsonPayload(payload)) return stdout;
|
|
95
|
+
|
|
96
|
+
const violations = [
|
|
97
|
+
...payload.violations,
|
|
98
|
+
...advisories.map((advisory) => ({ ...advisory, fix: null })),
|
|
99
|
+
];
|
|
100
|
+
const errors = violations.filter((v) => v.severity === "error").length;
|
|
101
|
+
return JSON.stringify({
|
|
102
|
+
violations,
|
|
103
|
+
summary: { errors, warnings: violations.length - errors },
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
88
107
|
export function isJsonPayload(value: unknown): value is JsonPayload {
|
|
89
108
|
if (!value || typeof value !== "object") return false;
|
|
90
109
|
const candidate = value as Partial<JsonPayload>;
|
package/cli/rust-engine.ts
CHANGED
|
@@ -3,7 +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 { KlintRule, RuleConfigValue } from "../core/types";
|
|
6
|
+
import type { KlintRule, RuleConfigValue, Violation } from "../core/types";
|
|
7
7
|
import {
|
|
8
8
|
emptyJsonPayload,
|
|
9
9
|
isJsonPayload,
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
parseJsonPayload,
|
|
14
14
|
toJsonCliResult,
|
|
15
15
|
toJsonPayload,
|
|
16
|
+
withAdvisories,
|
|
16
17
|
writeTextOutput,
|
|
17
18
|
} from "./output";
|
|
18
19
|
import { PACKAGE_ROOT } from "./paths";
|
|
@@ -25,6 +26,7 @@ import {
|
|
|
25
26
|
} from "./rust-config";
|
|
26
27
|
|
|
27
28
|
export function runRustEngine({
|
|
29
|
+
advisories,
|
|
28
30
|
fix,
|
|
29
31
|
json,
|
|
30
32
|
raw,
|
|
@@ -32,6 +34,7 @@ export function runRustEngine({
|
|
|
32
34
|
rulesFile,
|
|
33
35
|
startedAt,
|
|
34
36
|
}: {
|
|
37
|
+
advisories: Array<Omit<Violation, "fix">>;
|
|
35
38
|
fix: boolean;
|
|
36
39
|
json: boolean;
|
|
37
40
|
raw: {
|
|
@@ -73,7 +76,7 @@ export function runRustEngine({
|
|
|
73
76
|
|
|
74
77
|
if (result.stderr) process.stderr.write(result.stderr);
|
|
75
78
|
if (json) {
|
|
76
|
-
if (result.stdout) process.stdout.write(result.stdout);
|
|
79
|
+
if (result.stdout) process.stdout.write(withAdvisories(result.stdout, advisories));
|
|
77
80
|
process.exit(result.status ?? 1);
|
|
78
81
|
}
|
|
79
82
|
|
|
@@ -82,10 +85,11 @@ export function runRustEngine({
|
|
|
82
85
|
process.stderr.write("klint: --engine rust failed to parse Rust JSON output\n");
|
|
83
86
|
process.exit(1);
|
|
84
87
|
}
|
|
85
|
-
writeTextOutput(payload.violations, startedAt);
|
|
88
|
+
writeTextOutput([...payload.violations, ...advisories], startedAt);
|
|
86
89
|
}
|
|
87
90
|
|
|
88
91
|
export function runCompareEngine({
|
|
92
|
+
advisories,
|
|
89
93
|
fix,
|
|
90
94
|
json,
|
|
91
95
|
raw,
|
|
@@ -93,6 +97,7 @@ export function runCompareEngine({
|
|
|
93
97
|
rulesFile,
|
|
94
98
|
tsViolations,
|
|
95
99
|
}: {
|
|
100
|
+
advisories: Array<Omit<Violation, "fix">>;
|
|
96
101
|
fix: boolean;
|
|
97
102
|
json: boolean;
|
|
98
103
|
raw: {
|
|
@@ -154,7 +159,7 @@ export function runCompareEngine({
|
|
|
154
159
|
process.exit(1);
|
|
155
160
|
}
|
|
156
161
|
|
|
157
|
-
process.stdout.write(tsResult.stdout);
|
|
162
|
+
process.stdout.write(withAdvisories(tsResult.stdout, advisories));
|
|
158
163
|
process.exit(tsResult.status);
|
|
159
164
|
}
|
|
160
165
|
|
|
@@ -188,6 +193,7 @@ function writeRustEngineConfig({
|
|
|
188
193
|
}
|
|
189
194
|
|
|
190
195
|
export function runAutoEngine({
|
|
196
|
+
advisories,
|
|
191
197
|
fix,
|
|
192
198
|
json,
|
|
193
199
|
raw,
|
|
@@ -197,6 +203,7 @@ export function runAutoEngine({
|
|
|
197
203
|
customRules,
|
|
198
204
|
customRulesMap,
|
|
199
205
|
}: {
|
|
206
|
+
advisories: Array<Omit<Violation, "fix">>;
|
|
200
207
|
fix: boolean;
|
|
201
208
|
json: boolean;
|
|
202
209
|
raw: {
|
|
@@ -241,10 +248,10 @@ export function runAutoEngine({
|
|
|
241
248
|
const tsOutput = toJsonPayload(tsViolations);
|
|
242
249
|
const merged = mergeJsonOutputs(rustOutput, tsOutput);
|
|
243
250
|
if (json) {
|
|
244
|
-
process.stdout.write(JSON.stringify(merged));
|
|
251
|
+
process.stdout.write(withAdvisories(JSON.stringify(merged), advisories));
|
|
245
252
|
process.exit(merged.summary.errors > 0 ? 2 : 0);
|
|
246
253
|
}
|
|
247
|
-
writeTextOutput(merged.violations, startedAt);
|
|
254
|
+
writeTextOutput([...merged.violations, ...advisories], startedAt);
|
|
248
255
|
}
|
|
249
256
|
|
|
250
257
|
function runAutoRustSubset({
|
package/cli.ts
CHANGED
|
@@ -8,14 +8,26 @@ import { installSkill } from "./cli/install-skill";
|
|
|
8
8
|
import {
|
|
9
9
|
formatDuration,
|
|
10
10
|
toJsonCliResult,
|
|
11
|
+
withAdvisories,
|
|
11
12
|
writeDebugEvent,
|
|
12
13
|
writeTextOutput,
|
|
13
14
|
} from "./cli/output";
|
|
15
|
+
import { PACKAGE_ROOT } from "./cli/paths";
|
|
14
16
|
import { runAutoEngine, runCompareEngine, runRustEngine } from "./cli/rust-engine";
|
|
15
17
|
import { applyFixes } from "./core/fixer";
|
|
16
18
|
import { runKlint } from "./core/runner";
|
|
19
|
+
import { schemaVersionAdvisory } from "./core/schema-version";
|
|
17
20
|
import type { ArchConfig, KlintConfig, KlintRule, RuleConfigValue } from "./core/types";
|
|
18
21
|
|
|
22
|
+
async function installedVersion(): Promise<string> {
|
|
23
|
+
const manifest = await readFile(resolve(PACKAGE_ROOT, "package.json"), "utf-8");
|
|
24
|
+
try {
|
|
25
|
+
return (JSON.parse(manifest) as { version?: string }).version ?? "0.0.0";
|
|
26
|
+
} catch {
|
|
27
|
+
return "0.0.0";
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
19
31
|
interface CliOptions {
|
|
20
32
|
configDir?: string;
|
|
21
33
|
rulesFile?: string;
|
|
@@ -52,6 +64,9 @@ export async function main(opts: CliOptions = {}): Promise<void> {
|
|
|
52
64
|
else if (args[i] === "--help" || args[i] === "-h") {
|
|
53
65
|
printHelp();
|
|
54
66
|
process.exit(0);
|
|
67
|
+
} else if (args[i] === "--version" || args[i] === "-V") {
|
|
68
|
+
process.stdout.write(`klint ${await installedVersion()}\n`);
|
|
69
|
+
process.exit(0);
|
|
55
70
|
}
|
|
56
71
|
}
|
|
57
72
|
|
|
@@ -70,6 +85,7 @@ export async function main(opts: CliOptions = {}): Promise<void> {
|
|
|
70
85
|
}
|
|
71
86
|
|
|
72
87
|
interface RawConfig {
|
|
88
|
+
$schema?: string;
|
|
73
89
|
root?: string;
|
|
74
90
|
include?: string[];
|
|
75
91
|
plugins?: string[];
|
|
@@ -77,17 +93,25 @@ export async function main(opts: CliOptions = {}): Promise<void> {
|
|
|
77
93
|
arch?: unknown;
|
|
78
94
|
}
|
|
79
95
|
let raw: RawConfig;
|
|
96
|
+
let configText = "";
|
|
80
97
|
try {
|
|
81
|
-
|
|
82
|
-
raw = (usingYaml ? parseYaml(
|
|
98
|
+
configText = await readFile(configPath, "utf-8");
|
|
99
|
+
raw = (usingYaml ? parseYaml(configText) : JSON.parse(configText)) as RawConfig;
|
|
83
100
|
} catch {
|
|
84
101
|
process.stderr.write(`klint: failed to parse ${configPath}\n`);
|
|
85
102
|
process.exit(1);
|
|
86
103
|
}
|
|
87
104
|
const root = resolve(configDir, raw.root ?? ".");
|
|
88
105
|
|
|
106
|
+
const advisories = schemaVersionAdvisory({
|
|
107
|
+
schema: raw.$schema,
|
|
108
|
+
installed: await installedVersion(),
|
|
109
|
+
configFile: usingYaml ? "klint.yaml" : "klint.config.json",
|
|
110
|
+
configText,
|
|
111
|
+
});
|
|
112
|
+
|
|
89
113
|
if (engine === "rust") {
|
|
90
|
-
runRustEngine({ fix, json, raw, root, rulesFile, startedAt });
|
|
114
|
+
runRustEngine({ advisories, fix, json, raw, root, rulesFile, startedAt });
|
|
91
115
|
return;
|
|
92
116
|
}
|
|
93
117
|
if (
|
|
@@ -119,6 +143,7 @@ export async function main(opts: CliOptions = {}): Promise<void> {
|
|
|
119
143
|
|
|
120
144
|
if (engine === "auto") {
|
|
121
145
|
runAutoEngine({
|
|
146
|
+
advisories,
|
|
122
147
|
fix,
|
|
123
148
|
json,
|
|
124
149
|
raw,
|
|
@@ -145,6 +170,7 @@ export async function main(opts: CliOptions = {}): Promise<void> {
|
|
|
145
170
|
|
|
146
171
|
if (engine === "compare") {
|
|
147
172
|
runCompareEngine({
|
|
173
|
+
advisories,
|
|
148
174
|
fix,
|
|
149
175
|
json,
|
|
150
176
|
raw,
|
|
@@ -157,7 +183,7 @@ export async function main(opts: CliOptions = {}): Promise<void> {
|
|
|
157
183
|
|
|
158
184
|
if (json) {
|
|
159
185
|
const tsResult = toJsonCliResult(violations);
|
|
160
|
-
process.stdout.write(tsResult.stdout);
|
|
186
|
+
process.stdout.write(withAdvisories(tsResult.stdout, advisories));
|
|
161
187
|
process.exit(tsResult.status);
|
|
162
188
|
}
|
|
163
189
|
|
|
@@ -189,7 +215,7 @@ export async function main(opts: CliOptions = {}): Promise<void> {
|
|
|
189
215
|
process.stdout.write(`${msg} Finished in ${formatDuration(startedAt)}.\n`);
|
|
190
216
|
process.exit(0);
|
|
191
217
|
}
|
|
192
|
-
writeTextOutput(violations, startedAt);
|
|
218
|
+
writeTextOutput([...violations, ...advisories], startedAt);
|
|
193
219
|
}
|
|
194
220
|
|
|
195
221
|
if (import.meta.main) await main();
|
package/core/arch-comments.ts
CHANGED
|
@@ -52,22 +52,40 @@ export function commentLineSet(
|
|
|
52
52
|
return set;
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
-
/**
|
|
55
|
+
/**
|
|
56
|
+
* Comment lines whose source text matches an `ignore` pattern. They stay in the
|
|
57
|
+
* density denominator and keep a comment run contiguous, but count toward neither.
|
|
58
|
+
*/
|
|
59
|
+
export function ignoredCommentLines(
|
|
60
|
+
content: string,
|
|
61
|
+
commentLines: Iterable<number>,
|
|
62
|
+
matchesIgnore: (text: string) => boolean
|
|
63
|
+
): Set<number> {
|
|
64
|
+
const sourceLines = content.split("\n");
|
|
65
|
+
const ignored = new Set<number>();
|
|
66
|
+
for (const line of commentLines) {
|
|
67
|
+
if (matchesIgnore(sourceLines[line - 1] ?? "")) ignored.add(line);
|
|
68
|
+
}
|
|
69
|
+
return ignored;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Returns the first line at which a run of consecutive comment lines exceeds
|
|
74
|
+
* `limit`. An ignored line neither counts toward the height nor breaks the run.
|
|
75
|
+
*/
|
|
56
76
|
export function firstCommentBlockOverrun(
|
|
57
77
|
sortedLines: number[],
|
|
58
|
-
limit: number
|
|
78
|
+
limit: number,
|
|
79
|
+
ignored?: ReadonlySet<number>
|
|
59
80
|
): number | undefined {
|
|
60
|
-
let
|
|
61
|
-
let prev
|
|
62
|
-
for (
|
|
63
|
-
|
|
64
|
-
if (line === prev + 1) {
|
|
65
|
-
prev = line;
|
|
66
|
-
continue;
|
|
67
|
-
}
|
|
68
|
-
if (prev - runStart + 1 > limit) return runStart + limit;
|
|
69
|
-
runStart = line;
|
|
81
|
+
let counted = 0;
|
|
82
|
+
let prev: number | undefined;
|
|
83
|
+
for (const line of sortedLines) {
|
|
84
|
+
if (prev !== undefined && line !== prev + 1) counted = 0;
|
|
70
85
|
prev = line;
|
|
86
|
+
if (ignored?.has(line)) continue;
|
|
87
|
+
counted += 1;
|
|
88
|
+
if (counted > limit) return line;
|
|
71
89
|
}
|
|
72
90
|
return undefined;
|
|
73
91
|
}
|
package/core/arch.ts
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import { dirname, isAbsolute, resolve } from "node:path";
|
|
2
2
|
import ts from "typescript";
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
commentLineSet,
|
|
5
|
+
firstCommentBlockOverrun,
|
|
6
|
+
ignoredCommentLines,
|
|
7
|
+
} from "./arch-comments";
|
|
4
8
|
import { walkAst } from "./ast";
|
|
5
9
|
import { relativeSlashPath, toSlashPath } from "./paths";
|
|
6
10
|
import type { ArchConfig, Severity, Violation } from "./types";
|
|
@@ -309,6 +313,7 @@ export function runArchRules(
|
|
|
309
313
|
|
|
310
314
|
for (const rule of arch.maxCommentDensity ?? []) {
|
|
311
315
|
const severity: Severity = rule.severity ?? "error";
|
|
316
|
+
const ignoreMatcher = buildIgnoreMatcher(rule.ignore);
|
|
312
317
|
const inFiles = resolveLayerFiles(rule.in, layers, root, allFiles);
|
|
313
318
|
for (const file of inFiles) {
|
|
314
319
|
const content = fileContents.get(file);
|
|
@@ -316,7 +321,11 @@ export function runArchRules(
|
|
|
316
321
|
const total = countLines(content);
|
|
317
322
|
if (total === 0) continue;
|
|
318
323
|
const commentLines = commentLineSet(file, content, rule.countDocComments ?? false);
|
|
319
|
-
const
|
|
324
|
+
const ignored = ignoreMatcher
|
|
325
|
+
? ignoredCommentLines(content, commentLines, ignoreMatcher)
|
|
326
|
+
: undefined;
|
|
327
|
+
const counted = commentLines.size - (ignored?.size ?? 0);
|
|
328
|
+
const density = (counted / total) * 100;
|
|
320
329
|
if (density > rule.limit) {
|
|
321
330
|
violations.push({
|
|
322
331
|
file: relativeSlashPath(root, file),
|
|
@@ -333,6 +342,7 @@ export function runArchRules(
|
|
|
333
342
|
|
|
334
343
|
for (const rule of arch.maxCommentBlock ?? []) {
|
|
335
344
|
const severity: Severity = rule.severity ?? "error";
|
|
345
|
+
const ignoreMatcher = buildIgnoreMatcher(rule.ignore);
|
|
336
346
|
const inFiles = resolveLayerFiles(rule.in, layers, root, allFiles);
|
|
337
347
|
for (const file of inFiles) {
|
|
338
348
|
const content = fileContents.get(file);
|
|
@@ -340,7 +350,10 @@ export function runArchRules(
|
|
|
340
350
|
const commentLines = [
|
|
341
351
|
...commentLineSet(file, content, rule.countDocComments ?? false),
|
|
342
352
|
].sort((a, b) => a - b);
|
|
343
|
-
const
|
|
353
|
+
const ignored = ignoreMatcher
|
|
354
|
+
? ignoredCommentLines(content, commentLines, ignoreMatcher)
|
|
355
|
+
: undefined;
|
|
356
|
+
const overrun = firstCommentBlockOverrun(commentLines, rule.limit, ignored);
|
|
344
357
|
if (overrun !== undefined) {
|
|
345
358
|
violations.push({
|
|
346
359
|
file: relativeSlashPath(root, file),
|
|
@@ -402,6 +415,15 @@ function scanJsxElements(
|
|
|
402
415
|
|
|
403
416
|
const REGEX_PREFIX = "re:";
|
|
404
417
|
|
|
418
|
+
function buildIgnoreMatcher(
|
|
419
|
+
ignore: string | string[] | undefined
|
|
420
|
+
): ((line: string) => boolean) | undefined {
|
|
421
|
+
const patterns = toArray(ignore ?? []);
|
|
422
|
+
if (patterns.length === 0) return undefined;
|
|
423
|
+
const matchers = patterns.map(buildLineMatcher);
|
|
424
|
+
return (line) => matchers.some((matches) => matches(line));
|
|
425
|
+
}
|
|
426
|
+
|
|
405
427
|
function buildLineMatcher(pattern: string): (line: string) => boolean {
|
|
406
428
|
if (!pattern.startsWith(REGEX_PREFIX)) {
|
|
407
429
|
return (line) => line.includes(pattern);
|
package/core/config.schema.ts
CHANGED
|
@@ -244,9 +244,13 @@ const CountDocCommentsSchema = z
|
|
|
244
244
|
.boolean()
|
|
245
245
|
.optional()
|
|
246
246
|
.describe(
|
|
247
|
-
"Count doc-comments toward the limit. Doc-comments are `/** … */` JSDoc blocks (TS/JS) and
|
|
247
|
+
"Count doc-comments toward the limit. Doc-comments are `/** … */` JSDoc blocks (TS/JS), `///` (Swift), and `///` or `//!` (Rust); ordinary `//`, `/* */`, and `#` comments are always counted. Defaults to false, so documentation is exempt and only explanatory/inline comments are capped. Has no effect on Python: docstrings are string expressions rather than comments, so they never count toward either limit."
|
|
248
248
|
);
|
|
249
249
|
|
|
250
|
+
const CommentIgnoreSchema = StringOrStringArray.optional().describe(
|
|
251
|
+
"Comment lines matching these patterns are structural, not prose — marker comments a build or codegen step reads, for example — and count toward neither limit. A literal substring by default; prefix with `re:` for a regex. Ignored lines still count in the density denominator (they are physical lines) and still hold a comment block together, so a marker cannot be used to split an over-tall block."
|
|
252
|
+
);
|
|
253
|
+
|
|
250
254
|
const ArchMaxCommentDensityRuleSchema = z
|
|
251
255
|
.object({
|
|
252
256
|
limit: z
|
|
@@ -257,6 +261,7 @@ const ArchMaxCommentDensityRuleSchema = z
|
|
|
257
261
|
"Maximum percentage of a file's physical lines that may be comment lines. Denominator is total physical lines (code + comments + blanks), the same count `maxLines` uses. A file above this ratio is a violation."
|
|
258
262
|
),
|
|
259
263
|
countDocComments: CountDocCommentsSchema,
|
|
264
|
+
ignore: CommentIgnoreSchema,
|
|
260
265
|
in: StringOrStringArray.describe(
|
|
261
266
|
"Layer name(s) or glob(s) the limit applies to. Files outside the scope are not checked."
|
|
262
267
|
),
|
|
@@ -289,6 +294,7 @@ const ArchMaxCommentBlockRuleSchema = z
|
|
|
289
294
|
"Maximum number of consecutive comment lines allowed in a single block. A run of more comment lines than this — e.g. a tall comment stacked above a statement — is a violation, reported at the first offending line."
|
|
290
295
|
),
|
|
291
296
|
countDocComments: CountDocCommentsSchema,
|
|
297
|
+
ignore: CommentIgnoreSchema,
|
|
292
298
|
in: StringOrStringArray.describe(
|
|
293
299
|
"Layer name(s) or glob(s) the limit applies to. Files outside the scope are not checked."
|
|
294
300
|
),
|
|
@@ -371,7 +377,7 @@ export const KlintConfigSchema = z
|
|
|
371
377
|
.string()
|
|
372
378
|
.optional()
|
|
373
379
|
.describe(
|
|
374
|
-
"JSON Schema reference. Use ./klint.schema.json for local validation or https://klint.
|
|
380
|
+
"JSON Schema reference. Use ./klint.schema.json for local validation, or a version-tagged URL such as https://raw.githubusercontent.com/konvert7/klint/refs/tags/v0.29.0/klint.schema.json to pin the schema. A tagged URL naming a version other than the installed one raises a klint/schema-version warning."
|
|
375
381
|
),
|
|
376
382
|
root: z
|
|
377
383
|
.string()
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { Violation } from "./types";
|
|
2
|
+
|
|
3
|
+
const SCHEMA_VERSION_RULE = "klint/schema-version";
|
|
4
|
+
|
|
5
|
+
const TAGGED_SCHEMA_URL = /\/refs\/tags\/v(\d+\.\d+\.\d+(?:-[0-9A-Za-z.]+)?)\//;
|
|
6
|
+
const UNRELEASED = "0.0.0";
|
|
7
|
+
|
|
8
|
+
export function schemaUrlForVersion(version: string): string {
|
|
9
|
+
return `https://raw.githubusercontent.com/konvert7/klint/refs/tags/v${version}/klint.schema.json`;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function declaredSchemaVersion(schema: string | undefined): string | undefined {
|
|
13
|
+
return schema?.match(TAGGED_SCHEMA_URL)?.[1];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function schemaVersionAdvisory({
|
|
17
|
+
schema,
|
|
18
|
+
installed,
|
|
19
|
+
configFile,
|
|
20
|
+
configText,
|
|
21
|
+
}: {
|
|
22
|
+
schema: string | undefined;
|
|
23
|
+
installed: string;
|
|
24
|
+
configFile: string;
|
|
25
|
+
configText: string;
|
|
26
|
+
}): Array<Omit<Violation, "fix">> {
|
|
27
|
+
const declared = declaredSchemaVersion(schema);
|
|
28
|
+
if (declared === undefined || declared === installed || installed === UNRELEASED) {
|
|
29
|
+
return [];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return [
|
|
33
|
+
{
|
|
34
|
+
file: configFile,
|
|
35
|
+
line: schemaDeclarationLine(configText),
|
|
36
|
+
rule: SCHEMA_VERSION_RULE,
|
|
37
|
+
severity: "warn",
|
|
38
|
+
message: `config declares schema v${declared} but klint ${installed} is installed — update $schema to ${schemaUrlForVersion(installed)}`,
|
|
39
|
+
},
|
|
40
|
+
];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function schemaDeclarationLine(configText: string): number {
|
|
44
|
+
const index = configText.split("\n").findIndex((line) => line.includes("$schema"));
|
|
45
|
+
return index === -1 ? 1 : index + 1;
|
|
46
|
+
}
|
package/core/types.ts
CHANGED
|
@@ -108,8 +108,10 @@ interface ArchMaxLinesRule {
|
|
|
108
108
|
interface ArchCommentRuleBase {
|
|
109
109
|
limit: number;
|
|
110
110
|
in: string | string[];
|
|
111
|
-
/** Count doc-comments (`/** */` JSDoc,
|
|
111
|
+
/** Count doc-comments (`/** */` JSDoc, Swift `///`, Rust `///` and `//!`) toward the limit. Defaults to false. No effect on Python, whose docstrings are never counted. */
|
|
112
112
|
countDocComments?: boolean;
|
|
113
|
+
/** Comment lines matching these patterns count toward neither limit. Literal substring, or `re:` for a regex. */
|
|
114
|
+
ignore?: string | string[];
|
|
113
115
|
message?: string;
|
|
114
116
|
severity?: Exclude<Severity, "off">;
|
|
115
117
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@konvert7/klint",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.30.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": {
|
|
@@ -65,10 +65,10 @@
|
|
|
65
65
|
"zod": "^4.4.3"
|
|
66
66
|
},
|
|
67
67
|
"optionalDependencies": {
|
|
68
|
-
"@konvert7/klint-darwin-arm64": "0.
|
|
69
|
-
"@konvert7/klint-darwin-x64": "0.
|
|
70
|
-
"@konvert7/klint-linux-x64": "0.
|
|
71
|
-
"@konvert7/klint-win32-x64": "0.
|
|
68
|
+
"@konvert7/klint-darwin-arm64": "0.30.0",
|
|
69
|
+
"@konvert7/klint-darwin-x64": "0.30.0",
|
|
70
|
+
"@konvert7/klint-linux-x64": "0.30.0",
|
|
71
|
+
"@konvert7/klint-win32-x64": "0.30.0"
|
|
72
72
|
},
|
|
73
73
|
"devDependencies": {
|
|
74
74
|
"@biomejs/biome": "2.4.7",
|
|
@@ -39,6 +39,8 @@ Wire it into `klint.yaml` for editor autocomplete and validation:
|
|
|
39
39
|
| Raw HTML/JSX element may only appear in one primitive file | `arch.singleton` + `jsx-element` |
|
|
40
40
|
| A value-shaped pattern (regex) must never appear in a scoped layer | `arch.forbidden` + `pattern: "re:…"` |
|
|
41
41
|
| No file in a layer may exceed N lines | `arch.maxLines` + `limit` |
|
|
42
|
+
| No file may be more than N% comments | `arch.maxCommentDensity` + `limit` |
|
|
43
|
+
| No comment block may stack more than N lines | `arch.maxCommentBlock` + `limit` |
|
|
42
44
|
|
|
43
45
|
4. **Read `klint.yaml`** — check existing `arch.layers` and rules before adding anything. Add a new named layer to `arch.layers` if the file group doesn't exist yet.
|
|
44
46
|
|
|
@@ -169,10 +171,65 @@ arch:
|
|
|
169
171
|
|
|
170
172
|
Required: `limit` + `in`. The count is **total physical lines** — blanks and comments included, not code lines — and a file over the limit is flagged at line `limit + 1`. Enforced identically in the TS and Rust engines, across every language klint scans.
|
|
171
173
|
|
|
174
|
+
### Comment budget — cap explanatory prose
|
|
175
|
+
|
|
176
|
+
Two ceilings on how much of a file may be comments. Use them to push intent into names and small
|
|
177
|
+
functions rather than paragraphs that drift from the code they describe.
|
|
178
|
+
|
|
179
|
+
```yaml
|
|
180
|
+
arch:
|
|
181
|
+
# maxCommentDensity: what share of a file may be comment lines
|
|
182
|
+
maxCommentDensity:
|
|
183
|
+
- limit: 5 # percent; required
|
|
184
|
+
in: ["src/**"] # string OR array; supports ! negation
|
|
185
|
+
countDocComments: false # optional — default false, so JSDoc/docstrings are exempt
|
|
186
|
+
message: "Encode intent in names and small functions, not prose"
|
|
187
|
+
severity: error # optional — default error; use warn to record without blocking
|
|
188
|
+
|
|
189
|
+
# maxCommentBlock: how tall a single stack of comment lines may get
|
|
190
|
+
maxCommentBlock:
|
|
191
|
+
- limit: 2 # consecutive comment lines; required
|
|
192
|
+
in: ["src/**"]
|
|
193
|
+
ignore: ["@codegen:"] # optional — structural markers, not prose
|
|
194
|
+
message: "Extract a well-named function instead of stacking comment lines"
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
Required: `limit` + `in`. Density is a **percentage of total physical lines** (code + comments +
|
|
198
|
+
blanks — the same denominator `maxLines` uses). Block height counts **consecutive** comment lines and
|
|
199
|
+
is reported at the first offending line. Both exempt doc-comments by default — `/** … */` JSDoc and
|
|
200
|
+
Python docstrings — so documentation is not penalised; ordinary `//`, `/* */`, and `#` comments always
|
|
201
|
+
count. Set `countDocComments: true` to include them.
|
|
202
|
+
|
|
203
|
+
The two are complementary: density catches a file that is 30% commented in scattered one-liners,
|
|
204
|
+
block catches a ten-line essay above one function in an otherwise sparse file. Neither alone catches both.
|
|
205
|
+
|
|
206
|
+
**`ignore` — comments that are machinery, not prose.** Some comments are load-bearing input to a
|
|
207
|
+
build or codegen step (`// @codegen:region-start`, feature-strip markers, pragmas). They are comments to the
|
|
208
|
+
lexer but structure to the project, and counting them makes both limits measure the wrong thing:
|
|
209
|
+
|
|
210
|
+
```yaml
|
|
211
|
+
arch:
|
|
212
|
+
maxCommentDensity:
|
|
213
|
+
- limit: 5
|
|
214
|
+
in: ["src/**"]
|
|
215
|
+
ignore: ["@codegen:"] # literal substring, or "re:" for a regex
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
Two rules govern how an ignored line behaves, and both matter:
|
|
219
|
+
|
|
220
|
+
- **It stays in the density denominator.** A marker is a physical line like code, so a 20-line file
|
|
221
|
+
with 4 markers and 1 real comment measures 1/20, not 1/16.
|
|
222
|
+
- **It does not break a comment block.** `// a` / `// @marker` / `// b` / `// c` is a run of three
|
|
223
|
+
counted lines, not two runs. Otherwise sprinkling a marker every other line would defeat the cap.
|
|
224
|
+
|
|
225
|
+
`ignore` tests the **physical source line**, so it only ever applies to lines already identified as
|
|
226
|
+
comments — code containing the same text is untouched. Enforced identically in both engines.
|
|
227
|
+
|
|
172
228
|
## Pitfalls
|
|
173
229
|
|
|
174
230
|
- `pattern` in `singleton` and `forbidden` is a **literal substring** by default — it will not catch `process.env["KEY"]` (bracket notation). Either grep for both forms, or use a `re:` regex prefix to match them in one rule. Caveat: a literal pattern that itself begins with `re:` cannot be expressed (it is always read as a regex), and regexes must stay in the common JS/RE2 subset (no lookaround or backreferences) so both engines agree.
|
|
175
231
|
- `maxLines.limit` counts **total physical lines** (blanks and comments included), not code lines; a trailing newline does not add a line, and both engines count identically.
|
|
232
|
+
- `maxCommentDensity.limit` is a **percentage, not a line count** — `limit: 5` means 5% of the file, so a 40-line file is over budget at its third comment line. Measure before picking a number: land it as `severity: warn` first, read the reported densities, then tighten to `error`.
|
|
176
233
|
- `jsx-element` matches **intrinsic** (lowercase HTML) tags only — `button`, `input`, `label`. It does not match custom React components like `<Button>`; to restrict those, use `arch.imports` against the component's module instead.
|
|
177
234
|
- A `forbidden`/`singleton` stanza takes either `pattern` or `jsx-element`, never both — split into two stanzas if you need both.
|
|
178
235
|
- `only` in `singleton` is relative to the project root — use forward slashes on all platforms.
|
package/tools/generate-schema.ts
CHANGED
|
@@ -1,17 +1,27 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
|
-
import { writeFileSync } from "node:fs";
|
|
2
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { resolve } from "node:path";
|
|
4
4
|
import { stringify as toYaml } from "yaml";
|
|
5
5
|
import { KlintConfigSchema } from "../core/config.schema";
|
|
6
|
+
import { schemaUrlForVersion } from "../core/schema-version";
|
|
6
7
|
|
|
7
8
|
const raw = KlintConfigSchema.toJSONSchema() as Record<string, unknown>;
|
|
8
9
|
|
|
9
10
|
// Set Draft-07 (broad editor compatibility) instead of 2020-12.
|
|
10
11
|
delete raw.$schema;
|
|
11
12
|
|
|
13
|
+
function packageVersion(): string {
|
|
14
|
+
const manifest = readFileSync(resolve(import.meta.dir, "../package.json"), "utf-8");
|
|
15
|
+
try {
|
|
16
|
+
return (JSON.parse(manifest) as { version?: string }).version ?? "0.0.0";
|
|
17
|
+
} catch {
|
|
18
|
+
throw new Error("package.json is not valid JSON — cannot stamp the schema version");
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
12
22
|
const schema = {
|
|
13
23
|
$schema: "http://json-schema.org/draft-07/schema#",
|
|
14
|
-
$id:
|
|
24
|
+
$id: schemaUrlForVersion(packageVersion()),
|
|
15
25
|
...raw,
|
|
16
26
|
};
|
|
17
27
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
-
import { join } from "node:path";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
3
|
import { spawnSync } from "node:child_process";
|
|
4
4
|
|
|
5
5
|
const NATIVE_PACKAGES = [
|
|
@@ -57,6 +57,8 @@ export async function prepare(_, context) {
|
|
|
57
57
|
if (!existsSync(binary)) {
|
|
58
58
|
throw new Error(`Missing native binary for ${nativePackage.name}: ${binary}`);
|
|
59
59
|
}
|
|
60
|
+
|
|
61
|
+
writeFileSync(join(dirname(binary), "VERSION"), `${version}\n`);
|
|
60
62
|
}
|
|
61
63
|
}
|
|
62
64
|
|
|
@@ -18,7 +18,7 @@ export async function verifyConditions(_, context) {
|
|
|
18
18
|
}
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
-
export async function prepare(
|
|
21
|
+
export async function prepare(pluginConfig = {}, context) {
|
|
22
22
|
const cwd = context.cwd ?? process.cwd();
|
|
23
23
|
const version = context.nextRelease?.version;
|
|
24
24
|
|
|
@@ -29,6 +29,20 @@ export async function prepare(_, context) {
|
|
|
29
29
|
const packageJson = readPackageJson(cwd);
|
|
30
30
|
packageJson.version = version;
|
|
31
31
|
writePackageJson(cwd, packageJson);
|
|
32
|
+
|
|
33
|
+
regenerateSchema(cwd, pluginConfig.spawnSync ?? spawnSync);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function regenerateSchema(cwd, spawn) {
|
|
37
|
+
const result = spawn("bun", ["tools/generate-schema.ts"], {
|
|
38
|
+
cwd,
|
|
39
|
+
encoding: "utf-8",
|
|
40
|
+
stdio: "inherit",
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
if ((result.status ?? -1) !== 0) {
|
|
44
|
+
throw new Error(`Schema generation failed with exit code ${result.status ?? -1}`);
|
|
45
|
+
}
|
|
32
46
|
}
|
|
33
47
|
|
|
34
48
|
export async function publish(pluginConfig = {}, context) {
|