@konvert7/klint 0.7.1 → 0.8.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/core/arch.ts +97 -23
- package/core/config.schema.ts +45 -8
- package/core/runner.ts +2 -1
- package/core/types.ts +21 -3
- package/package.json +1 -1
package/core/arch.ts
CHANGED
|
@@ -78,7 +78,9 @@ function resolveLayerPrefixes(
|
|
|
78
78
|
layers: Record<string, string[]> | undefined,
|
|
79
79
|
root: string
|
|
80
80
|
): string[] {
|
|
81
|
-
return resolveGlobs(ref, layers)
|
|
81
|
+
return resolveGlobs(ref, layers)
|
|
82
|
+
.filter((g) => !g.startsWith("!"))
|
|
83
|
+
.map((g) => globToPrefix(g, root));
|
|
82
84
|
}
|
|
83
85
|
|
|
84
86
|
function resolveLayerFiles(
|
|
@@ -87,8 +89,18 @@ function resolveLayerFiles(
|
|
|
87
89
|
root: string,
|
|
88
90
|
allFiles: string[]
|
|
89
91
|
): string[] {
|
|
90
|
-
const
|
|
91
|
-
|
|
92
|
+
const globs = resolveGlobs(ref, layers);
|
|
93
|
+
const includePrefixes = globs
|
|
94
|
+
.filter((g) => !g.startsWith("!"))
|
|
95
|
+
.map((g) => globToPrefix(g, root));
|
|
96
|
+
const excludePrefixes = globs
|
|
97
|
+
.filter((g) => g.startsWith("!"))
|
|
98
|
+
.map((g) => globToPrefix(g.slice(1), root));
|
|
99
|
+
return allFiles.filter(
|
|
100
|
+
(f) =>
|
|
101
|
+
includePrefixes.some((p) => f === p || f.startsWith(`${p}/`)) &&
|
|
102
|
+
!excludePrefixes.some((p) => f === p || f.startsWith(`${p}/`))
|
|
103
|
+
);
|
|
92
104
|
}
|
|
93
105
|
|
|
94
106
|
function inPrefixes(absPath: string, prefixes: string[]): boolean {
|
|
@@ -196,16 +208,29 @@ export function runArchRules(
|
|
|
196
208
|
for (const rule of arch.forbidden ?? []) {
|
|
197
209
|
const severity: Severity = rule.severity ?? "error";
|
|
198
210
|
const inFiles = resolveLayerFiles(rule.in, layers, root, allFiles);
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
211
|
+
if ("jsx-element" in rule) {
|
|
212
|
+
scanJsxElements(
|
|
213
|
+
inFiles,
|
|
214
|
+
toArray(rule["jsx-element"]),
|
|
215
|
+
fileContents,
|
|
216
|
+
root,
|
|
217
|
+
"arch/forbidden",
|
|
218
|
+
rule.message,
|
|
219
|
+
severity,
|
|
220
|
+
violations
|
|
221
|
+
);
|
|
222
|
+
} else {
|
|
223
|
+
scanLinesForPattern(
|
|
224
|
+
inFiles,
|
|
225
|
+
rule.pattern,
|
|
226
|
+
fileContents,
|
|
227
|
+
root,
|
|
228
|
+
"arch/forbidden",
|
|
229
|
+
rule.message,
|
|
230
|
+
severity,
|
|
231
|
+
violations
|
|
232
|
+
);
|
|
233
|
+
}
|
|
209
234
|
}
|
|
210
235
|
|
|
211
236
|
for (const rule of arch.singleton ?? []) {
|
|
@@ -215,21 +240,70 @@ export function runArchRules(
|
|
|
215
240
|
? resolveLayerFiles(rule.in, layers, root, allFiles)
|
|
216
241
|
: allFiles;
|
|
217
242
|
const scope = inFiles.filter((f) => f !== onlyFile);
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
243
|
+
if ("jsx-element" in rule) {
|
|
244
|
+
scanJsxElements(
|
|
245
|
+
scope,
|
|
246
|
+
toArray(rule["jsx-element"]),
|
|
247
|
+
fileContents,
|
|
248
|
+
root,
|
|
249
|
+
"arch/singleton",
|
|
250
|
+
rule.message,
|
|
251
|
+
severity,
|
|
252
|
+
violations
|
|
253
|
+
);
|
|
254
|
+
} else {
|
|
255
|
+
scanLinesForPattern(
|
|
256
|
+
scope,
|
|
257
|
+
rule.pattern,
|
|
258
|
+
fileContents,
|
|
259
|
+
root,
|
|
260
|
+
"arch/singleton",
|
|
261
|
+
rule.message,
|
|
262
|
+
severity,
|
|
263
|
+
violations
|
|
264
|
+
);
|
|
265
|
+
}
|
|
228
266
|
}
|
|
229
267
|
|
|
230
268
|
return violations;
|
|
231
269
|
}
|
|
232
270
|
|
|
271
|
+
function toArray<T>(v: T | T[]): T[] {
|
|
272
|
+
return Array.isArray(v) ? v : [v];
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function scanJsxElements(
|
|
276
|
+
files: string[],
|
|
277
|
+
tagNames: string[],
|
|
278
|
+
fileContents: Map<string, string>,
|
|
279
|
+
root: string,
|
|
280
|
+
ruleName: string,
|
|
281
|
+
message: string,
|
|
282
|
+
severity: Severity,
|
|
283
|
+
violations: Violation[]
|
|
284
|
+
): void {
|
|
285
|
+
const targets = new Set(tagNames);
|
|
286
|
+
for (const file of files) {
|
|
287
|
+
if (!/\.(tsx|jsx)$/.test(file)) continue;
|
|
288
|
+
const content = fileContents.get(file);
|
|
289
|
+
if (!content) continue;
|
|
290
|
+
walkAst(file, content, (node, src) => {
|
|
291
|
+
if (!ts.isJsxOpeningElement(node) && !ts.isJsxSelfClosingElement(node)) return;
|
|
292
|
+
const tagName = node.tagName;
|
|
293
|
+
if (!ts.isIdentifier(tagName)) return;
|
|
294
|
+
if (!targets.has(tagName.text)) return;
|
|
295
|
+
const { line } = src.getLineAndCharacterOfPosition(tagName.getStart());
|
|
296
|
+
violations.push({
|
|
297
|
+
file: relative(root, file).replaceAll("\\", "/"),
|
|
298
|
+
line: line + 1,
|
|
299
|
+
message,
|
|
300
|
+
rule: ruleName,
|
|
301
|
+
severity,
|
|
302
|
+
});
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
233
307
|
function scanLinesForPattern(
|
|
234
308
|
files: string[],
|
|
235
309
|
pattern: string,
|
package/core/config.schema.ts
CHANGED
|
@@ -110,7 +110,7 @@ const ArchImportRuleSchema = z
|
|
|
110
110
|
],
|
|
111
111
|
});
|
|
112
112
|
|
|
113
|
-
const
|
|
113
|
+
const ArchForbiddenPatternRuleSchema = z
|
|
114
114
|
.object({
|
|
115
115
|
pattern: z
|
|
116
116
|
.string()
|
|
@@ -127,17 +127,38 @@ const ArchForbiddenRuleSchema = z
|
|
|
127
127
|
),
|
|
128
128
|
severity: ArchSeveritySchema.optional(),
|
|
129
129
|
})
|
|
130
|
-
.strict()
|
|
130
|
+
.strict();
|
|
131
|
+
|
|
132
|
+
const ArchForbiddenJsxRuleSchema = z
|
|
133
|
+
.object({
|
|
134
|
+
"jsx-element": StringOrStringArray.describe(
|
|
135
|
+
"JSX intrinsic element name(s) to forbid (e.g. `button`, `input`, `label`). AST-matched on opening and self-closing tag names — robust to whitespace, attributes, and naming collisions like `<buttonGroup>`."
|
|
136
|
+
),
|
|
137
|
+
in: StringOrStringArray.describe(
|
|
138
|
+
"Layer name(s) or glob(s) the element is forbidden in. Files outside the scope are not checked."
|
|
139
|
+
),
|
|
140
|
+
message: z
|
|
141
|
+
.string()
|
|
142
|
+
.describe(
|
|
143
|
+
"Required error message explaining why the raw element is forbidden. Typically points at the design-system replacement (e.g. `Use <Button /> from @/components/ui/button`)."
|
|
144
|
+
),
|
|
145
|
+
severity: ArchSeveritySchema.optional(),
|
|
146
|
+
})
|
|
147
|
+
.strict();
|
|
148
|
+
|
|
149
|
+
const ArchForbiddenRuleSchema = z
|
|
150
|
+
.union([ArchForbiddenPatternRuleSchema, ArchForbiddenJsxRuleSchema])
|
|
131
151
|
.describe(
|
|
132
|
-
"A forbidden-pattern rule. Blocks literal
|
|
152
|
+
"A forbidden-pattern rule. Blocks either a literal substring (`pattern:`) or a JSX element (`jsx-element:`) inside specific layers."
|
|
133
153
|
)
|
|
134
154
|
.meta({
|
|
135
155
|
examples: [
|
|
136
156
|
'arch:\n forbidden:\n - pattern: "console.log("\n in: core\n message: "Leaks into the agent event stream"',
|
|
157
|
+
'arch:\n forbidden:\n - jsx-element: ["button", "input", "label"]\n in: ["src/app/**/*.tsx", "src/components/**/*.tsx", "!src/components/ui/**"]\n message: "Use the design-system primitives in @/components/ui/* instead of raw HTML elements."',
|
|
137
158
|
],
|
|
138
159
|
});
|
|
139
160
|
|
|
140
|
-
const
|
|
161
|
+
const ArchSingletonPatternRuleSchema = z
|
|
141
162
|
.object({
|
|
142
163
|
pattern: z
|
|
143
164
|
.string()
|
|
@@ -152,20 +173,36 @@ const ArchSingletonRuleSchema = z
|
|
|
152
173
|
in: StringOrStringArray.optional().describe(
|
|
153
174
|
"Optional scope. Limit the scan to these layers/globs; defaults to all files in `include`."
|
|
154
175
|
),
|
|
155
|
-
message: z
|
|
176
|
+
message: z.string(),
|
|
177
|
+
severity: ArchSeveritySchema.optional(),
|
|
178
|
+
})
|
|
179
|
+
.strict();
|
|
180
|
+
|
|
181
|
+
const ArchSingletonJsxRuleSchema = z
|
|
182
|
+
.object({
|
|
183
|
+
"jsx-element": StringOrStringArray.describe(
|
|
184
|
+
"JSX intrinsic element name(s) allowed only inside the `only` file. AST-matched on tag names."
|
|
185
|
+
),
|
|
186
|
+
only: z
|
|
156
187
|
.string()
|
|
157
188
|
.describe(
|
|
158
|
-
"
|
|
189
|
+
"The single file path where the element is allowed (typically the design-system primitive that wraps it)."
|
|
159
190
|
),
|
|
191
|
+
in: StringOrStringArray.optional(),
|
|
192
|
+
message: z.string(),
|
|
160
193
|
severity: ArchSeveritySchema.optional(),
|
|
161
194
|
})
|
|
162
|
-
.strict()
|
|
195
|
+
.strict();
|
|
196
|
+
|
|
197
|
+
const ArchSingletonRuleSchema = z
|
|
198
|
+
.union([ArchSingletonPatternRuleSchema, ArchSingletonJsxRuleSchema])
|
|
163
199
|
.describe(
|
|
164
|
-
"A singleton-location rule. Pins a pattern to exactly one file — the only honest way to enforce a module of record."
|
|
200
|
+
"A singleton-location rule. Pins a pattern or JSX element to exactly one file — the only honest way to enforce a module of record or a design-system primitive."
|
|
165
201
|
)
|
|
166
202
|
.meta({
|
|
167
203
|
examples: [
|
|
168
204
|
'arch:\n singleton:\n - pattern: "process.env.API_KEY"\n only: "src/lib/auth.ts"\n message: "API key access must funnel through the auth module."',
|
|
205
|
+
'arch:\n singleton:\n - jsx-element: "button"\n only: "src/components/ui/button.tsx"\n in: ["src/**/*.tsx"]\n message: "Raw <button> belongs only to the Button primitive."',
|
|
169
206
|
],
|
|
170
207
|
});
|
|
171
208
|
|
package/core/runner.ts
CHANGED
|
@@ -17,7 +17,8 @@ function walk(dir: string): string[] {
|
|
|
17
17
|
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
18
18
|
const full = join(dir, entry.name);
|
|
19
19
|
if (entry.isDirectory()) out.push(...walk(full));
|
|
20
|
-
else if (
|
|
20
|
+
else if (/\.(tsx?|jsx?|mts|cts)$/.test(entry.name))
|
|
21
|
+
out.push(full.replaceAll("\\", "/"));
|
|
21
22
|
}
|
|
22
23
|
return out;
|
|
23
24
|
}
|
package/core/types.ts
CHANGED
|
@@ -63,15 +63,23 @@ interface ArchImportRule {
|
|
|
63
63
|
severity?: Exclude<Severity, "off">;
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
-
interface
|
|
67
|
-
pattern: string;
|
|
66
|
+
interface ArchForbiddenRuleBase {
|
|
68
67
|
in: string | string[];
|
|
69
68
|
message: string;
|
|
70
69
|
severity?: Exclude<Severity, "off">;
|
|
71
70
|
}
|
|
72
71
|
|
|
73
|
-
interface
|
|
72
|
+
interface ArchForbiddenPatternRule extends ArchForbiddenRuleBase {
|
|
74
73
|
pattern: string;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
interface ArchForbiddenJsxRule extends ArchForbiddenRuleBase {
|
|
77
|
+
"jsx-element": string | string[];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
type ArchForbiddenRule = ArchForbiddenPatternRule | ArchForbiddenJsxRule;
|
|
81
|
+
|
|
82
|
+
interface ArchSingletonRuleBase {
|
|
75
83
|
only: string;
|
|
76
84
|
message: string;
|
|
77
85
|
/** Limit scan to these files/layers. Defaults to all files. */
|
|
@@ -79,6 +87,16 @@ interface ArchSingletonRule {
|
|
|
79
87
|
severity?: Exclude<Severity, "off">;
|
|
80
88
|
}
|
|
81
89
|
|
|
90
|
+
interface ArchSingletonPatternRule extends ArchSingletonRuleBase {
|
|
91
|
+
pattern: string;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
interface ArchSingletonJsxRule extends ArchSingletonRuleBase {
|
|
95
|
+
"jsx-element": string | string[];
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
type ArchSingletonRule = ArchSingletonPatternRule | ArchSingletonJsxRule;
|
|
99
|
+
|
|
82
100
|
export interface ArchConfig {
|
|
83
101
|
layers?: Record<string, string[]>;
|
|
84
102
|
imports?: ArchImportRule[];
|
package/package.json
CHANGED