@cosmicdrift/kumiko-guards 0.356.0 → 0.358.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 (39) hide show
  1. package/package.json +2 -2
  2. package/src/_lib/bun-lock-packages.ts +5 -5
  3. package/src/_lib/guard-kit.ts +180 -131
  4. package/src/_lib/qn.ts +9 -9
  5. package/src/_lib/roots.ts +4 -3
  6. package/src/_lib/security-baseline.ts +193 -116
  7. package/src/_lib/sql-inventory.ts +34 -32
  8. package/src/changes.json +6 -0
  9. package/src/check-as-casts.ts +196 -180
  10. package/src/check-complexity.ts +68 -69
  11. package/src/check-predicates.ts +93 -67
  12. package/src/check-secret-literals.ts +49 -38
  13. package/src/check-single-runtime-instance.ts +1 -1
  14. package/src/cli.ts +30 -17
  15. package/src/guard-access-denied-test.ts +49 -23
  16. package/src/guard-app-feature-structure.ts +92 -73
  17. package/src/guard-broker-subscribe.ts +17 -17
  18. package/src/guard-comment-lang.ts +126 -54
  19. package/src/guard-direct-entity-writes.ts +58 -45
  20. package/src/guard-escape-hatch-declared.ts +26 -23
  21. package/src/guard-html-escape.ts +112 -86
  22. package/src/guard-i18n-keys.ts +39 -36
  23. package/src/guard-i18n-locale-mount.ts +47 -40
  24. package/src/guard-i18n-ui-strings.ts +108 -92
  25. package/src/guard-lib-test-coverage.ts +37 -37
  26. package/src/guard-loadall-events.ts +17 -17
  27. package/src/guard-no-framed-extension-sections.ts +4 -4
  28. package/src/guard-no-temporal-polyfill-import.ts +19 -1
  29. package/src/guard-pii-annotations.ts +16 -16
  30. package/src/guard-primitives-discipline.ts +61 -65
  31. package/src/guard-screen-conventions.ts +2 -0
  32. package/src/guard-table-ddl.ts +56 -62
  33. package/src/guard-tenant-escalation.ts +42 -34
  34. package/src/guard-test-stack-drift.ts +6 -6
  35. package/src/guard-test-timeouts.ts +5 -4
  36. package/src/guard-text-field-stance.ts +22 -22
  37. package/src/guard-thin-wrappers.ts +42 -45
  38. package/src/guard-write-handler-qns.ts +29 -29
  39. package/src/runtime-isolation-classify.ts +4 -3
@@ -93,7 +93,7 @@ type DirectWriteException = {
93
93
  readonly file: RegExp;
94
94
  readonly functionName: string;
95
95
  readonly table: string;
96
- readonly reason: string;
96
+ readonly justification: string;
97
97
  };
98
98
 
99
99
  const DIRECT_WRITE_EXCEPTIONS: readonly DirectWriteException[] = [
@@ -101,7 +101,7 @@ const DIRECT_WRITE_EXCEPTIONS: readonly DirectWriteException[] = [
101
101
  file: /(^|\/)packages\/bundled-features\/src\/template-resolver\/seeding\.ts$/,
102
102
  functionName: "resolveExistingForEventStoreSeed",
103
103
  table: "templateResourcesTable",
104
- reason: "Orphan projection row without events, no event-store verb possible",
104
+ justification: "Orphan projection row without events, no event-store verb possible",
105
105
  },
106
106
  ];
107
107
 
@@ -183,6 +183,53 @@ export function collectEsTables(files: readonly SourceFile[]): Set<TableId> {
183
183
  return tables;
184
184
  }
185
185
 
186
+ function unwrappedIdentifierDeclId(node: Node | undefined): TableId | undefined {
187
+ const identifier = node && unwrapTableExpression(node).asKind(SyntaxKind.Identifier);
188
+ return identifier ? declIdOf(identifier) : undefined;
189
+ }
190
+
191
+ // The `{ table }` override of `r.entity(name, def, { table })`.
192
+ function entityTableOverrideId(opts: Node | undefined): TableId | undefined {
193
+ if (opts?.getKind() !== SyntaxKind.ObjectLiteralExpression) return undefined;
194
+ const tableProp = opts.asKindOrThrow(SyntaxKind.ObjectLiteralExpression).getProperty("table");
195
+ if (tableProp?.getKind() !== SyntaxKind.PropertyAssignment) return undefined;
196
+ return unwrappedIdentifierDeclId(
197
+ tableProp.asKindOrThrow(SyntaxKind.PropertyAssignment).getInitializer(),
198
+ );
199
+ }
200
+
201
+ type EntityRegistration = {
202
+ readonly entityDid: TableId | undefined;
203
+ readonly overrideTableDid: TableId | undefined;
204
+ };
205
+
206
+ function entityRegistrationOf(call: CallExpression): EntityRegistration | undefined {
207
+ const expr = call.getExpression();
208
+ if (expr.getKind() !== SyntaxKind.PropertyAccessExpression) return undefined;
209
+ if (expr.asKindOrThrow(SyntaxKind.PropertyAccessExpression).getName() !== "entity") {
210
+ return undefined;
211
+ }
212
+ const args = call.getArguments();
213
+ if (!args[0] || args[0].getKind() !== SyntaxKind.StringLiteral) return undefined;
214
+ return {
215
+ entityDid: unwrappedIdentifierDeclId(args[1]),
216
+ overrideTableDid: entityTableOverrideId(args[2]),
217
+ };
218
+ }
219
+
220
+ // `const xTable = buildEntityTable(<name>, <entityDef>)` with a rebuildable entityDef.
221
+ function rebuildableEntityTableDeclId(
222
+ call: CallExpression,
223
+ rebuildableEntities: ReadonlySet<TableId>,
224
+ ): TableId | undefined {
225
+ if (call.getExpression().getText() !== "buildEntityTable") return undefined;
226
+ const entityDid = unwrappedIdentifierDeclId(call.getArguments()[1]);
227
+ if (!entityDid || !rebuildableEntities.has(entityDid)) return undefined;
228
+ const nameNode = call.getFirstAncestorByKind(SyntaxKind.VariableDeclaration)?.getNameNode();
229
+ if (nameNode?.getKind() !== SyntaxKind.Identifier) return undefined;
230
+ return declIdOf(nameNode.asKindOrThrow(SyntaxKind.Identifier));
231
+ }
232
+
186
233
  // Tables registered via `r.entity(name, entityDef)` as a rebuildable implicit
187
234
  // projection, linked entity→table via the shared entity-def symbol (see header), not by name string.
188
235
  export function collectEntityProjectionTables(files: readonly SourceFile[]): Set<TableId> {
@@ -192,33 +239,10 @@ export function collectEntityProjectionTables(files: readonly SourceFile[]): Set
192
239
  // Pass 1: entity-def decls that r.entity makes rebuildable (+ {table} override).
193
240
  for (const sf of files) {
194
241
  for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
195
- const expr = call.getExpression();
196
- if (expr.getKind() !== SyntaxKind.PropertyAccessExpression) continue;
197
- if (expr.asKindOrThrow(SyntaxKind.PropertyAccessExpression).getName() !== "entity") continue;
198
- const args = call.getArguments();
199
- if (!args[0] || args[0].getKind() !== SyntaxKind.StringLiteral) continue;
200
-
201
- const entityIdentifier =
202
- args[1] && unwrapTableExpression(args[1]).asKind(SyntaxKind.Identifier);
203
- if (entityIdentifier) {
204
- const did = declIdOf(entityIdentifier);
205
- if (did) rebuildableEntities.add(did);
206
- }
207
-
208
- const opts = args[2];
209
- if (opts?.getKind() === SyntaxKind.ObjectLiteralExpression) {
210
- const tableProp = opts
211
- .asKindOrThrow(SyntaxKind.ObjectLiteralExpression)
212
- .getProperty("table");
213
- if (tableProp?.getKind() === SyntaxKind.PropertyAssignment) {
214
- const init = tableProp.asKindOrThrow(SyntaxKind.PropertyAssignment).getInitializer();
215
- const tableIdentifier = init && unwrapTableExpression(init).asKind(SyntaxKind.Identifier);
216
- if (tableIdentifier) {
217
- const did = declIdOf(tableIdentifier);
218
- if (did) tables.add(did);
219
- }
220
- }
221
- }
242
+ const registration = entityRegistrationOf(call);
243
+ if (!registration) continue;
244
+ if (registration.entityDid) rebuildableEntities.add(registration.entityDid);
245
+ if (registration.overrideTableDid) tables.add(registration.overrideTableDid);
222
246
  }
223
247
  }
224
248
 
@@ -226,18 +250,7 @@ export function collectEntityProjectionTables(files: readonly SourceFile[]): Set
226
250
  // is in the rebuildable set → xTable is a rebuildable entity table.
227
251
  for (const sf of files) {
228
252
  for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
229
- if (call.getExpression().getText() !== "buildEntityTable") continue;
230
- const entityArg = call.getArguments()[1];
231
- const entityIdentifier =
232
- entityArg && unwrapTableExpression(entityArg).asKind(SyntaxKind.Identifier);
233
- if (!entityIdentifier) continue;
234
- const entityDid = declIdOf(entityIdentifier);
235
- if (!entityDid || !rebuildableEntities.has(entityDid)) continue;
236
-
237
- const varDecl = call.getFirstAncestorByKind(SyntaxKind.VariableDeclaration);
238
- const nameNode = varDecl?.getNameNode();
239
- if (nameNode?.getKind() !== SyntaxKind.Identifier) continue;
240
- const did = declIdOf(nameNode.asKindOrThrow(SyntaxKind.Identifier));
253
+ const did = rebuildableEntityTableDeclId(call, rebuildableEntities);
241
254
  if (did) tables.add(did);
242
255
  }
243
256
  }
@@ -259,7 +272,7 @@ type Violation = {
259
272
  * projection apply (e.g. a db.transaction sub-tx
260
273
  * in production code).
261
274
  */
262
- reason: "non-tx-receiver" | "tx-outside-apply";
275
+ kind: "non-tx-receiver" | "tx-outside-apply";
263
276
  };
264
277
 
265
278
  /**
@@ -427,7 +440,7 @@ export function scanDirectWrites(
427
440
  op: w.op,
428
441
  table: w.tableArg.getText(),
429
442
  snippet: call.getText().slice(0, 120),
430
- reason: "tx-outside-apply",
443
+ kind: "tx-outside-apply",
431
444
  });
432
445
  continue;
433
446
  }
@@ -438,7 +451,7 @@ export function scanDirectWrites(
438
451
  op: w.op,
439
452
  table: w.tableArg.getText(),
440
453
  snippet: call.getText().slice(0, 120),
441
- reason: "non-tx-receiver",
454
+ kind: "non-tx-receiver",
442
455
  });
443
456
  }
444
457
  return out;
@@ -483,7 +496,7 @@ export const guard: AstGuard = {
483
496
  violations.push({
484
497
  file: path.relative(ROOT, file),
485
498
  line: hit.line,
486
- message: `[${hit.reason}] ${hit.receiver}.${hit.op}(${hit.table}) — ${hit.snippet}`,
499
+ message: `[${hit.kind}] ${hit.receiver}.${hit.op}(${hit.table}) — ${hit.snippet}`,
487
500
  });
488
501
  }
489
502
  }
@@ -73,11 +73,13 @@
73
73
  */
74
74
  import * as path from "node:path";
75
75
  import {
76
+ type BindingElement,
76
77
  type Node,
77
78
  type ObjectLiteralExpression,
78
79
  type PropertyAssignment,
79
80
  type SourceFile,
80
81
  SyntaxKind,
82
+ type VariableDeclaration,
81
83
  VariableDeclarationKind,
82
84
  } from "ts-morph";
83
85
  import { isFrameworkImportOf } from "./_lib/framework-import";
@@ -158,32 +160,33 @@ function isDirectTenantDbAccess(node: Node): boolean {
158
160
  return false;
159
161
  }
160
162
 
163
+ function isTenantDbVariable(decl: VariableDeclaration): boolean {
164
+ if (decl.getTypeNode()?.getText() === "TenantDb") return true;
165
+ const init = decl.getInitializer();
166
+ return init !== undefined && isDirectTenantDbAccess(init);
167
+ }
168
+
169
+ function isCtxDbBindingElement(decl: BindingElement): boolean {
170
+ const propName = decl.getPropertyNameNode()?.getText() ?? decl.getName();
171
+ if (!DB_RECEIVER_NAMES.has(propName)) return false;
172
+ // Only `const { db } = ctx` — parameter destructuring (`({ db }) =>`)
173
+ // stays a deliberate false-negative, not worth the ambiguity.
174
+ const owner = decl.getParent()?.getParent();
175
+ const init = owner?.isKind(SyntaxKind.VariableDeclaration) ? owner.getInitializer() : undefined;
176
+ return init?.isKind(SyntaxKind.Identifier) === true && CTX_IDENTIFIER_NAMES.has(init.getText());
177
+ }
178
+
179
+ function isTenantDbDeclaration(decl: Node): boolean {
180
+ if (decl.isKind(SyntaxKind.VariableDeclaration)) return isTenantDbVariable(decl);
181
+ if (decl.isKind(SyntaxKind.BindingElement)) return isCtxDbBindingElement(decl);
182
+ if (decl.isKind(SyntaxKind.Parameter)) return decl.getTypeNode()?.getText() === "TenantDb";
183
+ return false;
184
+ }
185
+
161
186
  function isTenantDbExpression(node: Node): boolean {
162
187
  if (isDirectTenantDbAccess(node)) return true;
163
188
  if (!node.isKind(SyntaxKind.Identifier)) return false;
164
- const decls = node.getSymbol()?.getDeclarations() ?? [];
165
- for (const decl of decls) {
166
- if (decl.isKind(SyntaxKind.VariableDeclaration)) {
167
- if (decl.getTypeNode()?.getText() === "TenantDb") return true;
168
- const init = decl.getInitializer();
169
- if (init && isDirectTenantDbAccess(init)) return true;
170
- } else if (decl.isKind(SyntaxKind.BindingElement)) {
171
- const propName = decl.getPropertyNameNode()?.getText() ?? decl.getName();
172
- if (!DB_RECEIVER_NAMES.has(propName)) continue;
173
- // Only `const { db } = ctx` — parameter destructuring (`({ db }) =>`)
174
- // stays a deliberate false-negative, not worth the ambiguity.
175
- const owner = decl.getParent()?.getParent();
176
- const init = owner?.isKind(SyntaxKind.VariableDeclaration)
177
- ? owner.getInitializer()
178
- : undefined;
179
- if (init?.isKind(SyntaxKind.Identifier) && CTX_IDENTIFIER_NAMES.has(init.getText())) {
180
- return true;
181
- }
182
- } else if (decl.isKind(SyntaxKind.Parameter)) {
183
- if (decl.getTypeNode()?.getText() === "TenantDb") return true;
184
- }
185
- }
186
- return false;
189
+ return (node.getSymbol()?.getDeclarations() ?? []).some(isTenantDbDeclaration);
187
190
  }
188
191
 
189
192
  function findRawFindings(
@@ -33,6 +33,8 @@
33
33
  import * as path from "node:path";
34
34
  import {
35
35
  type ArrowFunction,
36
+ type BinaryExpression,
37
+ type CallExpression,
36
38
  type FunctionDeclaration,
37
39
  type FunctionExpression,
38
40
  type Identifier,
@@ -215,29 +217,120 @@ function isSafeLocalCall(callee: Node, sf: SourceFile, depth: number): boolean {
215
217
  });
216
218
  }
217
219
 
218
- function isSafeExpression(expr: Node, sf: SourceFile, depth: number): boolean {
219
- if (depth > MAX_RESOLVE_DEPTH) return false;
220
- if (expr.isKind(SyntaxKind.ParenthesizedExpression)) {
221
- return isSafeExpression(expr.getExpression(), sf, depth + 1);
222
- }
223
- if (
220
+ function isLiteralSafeKind(expr: Node): boolean {
221
+ return (
224
222
  expr.isKind(SyntaxKind.StringLiteral) ||
225
223
  expr.isKind(SyntaxKind.NumericLiteral) ||
226
224
  expr.isKind(SyntaxKind.TrueKeyword) ||
227
- expr.isKind(SyntaxKind.FalseKeyword)
225
+ expr.isKind(SyntaxKind.FalseKeyword) ||
226
+ expr.isKind(SyntaxKind.NoSubstitutionTemplateLiteral)
227
+ );
228
+ }
229
+
230
+ // A template with an HTML tag is scanned as its own candidate (incl. its
231
+ // html-ok comments). One without is never scanned, so its interpolations
232
+ // must be safe here.
233
+ function isSafeTemplateExpression(
234
+ expr: TemplateExpression,
235
+ sf: SourceFile,
236
+ depth: number,
237
+ ): boolean {
238
+ if (HTML_TAG.test(templateStaticText(expr))) return true;
239
+ return expr
240
+ .getTemplateSpans()
241
+ .every((span) => isSafeExpression(span.getExpression(), sf, depth + 1));
242
+ }
243
+
244
+ function isSafeBinaryExpression(expr: BinaryExpression, sf: SourceFile, depth: number): boolean {
245
+ const op = expr.getOperatorToken().getKind();
246
+ if (
247
+ op === SyntaxKind.QuestionQuestionToken ||
248
+ op === SyntaxKind.BarBarToken ||
249
+ op === SyntaxKind.AmpersandAmpersandToken
250
+ ) {
251
+ return (
252
+ isSafeExpression(expr.getLeft(), sf, depth + 1) &&
253
+ isSafeExpression(expr.getRight(), sf, depth + 1)
254
+ );
255
+ }
256
+ // String concatenation is safe when both operands are.
257
+ if (
258
+ op === SyntaxKind.PlusToken &&
259
+ isSafeExpression(expr.getLeft(), sf, depth + 1) &&
260
+ isSafeExpression(expr.getRight(), sf, depth + 1)
228
261
  ) {
229
262
  return true;
230
263
  }
231
- if (expr.isKind(SyntaxKind.NoSubstitutionTemplateLiteral)) return true;
232
- // A template with an HTML tag is scanned as its own candidate (incl. its
233
- // html-ok comments). One without is never scanned, so its interpolations
234
- // must be safe here.
235
- if (expr.isKind(SyntaxKind.TemplateExpression)) {
236
- if (HTML_TAG.test(templateStaticText(expr))) return true;
237
- return expr
238
- .getTemplateSpans()
239
- .every((span) => isSafeExpression(span.getExpression(), sf, depth + 1));
264
+ // Arithmetic (`${width / 2}`) is safe as long as the result type is number.
265
+ return isCompileTimeKnownType(expr.getType());
266
+ }
267
+
268
+ function calleeName(callee: Node): string {
269
+ return callee.isKind(SyntaxKind.PropertyAccessExpression)
270
+ ? callee.getNameNode().getText()
271
+ : callee.getText();
272
+ }
273
+
274
+ function isSafeMapCallbackResult(
275
+ mapCallback: ArrowFunction | FunctionExpression,
276
+ sf: SourceFile,
277
+ depth: number,
278
+ ): boolean {
279
+ const body = mapCallback.getBody();
280
+ if (Node.isBlock(body)) {
281
+ const stmts = body.getStatements();
282
+ const only = stmts.length === 1 ? stmts[0] : undefined;
283
+ const ret = only && Node.isReturnStatement(only) ? only.getExpression() : undefined;
284
+ return ret !== undefined && isSafeExpression(ret, sf, depth + 1);
285
+ }
286
+ return isSafeExpression(body, sf, depth + 1);
287
+ }
288
+
289
+ // `.join(...)` is only safe when the joined array itself consists of
290
+ // safe fragments (e.g. arr.map(x => escapeHtml(x)).join("")) — a raw
291
+ // `stringArray.join("")` from foreign data (parameter/import) was
292
+ // previously treated as unconditionally safe, a real bypass.
293
+ function isSafeJoinOfMappedFragments(callee: Node, sf: SourceFile, depth: number): boolean {
294
+ if (!callee.isKind(SyntaxKind.PropertyAccessExpression)) return false;
295
+ const receiver = callee.getExpression();
296
+ if (!receiver.isKind(SyntaxKind.CallExpression)) return false;
297
+ if (calleeName(receiver.getExpression()) !== "map") return false;
298
+ const mapCallback = receiver.getArguments()[0];
299
+ if (
300
+ !mapCallback ||
301
+ !(
302
+ mapCallback.isKind(SyntaxKind.ArrowFunction) ||
303
+ mapCallback.isKind(SyntaxKind.FunctionExpression)
304
+ )
305
+ ) {
306
+ return false;
240
307
  }
308
+ return isSafeMapCallbackResult(mapCallback, sf, depth);
309
+ }
310
+
311
+ function isSafeCallExpression(expr: CallExpression, sf: SourceFile, depth: number): boolean {
312
+ const callee = expr.getExpression();
313
+ const name = calleeName(callee);
314
+ if (SAFE_CALL_NAMES.has(name)) return true;
315
+ if (name === "join" && isSafeJoinOfMappedFragments(callee, sf, depth)) return true;
316
+ if (endsWithHtmlConvention(callee)) return true;
317
+ if (isSafeLocalCall(callee, sf, depth)) return true;
318
+ return isCompileTimeKnownType(expr.getType());
319
+ }
320
+
321
+ function isSafeIdentifierExpression(ident: Identifier, sf: SourceFile, depth: number): boolean {
322
+ if (UPPER_SNAKE.test(ident.getText())) return true;
323
+ if (isSafeLocalIdentifier(ident, sf, depth)) return true;
324
+ return isCompileTimeKnownType(ident.getType());
325
+ }
326
+
327
+ function isSafeExpression(expr: Node, sf: SourceFile, depth: number): boolean {
328
+ if (depth > MAX_RESOLVE_DEPTH) return false;
329
+ if (expr.isKind(SyntaxKind.ParenthesizedExpression)) {
330
+ return isSafeExpression(expr.getExpression(), sf, depth + 1);
331
+ }
332
+ if (isLiteralSafeKind(expr)) return true;
333
+ if (expr.isKind(SyntaxKind.TemplateExpression)) return isSafeTemplateExpression(expr, sf, depth);
241
334
  if (expr.isKind(SyntaxKind.TaggedTemplateExpression)) return isSafeTemplateTag(expr.getTag());
242
335
  if (expr.isKind(SyntaxKind.ConditionalExpression)) {
243
336
  return (
@@ -245,77 +338,10 @@ function isSafeExpression(expr: Node, sf: SourceFile, depth: number): boolean {
245
338
  isSafeExpression(expr.getWhenFalse(), sf, depth + 1)
246
339
  );
247
340
  }
248
- if (expr.isKind(SyntaxKind.BinaryExpression)) {
249
- const op = expr.getOperatorToken().getKind();
250
- if (
251
- op === SyntaxKind.QuestionQuestionToken ||
252
- op === SyntaxKind.BarBarToken ||
253
- op === SyntaxKind.AmpersandAmpersandToken
254
- ) {
255
- return (
256
- isSafeExpression(expr.getLeft(), sf, depth + 1) &&
257
- isSafeExpression(expr.getRight(), sf, depth + 1)
258
- );
259
- }
260
- // String concatenation is safe when both operands are.
261
- if (
262
- op === SyntaxKind.PlusToken &&
263
- isSafeExpression(expr.getLeft(), sf, depth + 1) &&
264
- isSafeExpression(expr.getRight(), sf, depth + 1)
265
- ) {
266
- return true;
267
- }
268
- // Arithmetic (`${width / 2}`) is safe as long as the result type is number.
269
- return isCompileTimeKnownType(expr.getType());
270
- }
341
+ if (expr.isKind(SyntaxKind.BinaryExpression)) return isSafeBinaryExpression(expr, sf, depth);
271
342
  if (endsWithHtmlConvention(expr)) return true;
272
- if (expr.isKind(SyntaxKind.CallExpression)) {
273
- const callee = expr.getExpression();
274
- const name = callee.isKind(SyntaxKind.PropertyAccessExpression)
275
- ? callee.getNameNode().getText()
276
- : callee.getText();
277
- if (SAFE_CALL_NAMES.has(name)) return true;
278
- // `.join(...)` is only safe when the joined array itself consists of
279
- // safe fragments (e.g. arr.map(x => escapeHtml(x)).join("")) — a raw
280
- // `stringArray.join("")` from foreign data (parameter/import) was
281
- // previously treated as unconditionally safe, a real bypass.
282
- if (name === "join" && callee.isKind(SyntaxKind.PropertyAccessExpression)) {
283
- const receiver = callee.getExpression();
284
- if (receiver.isKind(SyntaxKind.CallExpression)) {
285
- const receiverCallee = receiver.getExpression();
286
- const receiverName = receiverCallee.isKind(SyntaxKind.PropertyAccessExpression)
287
- ? receiverCallee.getNameNode().getText()
288
- : receiverCallee.getText();
289
- if (receiverName === "map") {
290
- const mapCallback = receiver.getArguments()[0];
291
- if (
292
- mapCallback &&
293
- (mapCallback.isKind(SyntaxKind.ArrowFunction) ||
294
- mapCallback.isKind(SyntaxKind.FunctionExpression))
295
- ) {
296
- const body = mapCallback.getBody();
297
- if (Node.isBlock(body)) {
298
- const stmts = body.getStatements();
299
- const only = stmts.length === 1 ? stmts[0] : undefined;
300
- const ret = only && Node.isReturnStatement(only) ? only.getExpression() : undefined;
301
- if (ret && isSafeExpression(ret, sf, depth + 1)) return true;
302
- } else if (isSafeExpression(body, sf, depth + 1)) {
303
- return true;
304
- }
305
- }
306
- }
307
- }
308
- }
309
- if (endsWithHtmlConvention(callee)) return true;
310
- if (isSafeLocalCall(callee, sf, depth)) return true;
311
- return isCompileTimeKnownType(expr.getType());
312
- }
313
- if (expr.isKind(SyntaxKind.Identifier)) {
314
- const ident = expr as Identifier;
315
- if (UPPER_SNAKE.test(ident.getText())) return true;
316
- if (isSafeLocalIdentifier(ident, sf, depth)) return true;
317
- return isCompileTimeKnownType(expr.getType());
318
- }
343
+ if (expr.isKind(SyntaxKind.CallExpression)) return isSafeCallExpression(expr, sf, depth);
344
+ if (expr.isKind(SyntaxKind.Identifier)) return isSafeIdentifierExpression(expr, sf, depth);
319
345
  if (
320
346
  expr.isKind(SyntaxKind.PropertyAccessExpression) ||
321
347
  expr.isKind(SyntaxKind.ElementAccessExpression)
@@ -1,9 +1,9 @@
1
1
  #!/usr/bin/env bun
2
2
  /**
3
- * Guard: i18n-Keys muessen definiert sein, bevor sie verwendet werden.
3
+ * Guard: i18n keys must be defined before they are used.
4
4
  *
5
- * Scan: t()-Calls in App-tsx, Definitionen aus r.translations und i18n-Bundles.
6
- * Deklarative Screen-/Nav-Keys: validateBoot (Runtime).
5
+ * Scan: t() calls in app tsx, definitions from r.translations and i18n bundles.
6
+ * Declarative screen/nav keys: validateBoot (runtime).
7
7
  *
8
8
  * Usage:
9
9
  * bun guards/guard-i18n-keys.ts
@@ -52,8 +52,8 @@ function isI18nBundleFile(filePath: string): boolean {
52
52
  // Flat single-file bundle (src/i18n.ts), same status as src/i18n/index.ts.
53
53
  /\/i18n\.ts$/.test(filePath) ||
54
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.
55
+ // Covers any depth below bundled-features/src/<feature>/
56
+ // (e.g. also .../schema/i18n.ts), not just exactly one level.
57
57
  /bundled-features\/src\/.*\/i18n\.ts$/.test(filePath)
58
58
  );
59
59
  }
@@ -240,6 +240,39 @@ function collectBundleDefinedKeys(sf: SourceFile): DefinedKey[] {
240
240
  return defined;
241
241
  }
242
242
 
243
+ // Collects the entries of one `keys:` initializer of an inline `.translations({...})` call.
244
+ function collectInlineKeysInitializer(
245
+ defined: DefinedKey[],
246
+ initializer: Node | undefined,
247
+ featureName: string,
248
+ file: string,
249
+ ): void {
250
+ if (initializer?.isKind(SyntaxKind.ObjectLiteralExpression)) {
251
+ for (const entry of extractKeysFromTranslationsObject(initializer)) {
252
+ pushDefined(defined, `${featureName}:${entry.key}`, entry.locales, file, entry.line);
253
+ if (entry.key.includes(":")) {
254
+ pushDefined(defined, entry.key, entry.locales, file, entry.line);
255
+ }
256
+ }
257
+ } else if (initializer?.isKind(SyntaxKind.Identifier)) {
258
+ // getDefinitionNodes() follows "go to definition" through an
259
+ // import alias to the real declaration (possibly in another
260
+ // file) instead of stopping at the ImportSpecifier — plain
261
+ // getSymbol().getDeclarations() only resolved a same-file const.
262
+ const decl = initializer
263
+ .getDefinitionNodes()
264
+ .find((n) => n.isKind(SyntaxKind.VariableDeclaration));
265
+ if (decl?.isKind(SyntaxKind.VariableDeclaration)) {
266
+ const bundleInit = decl.getInitializer();
267
+ if (bundleInit?.isKind(SyntaxKind.ObjectLiteralExpression)) {
268
+ for (const entry of extractKeysFromTranslationsObject(bundleInit)) {
269
+ addDefinedEntries(defined, [entry], file, featureName);
270
+ }
271
+ }
272
+ }
273
+ }
274
+ }
275
+
243
276
  function collectInlineTranslationsDefinedKeys(sf: SourceFile): DefinedKey[] {
244
277
  const defined: DefinedKey[] = [];
245
278
  for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
@@ -255,37 +288,7 @@ function collectInlineTranslationsDefinedKeys(sf: SourceFile): DefinedKey[] {
255
288
  for (const prop of first.getProperties()) {
256
289
  if (!prop.isKind(SyntaxKind.PropertyAssignment)) continue;
257
290
  if (prop.getNameNode().getText() !== "keys") continue;
258
- const initializer = prop.getInitializer();
259
- if (initializer?.isKind(SyntaxKind.ObjectLiteralExpression)) {
260
- for (const entry of extractKeysFromTranslationsObject(initializer)) {
261
- pushDefined(
262
- defined,
263
- `${featureName}:${entry.key}`,
264
- entry.locales,
265
- relFile(sf),
266
- entry.line,
267
- );
268
- if (entry.key.includes(":")) {
269
- pushDefined(defined, entry.key, entry.locales, relFile(sf), entry.line);
270
- }
271
- }
272
- } else if (initializer?.isKind(SyntaxKind.Identifier)) {
273
- // getDefinitionNodes() follows "go to definition" through an
274
- // import alias to the real declaration (possibly in another
275
- // file) instead of stopping at the ImportSpecifier — plain
276
- // getSymbol().getDeclarations() only resolved a same-file const.
277
- const decl = initializer
278
- .getDefinitionNodes()
279
- .find((n) => n.isKind(SyntaxKind.VariableDeclaration));
280
- if (decl?.isKind(SyntaxKind.VariableDeclaration)) {
281
- const bundleInit = decl.getInitializer();
282
- if (bundleInit?.isKind(SyntaxKind.ObjectLiteralExpression)) {
283
- for (const entry of extractKeysFromTranslationsObject(bundleInit)) {
284
- addDefinedEntries(defined, [entry], relFile(sf), featureName);
285
- }
286
- }
287
- }
288
- }
291
+ collectInlineKeysInitializer(defined, prop.getInitializer(), featureName, relFile(sf));
289
292
  }
290
293
  }
291
294
  return defined;