@cosmicdrift/kumiko-guards 0.1.1 → 0.281.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 (52) hide show
  1. package/package.json +5 -2
  2. package/src/_lib/guard-kit.ts +74 -19
  3. package/src/_lib/qn.ts +21 -0
  4. package/src/_lib/security-baseline-cli.ts +3 -3
  5. package/src/_lib/security-baseline.ts +8 -8
  6. package/src/changes.json +32 -0
  7. package/src/check-as-casts.ts +648 -0
  8. package/src/check-complexity.ts +292 -0
  9. package/src/check-predicates.ts +218 -0
  10. package/src/check-secret-literals.ts +126 -0
  11. package/src/cli.ts +59 -0
  12. package/src/guard-admin-api.ts +1 -1
  13. package/src/guard-app-feature-structure.ts +114 -0
  14. package/src/guard-broker-subscribe.ts +99 -0
  15. package/src/guard-error-reasons.ts +193 -0
  16. package/src/guard-escape-hatch-declared.ts +155 -35
  17. package/src/guard-fake-tests.ts +1 -1
  18. package/src/guard-feature-integration-tests.ts +184 -0
  19. package/src/guard-html-escape.ts +1 -1
  20. package/src/guard-i18n-keys.ts +440 -0
  21. package/src/guard-i18n-locale-mount.ts +317 -0
  22. package/src/guard-i18n-locale-terminology.ts +117 -0
  23. package/src/guard-i18n-ui-strings.ts +248 -0
  24. package/src/guard-lib-test-coverage.ts +156 -0
  25. package/src/guard-loadall-events.ts +133 -0
  26. package/src/guard-no-custom-primitives.ts +9 -10
  27. package/src/guard-no-date-api.ts +1 -1
  28. package/src/guard-no-direct-fs.ts +1 -1
  29. package/src/guard-no-inline-styles.ts +4 -4
  30. package/src/guard-no-logic-in-views.ts +3 -3
  31. package/src/guard-no-raw-hooks.ts +4 -5
  32. package/src/guard-open-to-all-reason.ts +1 -1
  33. package/src/guard-pii-annotations.ts +267 -0
  34. package/src/guard-pre-es-patterns.ts +1 -1
  35. package/src/guard-primitives-discipline.ts +3 -3
  36. package/src/guard-raw-classname.ts +3 -3
  37. package/src/guard-raw-interactive-elements.ts +3 -3
  38. package/src/guard-raw-sql.ts +2 -2
  39. package/src/guard-renderer-boundaries.ts +1 -1
  40. package/src/guard-restricted-symbols.ts +1 -1
  41. package/src/guard-screen-conventions.ts +161 -0
  42. package/src/guard-silent-skip.ts +1 -1
  43. package/src/guard-table-ddl.ts +159 -0
  44. package/src/guard-tailwind-scan-surface.ts +12 -12
  45. package/src/guard-test-stack-drift.ts +147 -0
  46. package/src/guard-text-field-stance.ts +222 -0
  47. package/src/guard-thin-wrappers.ts +6 -1
  48. package/src/guard-unsafe-json-parse.ts +1 -1
  49. package/src/guard-write-handler-qns.ts +242 -0
  50. package/src/run-guards.ts +36 -3
  51. package/src/run-repo-checks.ts +10 -1
  52. package/src/run-ui-guards.ts +11 -2
@@ -0,0 +1,440 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * Guard: i18n-Keys muessen definiert sein, bevor sie verwendet werden.
4
+ *
5
+ * Scan: t()-Calls in App-tsx, Definitionen aus r.translations und i18n-Bundles.
6
+ * Deklarative Screen-/Nav-Keys: validateBoot (Runtime).
7
+ *
8
+ * Usage:
9
+ * bun guards/guard-i18n-keys.ts
10
+ */
11
+
12
+ import { existsSync, readFileSync } from "node:fs";
13
+ import * as path from "node:path";
14
+ import { type Node, type ObjectLiteralExpression, type SourceFile, SyntaxKind } from "ts-morph";
15
+ import { type AstGuard, type GuardOutcome, runStandalone, type ScanSpec } from "./_lib/guard-kit";
16
+
17
+ const ROOT = process.cwd();
18
+
19
+ const SCAN: ScanSpec = {
20
+ scope: "source",
21
+ extensions: ["ts", "tsx"],
22
+ frameworkWithin: ["packages/*/src/**", "samples/apps/*/src/**", "samples/recipes/*/src/**"],
23
+ };
24
+ const EXCLUDE = /(__tests__|\.test\.tsx?$|\.integration\.tsx?$|\.d\.ts$)/;
25
+
26
+ interface UsedKey {
27
+ key: string;
28
+ file: string;
29
+ line: number;
30
+ }
31
+
32
+ interface DefinedKey {
33
+ fullKey: string;
34
+ locales: Set<string>;
35
+ file: string;
36
+ line: number;
37
+ }
38
+
39
+ function relFile(sf: SourceFile): string {
40
+ return path.relative(ROOT, sf.getFilePath());
41
+ }
42
+
43
+ function featureFromPath(filePath: string): string | null {
44
+ const rel = path.relative(ROOT, filePath);
45
+ const m = rel.match(/src\/features\/([^/]+)\//);
46
+ return m?.[1] ?? null;
47
+ }
48
+
49
+ function isI18nBundleFile(filePath: string): boolean {
50
+ return (
51
+ /\/i18n\//.test(filePath) ||
52
+ // Flat single-file bundle (src/i18n.ts), same status as src/i18n/index.ts.
53
+ /\/i18n\.ts$/.test(filePath) ||
54
+ /\/features\/[^/]+\/i18n\./.test(filePath) ||
55
+ // Deckt beliebige Tiefe unter bundled-features/src/<feature>/ ab
56
+ // (z.B. auch .../schema/i18n.ts), statt nur genau eine Ebene.
57
+ /bundled-features\/src\/.*\/i18n\.ts$/.test(filePath)
58
+ );
59
+ }
60
+
61
+ function collectUsedKeys(sf: SourceFile): UsedKey[] {
62
+ const keys: UsedKey[] = [];
63
+ for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
64
+ const exprText = call.getExpression().getText();
65
+ if (exprText !== "t" && !/(^|\.)t$/.test(exprText)) continue;
66
+ if (exprText === "test" || exprText === "expect") continue;
67
+ const args = call.getArguments();
68
+ if (args.length === 0) continue;
69
+ const first = args[0];
70
+ if (
71
+ !first?.isKind(SyntaxKind.StringLiteral) &&
72
+ !first?.isKind(SyntaxKind.NoSubstitutionTemplateLiteral)
73
+ )
74
+ continue;
75
+ const literal = first.getText().slice(1, -1);
76
+ if (!literal.includes(":")) continue;
77
+ keys.push({
78
+ key: literal,
79
+ file: relFile(sf),
80
+ line: call.getStartLineNumber(),
81
+ });
82
+ }
83
+ return keys;
84
+ }
85
+
86
+ function findEnclosingFeatureName(node: Node): string | null {
87
+ let cur: Node | undefined = node.getParent();
88
+ while (cur) {
89
+ if (cur.isKind(SyntaxKind.CallExpression)) {
90
+ const call = cur;
91
+ if (call.getExpression().getText() === "defineFeature") {
92
+ const first = call.getArguments()[0];
93
+ if (first?.isKind(SyntaxKind.StringLiteral)) {
94
+ return first.getText().slice(1, -1);
95
+ }
96
+ }
97
+ }
98
+ cur = cur.getParent();
99
+ }
100
+ return null;
101
+ }
102
+
103
+ function extractKeysFromTranslationsObject(
104
+ obj: ObjectLiteralExpression,
105
+ ): Array<{ key: string; locales: Set<string>; line: number }> {
106
+ const out: Array<{ key: string; locales: Set<string>; line: number }> = [];
107
+ for (const prop of obj.getProperties()) {
108
+ if (!prop.isKind(SyntaxKind.PropertyAssignment)) continue;
109
+ const nameNode = prop.getNameNode();
110
+ let keyName: string;
111
+ if (nameNode.isKind(SyntaxKind.StringLiteral)) {
112
+ keyName = nameNode.getText().slice(1, -1);
113
+ } else if (nameNode.isKind(SyntaxKind.Identifier)) {
114
+ keyName = nameNode.getText();
115
+ } else continue;
116
+
117
+ const initializer = prop.getInitializer();
118
+ if (!initializer?.isKind(SyntaxKind.ObjectLiteralExpression)) continue;
119
+ const locales = new Set<string>();
120
+ for (const localeProp of initializer.getProperties()) {
121
+ if (!localeProp.isKind(SyntaxKind.PropertyAssignment)) continue;
122
+ const localeName = localeProp.getNameNode();
123
+ if (localeName.isKind(SyntaxKind.StringLiteral))
124
+ locales.add(localeName.getText().slice(1, -1));
125
+ else if (localeName.isKind(SyntaxKind.Identifier)) locales.add(localeName.getText());
126
+ }
127
+ out.push({ key: keyName, locales, line: prop.getStartLineNumber() });
128
+ }
129
+ return out;
130
+ }
131
+
132
+ function extractLocaleFirstKeys(
133
+ obj: ObjectLiteralExpression,
134
+ ): Array<{ key: string; locales: Set<string>; line: number }> {
135
+ const localeMaps = new Map<string, ObjectLiteralExpression>();
136
+ for (const prop of obj.getProperties()) {
137
+ if (!prop.isKind(SyntaxKind.PropertyAssignment)) continue;
138
+ const localeName = prop.getNameNode().getText();
139
+ const init = prop.getInitializer();
140
+ if (init?.isKind(SyntaxKind.ObjectLiteralExpression)) {
141
+ localeMaps.set(localeName, init);
142
+ }
143
+ }
144
+ if (!localeMaps.has("de") && !localeMaps.has("en")) return [];
145
+
146
+ const keySet = new Set<string>();
147
+ for (const map of localeMaps.values()) {
148
+ for (const prop of map.getProperties()) {
149
+ if (!prop.isKind(SyntaxKind.PropertyAssignment)) continue;
150
+ const nameNode = prop.getNameNode();
151
+ if (nameNode.isKind(SyntaxKind.StringLiteral)) {
152
+ keySet.add(nameNode.getText().slice(1, -1));
153
+ }
154
+ }
155
+ }
156
+
157
+ const out: Array<{ key: string; locales: Set<string>; line: number }> = [];
158
+ for (const key of keySet) {
159
+ const locales = new Set<string>();
160
+ for (const [locale, map] of localeMaps) {
161
+ const has = map.getProperties().some((prop) => {
162
+ if (!prop.isKind(SyntaxKind.PropertyAssignment)) return false;
163
+ const nameNode = prop.getNameNode();
164
+ return nameNode.isKind(SyntaxKind.StringLiteral) && nameNode.getText().slice(1, -1) === key;
165
+ });
166
+ if (has) locales.add(locale);
167
+ }
168
+ out.push({ key, locales, line: obj.getStartLineNumber() });
169
+ }
170
+ return out;
171
+ }
172
+
173
+ function pushDefined(
174
+ defined: DefinedKey[],
175
+ fullKey: string,
176
+ locales: Set<string>,
177
+ file: string,
178
+ line: number,
179
+ ): void {
180
+ defined.push({ fullKey, locales, file, line });
181
+ }
182
+
183
+ function addDefinedEntries(
184
+ defined: DefinedKey[],
185
+ entries: Array<{ key: string; locales: Set<string>; line: number }>,
186
+ file: string,
187
+ featureName: string | null,
188
+ ): void {
189
+ for (const entry of entries) {
190
+ if (entry.key.includes(":")) {
191
+ pushDefined(defined, entry.key, entry.locales, file, entry.line);
192
+ continue;
193
+ }
194
+ if (featureName) {
195
+ pushDefined(defined, `${featureName}:${entry.key}`, entry.locales, file, entry.line);
196
+ }
197
+ }
198
+ }
199
+
200
+ function objectLiteralFromInitializer(node: Node | undefined): ObjectLiteralExpression | undefined {
201
+ if (!node) return undefined;
202
+ if (node.isKind(SyntaxKind.ObjectLiteralExpression)) return node;
203
+ if (
204
+ node.isKind(SyntaxKind.AsExpression) ||
205
+ node.isKind(SyntaxKind.ParenthesizedExpression) ||
206
+ node.isKind(SyntaxKind.SatisfiesExpression)
207
+ ) {
208
+ return objectLiteralFromInitializer(node.getExpression());
209
+ }
210
+ return undefined;
211
+ }
212
+
213
+ function isLocaleFirstBundle(obj: ObjectLiteralExpression): boolean {
214
+ const props = obj.getProperties().filter((p) => p.isKind(SyntaxKind.PropertyAssignment));
215
+ if (props.length === 0) return false;
216
+ const localeRe = /^(de|en|fr|es|it|nl|pt)$/;
217
+ return props.every((p) => localeRe.test(p.getNameNode().getText()));
218
+ }
219
+
220
+ function collectBundleDefinedKeys(sf: SourceFile): DefinedKey[] {
221
+ const filePath = sf.getFilePath();
222
+ if (!isI18nBundleFile(filePath)) return [];
223
+
224
+ const defined: DefinedKey[] = [];
225
+ const featureName = featureFromPath(filePath);
226
+
227
+ for (const decl of sf.getVariableDeclarations()) {
228
+ const obj = objectLiteralFromInitializer(decl.getInitializer());
229
+ if (!obj) continue;
230
+
231
+ if (isLocaleFirstBundle(obj)) {
232
+ addDefinedEntries(defined, extractLocaleFirstKeys(obj), relFile(sf), featureName);
233
+ continue;
234
+ }
235
+
236
+ const keyFirst = extractKeysFromTranslationsObject(obj);
237
+ if (keyFirst.length > 0) {
238
+ addDefinedEntries(defined, keyFirst, relFile(sf), featureName);
239
+ }
240
+ }
241
+
242
+ return defined;
243
+ }
244
+
245
+ function collectInlineTranslationsDefinedKeys(sf: SourceFile): DefinedKey[] {
246
+ const defined: DefinedKey[] = [];
247
+ for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
248
+ const expr = call.getExpression();
249
+ if (!expr.getText().endsWith(".translations")) continue;
250
+ const args = call.getArguments();
251
+ const first = args[0];
252
+ if (!first?.isKind(SyntaxKind.ObjectLiteralExpression)) continue;
253
+
254
+ const featureName = findEnclosingFeatureName(call);
255
+ if (!featureName) continue;
256
+
257
+ for (const prop of first.getProperties()) {
258
+ if (!prop.isKind(SyntaxKind.PropertyAssignment)) continue;
259
+ if (prop.getNameNode().getText() !== "keys") continue;
260
+ const initializer = prop.getInitializer();
261
+ if (initializer?.isKind(SyntaxKind.ObjectLiteralExpression)) {
262
+ for (const entry of extractKeysFromTranslationsObject(initializer)) {
263
+ pushDefined(
264
+ defined,
265
+ `${featureName}:${entry.key}`,
266
+ entry.locales,
267
+ relFile(sf),
268
+ entry.line,
269
+ );
270
+ if (entry.key.includes(":")) {
271
+ pushDefined(defined, entry.key, entry.locales, relFile(sf), entry.line);
272
+ }
273
+ }
274
+ } else if (initializer?.isKind(SyntaxKind.Identifier)) {
275
+ // getDefinitionNodes() follows "go to definition" through an
276
+ // import alias to the real declaration (possibly in another
277
+ // file) instead of stopping at the ImportSpecifier — plain
278
+ // getSymbol().getDeclarations() only resolved a same-file const.
279
+ const decl = initializer
280
+ .getDefinitionNodes()
281
+ .find((n) => n.isKind(SyntaxKind.VariableDeclaration));
282
+ if (decl?.isKind(SyntaxKind.VariableDeclaration)) {
283
+ const bundleInit = decl.getInitializer();
284
+ if (bundleInit?.isKind(SyntaxKind.ObjectLiteralExpression)) {
285
+ for (const entry of extractKeysFromTranslationsObject(bundleInit)) {
286
+ addDefinedEntries(defined, [entry], relFile(sf), featureName);
287
+ }
288
+ }
289
+ }
290
+ }
291
+ }
292
+ }
293
+ return defined;
294
+ }
295
+
296
+ /** Framework monorepo ships English-only core bundles; locale packages opt in separately. */
297
+ export function isFrameworkMonorepo(root: string): boolean {
298
+ return existsSync(path.join(root, "packages/framework/package.json"));
299
+ }
300
+
301
+ /** Parse `export const NAME = [...]` locale literals. Returns null if absent;
302
+ * throws if the export exists but is not a static string-literal array
303
+ * (spread / alias / region-tag-only without literals) — silent de/en fallback
304
+ * would hide a real declaration (infra#602). */
305
+ export function parseLocaleConstArray(source: string, constName: string): string[] | null {
306
+ const exportRe = new RegExp(`export\\s+const\\s+${constName}\\s*(?::[^=]+)?=\\s*([^;]+)`);
307
+ const match = source.match(exportRe);
308
+ if (!match?.[1]) return null;
309
+ const rhs = match[1].trim();
310
+ if (!rhs.startsWith("[")) {
311
+ throw new Error(
312
+ `i18n-keys: export const ${constName} is not a static array literal — refuse silent de/en fallback`,
313
+ );
314
+ }
315
+ if (rhs.includes("...")) {
316
+ throw new Error(
317
+ `i18n-keys: export const ${constName} uses array spreads — refuse partial/silent locale set`,
318
+ );
319
+ }
320
+ const locales = [...rhs.matchAll(/["']([a-z]{2}(?:-[A-Z]{2})?)["']/g)]
321
+ .map((m) => m[1])
322
+ .filter((l): l is string => l !== undefined);
323
+ if (locales.length === 0) {
324
+ throw new Error(
325
+ `i18n-keys: export const ${constName} = [...] has no string locale literals — refuse silent de/en fallback`,
326
+ );
327
+ }
328
+ return locales;
329
+ }
330
+
331
+ /** Resolve owning repo root from a guard-relative path (may be `../…`). */
332
+ export function repoRootForRelFile(rel: string): string {
333
+ const abs = path.isAbsolute(rel) ? rel : path.resolve(ROOT, rel);
334
+ let curr = path.dirname(abs);
335
+ let fallback: string | undefined;
336
+ while (curr !== path.dirname(curr)) {
337
+ const base = path.basename(curr);
338
+ // Never treat worktree/parent markers as a repo root (infra#602).
339
+ if (base === ".." || base === ".wt") {
340
+ curr = path.dirname(curr);
341
+ continue;
342
+ }
343
+ if (existsSync(path.join(curr, "packages/framework/package.json"))) {
344
+ return curr;
345
+ }
346
+ if (existsSync(path.join(curr, "package.json"))) {
347
+ // Outermost package.json wins so nested packages/* resolve to the repo.
348
+ fallback = curr;
349
+ }
350
+ curr = path.dirname(curr);
351
+ }
352
+ return fallback ?? ROOT;
353
+ }
354
+ /** Repo-declared locales for translation completeness; empty set skips the check. */
355
+ export function resolveExpectedLocales(root: string = ROOT): Set<string> {
356
+ if (isFrameworkMonorepo(root)) return new Set();
357
+
358
+ const declPaths: Array<{ rel: string; constName: string }> = [
359
+ { rel: "src/i18n-guard-locales.ts", constName: "I18N_GUARD_LOCALES" },
360
+ { rel: "src/marketing/locale-routes.ts", constName: "LOCALES" },
361
+ ];
362
+ for (const { rel, constName } of declPaths) {
363
+ const abs = path.join(root, rel);
364
+ if (!existsSync(abs)) continue;
365
+ const locales = parseLocaleConstArray(readFileSync(abs, "utf-8"), constName);
366
+ if (locales) return new Set(locales);
367
+ // parseLocaleConstArray throws on unreadable decls; null = no export.
368
+ }
369
+ return new Set(["de", "en"]);
370
+ }
371
+
372
+ export type I18nKeysRunOptions = {
373
+ /** Inject expected locales per owning root — tests must not rely on process.cwd(). */
374
+ readonly expectedLocalesForRoot?: (root: string) => Set<string>;
375
+ };
376
+
377
+ export function checkI18nKeys(
378
+ files: readonly SourceFile[],
379
+ options: I18nKeysRunOptions = {},
380
+ ): GuardOutcome {
381
+ const usedKeys: UsedKey[] = [];
382
+ const definedKeys: DefinedKey[] = [];
383
+
384
+ for (const sf of files) {
385
+ const filePath = sf.getFilePath();
386
+ if (EXCLUDE.test(filePath)) continue;
387
+ usedKeys.push(...collectUsedKeys(sf));
388
+ definedKeys.push(...collectInlineTranslationsDefinedKeys(sf));
389
+ definedKeys.push(...collectBundleDefinedKeys(sf));
390
+ }
391
+
392
+ const definedSet = new Set(definedKeys.map((d) => d.fullKey));
393
+ const missing = usedKeys.filter((u) => !definedSet.has(u.key));
394
+ const resolveLocales = options.expectedLocalesForRoot ?? resolveExpectedLocales;
395
+ const expectedByRoot = new Map<string, Set<string>>();
396
+ const expectedForFile = (rel: string): Set<string> => {
397
+ const root = repoRootForRelFile(rel);
398
+ let locales = expectedByRoot.get(root);
399
+ if (!locales) {
400
+ locales = resolveLocales(root);
401
+ expectedByRoot.set(root, locales);
402
+ }
403
+ return locales;
404
+ };
405
+
406
+ const localeViolations = definedKeys.flatMap((d) => {
407
+ const expectedLocales = expectedForFile(d.file);
408
+ const missingLocales = [...expectedLocales].filter((l) => !d.locales.has(l));
409
+ if (missingLocales.length === 0) return [];
410
+ return [
411
+ {
412
+ file: d.file,
413
+ line: d.line,
414
+ message: `Key "${d.fullKey}" is missing locale: ${missingLocales.join(", ")}`,
415
+ },
416
+ ];
417
+ });
418
+
419
+ return {
420
+ violations: [
421
+ ...missing.map((m) => ({
422
+ file: m.file,
423
+ line: m.line,
424
+ message: `used key without a definition: "${m.key}"`,
425
+ })),
426
+ ...localeViolations,
427
+ ],
428
+ };
429
+ }
430
+
431
+ export const guard: AstGuard = {
432
+ name: "i18n-Keys Guard",
433
+ scan: SCAN,
434
+ hint: "Used i18n key without a definition, or missing locale — add the key/locale to the feature's translations map (locales from src/i18n-guard-locales.ts or src/marketing/locale-routes.ts).",
435
+ run(files) {
436
+ return checkI18nKeys(files);
437
+ },
438
+ };
439
+
440
+ if (import.meta.main) runStandalone(guard);