@cosmicdrift/kumiko-guards 0.1.0 → 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.
- package/package.json +4 -1
- package/src/_lib/git-env.ts +22 -0
- package/src/_lib/guard-kit.ts +74 -19
- package/src/_lib/qn.ts +21 -0
- package/src/_lib/roots.ts +1 -18
- package/src/_lib/security-baseline-cli.ts +3 -3
- package/src/_lib/security-baseline.ts +8 -8
- package/src/changes.json +40 -0
- package/src/check-as-casts.ts +648 -0
- package/src/check-complexity.ts +292 -0
- package/src/check-predicates.ts +218 -0
- package/src/check-secret-literals.ts +126 -0
- package/src/cli.ts +59 -0
- package/src/guard-admin-api.ts +1 -1
- package/src/guard-app-feature-structure.ts +114 -0
- package/src/guard-broker-subscribe.ts +99 -0
- package/src/guard-error-reasons.ts +185 -0
- package/src/guard-escape-hatch-declared.ts +26 -19
- package/src/guard-fake-tests.ts +1 -1
- package/src/guard-feature-integration-tests.ts +184 -0
- package/src/guard-html-escape.ts +1 -1
- package/src/guard-i18n-keys.ts +440 -0
- package/src/guard-i18n-locale-mount.ts +317 -0
- package/src/guard-i18n-locale-terminology.ts +117 -0
- package/src/guard-i18n-ui-strings.ts +248 -0
- package/src/guard-lib-test-coverage.ts +156 -0
- package/src/guard-loadall-events.ts +133 -0
- package/src/guard-no-custom-primitives.ts +9 -10
- package/src/guard-no-date-api.ts +1 -1
- package/src/guard-no-direct-fs.ts +1 -1
- package/src/guard-no-inline-styles.ts +4 -4
- package/src/guard-no-logic-in-views.ts +3 -3
- package/src/guard-no-raw-hooks.ts +4 -5
- package/src/guard-open-to-all-reason.ts +1 -1
- package/src/guard-pii-annotations.ts +267 -0
- package/src/guard-pre-es-patterns.ts +1 -1
- package/src/guard-primitives-discipline.ts +3 -3
- package/src/guard-raw-classname.ts +3 -3
- package/src/guard-raw-interactive-elements.ts +3 -3
- package/src/guard-raw-sql.ts +2 -2
- package/src/guard-renderer-boundaries.ts +1 -1
- package/src/guard-restricted-symbols.ts +1 -1
- package/src/guard-screen-conventions.ts +161 -0
- package/src/guard-silent-skip.ts +1 -1
- package/src/guard-table-ddl.ts +159 -0
- package/src/guard-tailwind-scan-surface.ts +12 -12
- package/src/guard-test-stack-drift.ts +147 -0
- package/src/guard-text-field-stance.ts +222 -0
- package/src/guard-thin-wrappers.ts +6 -1
- package/src/guard-unsafe-json-parse.ts +1 -1
- package/src/guard-write-handler-qns.ts +242 -0
- package/src/run-guards.ts +36 -3
- package/src/run-repo-checks.ts +10 -1
- 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);
|