@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.
Files changed (47) hide show
  1. package/LICENSE +57 -0
  2. package/README.md +16 -0
  3. package/package.json +40 -0
  4. package/src/_lib/baseline-compare.ts +56 -0
  5. package/src/_lib/generic-reason.ts +39 -0
  6. package/src/_lib/guard-kit.ts +534 -0
  7. package/src/_lib/handler-name-forms.ts +29 -0
  8. package/src/_lib/ignore-tag.ts +24 -0
  9. package/src/_lib/primitives-access.ts +19 -0
  10. package/src/_lib/roots.ts +304 -0
  11. package/src/_lib/scan-lines.ts +25 -0
  12. package/src/_lib/scan-scope.ts +152 -0
  13. package/src/_lib/security-baseline-cli.ts +54 -0
  14. package/src/_lib/security-baseline.ts +325 -0
  15. package/src/_lib/sql-inventory.ts +267 -0
  16. package/src/guard-access-denied-test.ts +135 -0
  17. package/src/guard-admin-api.ts +134 -0
  18. package/src/guard-cross-feature-imports.ts +244 -0
  19. package/src/guard-direct-entity-writes.ts +387 -0
  20. package/src/guard-direct-fetch.ts +154 -0
  21. package/src/guard-escape-hatch-declared.ts +520 -0
  22. package/src/guard-fake-tests.ts +137 -0
  23. package/src/guard-html-escape.ts +345 -0
  24. package/src/guard-no-custom-primitives.ts +196 -0
  25. package/src/guard-no-date-api.ts +186 -0
  26. package/src/guard-no-direct-fs.ts +232 -0
  27. package/src/guard-no-direct-process-env.ts +126 -0
  28. package/src/guard-no-inline-styles.ts +58 -0
  29. package/src/guard-no-logic-in-views.ts +147 -0
  30. package/src/guard-no-raw-hooks.ts +76 -0
  31. package/src/guard-open-to-all-reason.ts +112 -0
  32. package/src/guard-pre-es-patterns.ts +199 -0
  33. package/src/guard-primitives-discipline.ts +330 -0
  34. package/src/guard-raw-classname.ts +111 -0
  35. package/src/guard-raw-interactive-elements.ts +154 -0
  36. package/src/guard-raw-sql.ts +89 -0
  37. package/src/guard-renderer-boundaries.ts +157 -0
  38. package/src/guard-restricted-symbols.ts +138 -0
  39. package/src/guard-silent-skip.ts +186 -0
  40. package/src/guard-tailwind-scan-surface.ts +588 -0
  41. package/src/guard-tenant-escalation.ts +312 -0
  42. package/src/guard-thin-wrappers.ts +422 -0
  43. package/src/guard-unsafe-json-parse.ts +86 -0
  44. package/src/index.ts +29 -0
  45. package/src/run-guards.ts +78 -0
  46. package/src/run-repo-checks.ts +22 -0
  47. 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
+ }