@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,345 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* Guard: HTML-building template literals must escape every interpolation.
|
|
4
|
+
*
|
|
5
|
+
* XSS protection in this codebase is a callsite convention
|
|
6
|
+
* (escapeHtml/escapeHtmlAttr from kumiko-headless) — this guard makes the
|
|
7
|
+
* convention enforceable. A template literal counts as HTML-building when
|
|
8
|
+
* its static text contains a known HTML/MJML/SVG tag. Every interpolation
|
|
9
|
+
* inside it must then be recognizably safe:
|
|
10
|
+
*
|
|
11
|
+
* - Call to escapeHtml / escapeHtmlAttr / escapeXml / raw
|
|
12
|
+
* - html`...` tagged template (the tag escapes itself)
|
|
13
|
+
* - Name ends in `Html` (convention: pre-rendered, already-escaped HTML)
|
|
14
|
+
* - UPPER_SNAKE constant (compile-time authored: CSS blocks, data URIs)
|
|
15
|
+
* - String-literal type or literal union (as-const copy tables, enums)
|
|
16
|
+
* - number/boolean type
|
|
17
|
+
* - Local variable whose initializer is itself safe; local function
|
|
18
|
+
* (its template literals are scanned independently)
|
|
19
|
+
* - `.join(...)` call (the joined fragments are their own template literals)
|
|
20
|
+
* - Ternary / ?? / || / && with uniformly safe branches
|
|
21
|
+
*
|
|
22
|
+
* Anything else — parameters, imports, property access on foreign data — is
|
|
23
|
+
* a violation: potential stored/reflected XSS.
|
|
24
|
+
*
|
|
25
|
+
* Deliberate per-line exception: `// html-ok: <reason>` on the interpolation
|
|
26
|
+
* line or the line above (e.g. error-message texts that only contain tag
|
|
27
|
+
* snippets as text).
|
|
28
|
+
*
|
|
29
|
+
* Usage:
|
|
30
|
+
* bun guards/guard-html-escape.ts
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import * as path from "node:path";
|
|
34
|
+
import {
|
|
35
|
+
type Identifier,
|
|
36
|
+
Node,
|
|
37
|
+
type SourceFile,
|
|
38
|
+
SyntaxKind,
|
|
39
|
+
type TemplateExpression,
|
|
40
|
+
type Type,
|
|
41
|
+
} from "ts-morph";
|
|
42
|
+
import { type AstGuard, runStandalone, type ScanSpec } from "./_lib/guard-kit";
|
|
43
|
+
|
|
44
|
+
const ROOT = process.cwd();
|
|
45
|
+
|
|
46
|
+
const SCAN: ScanSpec = {
|
|
47
|
+
scope: "source",
|
|
48
|
+
extensions: ["ts", "tsx"],
|
|
49
|
+
frameworkWithin: ["packages/*/src/**"],
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
// tools/docgen: MDX/markdown codegen from its own sources, no runtime HTML.
|
|
53
|
+
const EXCLUDE =
|
|
54
|
+
/(__tests__|__mocks__|\.test\.tsx?$|\.integration\.tsx?$|\.d\.ts$|\/escape\.ts$|\/html-template\.ts$|\/tools\/docgen\/)/;
|
|
55
|
+
|
|
56
|
+
// Only real HTML/MJML/SVG tags count — `<repo>` placeholders in CLI text or
|
|
57
|
+
// generics in strings should not fire.
|
|
58
|
+
const HTML_TAG =
|
|
59
|
+
/<\/?(!doctype|html|head|body|title|meta|link|style|script|noscript|div|span|p|a|ul|ol|li|dl|dt|dd|table|thead|tbody|tfoot|tr|td|th|h[1-6]|br|hr|img|svg|path|rect|circle|text|g|strong|em|b|i|u|s|small|sub|sup|button|form|input|label|select|option|textarea|fieldset|legend|section|article|header|footer|nav|main|aside|figure|figcaption|blockquote|pre|code|iframe|video|audio|source|picture|details|summary|dialog|mj-[a-z-]+)[\s>/]/i;
|
|
60
|
+
|
|
61
|
+
// Guard boundary (deliberate, not a code fix): matches only the bare
|
|
62
|
+
// method/tag name, not the callee root — an arbitrary `obj.raw()`/
|
|
63
|
+
// `obj.escapeXml()` or a locally defined `raw()` without real escaping
|
|
64
|
+
// passes the gate. Likewise the guard does not distinguish text from
|
|
65
|
+
// attribute context: `escapeHtml()` in an attribute value
|
|
66
|
+
// (`href="${escapeHtml(url)}"`) counts as safe even though only
|
|
67
|
+
// `escapeHtmlAttr()` covers quote/attribute breakout.
|
|
68
|
+
const SAFE_CALL_NAMES = new Set(["escapeHtml", "escapeHtmlAttr", "escapeXml", "raw"]);
|
|
69
|
+
|
|
70
|
+
const SAFE_TEMPLATE_TAGS = new Set(["html", "raw"]);
|
|
71
|
+
|
|
72
|
+
const UPPER_SNAKE = /^[A-Z][A-Z0-9_]*$/;
|
|
73
|
+
|
|
74
|
+
const MAX_RESOLVE_DEPTH = 6;
|
|
75
|
+
|
|
76
|
+
function templateStaticText(tpl: Node): string {
|
|
77
|
+
if (tpl.isKind(SyntaxKind.NoSubstitutionTemplateLiteral)) {
|
|
78
|
+
return tpl.getLiteralText();
|
|
79
|
+
}
|
|
80
|
+
const t = tpl as TemplateExpression;
|
|
81
|
+
return [
|
|
82
|
+
t.getHead().getLiteralText(),
|
|
83
|
+
...t.getTemplateSpans().map((s) => s.getLiteral().getLiteralText()),
|
|
84
|
+
].join("\n");
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function isCompileTimeKnownType(type: Type): boolean {
|
|
88
|
+
if (
|
|
89
|
+
type.isNumber() ||
|
|
90
|
+
type.isBoolean() ||
|
|
91
|
+
type.isBooleanLiteral() ||
|
|
92
|
+
type.isNumberLiteral() ||
|
|
93
|
+
type.isStringLiteral() ||
|
|
94
|
+
type.isEnumLiteral() ||
|
|
95
|
+
type.isUndefined() ||
|
|
96
|
+
type.isNull()
|
|
97
|
+
) {
|
|
98
|
+
return true;
|
|
99
|
+
}
|
|
100
|
+
if (type.isUnion()) {
|
|
101
|
+
return type.getUnionTypes().every(isCompileTimeKnownType);
|
|
102
|
+
}
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function endsWithHtmlConvention(expr: Node): boolean {
|
|
107
|
+
if (expr.isKind(SyntaxKind.Identifier)) return /Html$/.test(expr.getText());
|
|
108
|
+
if (expr.isKind(SyntaxKind.PropertyAccessExpression)) {
|
|
109
|
+
return /Html$/.test(expr.getNameNode().getText());
|
|
110
|
+
}
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Import declarations live in the same file AST-wise — but they don't count
|
|
115
|
+
// as local (the value comes from outside).
|
|
116
|
+
const IMPORT_DECL_KINDS = new Set<SyntaxKind>([
|
|
117
|
+
SyntaxKind.ImportSpecifier,
|
|
118
|
+
SyntaxKind.ImportClause,
|
|
119
|
+
SyntaxKind.NamespaceImport,
|
|
120
|
+
SyntaxKind.ImportEqualsDeclaration,
|
|
121
|
+
]);
|
|
122
|
+
|
|
123
|
+
function localDeclarations(ident: Identifier, sf: SourceFile): Node[] {
|
|
124
|
+
const decls = ident.getSymbol()?.getDeclarations() ?? [];
|
|
125
|
+
if (decls.length === 0) return [];
|
|
126
|
+
const allLocal = decls.every(
|
|
127
|
+
(d) =>
|
|
128
|
+
d.getSourceFile() === sf &&
|
|
129
|
+
!IMPORT_DECL_KINDS.has(d.getKind()) &&
|
|
130
|
+
!d.isKind(SyntaxKind.Parameter),
|
|
131
|
+
);
|
|
132
|
+
return allLocal ? decls : [];
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function isSafeLocalIdentifier(ident: Identifier, sf: SourceFile, depth: number): boolean {
|
|
136
|
+
const decls = localDeclarations(ident, sf);
|
|
137
|
+
if (decls.length === 0) return false;
|
|
138
|
+
return decls.every((d) => {
|
|
139
|
+
if (
|
|
140
|
+
d.isKind(SyntaxKind.FunctionDeclaration) ||
|
|
141
|
+
d.isKind(SyntaxKind.ClassDeclaration) ||
|
|
142
|
+
d.isKind(SyntaxKind.EnumDeclaration)
|
|
143
|
+
) {
|
|
144
|
+
return true;
|
|
145
|
+
}
|
|
146
|
+
if (d.isKind(SyntaxKind.VariableDeclaration)) {
|
|
147
|
+
const init = d.getInitializer();
|
|
148
|
+
if (!init) return false;
|
|
149
|
+
if (init.isKind(SyntaxKind.ArrowFunction) || init.isKind(SyntaxKind.FunctionExpression)) {
|
|
150
|
+
return true;
|
|
151
|
+
}
|
|
152
|
+
return isSafeExpression(init, sf, depth + 1);
|
|
153
|
+
}
|
|
154
|
+
return false;
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function calleeRootIdentifier(expr: Node): Identifier | undefined {
|
|
159
|
+
let cur = expr;
|
|
160
|
+
while (cur.isKind(SyntaxKind.PropertyAccessExpression)) {
|
|
161
|
+
cur = cur.getExpression();
|
|
162
|
+
}
|
|
163
|
+
return cur.isKind(SyntaxKind.Identifier) ? (cur as Identifier) : undefined;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function isSafeExpression(expr: Node, sf: SourceFile, depth: number): boolean {
|
|
167
|
+
if (depth > MAX_RESOLVE_DEPTH) return false;
|
|
168
|
+
if (expr.isKind(SyntaxKind.ParenthesizedExpression)) {
|
|
169
|
+
return isSafeExpression(expr.getExpression(), sf, depth + 1);
|
|
170
|
+
}
|
|
171
|
+
if (
|
|
172
|
+
expr.isKind(SyntaxKind.StringLiteral) ||
|
|
173
|
+
expr.isKind(SyntaxKind.NumericLiteral) ||
|
|
174
|
+
expr.isKind(SyntaxKind.TrueKeyword) ||
|
|
175
|
+
expr.isKind(SyntaxKind.FalseKeyword)
|
|
176
|
+
) {
|
|
177
|
+
return true;
|
|
178
|
+
}
|
|
179
|
+
// Nested template literals are scanned as their own candidates.
|
|
180
|
+
if (
|
|
181
|
+
expr.isKind(SyntaxKind.TemplateExpression) ||
|
|
182
|
+
expr.isKind(SyntaxKind.NoSubstitutionTemplateLiteral) ||
|
|
183
|
+
expr.isKind(SyntaxKind.TaggedTemplateExpression)
|
|
184
|
+
) {
|
|
185
|
+
return true;
|
|
186
|
+
}
|
|
187
|
+
if (expr.isKind(SyntaxKind.ConditionalExpression)) {
|
|
188
|
+
return (
|
|
189
|
+
isSafeExpression(expr.getWhenTrue(), sf, depth + 1) &&
|
|
190
|
+
isSafeExpression(expr.getWhenFalse(), sf, depth + 1)
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
if (expr.isKind(SyntaxKind.BinaryExpression)) {
|
|
194
|
+
const op = expr.getOperatorToken().getKind();
|
|
195
|
+
if (
|
|
196
|
+
op === SyntaxKind.QuestionQuestionToken ||
|
|
197
|
+
op === SyntaxKind.BarBarToken ||
|
|
198
|
+
op === SyntaxKind.AmpersandAmpersandToken
|
|
199
|
+
) {
|
|
200
|
+
return (
|
|
201
|
+
isSafeExpression(expr.getLeft(), sf, depth + 1) &&
|
|
202
|
+
isSafeExpression(expr.getRight(), sf, depth + 1)
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
// Arithmetic (`${width / 2}`) is safe as long as the result type is number.
|
|
206
|
+
return isCompileTimeKnownType(expr.getType());
|
|
207
|
+
}
|
|
208
|
+
if (endsWithHtmlConvention(expr)) return true;
|
|
209
|
+
if (expr.isKind(SyntaxKind.CallExpression)) {
|
|
210
|
+
const callee = expr.getExpression();
|
|
211
|
+
const name = callee.isKind(SyntaxKind.PropertyAccessExpression)
|
|
212
|
+
? callee.getNameNode().getText()
|
|
213
|
+
: callee.getText();
|
|
214
|
+
if (SAFE_CALL_NAMES.has(name)) return true;
|
|
215
|
+
// `.join(...)` is only safe when the joined array itself consists of
|
|
216
|
+
// safe fragments (e.g. arr.map(x => escapeHtml(x)).join("")) — a raw
|
|
217
|
+
// `stringArray.join("")` from foreign data (parameter/import) was
|
|
218
|
+
// previously treated as unconditionally safe, a real bypass.
|
|
219
|
+
if (name === "join" && callee.isKind(SyntaxKind.PropertyAccessExpression)) {
|
|
220
|
+
const receiver = callee.getExpression();
|
|
221
|
+
if (receiver.isKind(SyntaxKind.CallExpression)) {
|
|
222
|
+
const receiverCallee = receiver.getExpression();
|
|
223
|
+
const receiverName = receiverCallee.isKind(SyntaxKind.PropertyAccessExpression)
|
|
224
|
+
? receiverCallee.getNameNode().getText()
|
|
225
|
+
: receiverCallee.getText();
|
|
226
|
+
if (receiverName === "map") {
|
|
227
|
+
const mapCallback = receiver.getArguments()[0];
|
|
228
|
+
if (
|
|
229
|
+
mapCallback &&
|
|
230
|
+
(mapCallback.isKind(SyntaxKind.ArrowFunction) ||
|
|
231
|
+
mapCallback.isKind(SyntaxKind.FunctionExpression))
|
|
232
|
+
) {
|
|
233
|
+
const body = mapCallback.getBody();
|
|
234
|
+
if (Node.isBlock(body)) {
|
|
235
|
+
const stmts = body.getStatements();
|
|
236
|
+
const only = stmts.length === 1 ? stmts[0] : undefined;
|
|
237
|
+
const ret = only && Node.isReturnStatement(only) ? only.getExpression() : undefined;
|
|
238
|
+
if (ret && isSafeExpression(ret, sf, depth + 1)) return true;
|
|
239
|
+
} else if (isSafeExpression(body, sf, depth + 1)) {
|
|
240
|
+
return true;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
if (endsWithHtmlConvention(callee)) return true;
|
|
247
|
+
const root = calleeRootIdentifier(callee);
|
|
248
|
+
if (root && isSafeLocalIdentifier(root, sf, depth)) return true;
|
|
249
|
+
return isCompileTimeKnownType(expr.getType());
|
|
250
|
+
}
|
|
251
|
+
if (expr.isKind(SyntaxKind.Identifier)) {
|
|
252
|
+
const ident = expr as Identifier;
|
|
253
|
+
if (UPPER_SNAKE.test(ident.getText())) return true;
|
|
254
|
+
if (isSafeLocalIdentifier(ident, sf, depth)) return true;
|
|
255
|
+
return isCompileTimeKnownType(expr.getType());
|
|
256
|
+
}
|
|
257
|
+
if (
|
|
258
|
+
expr.isKind(SyntaxKind.PropertyAccessExpression) ||
|
|
259
|
+
expr.isKind(SyntaxKind.ElementAccessExpression)
|
|
260
|
+
) {
|
|
261
|
+
return isCompileTimeKnownType(expr.getType());
|
|
262
|
+
}
|
|
263
|
+
return false;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Error messages contain HTML only as text (`<div id="${rootId}"> not
|
|
267
|
+
// found`) — they are never rendered.
|
|
268
|
+
function isInsideErrorConstruction(tpl: Node): boolean {
|
|
269
|
+
let cur: Node | undefined = tpl.getParent();
|
|
270
|
+
while (cur && !Node.isStatement(cur)) {
|
|
271
|
+
if (cur.isKind(SyntaxKind.NewExpression)) {
|
|
272
|
+
const name = cur.getExpression().getText();
|
|
273
|
+
if (/Error$/.test(name)) return true;
|
|
274
|
+
}
|
|
275
|
+
cur = cur.getParent();
|
|
276
|
+
}
|
|
277
|
+
return cur?.isKind(SyntaxKind.ThrowStatement) ?? false;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function isInsideSafeTag(tpl: Node): boolean {
|
|
281
|
+
const parent = tpl.getParent();
|
|
282
|
+
if (!parent?.isKind(SyntaxKind.TaggedTemplateExpression)) return false;
|
|
283
|
+
const tag = parent.getTag();
|
|
284
|
+
const name = tag.isKind(SyntaxKind.PropertyAccessExpression)
|
|
285
|
+
? tag.getNameNode().getText()
|
|
286
|
+
: tag.getText();
|
|
287
|
+
return SAFE_TEMPLATE_TAGS.has(name);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function hasHtmlOkComment(lines: readonly string[], line: number): boolean {
|
|
291
|
+
const current = lines[line - 1] ?? "";
|
|
292
|
+
const previous = lines[line - 2] ?? "";
|
|
293
|
+
return current.includes("html-ok:") || previous.includes("html-ok:");
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
interface UnsafeSite {
|
|
297
|
+
file: string;
|
|
298
|
+
line: number;
|
|
299
|
+
snippet: string;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function scanFile(sf: SourceFile): UnsafeSite[] {
|
|
303
|
+
const sites: UnsafeSite[] = [];
|
|
304
|
+
let lines: string[] | undefined;
|
|
305
|
+
for (const tpl of sf.getDescendantsOfKind(SyntaxKind.TemplateExpression)) {
|
|
306
|
+
if (!HTML_TAG.test(templateStaticText(tpl))) continue;
|
|
307
|
+
if (isInsideSafeTag(tpl)) continue;
|
|
308
|
+
if (isInsideErrorConstruction(tpl)) continue;
|
|
309
|
+
for (const span of tpl.getTemplateSpans()) {
|
|
310
|
+
const expr = span.getExpression();
|
|
311
|
+
if (isSafeExpression(expr, sf, 0)) continue;
|
|
312
|
+
const line = expr.getStartLineNumber();
|
|
313
|
+
lines ??= sf.getFullText().split("\n");
|
|
314
|
+
if (hasHtmlOkComment(lines, line)) continue;
|
|
315
|
+
sites.push({
|
|
316
|
+
file: path.relative(ROOT, sf.getFilePath()),
|
|
317
|
+
line,
|
|
318
|
+
snippet: expr.getText().slice(0, 80),
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
return sites;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
export const guard: AstGuard = {
|
|
326
|
+
name: "HTML-Escape Guard",
|
|
327
|
+
scan: SCAN,
|
|
328
|
+
hint: "Interpolation in HTML-Template-Literal escapen: escapeHtml()/escapeHtmlAttr() aus @cosmicdrift/kumiko-headless. Vorgerendertes HTML per `*Html`-Namen kennzeichnen; statische Copy-Tabellen `as const` typen; bewusste Ausnahme mit `// html-ok: <warum>`.",
|
|
329
|
+
run(files) {
|
|
330
|
+
const violations: Array<{ file: string; line: number; message: string }> = [];
|
|
331
|
+
for (const sf of files) {
|
|
332
|
+
if (EXCLUDE.test(sf.getFilePath())) continue;
|
|
333
|
+
for (const site of scanFile(sf)) {
|
|
334
|
+
violations.push({
|
|
335
|
+
file: site.file,
|
|
336
|
+
line: site.line,
|
|
337
|
+
message: `unescaped interpolation in HTML template — \${${site.snippet}}`,
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
return { violations };
|
|
342
|
+
},
|
|
343
|
+
};
|
|
344
|
+
|
|
345
|
+
if (import.meta.main) runStandalone(guard);
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
// Apps no longer build their own UI primitives: components with primitive
|
|
3
|
+
// names (…Card, …Table, …Badge, …Button, …Chart, ProgressBar, MiniStat, …)
|
|
4
|
+
// exist in the framework (usePrimitives() resp. widgets/) — app rebuilds
|
|
5
|
+
// drift visually and cost every migration twice.
|
|
6
|
+
//
|
|
7
|
+
// Second rule (infra#748): raw form HTML (<input>/<select>/<textarea>/
|
|
8
|
+
// <label>) in app/feature web code, regardless of what the file imports —
|
|
9
|
+
// an import gate here previously let the designer's raw <input>s pass just
|
|
10
|
+
// by importing from @cosmicdrift/kumiko-renderer (types only) instead of
|
|
11
|
+
// -renderer-web, without ever touching a framework primitive. Baseline-
|
|
12
|
+
// ratcheted (freeze the existing backlog, no half-ratchet). Deviating from
|
|
13
|
+
// the usual "no baseline file = warn-only until --write-baseline": the
|
|
14
|
+
// measured backlog is 0 in every locally checked repo, so this fails
|
|
15
|
+
// closed against an empty baseline while no baseline file exists —
|
|
16
|
+
// otherwise the rule would stay silent in every app repo until someone
|
|
17
|
+
// manually runs --write-baseline there.
|
|
18
|
+
//
|
|
19
|
+
// Part of App-Mounting 2.0 (infra#208).
|
|
20
|
+
|
|
21
|
+
import { existsSync } from "node:fs";
|
|
22
|
+
import * as path from "node:path";
|
|
23
|
+
import { type SourceFile, SyntaxKind } from "ts-morph";
|
|
24
|
+
import {
|
|
25
|
+
type AstGuard,
|
|
26
|
+
baselineRatchet,
|
|
27
|
+
buildSharedProject,
|
|
28
|
+
compareToBaseline,
|
|
29
|
+
filesForGuard,
|
|
30
|
+
type GuardViolation,
|
|
31
|
+
runStandalone,
|
|
32
|
+
type ScanSpec,
|
|
33
|
+
} from "./_lib/guard-kit";
|
|
34
|
+
import { hasIgnoreTag } from "./_lib/ignore-tag";
|
|
35
|
+
|
|
36
|
+
const ROOT = process.cwd();
|
|
37
|
+
|
|
38
|
+
const SCAN: ScanSpec = {
|
|
39
|
+
scope: "source",
|
|
40
|
+
extensions: ["tsx"],
|
|
41
|
+
frameworkWithin: ["packages/bundled-features/src/**"],
|
|
42
|
+
};
|
|
43
|
+
const EXCLUDE = /(__tests__|\.test\.tsx?$|\.integration\.tsx?$|\.d\.ts$)/;
|
|
44
|
+
const IGNORE_TAG = "kumiko-lint-ignore no-custom-primitives";
|
|
45
|
+
|
|
46
|
+
// PascalCase + primitive suffix, or a known widget name.
|
|
47
|
+
const PRIMITIVE_NAME =
|
|
48
|
+
/^[A-Z]\w*(Card|Table|Badge|Button|Chart|Modal|Dialog|Tooltip|Spinner|Sparkline)$|^(ProgressBar|MiniStat|StatCard|EmptyState|LoadingState|ErrorState|ModeSwitch|Collapsible\w*|DetailList)$/;
|
|
49
|
+
|
|
50
|
+
const RAW_FORM_TAGS = new Set(["input", "select", "textarea", "label"]);
|
|
51
|
+
|
|
52
|
+
type RawFormFinding = {
|
|
53
|
+
readonly file: string;
|
|
54
|
+
readonly line: number;
|
|
55
|
+
readonly tag: string;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
function collectRawFormHtml(sf: SourceFile): RawFormFinding[] {
|
|
59
|
+
const findings: RawFormFinding[] = [];
|
|
60
|
+
const elements = [
|
|
61
|
+
...sf.getDescendantsOfKind(SyntaxKind.JsxOpeningElement),
|
|
62
|
+
...sf.getDescendantsOfKind(SyntaxKind.JsxSelfClosingElement),
|
|
63
|
+
];
|
|
64
|
+
for (const el of elements) {
|
|
65
|
+
const tag = el.getTagNameNode().getText();
|
|
66
|
+
if (!RAW_FORM_TAGS.has(tag)) continue;
|
|
67
|
+
if (hasIgnoreTag(el, IGNORE_TAG)) continue;
|
|
68
|
+
findings.push({
|
|
69
|
+
file: path.relative(ROOT, sf.getFilePath()),
|
|
70
|
+
line: el.getStartLineNumber(),
|
|
71
|
+
tag,
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
return findings;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function countByFile(findings: readonly RawFormFinding[]): Record<string, number> {
|
|
78
|
+
const counts: Record<string, number> = {};
|
|
79
|
+
for (const f of findings) counts[f.file] = (counts[f.file] ?? 0) + 1;
|
|
80
|
+
return counts;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const BASELINE_FILE = ".kumiko-no-custom-primitives-baseline.json";
|
|
84
|
+
const BASELINE_PATH = path.join(ROOT, BASELINE_FILE);
|
|
85
|
+
const rawFormHtmlBaseline = baselineRatchet({
|
|
86
|
+
file: BASELINE_PATH,
|
|
87
|
+
formatVersion: 1,
|
|
88
|
+
unit: "Fund(e) rohes Formular-HTML",
|
|
89
|
+
});
|
|
90
|
+
const RAW_FORM_HTML_REMEDIATION =
|
|
91
|
+
"Framework-Widget nutzen (@cosmicdrift/kumiko-renderer-web: Field/Input, ComboboxInput, …) " +
|
|
92
|
+
`oder usePrimitives(). Echte Ausnahme: // ${IGNORE_TAG} <Grund>`;
|
|
93
|
+
|
|
94
|
+
// The rule is new: measured backlog is 0 in every locally checked repo
|
|
95
|
+
// (infra#748). Without a baseline file, check fail-closed against an empty
|
|
96
|
+
// baseline instead of (as usual) warn-only until the first
|
|
97
|
+
// `--write-baseline` — otherwise the rule would stay silent in every app
|
|
98
|
+
// repo until it separately commits its own baseline file (every baseline
|
|
99
|
+
// file is repo-local, see .kumiko-comment-lang-baseline.json etc.).
|
|
100
|
+
function checkRawFormHtmlBaseline(findings: readonly RawFormFinding[]): GuardViolation[] {
|
|
101
|
+
const resolveLine = (file: string): number => findings.find((f) => f.file === file)?.line ?? 1;
|
|
102
|
+
const current = countByFile(findings);
|
|
103
|
+
if (!existsSync(BASELINE_PATH)) {
|
|
104
|
+
const { regressions } = compareToBaseline(current, {});
|
|
105
|
+
return regressions.map((r) => ({
|
|
106
|
+
file: r.file,
|
|
107
|
+
line: resolveLine(r.file),
|
|
108
|
+
message: `${r.current} Fund(e) rohes Formular-HTML (keine Baseline — Altbestand ist 0). ${RAW_FORM_HTML_REMEDIATION}`,
|
|
109
|
+
}));
|
|
110
|
+
}
|
|
111
|
+
return rawFormHtmlBaseline.check(current, RAW_FORM_HTML_REMEDIATION, {
|
|
112
|
+
formatDriftRemediation:
|
|
113
|
+
"Einmalig `bun guards/guard-no-custom-primitives.ts --write-baseline` aufrufen.",
|
|
114
|
+
resolveLine,
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function analyse(
|
|
119
|
+
files: readonly SourceFile[],
|
|
120
|
+
compareBaseline: boolean,
|
|
121
|
+
): { violations: GuardViolation[] } {
|
|
122
|
+
const violations: GuardViolation[] = [];
|
|
123
|
+
const rawFormFindings: RawFormFinding[] = [];
|
|
124
|
+
for (const sf of files) {
|
|
125
|
+
if (EXCLUDE.test(sf.getFilePath())) continue;
|
|
126
|
+
const candidates: {
|
|
127
|
+
name: string;
|
|
128
|
+
line: number;
|
|
129
|
+
node: Parameters<typeof hasIgnoreTag>[0];
|
|
130
|
+
}[] = [];
|
|
131
|
+
for (const fn of sf.getFunctions()) {
|
|
132
|
+
const name = fn.getName();
|
|
133
|
+
if (name !== undefined) candidates.push({ name, line: fn.getStartLineNumber(), node: fn });
|
|
134
|
+
}
|
|
135
|
+
for (const v of sf.getVariableDeclarations()) {
|
|
136
|
+
const init = v.getInitializer();
|
|
137
|
+
if (init === undefined) continue;
|
|
138
|
+
const kind = init.getKind();
|
|
139
|
+
if (kind !== SyntaxKind.ArrowFunction && kind !== SyntaxKind.FunctionExpression) continue;
|
|
140
|
+
candidates.push({
|
|
141
|
+
name: v.getName(),
|
|
142
|
+
line: v.getStartLineNumber(),
|
|
143
|
+
node: v,
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
for (const c of candidates) {
|
|
147
|
+
if (!PRIMITIVE_NAME.test(c.name)) continue;
|
|
148
|
+
if (hasIgnoreTag(c.node, IGNORE_TAG)) continue;
|
|
149
|
+
violations.push({
|
|
150
|
+
file: sf.getFilePath(),
|
|
151
|
+
line: c.line,
|
|
152
|
+
message: `App-lokales UI-Primitive "${c.name}" — Framework-Widget/Primitive nutzen`,
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
rawFormFindings.push(...collectRawFormHtml(sf));
|
|
156
|
+
}
|
|
157
|
+
if (!compareBaseline) {
|
|
158
|
+
console.log(" Baseline-Vergleich uebersprungen (--no-baseline).");
|
|
159
|
+
} else {
|
|
160
|
+
violations.push(...checkRawFormHtmlBaseline(rawFormFindings));
|
|
161
|
+
}
|
|
162
|
+
return { violations };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export const guard: AstGuard = {
|
|
166
|
+
name: "No-Custom-Primitives Guard (App-Repos)",
|
|
167
|
+
scan: SCAN,
|
|
168
|
+
hint:
|
|
169
|
+
"Framework-Widget nutzen (@cosmicdrift/kumiko-renderer-web: StatCard, SectionCard, StatusBadge, QueryTable, Charts, …) " +
|
|
170
|
+
`oder usePrimitives(). Echte Domain-Komponente ohne Framework-Pendant: // ${IGNORE_TAG} <Grund>`,
|
|
171
|
+
run: (files: readonly SourceFile[]) => analyse(files, true),
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
// Flags are read ONLY here, not in run() — the shared runner
|
|
175
|
+
// (run-guards.ts/run-ui-guards.ts) runs every guard with the same argv, a
|
|
176
|
+
// --write-baseline there must not silently rewrite the baseline (same
|
|
177
|
+
// pattern as guard-pii-annotations.ts).
|
|
178
|
+
//
|
|
179
|
+
// No --no-baseline: unlike guard-pii-annotations.ts this guard has a
|
|
180
|
+
// second, non-baselined rule (PRIMITIVE_NAME) — a --no-baseline that
|
|
181
|
+
// discards analyse()'s return and exits 0 would silence that too. Nobody
|
|
182
|
+
// reads the flag (the shared runner only calls run()).
|
|
183
|
+
if (import.meta.main) {
|
|
184
|
+
const args = process.argv.slice(2);
|
|
185
|
+
if (args.includes("--write-baseline")) {
|
|
186
|
+
const project = buildSharedProject([guard]);
|
|
187
|
+
const findings: RawFormFinding[] = [];
|
|
188
|
+
for (const sf of filesForGuard(project, guard)) {
|
|
189
|
+
if (EXCLUDE.test(sf.getFilePath())) continue;
|
|
190
|
+
findings.push(...collectRawFormHtml(sf));
|
|
191
|
+
}
|
|
192
|
+
rawFormHtmlBaseline.write(countByFile(findings));
|
|
193
|
+
process.exit(0);
|
|
194
|
+
}
|
|
195
|
+
runStandalone(guard);
|
|
196
|
+
}
|