@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 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).map((g) => globToPrefix(g, root));
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 prefixes = resolveLayerPrefixes(ref, layers, root);
91
- return allFiles.filter((f) => prefixes.some((p) => f === p || f.startsWith(`${p}/`)));
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
- scanLinesForPattern(
200
- inFiles,
201
- rule.pattern,
202
- fileContents,
203
- root,
204
- "arch/forbidden",
205
- rule.message,
206
- severity,
207
- violations
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
- scanLinesForPattern(
219
- scope,
220
- rule.pattern,
221
- fileContents,
222
- root,
223
- "arch/singleton",
224
- rule.message,
225
- severity,
226
- violations
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,
@@ -110,7 +110,7 @@ const ArchImportRuleSchema = z
110
110
  ],
111
111
  });
112
112
 
113
- const ArchForbiddenRuleSchema = z
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 substrings (console.log, process.exit, raw SDK fetches, etc.) inside specific layers."
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 ArchSingletonRuleSchema = z
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
- "Required error message explaining why the pattern must stay singleton (security boundary, module-of-record, etc.)."
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 (entry.name.endsWith(".ts")) out.push(full.replaceAll("\\", "/"));
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 ArchForbiddenRule {
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 ArchSingletonRule {
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@konvert7/klint",
3
- "version": "0.7.1",
3
+ "version": "0.8.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": {