@cosmicdrift/kumiko-guards 0.1.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/LICENSE +57 -0
- package/README.md +16 -0
- package/package.json +40 -0
- package/src/_lib/baseline-compare.ts +56 -0
- package/src/_lib/generic-reason.ts +39 -0
- package/src/_lib/guard-kit.ts +534 -0
- package/src/_lib/handler-name-forms.ts +29 -0
- package/src/_lib/ignore-tag.ts +24 -0
- package/src/_lib/primitives-access.ts +19 -0
- package/src/_lib/roots.ts +304 -0
- package/src/_lib/scan-lines.ts +25 -0
- package/src/_lib/scan-scope.ts +152 -0
- package/src/_lib/security-baseline-cli.ts +54 -0
- package/src/_lib/security-baseline.ts +325 -0
- package/src/_lib/sql-inventory.ts +267 -0
- package/src/guard-access-denied-test.ts +135 -0
- package/src/guard-admin-api.ts +134 -0
- package/src/guard-cross-feature-imports.ts +244 -0
- package/src/guard-direct-entity-writes.ts +387 -0
- package/src/guard-direct-fetch.ts +154 -0
- package/src/guard-escape-hatch-declared.ts +520 -0
- package/src/guard-fake-tests.ts +137 -0
- package/src/guard-html-escape.ts +345 -0
- package/src/guard-no-custom-primitives.ts +196 -0
- package/src/guard-no-date-api.ts +186 -0
- package/src/guard-no-direct-fs.ts +232 -0
- package/src/guard-no-direct-process-env.ts +126 -0
- package/src/guard-no-inline-styles.ts +58 -0
- package/src/guard-no-logic-in-views.ts +147 -0
- package/src/guard-no-raw-hooks.ts +76 -0
- package/src/guard-open-to-all-reason.ts +112 -0
- package/src/guard-pre-es-patterns.ts +199 -0
- package/src/guard-primitives-discipline.ts +330 -0
- package/src/guard-raw-classname.ts +111 -0
- package/src/guard-raw-interactive-elements.ts +154 -0
- package/src/guard-raw-sql.ts +89 -0
- package/src/guard-renderer-boundaries.ts +157 -0
- package/src/guard-restricted-symbols.ts +138 -0
- package/src/guard-silent-skip.ts +186 -0
- package/src/guard-tailwind-scan-surface.ts +588 -0
- package/src/guard-tenant-escalation.ts +312 -0
- package/src/guard-thin-wrappers.ts +422 -0
- package/src/guard-unsafe-json-parse.ts +86 -0
- package/src/index.ts +29 -0
- package/src/run-guards.ts +78 -0
- package/src/run-repo-checks.ts +22 -0
- package/src/run-ui-guards.ts +25 -0
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* Guard: enforces that app-/feature-web code uses UI building blocks from
|
|
4
|
+
* the framework primitives, not hand-rolled Tailwind on raw HTML tags.
|
|
5
|
+
*
|
|
6
|
+
* Why: hand-rolled Tailwind in `web/` folders costs twice:
|
|
7
|
+
* 1. Style drift — every renderer/theme update then has to be re-applied
|
|
8
|
+
* to each hand-tailwound screen individually.
|
|
9
|
+
* 2. Multi-platform lock-out — native (renderer-native) has no
|
|
10
|
+
* `<table>`, `<form>`, `<button>`. Direct DOM tags bind the code to
|
|
11
|
+
* web. Framework primitives (`<DataTable>`, `<Form>`, `<Button>`) are
|
|
12
|
+
* contractually platform-neutral.
|
|
13
|
+
*
|
|
14
|
+
* Forbidden in app-/feature-web code:
|
|
15
|
+
*
|
|
16
|
+
* <table>/<thead>/<tbody>/<tr>/<td>/<th> → <DataTable>
|
|
17
|
+
* <form> → <Form>
|
|
18
|
+
* <input> → <Input> (in <Field>)
|
|
19
|
+
* <button> → <Button>
|
|
20
|
+
* <select> → <ComboboxInput>
|
|
21
|
+
* <textarea> → <Input kind="textarea">
|
|
22
|
+
* <dialog> → <DefaultDialog>
|
|
23
|
+
* alert() / window.alert() → <DefaultDialog> from @cosmicdrift/kumiko-renderer-web
|
|
24
|
+
* className "bg-card" (hand-rolled card) → <Card> (slots/options)
|
|
25
|
+
*
|
|
26
|
+
* Allowed: containers and text — `<div>`, `<span>`, `<section>`,
|
|
27
|
+
* `<header>`, `<main>`, `<nav>`, `<aside>`, `<article>`,
|
|
28
|
+
* `<h1>`-`<h6>`, `<p>`, `<ul>`, `<ol>`, `<li>`, `<a>`, `<img>`,
|
|
29
|
+
* `<svg>`, `<label>`, `<small>`, `<strong>`, `<em>`, `<br>`, `<hr>`,
|
|
30
|
+
* `<pre>`, `<code>`.
|
|
31
|
+
*
|
|
32
|
+
* `__tests__` folders are excluded. Per-line override:
|
|
33
|
+
* `// kumiko-lint-ignore primitives-discipline <reason>`
|
|
34
|
+
* (on the same line or the line directly above).
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
import * as fs from "node:fs";
|
|
38
|
+
import * as path from "node:path";
|
|
39
|
+
import { type RepoCheck, reportResults, runRepoChecks } from "./_lib/guard-kit";
|
|
40
|
+
import { isFlatSrcLayout, type RepoRoot, sourceRootDirs } from "./_lib/roots";
|
|
41
|
+
|
|
42
|
+
const FORBIDDEN_TAGS: ReadonlyArray<{
|
|
43
|
+
readonly tag: string;
|
|
44
|
+
readonly counterpart: string;
|
|
45
|
+
}> = [
|
|
46
|
+
{ tag: "table", counterpart: "<DataTable>" },
|
|
47
|
+
{ tag: "thead", counterpart: "<DataTable>" },
|
|
48
|
+
{ tag: "tbody", counterpart: "<DataTable>" },
|
|
49
|
+
{ tag: "tr", counterpart: "<DataTable>" },
|
|
50
|
+
{ tag: "td", counterpart: "<DataTable>" },
|
|
51
|
+
{ tag: "th", counterpart: "<DataTable>" },
|
|
52
|
+
{ tag: "form", counterpart: "<Form>" },
|
|
53
|
+
{ tag: "input", counterpart: "<Input> (über <Field>)" },
|
|
54
|
+
{ tag: "button", counterpart: "<Button>" },
|
|
55
|
+
{ tag: "select", counterpart: "<ComboboxInput>" },
|
|
56
|
+
{ tag: "textarea", counterpart: '<Input kind="textarea">' },
|
|
57
|
+
{ tag: "dialog", counterpart: "<DefaultDialog>" },
|
|
58
|
+
];
|
|
59
|
+
|
|
60
|
+
const FORBIDDEN_CALLS: ReadonlyArray<{
|
|
61
|
+
readonly call: string;
|
|
62
|
+
readonly counterpart: string;
|
|
63
|
+
}> = [{ call: "alert", counterpart: "<DefaultDialog> aus @cosmicdrift/kumiko-renderer-web" }];
|
|
64
|
+
|
|
65
|
+
// Forbidden className tokens: an allowed tag (`<div>`), but the class gives
|
|
66
|
+
// away hand-rolled primitive chrome. `bg-card` is the dedicated card-surface
|
|
67
|
+
// token — seeing it in a className means a hand-rebuilt card instead of
|
|
68
|
+
// `<Card>` (slots/options). Exactly the copy-paste drift the Card primitive
|
|
69
|
+
// is meant to eliminate. Real exceptions (foundation token swatches,
|
|
70
|
+
// navigation surfaces) carry `// kumiko-lint-ignore primitives-discipline`.
|
|
71
|
+
const FORBIDDEN_CLASSES: ReadonlyArray<{
|
|
72
|
+
readonly token: string;
|
|
73
|
+
readonly counterpart: string;
|
|
74
|
+
}> = [{ token: "bg-card", counterpart: "<Card>" }];
|
|
75
|
+
|
|
76
|
+
const IGNORE_TAG = "kumiko-lint-ignore primitives-discipline";
|
|
77
|
+
|
|
78
|
+
export type ScopeKind = "bundled-features" | "samples" | "app";
|
|
79
|
+
|
|
80
|
+
type ScanScope = {
|
|
81
|
+
readonly kind: ScopeKind;
|
|
82
|
+
readonly root: string;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
// Framework checkout enforces bundled-features + samples. App-repo checkout
|
|
86
|
+
// scans the app's web/ screens — primitives-discipline as a ratchet per app
|
|
87
|
+
// repo (infra#224). Every other resolved repo (enterprise, platform, ...)
|
|
88
|
+
// scans alongside the others in one run — flatMap all of them instead of
|
|
89
|
+
// cascading, which would silently skip every root after the first, exactly
|
|
90
|
+
// the silent-skip this guard fixes.
|
|
91
|
+
function resolveScopes(roots: readonly RepoRoot[]): ScanScope[] {
|
|
92
|
+
const frameworkRoot = roots.find((r) => r.kind === "framework");
|
|
93
|
+
if (frameworkRoot) {
|
|
94
|
+
return [
|
|
95
|
+
{
|
|
96
|
+
kind: "bundled-features",
|
|
97
|
+
root: path.join(frameworkRoot.absPath, "packages/bundled-features/src"),
|
|
98
|
+
},
|
|
99
|
+
{ kind: "samples", root: path.join(frameworkRoot.absPath, "samples") },
|
|
100
|
+
];
|
|
101
|
+
}
|
|
102
|
+
const appRoot = roots.find((r) => r.kind === "app" && isFlatSrcLayout(r));
|
|
103
|
+
if (appRoot) {
|
|
104
|
+
return [{ kind: "app", root: path.join(appRoot.absPath, "src") }];
|
|
105
|
+
}
|
|
106
|
+
const otherRoots = roots.filter((r) => r.kind !== "framework" && !isFlatSrcLayout(r));
|
|
107
|
+
return otherRoots.flatMap(sourceRootDirs).map((root) => ({ kind: "app" as const, root }));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function resolveRoot(roots: readonly RepoRoot[]): string {
|
|
111
|
+
const frameworkRoot = roots.find((r) => r.kind === "framework");
|
|
112
|
+
if (frameworkRoot) return frameworkRoot.absPath;
|
|
113
|
+
const appRoot = roots.find((r) => r.kind === "app" && isFlatSrcLayout(r));
|
|
114
|
+
if (appRoot) return appRoot.absPath;
|
|
115
|
+
const otherRoots = roots.filter((r) => r.kind !== "framework" && !isFlatSrcLayout(r));
|
|
116
|
+
return otherRoots[0]?.absPath ?? process.cwd();
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
type Violation = {
|
|
120
|
+
readonly file: string;
|
|
121
|
+
readonly line: number;
|
|
122
|
+
readonly kind: "tag" | "call" | "class";
|
|
123
|
+
readonly tag: string;
|
|
124
|
+
readonly counterpart: string;
|
|
125
|
+
readonly excerpt: string;
|
|
126
|
+
readonly scope: ScopeKind;
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
function isWebFile(absPath: string, scopeRoot: string): boolean {
|
|
130
|
+
// Only .tsx files under a `web/` or `public/` path segment (prevents
|
|
131
|
+
// scanning random .tsx in screens/ or feature roots — those are schema
|
|
132
|
+
// definitions, not web code).
|
|
133
|
+
if (!absPath.endsWith(".tsx")) return false;
|
|
134
|
+
const rel = path.relative(scopeRoot, absPath);
|
|
135
|
+
const parts = rel.split(path.sep);
|
|
136
|
+
return parts.includes("web") || parts.includes("public");
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function walk(dir: string, out: string[]): void {
|
|
140
|
+
if (!fs.existsSync(dir)) return;
|
|
141
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
142
|
+
const full = path.join(dir, entry.name);
|
|
143
|
+
if (entry.isDirectory()) {
|
|
144
|
+
if (entry.name === "__tests__") continue;
|
|
145
|
+
if (entry.name === "node_modules") continue;
|
|
146
|
+
if (entry.name === "dist") continue;
|
|
147
|
+
walk(full, out);
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
if (entry.isFile()) out.push(full);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function buildTagPattern(tag: string): RegExp {
|
|
155
|
+
// Match `<tag` at the JSX opening: after whitespace, a paren, or >.
|
|
156
|
+
// Negative lookahead `[a-zA-Z0-9-]` so `<input` doesn't match `<inputfoo`.
|
|
157
|
+
// Doesn't match the closing tag (`</tag>`) — that would be redundant.
|
|
158
|
+
return new RegExp(`(^|[\\s(>{,;])<${tag}(?![a-zA-Z0-9-])`, "u");
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const TAG_PATTERNS = FORBIDDEN_TAGS.map((t) => ({ ...t, pattern: buildTagPattern(t.tag) }));
|
|
162
|
+
|
|
163
|
+
// Match `alert(` and `window.alert(` — the native browser dialog is forbidden.
|
|
164
|
+
const CALL_PATTERNS = FORBIDDEN_CALLS.map((c) => ({
|
|
165
|
+
...c,
|
|
166
|
+
// Lookbehind instead of \b: \b also matches after a dot — `toast.alert(`
|
|
167
|
+
// would otherwise be a hit. window.alert( stays deliberately forbidden.
|
|
168
|
+
pattern: new RegExp(`(?<![.\\w$])(?:window\\.)?${c.call}\\s*\\(`, "u"),
|
|
169
|
+
}));
|
|
170
|
+
|
|
171
|
+
// `\b` alone isn't enough: `-` is itself a non-word character, so
|
|
172
|
+
// `\bbg-card\b` also matches as a substring in a longer hyphen-prefixed
|
|
173
|
+
// token (e.g. a hypothetical `my-bg-card`). A lookaround against word
|
|
174
|
+
// characters AND `-` on both sides keeps `bg-card` exact, without losing
|
|
175
|
+
// `hover:bg-card`/`bg-card/40`.
|
|
176
|
+
const CLASS_PATTERNS = FORBIDDEN_CLASSES.map((c) => ({
|
|
177
|
+
...c,
|
|
178
|
+
pattern: new RegExp(`(?<![\\w-])${c.token}(?![\\w-])`, "u"),
|
|
179
|
+
}));
|
|
180
|
+
|
|
181
|
+
function hasIgnore(currentLine: string, prevLine: string): boolean {
|
|
182
|
+
return currentLine.includes(IGNORE_TAG) || prevLine.includes(IGNORE_TAG);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export function checkFile(
|
|
186
|
+
file: string,
|
|
187
|
+
scope: ScopeKind,
|
|
188
|
+
root: string = process.cwd(),
|
|
189
|
+
): Violation[] {
|
|
190
|
+
const text = fs.readFileSync(file, "utf-8");
|
|
191
|
+
const lines = text.split("\n");
|
|
192
|
+
const violations: Violation[] = [];
|
|
193
|
+
let inBlockComment = false;
|
|
194
|
+
for (let i = 0; i < lines.length; i++) {
|
|
195
|
+
const line = lines[i] ?? "";
|
|
196
|
+
const trimmed = line.trim();
|
|
197
|
+
// Block-comment tracking (heuristic): `/* ... */` across multiple lines.
|
|
198
|
+
// Single-line `//` and `*` (inside /* */) are skipped.
|
|
199
|
+
if (inBlockComment) {
|
|
200
|
+
if (trimmed.includes("*/")) inBlockComment = false;
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
if (trimmed.startsWith("/*") && !trimmed.includes("*/")) {
|
|
204
|
+
inBlockComment = true;
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
if (trimmed.startsWith("//") || trimmed.startsWith("*")) continue;
|
|
208
|
+
// Strip trailing inline comments — `foo(); // alert() would be wrong`
|
|
209
|
+
// must not match. (?<!:) leaves URLs (https://…) in strings alone;
|
|
210
|
+
// string contents themselves stay an accepted heuristic gap.
|
|
211
|
+
const codeOnly = line.replace(/(?<!:)\/\/.*$/u, "");
|
|
212
|
+
|
|
213
|
+
const prev = i > 0 ? (lines[i - 1] ?? "") : "";
|
|
214
|
+
if (hasIgnore(line, prev)) continue;
|
|
215
|
+
|
|
216
|
+
for (const { tag, counterpart, pattern } of TAG_PATTERNS) {
|
|
217
|
+
if (pattern.test(codeOnly)) {
|
|
218
|
+
violations.push({
|
|
219
|
+
file: path.relative(root, file),
|
|
220
|
+
line: i + 1,
|
|
221
|
+
kind: "tag",
|
|
222
|
+
tag,
|
|
223
|
+
counterpart,
|
|
224
|
+
excerpt: trimmed.length > 120 ? `${trimmed.slice(0, 117)}...` : trimmed,
|
|
225
|
+
scope,
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
for (const { call, counterpart, pattern } of CALL_PATTERNS) {
|
|
230
|
+
if (pattern.test(codeOnly)) {
|
|
231
|
+
violations.push({
|
|
232
|
+
file: path.relative(root, file),
|
|
233
|
+
line: i + 1,
|
|
234
|
+
kind: "call",
|
|
235
|
+
tag: call,
|
|
236
|
+
counterpart,
|
|
237
|
+
excerpt: trimmed.length > 120 ? `${trimmed.slice(0, 117)}...` : trimmed,
|
|
238
|
+
scope,
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
for (const { token, counterpart, pattern } of CLASS_PATTERNS) {
|
|
243
|
+
if (pattern.test(codeOnly)) {
|
|
244
|
+
violations.push({
|
|
245
|
+
file: path.relative(root, file),
|
|
246
|
+
line: i + 1,
|
|
247
|
+
kind: "class",
|
|
248
|
+
tag: token,
|
|
249
|
+
counterpart,
|
|
250
|
+
excerpt: trimmed.length > 120 ? `${trimmed.slice(0, 117)}...` : trimmed,
|
|
251
|
+
scope,
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
return violations;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function violationMessage(v: Violation): string {
|
|
260
|
+
const label =
|
|
261
|
+
v.kind === "call" ? `${v.tag}()` : v.kind === "class" ? `class "${v.tag}"` : `<${v.tag}>`;
|
|
262
|
+
return `${label} → ${v.counterpart} | ${v.excerpt}`;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const HINT =
|
|
266
|
+
"Migration: usePrimitives() in eine Custom-Screen-Komponente, oder schema-driven via EntityListScreenDefinition / EntityEditScreenDefinition wo möglich. Override pro Zeile: // kumiko-lint-ignore primitives-discipline <reason>";
|
|
267
|
+
|
|
268
|
+
export const check: RepoCheck = {
|
|
269
|
+
name: "Primitives-Discipline Guard",
|
|
270
|
+
hint: HINT,
|
|
271
|
+
run(roots) {
|
|
272
|
+
const scopes = resolveScopes(roots);
|
|
273
|
+
if (scopes.length === 0) {
|
|
274
|
+
// infra exits 1 here ("weder FRAMEWORK_ROOT, APP_ROOT noch ein anderer
|
|
275
|
+
// Repo-Root aufgelöst") — the vacuity floor below reproduces that.
|
|
276
|
+
return { violations: [], matchedFiles: 0, notApplicable: false };
|
|
277
|
+
}
|
|
278
|
+
const root = resolveRoot(roots);
|
|
279
|
+
// Pre-filter count (every file walk() found, before the web/-segment
|
|
280
|
+
// filter): an app repo whose screens are schema-driven with zero
|
|
281
|
+
// web/*.tsx (phronexsis before its first screen) must not be flagged as
|
|
282
|
+
// vacuous just because isWebFile() legitimately matched nothing.
|
|
283
|
+
let walkedFiles = 0;
|
|
284
|
+
const violations: { file: string; line: number; message: string }[] = [];
|
|
285
|
+
const warnings: { file: string; line: number; message: string }[] = [];
|
|
286
|
+
|
|
287
|
+
for (const scope of scopes) {
|
|
288
|
+
const files: string[] = [];
|
|
289
|
+
walk(scope.root, files);
|
|
290
|
+
walkedFiles += files.length;
|
|
291
|
+
const webFiles = files.filter((f) => isWebFile(f, scope.root));
|
|
292
|
+
for (const file of webFiles) {
|
|
293
|
+
const target = scope.kind === "samples" ? warnings : violations;
|
|
294
|
+
for (const v of checkFile(file, scope.kind, root)) {
|
|
295
|
+
target.push({ file: v.file, line: v.line, message: violationMessage(v) });
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
return {
|
|
301
|
+
violations,
|
|
302
|
+
warnings: warnings.length > 0 ? warnings : undefined,
|
|
303
|
+
matchedFiles: walkedFiles,
|
|
304
|
+
notApplicable: false,
|
|
305
|
+
};
|
|
306
|
+
},
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
if (import.meta.main) {
|
|
310
|
+
// --strict also blocks the samples scope (infra's `--strict` mode);
|
|
311
|
+
// --strict-bundled is accepted for CLI compatibility — bundled-features is
|
|
312
|
+
// already always-blocking here (what framework CI ran as --strict-bundled).
|
|
313
|
+
const strict = process.argv.includes("--strict");
|
|
314
|
+
const effectiveCheck: RepoCheck = strict
|
|
315
|
+
? {
|
|
316
|
+
name: check.name,
|
|
317
|
+
hint: check.hint,
|
|
318
|
+
async run(roots) {
|
|
319
|
+
const outcome = await check.run(roots);
|
|
320
|
+
return {
|
|
321
|
+
violations: [...outcome.violations, ...(outcome.warnings ?? [])],
|
|
322
|
+
matchedFiles: outcome.matchedFiles,
|
|
323
|
+
notApplicable: outcome.notApplicable,
|
|
324
|
+
};
|
|
325
|
+
},
|
|
326
|
+
}
|
|
327
|
+
: check;
|
|
328
|
+
const failed = reportResults(await runRepoChecks([effectiveCheck]));
|
|
329
|
+
process.exit(failed > 0 ? 1 : 0);
|
|
330
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
// Design comes from widgets/theme tokens, not raw Tailwind in app code.
|
|
3
|
+
// Flags design-carrying classes in app `className`: default-palette colors
|
|
4
|
+
// (bg-red-500), arbitrary color values (bg-[#fff], text-[rgb(...)]), shadow-*.
|
|
5
|
+
// Layout utilities (flex/grid/gap/p-/m-/w-) and token classes
|
|
6
|
+
// (bg-primary, text-status-ok, …) stay allowed.
|
|
7
|
+
//
|
|
8
|
+
// Part of App-Mounting 2.0 (infra#208). Enabled per app repo in the
|
|
9
|
+
// respective migration PR (ui-guards input in _app-test.yml).
|
|
10
|
+
|
|
11
|
+
import { type JsxAttribute, type SourceFile, SyntaxKind } from "ts-morph";
|
|
12
|
+
import { type AstGuard, type GuardViolation, runStandalone, type ScanSpec } from "./_lib/guard-kit";
|
|
13
|
+
import { hasIgnoreTag } from "./_lib/ignore-tag";
|
|
14
|
+
|
|
15
|
+
const SCAN: ScanSpec = {
|
|
16
|
+
scope: "source",
|
|
17
|
+
extensions: ["tsx"],
|
|
18
|
+
frameworkWithin: ["packages/bundled-features/src/**"],
|
|
19
|
+
};
|
|
20
|
+
const EXCLUDE = /(__tests__|\.test\.tsx?$|\.integration\.tsx?$|\.d\.ts$)/;
|
|
21
|
+
const IGNORE_TAG = "kumiko-lint-ignore raw-classname";
|
|
22
|
+
|
|
23
|
+
const PALETTE =
|
|
24
|
+
"red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose|slate|gray|zinc|neutral|stone";
|
|
25
|
+
const COLOR_PREFIX =
|
|
26
|
+
"bg|text|border|ring|fill|stroke|from|via|to|outline|decoration|divide|accent|caret";
|
|
27
|
+
|
|
28
|
+
const PALETTE_CLASS = new RegExp(`^(${COLOR_PREFIX})-(${PALETTE})-\\d+(/\\d+)?$`);
|
|
29
|
+
const ARBITRARY_COLOR = new RegExp(`^(${COLOR_PREFIX})-\\[(#|rgb|hsl|oklch|color-mix)`);
|
|
30
|
+
// Only shadows — `rounded-*` was included initially, but the evidence
|
|
31
|
+
// across studio/publicstatus/money-horse shows: radius hits are
|
|
32
|
+
// chips/pills/small surfaces, not design drift (that comes via
|
|
33
|
+
// colors/shadows). 41 of 44 mh hits were rounded noise.
|
|
34
|
+
const CHROME_CLASS = /^shadow(-.+)?$/;
|
|
35
|
+
|
|
36
|
+
// Strip modifier prefixes (hover:, dark:, sm:, group-open:, …) — the base
|
|
37
|
+
// class is what gets checked.
|
|
38
|
+
function baseClass(token: string): string {
|
|
39
|
+
const idx = token.lastIndexOf(":");
|
|
40
|
+
return idx === -1 ? token : token.slice(idx + 1);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function offendingTokens(text: string): string[] {
|
|
44
|
+
return text
|
|
45
|
+
.split(/\s+/)
|
|
46
|
+
.filter((t) => t.length > 0)
|
|
47
|
+
.map(baseClass)
|
|
48
|
+
.filter((t) => PALETTE_CLASS.test(t) || ARBITRARY_COLOR.test(t) || CHROME_CLASS.test(t));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function classNameStrings(attr: JsxAttribute): { text: string; line: number }[] {
|
|
52
|
+
const init = attr.getInitializer();
|
|
53
|
+
if (init === undefined) return [];
|
|
54
|
+
if (init.getKind() === SyntaxKind.StringLiteral) {
|
|
55
|
+
return [
|
|
56
|
+
{
|
|
57
|
+
text: init.asKindOrThrow(SyntaxKind.StringLiteral).getLiteralText(),
|
|
58
|
+
line: init.getStartLineNumber(),
|
|
59
|
+
},
|
|
60
|
+
];
|
|
61
|
+
}
|
|
62
|
+
// Expression: collect every string/template component (covers
|
|
63
|
+
// cn("…", cond && "…") and template literals).
|
|
64
|
+
const parts: { text: string; line: number }[] = [];
|
|
65
|
+
for (const s of init.getDescendantsOfKind(SyntaxKind.StringLiteral)) {
|
|
66
|
+
parts.push({ text: s.getLiteralText(), line: s.getStartLineNumber() });
|
|
67
|
+
}
|
|
68
|
+
for (const s of init.getDescendantsOfKind(SyntaxKind.NoSubstitutionTemplateLiteral)) {
|
|
69
|
+
parts.push({ text: s.getLiteralText(), line: s.getStartLineNumber() });
|
|
70
|
+
}
|
|
71
|
+
for (const kind of [
|
|
72
|
+
SyntaxKind.TemplateHead,
|
|
73
|
+
SyntaxKind.TemplateMiddle,
|
|
74
|
+
SyntaxKind.TemplateTail,
|
|
75
|
+
]) {
|
|
76
|
+
for (const s of init.getDescendantsOfKind(kind)) {
|
|
77
|
+
parts.push({ text: s.getText().replace(/^[`}]|[`$]{?$/g, ""), line: s.getStartLineNumber() });
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return parts;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export const guard: AstGuard = {
|
|
84
|
+
name: "Raw-ClassName Guard (App-Repos)",
|
|
85
|
+
scan: SCAN,
|
|
86
|
+
hint:
|
|
87
|
+
"Design-tragende Klassen gehören in Widgets/Theme-Tokens (@cosmicdrift/kumiko-renderer-web widgets/, --color-status-*). " +
|
|
88
|
+
`Begründete Ausnahme: // ${IGNORE_TAG} <Grund>`,
|
|
89
|
+
run(files: readonly SourceFile[]) {
|
|
90
|
+
const violations: GuardViolation[] = [];
|
|
91
|
+
for (const sf of files) {
|
|
92
|
+
if (EXCLUDE.test(sf.getFilePath())) continue;
|
|
93
|
+
for (const attr of sf.getDescendantsOfKind(SyntaxKind.JsxAttribute)) {
|
|
94
|
+
if (attr.getNameNode().getText() !== "className") continue;
|
|
95
|
+
if (hasIgnoreTag(attr, IGNORE_TAG)) continue;
|
|
96
|
+
for (const part of classNameStrings(attr)) {
|
|
97
|
+
const bad = offendingTokens(part.text);
|
|
98
|
+
if (bad.length === 0) continue;
|
|
99
|
+
violations.push({
|
|
100
|
+
file: sf.getFilePath(),
|
|
101
|
+
line: part.line,
|
|
102
|
+
message: `design-tragende Tailwind-Klassen in App-Code: ${bad.join(", ")}`,
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return { violations };
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
if (import.meta.main) runStandalone(guard);
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* Guard: raw interactive HTML elements in feature UI. <a>, <details> and
|
|
4
|
+
* <summary> each have a framework replacement (usePrimitives().Link,
|
|
5
|
+
* CollapsibleSection from @cosmicdrift/kumiko-renderer-web) but nothing was
|
|
6
|
+
* checking for them — turn-cards.tsx/agent-layer.tsx (raw <details>/<summary>)
|
|
7
|
+
* and agent-settings.tsx (raw <a href>) shipped past CI in kumiko-enterprise.
|
|
8
|
+
*
|
|
9
|
+
* <button>/<input>/<select>/<textarea>/<dialog>/<form> are deliberately NOT
|
|
10
|
+
* in this guard: <button>/<input>/<select>/<textarea>/<dialog> are already
|
|
11
|
+
* enforced by guard-primitives-discipline.ts (its own CI step, not this
|
|
12
|
+
* bundle), and <form> is a documented exception (PR #488, agent-layer.tsx:71
|
|
13
|
+
* uses a native <form onSubmit> on purpose) — adding either back here would
|
|
14
|
+
* duplicate or contradict an existing rule.
|
|
15
|
+
*
|
|
16
|
+
* Only fires when the file demonstrably has primitives access (same gate as
|
|
17
|
+
* guard-no-custom-primitives's raw-form-html rule) — proof the replacement
|
|
18
|
+
* was reachable, not just a tag ban on files outside the primitives world.
|
|
19
|
+
*
|
|
20
|
+
* Baseline-regression guard like guard-tailwind-scan-surface.ts: pins the
|
|
21
|
+
* currently-known violation count per file. Reductions are allowed but
|
|
22
|
+
* don't auto-update the baseline. Without a baseline file the guard stays
|
|
23
|
+
* warning-only (bootstrap: run `--write-baseline` once).
|
|
24
|
+
*
|
|
25
|
+
* Usage:
|
|
26
|
+
* bun guards/guard-raw-interactive-elements.ts # compare against baseline
|
|
27
|
+
* bun guards/guard-raw-interactive-elements.ts --write-baseline # (re)write the baseline
|
|
28
|
+
* bun guards/guard-raw-interactive-elements.ts --no-baseline # skip the comparison
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import * as path from "node:path";
|
|
32
|
+
import { type SourceFile, SyntaxKind } from "ts-morph";
|
|
33
|
+
import {
|
|
34
|
+
type AstGuard,
|
|
35
|
+
baselineRatchet,
|
|
36
|
+
buildSharedProject,
|
|
37
|
+
filesForGuard,
|
|
38
|
+
type GuardOutcome,
|
|
39
|
+
runStandalone,
|
|
40
|
+
type ScanSpec,
|
|
41
|
+
} from "./_lib/guard-kit";
|
|
42
|
+
import { hasIgnoreTag } from "./_lib/ignore-tag";
|
|
43
|
+
import { hasPrimitivesAccess } from "./_lib/primitives-access";
|
|
44
|
+
|
|
45
|
+
const ROOT = process.cwd();
|
|
46
|
+
|
|
47
|
+
const SCAN: ScanSpec = {
|
|
48
|
+
scope: "source",
|
|
49
|
+
extensions: ["tsx"],
|
|
50
|
+
frameworkWithin: ["packages/bundled-features/src/**"],
|
|
51
|
+
};
|
|
52
|
+
const EXCLUDE = /(__tests__|\.test\.tsx?$|\.integration\.tsx?$|\.d\.ts$)/;
|
|
53
|
+
const IGNORE_TAG = "kumiko-lint-ignore raw-interactive-elements";
|
|
54
|
+
|
|
55
|
+
type BannedTag = "a" | "details" | "summary";
|
|
56
|
+
|
|
57
|
+
const REPLACEMENT: Readonly<Record<BannedTag, string>> = {
|
|
58
|
+
a: "usePrimitives().Link",
|
|
59
|
+
details: "CollapsibleSection (@cosmicdrift/kumiko-renderer-web)",
|
|
60
|
+
summary: "CollapsibleSection (@cosmicdrift/kumiko-renderer-web)",
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
function isBannedTag(tag: string): tag is BannedTag {
|
|
64
|
+
return Object.hasOwn(REPLACEMENT, tag);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export type Finding = {
|
|
68
|
+
readonly file: string;
|
|
69
|
+
readonly line: number;
|
|
70
|
+
readonly tag: BannedTag;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
function scan(files: readonly SourceFile[]): Finding[] {
|
|
74
|
+
const findings: Finding[] = [];
|
|
75
|
+
for (const sf of files) {
|
|
76
|
+
if (EXCLUDE.test(sf.getFilePath())) continue;
|
|
77
|
+
if (!hasPrimitivesAccess(sf)) continue;
|
|
78
|
+
const elements = [
|
|
79
|
+
...sf.getDescendantsOfKind(SyntaxKind.JsxOpeningElement),
|
|
80
|
+
...sf.getDescendantsOfKind(SyntaxKind.JsxSelfClosingElement),
|
|
81
|
+
];
|
|
82
|
+
for (const el of elements) {
|
|
83
|
+
const tag = el.getTagNameNode().getText();
|
|
84
|
+
if (!isBannedTag(tag)) continue;
|
|
85
|
+
if (hasIgnoreTag(el, IGNORE_TAG)) continue;
|
|
86
|
+
const file = path.relative(ROOT, sf.getFilePath());
|
|
87
|
+
const line = el.getStartLineNumber();
|
|
88
|
+
findings.push({ file, line, tag });
|
|
89
|
+
console.warn(
|
|
90
|
+
` [raw-interactive-elements WARN] ${file}:${line} raw <${tag}> — use ${REPLACEMENT[tag]}`,
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return findings;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function baselineCounts(findings: readonly Finding[]): Record<string, number> {
|
|
98
|
+
const counts: Record<string, number> = {};
|
|
99
|
+
for (const f of findings) counts[f.file] = (counts[f.file] ?? 0) + 1;
|
|
100
|
+
return counts;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const BASELINE_FILE = ".kumiko-raw-interactive-elements-baseline.json";
|
|
104
|
+
const rawInteractiveElementsBaseline = baselineRatchet({
|
|
105
|
+
file: path.join(ROOT, BASELINE_FILE),
|
|
106
|
+
formatVersion: 1,
|
|
107
|
+
unit: "Fund(e) rohes interaktives HTML",
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
const REMEDIATION =
|
|
111
|
+
"Framework replacement: <a> → usePrimitives().Link, <details>/<summary> → CollapsibleSection " +
|
|
112
|
+
`(@cosmicdrift/kumiko-renderer-web). Genuine exception: // ${IGNORE_TAG} <reason>`;
|
|
113
|
+
|
|
114
|
+
function analyse(files: readonly SourceFile[], compareBaseline: boolean): GuardOutcome {
|
|
115
|
+
const findings = scan(files);
|
|
116
|
+
if (!compareBaseline) {
|
|
117
|
+
console.log(" Baseline-Vergleich uebersprungen (--no-baseline).");
|
|
118
|
+
return { violations: [] };
|
|
119
|
+
}
|
|
120
|
+
const resolveLine = (file: string): number => findings.find((f) => f.file === file)?.line ?? 1;
|
|
121
|
+
return {
|
|
122
|
+
violations: rawInteractiveElementsBaseline.check(baselineCounts(findings), REMEDIATION, {
|
|
123
|
+
formatDriftRemediation:
|
|
124
|
+
"Einmalig `bun guards/guard-raw-interactive-elements.ts --write-baseline` aufrufen.",
|
|
125
|
+
resolveLine,
|
|
126
|
+
}),
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export const guard: AstGuard = {
|
|
131
|
+
name: "Raw-Interactive-Elements Guard (App-Repos)",
|
|
132
|
+
scan: SCAN,
|
|
133
|
+
hint: REMEDIATION,
|
|
134
|
+
run: (files: readonly SourceFile[]) => analyse(files, true),
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
// Flags are read ONLY here, not in run() — the shared runner
|
|
138
|
+
// (run-ui-guards.ts) runs every guard with the same argv, a
|
|
139
|
+
// --write-baseline there must not silently rewrite the baseline.
|
|
140
|
+
if (import.meta.main) {
|
|
141
|
+
const args = process.argv.slice(2);
|
|
142
|
+
if (args.includes("--write-baseline")) {
|
|
143
|
+
const project = buildSharedProject([guard]);
|
|
144
|
+
const findings = scan(filesForGuard(project, guard));
|
|
145
|
+
rawInteractiveElementsBaseline.write(baselineCounts(findings));
|
|
146
|
+
process.exit(0);
|
|
147
|
+
}
|
|
148
|
+
if (args.includes("--no-baseline")) {
|
|
149
|
+
const project = buildSharedProject([guard]);
|
|
150
|
+
analyse(filesForGuard(project, guard), false);
|
|
151
|
+
process.exit(0);
|
|
152
|
+
}
|
|
153
|
+
runStandalone(guard);
|
|
154
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* Raw-SQL Guard — blocks `.unsafe()` / `asRawClient()` outside allowed paths.
|
|
4
|
+
*
|
|
5
|
+
* Escape hatch for a justified raw-SQL call:
|
|
6
|
+
* // kumiko-lint-ignore raw-sql <reason>
|
|
7
|
+
* on the call's own line or the line directly above (one hit per marker). A bare tag with no
|
|
8
|
+
* reason text after it does NOT suppress the finding — production code
|
|
9
|
+
* should otherwise route SQL through `db/queries/*` or typed
|
|
10
|
+
* `bun-db/query` helpers.
|
|
11
|
+
*
|
|
12
|
+
* Plan: kumiko-platform/docs/plans/architecture/intern/sql-queries-consolidation.md
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { type RepoCheck, reportResults, runRepoChecks } from "./_lib/guard-kit";
|
|
16
|
+
import { type RepoRoot, resolveRepoRoots } from "./_lib/roots";
|
|
17
|
+
import { BLOCKING_SQL_KINDS, scanRepo, sqlScanLayoutFor } from "./_lib/sql-inventory";
|
|
18
|
+
|
|
19
|
+
export type RawSqlFinding = {
|
|
20
|
+
readonly repo: string;
|
|
21
|
+
readonly file: string;
|
|
22
|
+
readonly line: number;
|
|
23
|
+
readonly kind: string;
|
|
24
|
+
readonly snippet: string;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
async function scanAllRepos(roots: readonly RepoRoot[]): Promise<{
|
|
28
|
+
readonly findings: RawSqlFinding[];
|
|
29
|
+
readonly scannedFiles: number;
|
|
30
|
+
}> {
|
|
31
|
+
const findings: RawSqlFinding[] = [];
|
|
32
|
+
let scannedFiles = 0;
|
|
33
|
+
|
|
34
|
+
for (const root of roots) {
|
|
35
|
+
const report = await scanRepo(root.absPath, sqlScanLayoutFor(root));
|
|
36
|
+
scannedFiles += report.scannedFiles;
|
|
37
|
+
|
|
38
|
+
for (const hit of report.hits) {
|
|
39
|
+
if (hit.allowed) continue;
|
|
40
|
+
if (hit.markerSuppressed) continue;
|
|
41
|
+
if (hit.file.includes("/__tests__/")) continue;
|
|
42
|
+
if (!(BLOCKING_SQL_KINDS as readonly string[]).includes(hit.kind)) continue;
|
|
43
|
+
findings.push({
|
|
44
|
+
repo: root.name,
|
|
45
|
+
file: hit.file,
|
|
46
|
+
line: hit.line,
|
|
47
|
+
kind: hit.kind,
|
|
48
|
+
snippet: hit.snippet,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return { findings, scannedFiles };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export async function collectRawSqlFindings(
|
|
57
|
+
roots: readonly RepoRoot[] = resolveRepoRoots(),
|
|
58
|
+
): Promise<readonly RawSqlFinding[]> {
|
|
59
|
+
return (await scanAllRepos(roots)).findings;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export const check: RepoCheck = {
|
|
63
|
+
name: "guard-raw-sql",
|
|
64
|
+
hint:
|
|
65
|
+
"Regel: Runtime-SQL nur in db/queries/*, bun-db/query.ts, testing/*, oder mit " +
|
|
66
|
+
"// kumiko-lint-ignore raw-sql <Grund> auf der Zeile bzw. der Zeile davor.",
|
|
67
|
+
async run(roots) {
|
|
68
|
+
// kumiko-platform's deliberate empty scan-dir list must not read as vacuous (infra#610).
|
|
69
|
+
const applicableRoots = roots.filter((r) => sqlScanLayoutFor(r) !== "none");
|
|
70
|
+
if (applicableRoots.length === 0) {
|
|
71
|
+
return { violations: [], matchedFiles: 0, notApplicable: true };
|
|
72
|
+
}
|
|
73
|
+
const { findings, scannedFiles } = await scanAllRepos(applicableRoots);
|
|
74
|
+
return {
|
|
75
|
+
violations: findings.map((f) => ({
|
|
76
|
+
file: f.file,
|
|
77
|
+
line: f.line,
|
|
78
|
+
message: `[${f.kind}] ${f.snippet}`,
|
|
79
|
+
})),
|
|
80
|
+
matchedFiles: scannedFiles,
|
|
81
|
+
notApplicable: false,
|
|
82
|
+
};
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
if (import.meta.main) {
|
|
87
|
+
const failed = reportResults(await runRepoChecks([check]));
|
|
88
|
+
process.exit(failed > 0 ? 1 : 0);
|
|
89
|
+
}
|