@cosmicdrift/kumiko-guards 0.1.1 → 0.3.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 +4 -1
  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 +185 -0
  16. package/src/guard-escape-hatch-declared.ts +26 -19
  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,317 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * Guard: App-Mount-Punkte muessen das deutsche Locale-Feature registrieren,
4
+ * wenn das Repo von @cosmicdrift/kumiko-locale-de abhaengt.
5
+ *
6
+ * guard-i18n-keys.ts prueft nur, ob ein verwendeter t()-Key definiert ist —
7
+ * und kennt kein Konzept von "Mount-Punkt". Ein Mount, der localeDeClient()
8
+ * (Client) bzw. localeDe() (Server) nie aufruft, faellt dort komplett durch
9
+ * (kumiko-studio#191, publicstatus#365, infra#533).
10
+ *
11
+ * Scope-Gate: das package.json am naechsten zur Datei muss auf
12
+ * kumiko-locale-de zeigen (aktuell kumiko-studio, publicstatus, solon,
13
+ * offlot-app) — ein Node-Resolution-Walk ueber ts-morphs FileSystemHost statt guards/_lib/
14
+ * roots.ts' Sibling-Repo-Liste, damit der Gate auch im In-Memory-Test der
15
+ * Guard-Suite (kein echter Sibling-Checkout) feuert.
16
+ *
17
+ * Usage:
18
+ * bun guards/guard-i18n-locale-mount.ts
19
+ */
20
+
21
+ import { dirname, join, relative as pathRelative } from "node:path";
22
+ import {
23
+ type FileSystemHost,
24
+ type Identifier,
25
+ type Node,
26
+ type SourceFile,
27
+ SyntaxKind,
28
+ } from "ts-morph";
29
+ import {
30
+ type AstGuard,
31
+ type GuardViolation,
32
+ isLocalFinding,
33
+ runStandalone,
34
+ type ScanSpec,
35
+ } from "./_lib/guard-kit";
36
+
37
+ const ROOT = process.cwd();
38
+ const LOCALE_DE_DEP = "@cosmicdrift/kumiko-locale-de";
39
+
40
+ const SCAN: ScanSpec = {
41
+ scope: "source",
42
+ extensions: ["ts", "tsx"],
43
+ kinds: ["library", "app"],
44
+ };
45
+ const EXCLUDE = /(__tests__|\.test\.tsx?$|\.integration\.tsx?$|\.d\.ts$)/;
46
+
47
+ const MOUNT_FACTORY_NAMES = new Set(["createKumikoApp", "createPublicSurface"]);
48
+
49
+ export function nearestPackageJson(fs: FileSystemHost, fromDir: string): string | undefined {
50
+ let dir = fromDir;
51
+ for (let i = 0; i < 40; i++) {
52
+ const candidate = join(dir, "package.json");
53
+ if (fs.fileExistsSync(candidate)) return candidate;
54
+ const parent = dirname(dir);
55
+ if (parent === dir) return undefined;
56
+ dir = parent;
57
+ }
58
+ return undefined;
59
+ }
60
+
61
+ export function hasLocaleDeDependency(fs: FileSystemHost, pkgPath: string): boolean {
62
+ let pkg: { dependencies?: Record<string, string>; devDependencies?: Record<string, string> };
63
+ try {
64
+ pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
65
+ } catch {
66
+ return false;
67
+ }
68
+ return LOCALE_DE_DEP in (pkg.dependencies ?? {}) || LOCALE_DE_DEP in (pkg.devDependencies ?? {});
69
+ }
70
+
71
+ type Gate = { readonly pkgPath: string; readonly gated: boolean };
72
+
73
+ function gateFor(sf: SourceFile, cache: Map<string, Gate>): Gate | undefined {
74
+ const fs = sf.getProject().getFileSystem();
75
+ const pkgPath = nearestPackageJson(fs, dirname(sf.getFilePath()));
76
+ if (pkgPath === undefined) return undefined;
77
+ const cached = cache.get(pkgPath);
78
+ if (cached !== undefined) return cached;
79
+ const gate: Gate = { pkgPath, gated: hasLocaleDeDependency(fs, pkgPath) };
80
+ cache.set(pkgPath, gate);
81
+ return gate;
82
+ }
83
+
84
+ // localeDeClient() (clientFeatures) matcht per Call-Text; `{ de: ... }` deckt
85
+ // die rohe <LocaleProvider fallbackBundles={[{ de: bundle }, ...]}>-Komposition
86
+ // ab, die keinen localeDeClient()-Call verwendet.
87
+ function localeDeClientRef(node: Node): boolean {
88
+ if (node.isKind(SyntaxKind.Identifier) && node.getText() === "localeDeClient") return true;
89
+ if (node.isKind(SyntaxKind.CallExpression)) return localeDeClientRef(node.getExpression());
90
+ if (node.isKind(SyntaxKind.PropertyAccessExpression))
91
+ return localeDeClientRef(node.getExpression());
92
+ return false;
93
+ }
94
+
95
+ // Go-to-definition resolves an import binding all the way to its real
96
+ // VariableDeclaration in one hop — except a ShorthandPropertyAssignment's name
97
+ // node, which resolves only to the ImportSpecifier and needs a second hop
98
+ // through the specifier's own name to get there.
99
+ function resolveDeclaration(identifier: Identifier, depth = 0): Node | undefined {
100
+ if (depth > 3) return undefined;
101
+ const def = identifier.getDefinitionNodes()[0];
102
+ if (def === undefined) return undefined;
103
+ if (def.isKind(SyntaxKind.VariableDeclaration)) return def;
104
+ if (def.isKind(SyntaxKind.ImportSpecifier)) {
105
+ const specName = def.getNameNode();
106
+ return specName.isKind(SyntaxKind.Identifier)
107
+ ? resolveDeclaration(specName, depth + 1)
108
+ : undefined;
109
+ }
110
+ return undefined;
111
+ }
112
+
113
+ // `as const` and `as const satisfies T[]` (both in active use, e.g.
114
+ // kumiko-studio's run-config.ts) wrap the array literal in an AsExpression
115
+ // and/or SatisfiesExpression — unwrap either so callers see the literal underneath.
116
+ function unwrapAsExpression(node: Node): Node {
117
+ if (node.isKind(SyntaxKind.AsExpression) || node.isKind(SyntaxKind.SatisfiesExpression)) {
118
+ return unwrapAsExpression(node.getExpression());
119
+ }
120
+ return node;
121
+ }
122
+
123
+ // Scoped to the mount's own repo: in the shared multi-repo ts-morph Project
124
+ // the aggregate runner builds, a resolved declaration is only trusted when it
125
+ // lives under the same package.json root as the spreading/referencing file —
126
+ // never a same-named declaration from a different repo. Shared by array
127
+ // (`[...CONST]`) and object (`{ ...CONST }`) spread resolution alike.
128
+ function resolveSameRepoInitializer(identifier: Identifier): Node | undefined {
129
+ const decl = resolveDeclaration(identifier);
130
+ if (decl === undefined || !decl.isKind(SyntaxKind.VariableDeclaration)) return undefined;
131
+ const fs = identifier.getSourceFile().getProject().getFileSystem();
132
+ const originPkg = nearestPackageJson(fs, dirname(identifier.getSourceFile().getFilePath()));
133
+ const targetPkg = nearestPackageJson(fs, dirname(decl.getSourceFile().getFilePath()));
134
+ if (originPkg === undefined || originPkg !== targetPkg) return undefined;
135
+ const init = decl.getInitializer();
136
+ return init === undefined ? undefined : unwrapAsExpression(init);
137
+ }
138
+
139
+ // Resolves an array-shaped value node down to its elements, whether it's a
140
+ // literal right there or an identifier pointing at a same-repo constant
141
+ // (`const CONST = [...]`, `... as const` included).
142
+ function arrayElementsOf(value: Node | undefined): Node[] | undefined {
143
+ if (value === undefined) return undefined;
144
+ if (value.isKind(SyntaxKind.ArrayLiteralExpression)) return value.getElements();
145
+ if (value.isKind(SyntaxKind.Identifier)) {
146
+ return arrayElementsOf(resolveSameRepoInitializer(value));
147
+ }
148
+ return undefined;
149
+ }
150
+
151
+ function elementRegistersGerman(el: Node): boolean {
152
+ if (el.isKind(SyntaxKind.SpreadElement)) {
153
+ const expr = el.getExpression();
154
+ if (expr.isKind(SyntaxKind.Identifier)) {
155
+ const init = resolveSameRepoInitializer(expr);
156
+ if (init !== undefined) return elementRegistersGerman(init);
157
+ // Unresolvable or cross-repo identifier spread — fail-open rather than risk a false alarm.
158
+ // Safe here: clientFeatures is already known to exist as an array, this only concerns
159
+ // one of its elements — other elements are still checked.
160
+ return true;
161
+ }
162
+ return elementRegistersGerman(expr);
163
+ }
164
+ if (el.isKind(SyntaxKind.ArrayLiteralExpression)) {
165
+ return el.getElements().some(elementRegistersGerman);
166
+ }
167
+ // localeDeClient().translations (kumiko-studio auth-mount shape)
168
+ if (el.isKind(SyntaxKind.PropertyAccessExpression)) {
169
+ return localeDeClientRef(el) || elementRegistersGerman(el.getExpression());
170
+ }
171
+ if (el.isKind(SyntaxKind.CallExpression)) {
172
+ return localeDeClientRef(el.getExpression());
173
+ }
174
+ if (el.isKind(SyntaxKind.ObjectLiteralExpression)) {
175
+ return el
176
+ .getProperties()
177
+ .some((p) => p.isKind(SyntaxKind.PropertyAssignment) && p.getNameNode().getText() === "de");
178
+ }
179
+ return false;
180
+ }
181
+
182
+ function jsxTagNameOf(attr: Node): string | undefined {
183
+ const el = attr.getParent()?.getParent();
184
+ if (el?.isKind(SyntaxKind.JsxOpeningElement)) return el.getTagNameNode().getText();
185
+ if (el?.isKind(SyntaxKind.JsxSelfClosingElement)) return el.getTagNameNode().getText();
186
+ return undefined;
187
+ }
188
+
189
+ // `clientFeatures: [...]`, `clientFeatures,` (shorthand) and `clientFeatures: clientFeatures`
190
+ // all reference the same array — resolve the value node down to its elements either way.
191
+ function clientFeaturesElements(prop: Node | undefined): Node[] | undefined {
192
+ const value = prop?.isKind(SyntaxKind.PropertyAssignment)
193
+ ? prop.getInitializer()
194
+ : prop?.isKind(SyntaxKind.ShorthandPropertyAssignment)
195
+ ? prop.getNameNode()
196
+ : undefined;
197
+ return arrayElementsOf(value);
198
+ }
199
+
200
+ const MAX_OPTIONS_SPREAD_DEPTH = 5;
201
+
202
+ // Determines whether the mount call's options object — a literal right there,
203
+ // or an identifier pointing at a same-repo constant (offlot's `APP_OPTIONS`
204
+ // shape) — carries a `clientFeatures` property that registers German. Follows
205
+ // `{ ...OTHER_OPTIONS, clientFeatures: [...] }` object spreads the same way
206
+ // arrayElementsOf follows array spreads, so `clientFeatures` no longer has to
207
+ // stay a literal at the call site to satisfy this guard (infra#734).
208
+ //
209
+ // Fail-closed (not fail-open) on an unresolvable/cross-repo spread source:
210
+ // unlike a single array element, a whole unresolved options object could be
211
+ // the only place `clientFeatures` lives — silently passing it would make the
212
+ // guard blind again, exactly the risk infra#734 called out.
213
+ function objectRegistersGermanClientFeatures(value: Node | undefined, depth = 0): boolean {
214
+ if (value === undefined || depth > MAX_OPTIONS_SPREAD_DEPTH) return false;
215
+ if (value.isKind(SyntaxKind.Identifier)) {
216
+ return objectRegistersGermanClientFeatures(resolveSameRepoInitializer(value), depth + 1);
217
+ }
218
+ if (!value.isKind(SyntaxKind.ObjectLiteralExpression)) return false;
219
+ const direct = value
220
+ .getProperties()
221
+ .find(
222
+ (p) =>
223
+ (p.isKind(SyntaxKind.PropertyAssignment) ||
224
+ p.isKind(SyntaxKind.ShorthandPropertyAssignment)) &&
225
+ p.getNameNode().getText() === "clientFeatures",
226
+ );
227
+ if (direct !== undefined)
228
+ return clientFeaturesElements(direct)?.some(elementRegistersGerman) ?? false;
229
+ return value
230
+ .getProperties()
231
+ .some(
232
+ (p) =>
233
+ p.isKind(SyntaxKind.SpreadAssignment) &&
234
+ objectRegistersGermanClientFeatures(p.getExpression(), depth + 1),
235
+ );
236
+ }
237
+
238
+ function clientMountViolations(sf: SourceFile): GuardViolation[] {
239
+ const violations: GuardViolation[] = [];
240
+
241
+ for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
242
+ if (!MOUNT_FACTORY_NAMES.has(call.getExpression().getText())) continue;
243
+ const arg = call.getArguments()[0];
244
+ const registered = objectRegistersGermanClientFeatures(arg);
245
+ if (!registered) {
246
+ violations.push({
247
+ file: sf.getFilePath(),
248
+ line: call.getStartLineNumber(),
249
+ message: `Mount point "${call.getExpression().getText()}(...)" does not register a German locale feature (localeDeClient() missing in clientFeatures) — repo depends on ${LOCALE_DE_DEP}.`,
250
+ });
251
+ }
252
+ }
253
+
254
+ for (const attr of sf.getDescendantsOfKind(SyntaxKind.JsxAttribute)) {
255
+ if (attr.getNameNode().getText() !== "fallbackBundles") continue;
256
+ if (jsxTagNameOf(attr) !== "LocaleProvider") continue;
257
+ const init = attr.getInitializer();
258
+ const expr = init?.isKind(SyntaxKind.JsxExpression) ? init.getExpression() : undefined;
259
+ const elements = arrayElementsOf(expr);
260
+ const registered = elements?.some(elementRegistersGerman) ?? false;
261
+ if (!registered) {
262
+ violations.push({
263
+ file: sf.getFilePath(),
264
+ line: attr.getStartLineNumber(),
265
+ message: `<LocaleProvider fallbackBundles={...}> does not register a German locale bundle ({ de: ... }) — repo depends on ${LOCALE_DE_DEP}.`,
266
+ });
267
+ }
268
+ }
269
+
270
+ return violations;
271
+ }
272
+
273
+ function hasServerLocaleCall(sf: SourceFile): boolean {
274
+ return sf
275
+ .getDescendantsOfKind(SyntaxKind.CallExpression)
276
+ .some((c) => c.getExpression().getText() === "localeDe");
277
+ }
278
+
279
+ export function findViolations(files: readonly SourceFile[]): GuardViolation[] {
280
+ const relevant = files.filter((sf) => !EXCLUDE.test(sf.getFilePath()));
281
+ const cache = new Map<string, Gate>();
282
+ const serverCallSeen = new Set<string>();
283
+
284
+ const violations: GuardViolation[] = [];
285
+ for (const sf of relevant) {
286
+ const gate = gateFor(sf, cache);
287
+ if (gate === undefined || !gate.gated) continue;
288
+ violations.push(...clientMountViolations(sf));
289
+ if (hasServerLocaleCall(sf)) serverCallSeen.add(gate.pkgPath);
290
+ }
291
+
292
+ const gatedPkgPaths = new Set([...cache.values()].filter((g) => g.gated).map((g) => g.pkgPath));
293
+ for (const pkgPath of gatedPkgPaths) {
294
+ if (serverCallSeen.has(pkgPath)) continue;
295
+ violations.push({
296
+ file: pkgPath,
297
+ line: 1,
298
+ message: `Repo depends on ${LOCALE_DE_DEP}, but no server-side localeDe() call was found — German mail templates (registerMailTranslations) are not registered.`,
299
+ });
300
+ }
301
+
302
+ return violations;
303
+ }
304
+
305
+ export const guard: AstGuard = {
306
+ name: "i18n-Locale-Mount Guard",
307
+ scan: SCAN,
308
+ hint: "Mount point (createKumikoApp/createPublicSurface/LocaleProvider) without a German locale feature — add localeDeClient() to clientFeatures, or localeDe() to the server feature list.",
309
+ run(files) {
310
+ const violations = findViolations(files)
311
+ .map((v) => ({ ...v, file: pathRelative(ROOT, v.file) }))
312
+ .filter(isLocalFinding);
313
+ return { violations };
314
+ },
315
+ };
316
+
317
+ if (import.meta.main) runStandalone(guard);
@@ -0,0 +1,117 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * Guard: locale bundle values must follow Mandant/Organización terminology (#2200, fw#2355).
4
+ *
5
+ * German UI copy always says "Mandant" (never "Tenant"/"Organisation"); Spanish always
6
+ * says "Organización" (never the "tenant" loanword). Only translation *values* are
7
+ * checked — JSON key names like `tenant.members.*` are ignored. Role identifiers
8
+ * such as TenantAdmin/SystemAdmin in prose are allowed.
9
+ *
10
+ * Usage:
11
+ * bun guards/guard-i18n-locale-terminology.ts
12
+ *
13
+ * Ignore: // kumiko-lint-ignore i18n-locale-terminology
14
+ */
15
+
16
+ import { relative as pathRelative } from "node:path";
17
+ import { type Node, type SourceFile, SyntaxKind } from "ts-morph";
18
+ import {
19
+ type AstGuard,
20
+ type GuardViolation,
21
+ isLocalFinding,
22
+ runStandalone,
23
+ type ScanSpec,
24
+ } from "./_lib/guard-kit";
25
+ import { hasIgnoreTag } from "./_lib/ignore-tag";
26
+
27
+ const ROOT = process.cwd();
28
+
29
+ const SCAN: ScanSpec = {
30
+ scope: "source",
31
+ extensions: ["ts"],
32
+ kinds: ["framework"],
33
+ frameworkWithin: ["packages/locale-de/src/strings.ts", "packages/locale-es/src/strings.ts"],
34
+ };
35
+ const IGNORE_TAG = "kumiko-lint-ignore i18n-locale-terminology";
36
+
37
+ type LocaleRule = {
38
+ readonly preferred: string;
39
+ readonly forbidden: readonly RegExp[];
40
+ };
41
+
42
+ const RULES: Record<"de" | "es", LocaleRule> = {
43
+ de: {
44
+ preferred: "Mandant",
45
+ // Tenants? covers plural; Organisation(en|s)? without trailing \b so
46
+ // compounds like Organisations-ID / Organisationsstruktur still match
47
+ // (infra#603). TenantAdmin stays safe: \b after Tenant fails on 'A'.
48
+ forbidden: [/\bTenants?\b/i, /\bOrganisation(en|s)?/],
49
+ },
50
+ es: {
51
+ preferred: "Organización",
52
+ forbidden: [/\btenants?\b/i],
53
+ },
54
+ };
55
+
56
+ function localeFromPath(filePath: string): "de" | "es" | undefined {
57
+ if (filePath.includes("locale-de/src/strings.ts")) return "de";
58
+ if (filePath.includes("locale-es/src/strings.ts")) return "es";
59
+ return undefined;
60
+ }
61
+
62
+ function stringLiteralText(node: Node): string | undefined {
63
+ if (node.isKind(SyntaxKind.StringLiteral)) return node.getLiteralText();
64
+ if (node.isKind(SyntaxKind.NoSubstitutionTemplateLiteral)) return node.getLiteralValue();
65
+ return undefined;
66
+ }
67
+
68
+ function forbiddenTerm(text: string, rule: LocaleRule): string | undefined {
69
+ for (const re of rule.forbidden) {
70
+ const match = text.match(re);
71
+ if (match !== null) return match[0];
72
+ }
73
+ return undefined;
74
+ }
75
+
76
+ export function findViolations(files: readonly SourceFile[]): GuardViolation[] {
77
+ const violations: GuardViolation[] = [];
78
+
79
+ for (const sf of files) {
80
+ const locale = localeFromPath(sf.getFilePath());
81
+ if (locale === undefined) continue;
82
+ const rule = RULES[locale];
83
+
84
+ for (const prop of sf.getDescendantsOfKind(SyntaxKind.PropertyAssignment)) {
85
+ if (hasIgnoreTag(prop, IGNORE_TAG)) continue;
86
+ const init = prop.getInitializer();
87
+ if (init === undefined) continue;
88
+ const text = stringLiteralText(init);
89
+ if (text === undefined) continue;
90
+
91
+ const hit = forbiddenTerm(text, rule);
92
+ if (hit !== undefined) {
93
+ violations.push({
94
+ file: sf.getFilePath(),
95
+ line: prop.getStartLineNumber(),
96
+ message: `[${locale}] verbotener Begriff "${hit}" in Übersetzungswert — nutze "${rule.preferred}" (fw#2200).`,
97
+ });
98
+ }
99
+ }
100
+ }
101
+
102
+ return violations;
103
+ }
104
+
105
+ export const guard: AstGuard = {
106
+ name: "i18n-Locale-Terminology Guard",
107
+ scan: SCAN,
108
+ hint: "DE: Mandant statt Tenant/Organisation; ES: Organización statt tenant-Loanword. Rollen wie TenantAdmin sind OK.",
109
+ run(files) {
110
+ const violations = findViolations(files)
111
+ .map((v) => ({ ...v, file: pathRelative(ROOT, v.file) }))
112
+ .filter(isLocalFinding);
113
+ return { violations };
114
+ },
115
+ };
116
+
117
+ if (import.meta.main) runStandalone(guard);
@@ -0,0 +1,248 @@
1
+ #!/usr/bin/env bun
2
+ // Kein hardcodeter UI-Text in App-Web-Code: JSX-Textknoten und Label-artige
3
+ // String-Props müssen über t("…")-Keys laufen (guard-i18n-keys prüft dann,
4
+ // dass die Keys definiert sind — dieser Guard schließt die Lücke davor:
5
+ // Strings, die nie zu Keys wurden, z.B. "Lade Tenants…").
6
+ //
7
+ // Teil von App-Mounting 2.0 (infra#208).
8
+
9
+ import { type CallExpression, type Node, type SourceFile, SyntaxKind } from "ts-morph";
10
+ import { type AstGuard, type GuardViolation, runStandalone, type ScanSpec } from "./_lib/guard-kit";
11
+ import { hasIgnoreTag } from "./_lib/ignore-tag";
12
+
13
+ // Nur Web-/Public-Code — Server-Code (Handler-Fehlertexte) läuft über die
14
+ // Error-i18n-Pipeline und hat eigene Guards.
15
+ //
16
+ // samples/ deliberately NOT scanned (tried in infra#478, rolled back): the
17
+ // showcase/gallery pages are dev docs about the framework API itself
18
+ // ("Form.title / toolbarTitle", "children — auto from schema.navs"), not
19
+ // app UI text a real user sees. 354 hits in the trial run, almost all such
20
+ // API annotations. guard-i18n-keys covers samples/ (t() calls +
21
+ // r.translations definitions); this guard stays scoped to app repos.
22
+ //
23
+ // src/features/**/feature.ts covers feature.ts registrar calls (r.nav/r.screen
24
+ // object-literal arguments) — infra#504: those sit outside JSX and were a
25
+ // blind spot. Other server code (handlers/, lib/) has its own i18n pipeline
26
+ // and is filtered out below by requiring an r.*-call ancestor.
27
+ const SCAN: ScanSpec = {
28
+ scope: "source",
29
+ extensions: ["ts", "tsx"],
30
+ within: [
31
+ "**/web/**/*.tsx",
32
+ "public/**/*.tsx",
33
+ "features/**/feature.ts",
34
+ "features/**/register/**/*.ts",
35
+ ],
36
+ frameworkWithin: ["packages/bundled-features/src/**"],
37
+ };
38
+ const EXCLUDE = /(__tests__|\.test\.tsx?$|\.integration\.tsx?$|\.d\.ts$)/;
39
+ const IGNORE_TAG = "kumiko-lint-ignore i18n-ui-strings";
40
+
41
+ // Mindestens zwei Buchstaben in Folge = menschenlesbarer Text (lässt "—",
42
+ // Zahlen, Interpunktion und Einzel-Zeichen durch).
43
+ const HUMAN_TEXT = /\p{L}{2,}/u;
44
+
45
+ // i18n-Key-Shape (e.g. "tenant.nav.members", "money-horse:nav.scenarioCompare"):
46
+ // alphanumeric/dash segments (camelCase allowed) joined by "." or ":", at
47
+ // least one separator — real keys have no spaces and no bare single word.
48
+ // r.nav/r.screen label props are keys, not raw text, unlike JSX props.
49
+ const I18N_KEY_SHAPE =
50
+ /^[a-zA-Z0-9_]+(?:-[a-zA-Z0-9_]+)*(?:[:.][a-zA-Z0-9_]+(?:-[a-zA-Z0-9_]+)*)+$/;
51
+
52
+ const LABEL_PROPS = new Set([
53
+ "label",
54
+ "title",
55
+ "subtitle",
56
+ "placeholder",
57
+ "description",
58
+ "confirm",
59
+ "confirmLabel",
60
+ "emptyLabel",
61
+ "ariaLabel",
62
+ "aria-label",
63
+ "startLabel",
64
+ "endLabel",
65
+ ]);
66
+
67
+ // projectionDetail screens: `header: { title, subtitle, status }` names
68
+ // columns from the query row (RecordHeaderSpec, packages/types/src/screen.ts),
69
+ // not UI text — analogous to `metrics: [...]`, which stays out of LABEL_PROPS
70
+ // for the same reason. Only exempt when directly nested under a `header:`
71
+ // property so a section/column `title` elsewhere stays flagged.
72
+ const HEADER_SPEC_PROPS = new Set(["title", "subtitle", "status"]);
73
+
74
+ const REGISTRAR_CALL = /^r\.[a-zA-Z]+$/;
75
+
76
+ const LOGICAL_OPERATORS = new Set([SyntaxKind.AmpersandAmpersandToken, SyntaxKind.BarBarToken]);
77
+
78
+ function isTernaryOrLogical(node: Node): boolean {
79
+ if (node.isKind(SyntaxKind.ConditionalExpression)) return true;
80
+ return (
81
+ node.isKind(SyntaxKind.BinaryExpression) &&
82
+ LOGICAL_OPERATORS.has(node.getOperatorToken().getKind())
83
+ );
84
+ }
85
+
86
+ type TextPiece = { readonly text: string; readonly node: Node };
87
+
88
+ // Leaf strings a ternary/logical branch can bottom out on: a plain string, or
89
+ // a template literal's static spans (interpolated `${…}` values are skipped —
90
+ // only the literal text around them is human-authored copy, e.g. "Apply " /
91
+ // " change(s)" in `Apply ${n} change(s)`).
92
+ function textPieces(node: Node): TextPiece[] {
93
+ if (
94
+ node.isKind(SyntaxKind.StringLiteral) ||
95
+ node.isKind(SyntaxKind.NoSubstitutionTemplateLiteral)
96
+ ) {
97
+ return [{ text: node.getLiteralText(), node }];
98
+ }
99
+ if (node.isKind(SyntaxKind.TemplateExpression)) {
100
+ const pieces: TextPiece[] = [{ text: node.getHead().getLiteralText(), node }];
101
+ for (const span of node.getTemplateSpans()) {
102
+ pieces.push({
103
+ text: span.getLiteral().getLiteralText(),
104
+ node: span.getLiteral(),
105
+ });
106
+ }
107
+ return pieces;
108
+ }
109
+ return [];
110
+ }
111
+
112
+ // Only descends through ternary/logical branches — a literal reached through
113
+ // any other expression (function call, member access, …) is out of scope, so
114
+ // e.g. `formatLabel("Save")` inside a ternary branch is not flattened into it.
115
+ function collectTernaryLogicText(node: Node): TextPiece[] {
116
+ if (node.isKind(SyntaxKind.ConditionalExpression)) {
117
+ return [
118
+ ...collectTernaryLogicText(node.getWhenTrue()),
119
+ ...collectTernaryLogicText(node.getWhenFalse()),
120
+ ];
121
+ }
122
+ if (
123
+ node.isKind(SyntaxKind.BinaryExpression) &&
124
+ LOGICAL_OPERATORS.has(node.getOperatorToken().getKind())
125
+ ) {
126
+ return [
127
+ ...collectTernaryLogicText(node.getLeft()),
128
+ ...collectTernaryLogicText(node.getRight()),
129
+ ];
130
+ }
131
+ return textPieces(node);
132
+ }
133
+
134
+ // infra#711: a hop out of a helper is only taken while that helper call is
135
+ // itself an argument of the next call — a call nested deeper (inside an object
136
+ // or array, e.g. `createTextField({ label })` under `createEntity({ fields })`)
137
+ // builds a value instead of forwarding the registrar's argument. The full chain
138
+ // comes back so the line-anchored ignore tag also works above the helper.
139
+ function registrarCallChain(prop: Node): CallExpression[] | undefined {
140
+ const chain: CallExpression[] = [];
141
+ let call = prop.getFirstAncestorByKind(SyntaxKind.CallExpression);
142
+ while (call !== undefined) {
143
+ chain.push(call);
144
+ if (REGISTRAR_CALL.test(call.getExpression().getText())) return chain;
145
+ call = call.getParentIfKind(SyntaxKind.CallExpression);
146
+ }
147
+ return undefined;
148
+ }
149
+
150
+ export const guard: AstGuard = {
151
+ name: "i18n-UI-Strings Guard (App-Repos)",
152
+ scan: SCAN,
153
+ hint:
154
+ 'UI text belongs in i18n bundles + t("feature:key") — not as a literal in JSX. ' +
155
+ `Justified exception (e.g. proper noun/brand): // ${IGNORE_TAG} <reason>`,
156
+ run(files: readonly SourceFile[]) {
157
+ const violations: GuardViolation[] = [];
158
+ for (const sf of files) {
159
+ if (EXCLUDE.test(sf.getFilePath())) continue;
160
+ for (const textNode of sf.getDescendantsOfKind(SyntaxKind.JsxText)) {
161
+ const text = textNode.getText().trim();
162
+ if (!HUMAN_TEXT.test(text)) continue;
163
+ if (hasIgnoreTag(textNode, IGNORE_TAG)) continue;
164
+ violations.push({
165
+ file: sf.getFilePath(),
166
+ line: textNode.getStartLineNumber(),
167
+ message: `hardcoded JSX text: "${text.slice(0, 40)}${text.length > 40 ? "…" : ""}"`,
168
+ });
169
+ }
170
+ for (const attr of sf.getDescendantsOfKind(SyntaxKind.JsxAttribute)) {
171
+ if (!LABEL_PROPS.has(attr.getNameNode().getText())) continue;
172
+ const init = attr.getInitializer();
173
+ if (init === undefined || init.getKind() !== SyntaxKind.StringLiteral) continue;
174
+ const value = init.asKindOrThrow(SyntaxKind.StringLiteral).getLiteralText();
175
+ if (!HUMAN_TEXT.test(value)) continue;
176
+ if (hasIgnoreTag(attr, IGNORE_TAG)) continue;
177
+ violations.push({
178
+ file: sf.getFilePath(),
179
+ line: attr.getStartLineNumber(),
180
+ message: `hardcoded label prop ${attr.getNameNode().getText()}="${value.slice(0, 40)}"`,
181
+ });
182
+ }
183
+ // Ternary/logical JSX expressions: `{saving ? "Saving…" : "Save"}` as a
184
+ // child, or `title={busy ? "…" : "Send"}` on a label prop — a string
185
+ // literal reachable only via getDescendantsOfKind(JsxText/JsxAttribute)
186
+ // above never fires here since the literal sits one level deeper,
187
+ // inside the {…} expression.
188
+ for (const jsxExpr of sf.getDescendantsOfKind(SyntaxKind.JsxExpression)) {
189
+ const inner = jsxExpr.getExpression();
190
+ if (inner === undefined || !isTernaryOrLogical(inner)) continue;
191
+ const attr = jsxExpr.getParentIfKind(SyntaxKind.JsxAttribute);
192
+ if (attr !== undefined && !LABEL_PROPS.has(attr.getNameNode().getText())) continue;
193
+ if (hasIgnoreTag(jsxExpr, IGNORE_TAG)) continue;
194
+ for (const piece of collectTernaryLogicText(inner)) {
195
+ if (!HUMAN_TEXT.test(piece.text)) continue;
196
+ const shown = `${piece.text.slice(0, 40)}${piece.text.length > 40 ? "…" : ""}`;
197
+ violations.push({
198
+ file: sf.getFilePath(),
199
+ line: piece.node.getStartLineNumber(),
200
+ message:
201
+ attr !== undefined
202
+ ? `hardcoded label prop ${attr.getNameNode().getText()} in ternary/logical expression: "${shown}"`
203
+ : `hardcoded JSX text in ternary/logical expression: "${shown}"`,
204
+ });
205
+ }
206
+ }
207
+ // r.nav({ label: "..." }) etc.: registrar param is conventionally
208
+ // named "r" across framework/bundled-features/app repos (verified,
209
+ // no exceptions found) — see the samples/ scan-boundary note above.
210
+ for (const prop of sf.getDescendantsOfKind(SyntaxKind.PropertyAssignment)) {
211
+ if (!LABEL_PROPS.has(prop.getName())) continue;
212
+ if (HEADER_SPEC_PROPS.has(prop.getName())) {
213
+ const enclosingObject = prop.getParentIfKind(SyntaxKind.ObjectLiteralExpression);
214
+ const enclosingProp = enclosingObject?.getParentIfKind(SyntaxKind.PropertyAssignment);
215
+ if (enclosingProp?.getName() === "header") continue;
216
+ }
217
+ const init = prop.getInitializer();
218
+ if (init === undefined || init.getKind() !== SyntaxKind.StringLiteral) continue;
219
+ const value = init.asKindOrThrow(SyntaxKind.StringLiteral).getLiteralText();
220
+ if (!HUMAN_TEXT.test(value) || I18N_KEY_SHAPE.test(value)) continue;
221
+ const callChain = registrarCallChain(prop);
222
+ if (callChain === undefined) continue;
223
+ // The agent-doc slot (kumiko-framework#2615) is LLM metadata for the
224
+ // AI tool catalog: English by design, never rendered as UI text. The
225
+ // slot is the options object handed to a call on the registrar
226
+ // argument chain, so forwarding it through a `defineEntity*Handler`
227
+ // helper is the same slot and stays exempt.
228
+ if (
229
+ prop.getName() === "description" &&
230
+ prop
231
+ .getParentIfKind(SyntaxKind.ObjectLiteralExpression)
232
+ ?.getParentIfKind(SyntaxKind.CallExpression) !== undefined
233
+ )
234
+ continue;
235
+ if (callChain.some((c) => hasIgnoreTag(c, IGNORE_TAG)) || hasIgnoreTag(prop, IGNORE_TAG))
236
+ continue;
237
+ violations.push({
238
+ file: sf.getFilePath(),
239
+ line: prop.getStartLineNumber(),
240
+ message: `hardcoded label property ${prop.getName()}="${value.slice(0, 40)}" in r.* call — use an i18n key instead of plain text`,
241
+ });
242
+ }
243
+ }
244
+ return { violations };
245
+ },
246
+ };
247
+
248
+ if (import.meta.main) runStandalone(guard);