@cosmicdrift/kumiko-framework 0.289.0 → 0.291.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 (68) hide show
  1. package/package.json +4 -4
  2. package/src/__tests__/field-access.integration.test.ts +7 -3
  3. package/src/__tests__/ownership-where-write-path.integration.test.ts +1 -1
  4. package/src/__tests__/ownership.integration.test.ts +1 -1
  5. package/src/__tests__/pii-personal-migration-report-codemod.test.ts +160 -0
  6. package/src/api/__tests__/server-boot-guards.test.ts +42 -0
  7. package/src/api/__tests__/server-error-logging.test.ts +168 -17
  8. package/src/api/request-context.ts +31 -0
  9. package/src/api/request-id-middleware.ts +2 -1
  10. package/src/api/routes.ts +35 -3
  11. package/src/api/server.ts +30 -4
  12. package/src/changes.json +69 -0
  13. package/src/crypto/__tests__/blind-index.test.ts +1 -1
  14. package/src/crypto/__tests__/event-pii.test.ts +110 -9
  15. package/src/crypto/__tests__/pii-field-encryption.test.ts +2 -2
  16. package/src/crypto/__tests__/subject-resolver.test.ts +25 -4
  17. package/src/crypto/subject-resolver.ts +25 -8
  18. package/src/db/__tests__/blind-index.integration.test.ts +1 -1
  19. package/src/db/__tests__/eagerload.integration.test.ts +12 -2
  20. package/src/db/__tests__/entity-field-encryption.test.ts +2 -2
  21. package/src/db/__tests__/event-store-executor-context.pii-roundtrip.test.ts +1 -1
  22. package/src/db/__tests__/event-store-executor-money-rehydrate.integration.test.ts +7 -2
  23. package/src/db/__tests__/event-store-executor-write-verbs.integration.test.ts +3 -1
  24. package/src/db/__tests__/event-store-executor.integration.test.ts +15 -5
  25. package/src/db/__tests__/list-filter-field-access.integration.test.ts +6 -2
  26. package/src/db/queries/shadow-swap.ts +35 -0
  27. package/src/engine/__tests__/boot-validator-action-wiring.test.ts +32 -0
  28. package/src/engine/__tests__/boot-validator-boot-check.test.ts +1 -1
  29. package/src/engine/__tests__/boot-validator-pii-retention.test.ts +148 -15
  30. package/src/engine/__tests__/boot-validator.test.ts +226 -0
  31. package/src/engine/__tests__/build-app-schema.test.ts +18 -0
  32. package/src/engine/__tests__/engine.test.ts +87 -0
  33. package/src/engine/__tests__/entity-presave-wiring.integration.test.ts +6 -2
  34. package/src/engine/__tests__/factories-long-text.test.ts +6 -1
  35. package/src/engine/__tests__/form-money-currency-types.test.ts +90 -0
  36. package/src/engine/boot-validator/__tests__/record-owned.test.ts +12 -2
  37. package/src/engine/boot-validator/action-wiring.ts +2 -1
  38. package/src/engine/boot-validator/entity-handler.ts +44 -0
  39. package/src/engine/boot-validator/index.ts +7 -2
  40. package/src/engine/boot-validator/pii-retention.ts +8 -0
  41. package/src/engine/boot-validator/screens.ts +54 -12
  42. package/src/engine/create-app.ts +54 -0
  43. package/src/engine/feature-config-events-jobs.ts +19 -0
  44. package/src/engine/index.ts +2 -0
  45. package/src/engine/qualified-name.ts +9 -0
  46. package/src/engine/screen-helpers.ts +1 -0
  47. package/src/event-store/__tests__/backfill-pii.integration.test.ts +32 -8
  48. package/src/event-store/__tests__/event-attribution.integration.test.ts +186 -0
  49. package/src/event-store/event-store.ts +23 -2
  50. package/src/i18n/required-surface-keys.ts +1 -0
  51. package/src/jobs/__tests__/job-last-success.integration.test.ts +135 -0
  52. package/src/jobs/index.ts +7 -1
  53. package/src/jobs/job-runner.ts +104 -6
  54. package/src/logging/utils.ts +14 -1
  55. package/src/observability/index.ts +1 -0
  56. package/src/observability/standard-metrics.ts +20 -0
  57. package/src/pipeline/__tests__/blind-index-rebuild-guard.integration.test.ts +96 -0
  58. package/src/pipeline/active-membership.ts +10 -4
  59. package/src/pipeline/append-event-core.ts +2 -11
  60. package/src/pipeline/dispatch-shared.ts +17 -5
  61. package/src/pipeline/event-dispatcher-delivery.ts +15 -3
  62. package/src/pipeline/projection-rebuild.ts +7 -0
  63. package/src/schema-cli.ts +21 -0
  64. package/src/scripts/codemod/pii-personal-migration.ts +242 -2
  65. package/src/stack/__tests__/ownership-boot-guard.integration.test.ts +1 -1
  66. package/src/testing/__tests__/e2e-generator.test.ts +50 -0
  67. package/src/testing/e2e-generator.ts +4 -3
  68. package/src/ui-types/index.ts +2 -0
@@ -0,0 +1,96 @@
1
+ // projection-rebuild aborts instead of silently NULLing an already-populated
2
+ // blind-index column when this process has no blind-index key configured
3
+ // (fw#3091) — see assertNoBlindIndexLoss in db/queries/shadow-swap.ts.
4
+
5
+ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from "bun:test";
6
+ import { resetBlindIndexKeyForTests } from "@cosmicdrift/kumiko-framework/testing";
7
+ import { computeBlindIndex, configureBlindIndexKey, decodeBlindIndexKey } from "../../crypto";
8
+ import { createEventStoreExecutor } from "../../db/event-store-executor";
9
+ import { asRawClient } from "../../db/query";
10
+ import { buildEntityTable } from "../../db/table-builder";
11
+ import { createTenantDb, type TenantDb } from "../../db/tenant-db";
12
+ import { createEntity, createRegistry, createTextField, defineFeature } from "../../engine";
13
+ import { createEventsTable } from "../../event-store";
14
+ import { createProjectionStateTable, rebuildProjection } from "../../pipeline";
15
+ import { createTestDb, type TestDb, TestUsers, unsafeCreateEntityTable } from "../../stack";
16
+
17
+ const TEST_KEY_B64 = Buffer.alloc(32, 9).toString("base64");
18
+ const TEST_KEY = decodeBlindIndexKey(TEST_KEY_B64);
19
+
20
+ const personEntity = createEntity({
21
+ table: "read_bidx_guard_persons",
22
+ fields: {
23
+ email: createTextField({ required: true, personal: "self", find: "exact" }),
24
+ },
25
+ });
26
+ const personFeature = defineFeature("bidxguardtest", (r) => {
27
+ r.entity("person", personEntity);
28
+ });
29
+ const personTable = buildEntityTable("person", personEntity);
30
+ const implicitName = "bidxguardtest:projection:person-entity";
31
+
32
+ const admin = TestUsers.admin;
33
+ let testDb: TestDb;
34
+ let tdb: TenantDb;
35
+ const registry = createRegistry([personFeature]);
36
+ const crud = createEventStoreExecutor(personTable, personEntity, { entityName: "person" });
37
+
38
+ beforeAll(async () => {
39
+ testDb = await createTestDb();
40
+ await unsafeCreateEntityTable(testDb.db, personEntity, "person");
41
+ await createEventsTable(testDb.db);
42
+ await createProjectionStateTable(testDb.db);
43
+ tdb = createTenantDb(testDb.db, admin.tenantId);
44
+ });
45
+
46
+ afterAll(async () => {
47
+ await testDb.cleanup();
48
+ });
49
+
50
+ beforeEach(async () => {
51
+ await asRawClient(testDb.db).unsafe(
52
+ `TRUNCATE kumiko_events, read_bidx_guard_persons, kumiko_projections RESTART IDENTITY CASCADE`,
53
+ );
54
+ configureBlindIndexKey(TEST_KEY_B64);
55
+ });
56
+
57
+ afterEach(() => {
58
+ resetBlindIndexKeyForTests();
59
+ });
60
+
61
+ async function rawRow(id: string): Promise<Record<string, unknown>> {
62
+ const rows = await asRawClient(testDb.db).unsafe<Record<string, unknown>>(
63
+ `SELECT * FROM read_bidx_guard_persons WHERE id = $1`,
64
+ [id],
65
+ );
66
+ const row = rows[0];
67
+ if (!row) throw new Error(`no row for ${id}`);
68
+ return row;
69
+ }
70
+
71
+ describe("projection-rebuild — blind-index loss guard (fw#3091)", () => {
72
+ test("populated bidx column + no key in this process → rebuild throws, live table untouched", async () => {
73
+ const created = await crud.create({ email: "marc@example.com" }, admin, tdb);
74
+ if (!created.isSuccess) throw new Error("create failed");
75
+ const before = await rawRow(String(created.data.id));
76
+ expect(before["email_bidx"]).toBe(computeBlindIndex(TEST_KEY, "marc@example.com"));
77
+
78
+ resetBlindIndexKeyForTests();
79
+ await expect(rebuildProjection(implicitName, { db: testDb.db, registry })).rejects.toThrow(
80
+ /KUMIKO_BLIND_INDEX_KEY/,
81
+ );
82
+
83
+ const after = await rawRow(String(created.data.id));
84
+ expect(after).toEqual(before);
85
+ });
86
+
87
+ test("populated bidx column + key configured → rebuild succeeds, bidx recomputed", async () => {
88
+ const created = await crud.create({ email: "marc@example.com" }, admin, tdb);
89
+ if (!created.isSuccess) throw new Error("create failed");
90
+
91
+ await rebuildProjection(implicitName, { db: testDb.db, registry });
92
+
93
+ const after = await rawRow(String(created.data.id));
94
+ expect(after["email_bidx"]).toBe(computeBlindIndex(TEST_KEY, "marc@example.com"));
95
+ });
96
+ });
@@ -97,18 +97,21 @@ export function resolvePrincipalPlugin(registry: Registry): PrincipalStatusPlugi
97
97
  return usage.options;
98
98
  }
99
99
 
100
- function resolveLifecyclePlugin(registry: Registry): TenantLifecycleStatusPlugin | undefined {
100
+ export function resolveTenantLifecyclePlugin(
101
+ registry: Registry,
102
+ caller: string,
103
+ ): TenantLifecycleStatusPlugin | undefined {
101
104
  const usages = registry.getExtensionUsages(EXT_TENANT_LIFECYCLE_STATUS);
102
105
  if (usages.length > 1) {
103
106
  throw new InternalError({
104
- message: `dispatcher.resolveActiveMembership: multiple "${EXT_TENANT_LIFECYCLE_STATUS}" providers registered — exactly one (or zero) is expected.`,
107
+ message: `${caller}: multiple "${EXT_TENANT_LIFECYCLE_STATUS}" providers registered — exactly one (or zero) is expected.`,
105
108
  });
106
109
  }
107
110
  const usage = usages[0];
108
111
  if (!usage) return undefined;
109
112
  if (!isTenantLifecycleStatusPlugin(usage.options)) {
110
113
  throw new InternalError({
111
- message: `dispatcher.resolveActiveMembership: "${usage.entityName}" registered under "${EXT_TENANT_LIFECYCLE_STATUS}" without a resolveStatus(tenantId, {db}) — extension options must be a TenantLifecycleStatusPlugin.`,
114
+ message: `${caller}: "${usage.entityName}" registered under "${EXT_TENANT_LIFECYCLE_STATUS}" without a resolveStatus(tenantId, {db}) — extension options must be a TenantLifecycleStatusPlugin.`,
112
115
  });
113
116
  }
114
117
  return usage.options;
@@ -160,7 +163,10 @@ export async function resolveActiveMembershipFn(
160
163
  return { kind: "rejected", reason: "principal_blocked" };
161
164
  }
162
165
 
163
- const lifecyclePlugin = resolveLifecyclePlugin(registry);
166
+ const lifecyclePlugin = resolveTenantLifecyclePlugin(
167
+ registry,
168
+ "dispatcher.resolveActiveMembership",
169
+ );
164
170
  if (lifecyclePlugin) {
165
171
  const lifecycle = await lifecyclePlugin.resolveStatus(tenantId, { db });
166
172
  if (isTeardownRejected(lifecycle, policy)) {
@@ -1,6 +1,6 @@
1
1
  import { requestContext } from "../api/request-context";
2
2
  import type { DbRunner } from "../db/connection";
3
- import { toKebab } from "../engine/qualified-name";
3
+ import { qnScope, toKebab } from "../engine/qualified-name";
4
4
  import type { AppendEventArgs, Registry, TenantId } from "../engine/types";
5
5
  import { InternalError, validationErrorFromZod } from "../errors";
6
6
  import { isStreamArchived } from "../event-store/archive";
@@ -32,15 +32,6 @@ export type AppendDomainEventCoreDeps = {
32
32
  readonly callerFeature?: string;
33
33
  };
34
34
 
35
- // Extract the owning feature from a qualified event name. Events are
36
- // registered as "<feature>:event:<short>" (see registry.ts qualify()) so the
37
- // prefix before the first ":" is the owner. Falls back to undefined if the
38
- // name isn't qualified — callers then skip the cross-feature check.
39
- function eventOwnerFeature(qualifiedName: string): string | undefined {
40
- const idx = qualifiedName.indexOf(":");
41
- return idx > 0 ? qualifiedName.slice(0, idx) : undefined;
42
- }
43
-
44
35
  // System-event prefix: events under this namespace bypass the registry +
45
36
  // ownership checks. Reserved for framework-internal coordination (step-
46
37
  // engine deferred dispatch, lifecycle signals). The matching MSP filters
@@ -71,7 +62,7 @@ export async function appendDomainEventCore(
71
62
  // into kebab-case for the event/handler names (pubsub-orders:event:…) — so
72
63
  // we compare the kebab form on both sides.
73
64
  if (deps.callerFeature && !isSystemEvent) {
74
- const owner = eventOwnerFeature(args.type);
65
+ const owner = qnScope(args.type);
75
66
  const callerKebab = toKebab(deps.callerFeature);
76
67
  if (owner && owner !== callerKebab) {
77
68
  throw new InternalError({
@@ -1,4 +1,4 @@
1
- import { requestContext } from "../api/request-context";
1
+ import { requestContext, runWithOrigin } from "../api/request-context";
2
2
  import type { SseBroker } from "../api/sse-broker";
3
3
  import type { DbConnection, DbRunner, DbTx } from "../db/connection";
4
4
  import { runInSavepoint, selectMany } from "../db/query";
@@ -907,7 +907,12 @@ export async function runHandlerInstrumented<T>(
907
907
  },
908
908
  async (span) => {
909
909
  try {
910
- const result = await inner();
910
+ // #3043 — everything the handler writes, including entity-executor
911
+ // writes below it, is attributed to this handler.
912
+ const result = await runWithOrigin(
913
+ { handler: type, feature: registry.getHandlerFeature(type) },
914
+ inner,
915
+ );
911
916
  if (operation === "write" && isFailedWriteResult(result)) {
912
917
  success = false;
913
918
  errorClass = result.error?.code ?? "UnknownError";
@@ -963,11 +968,18 @@ export async function* runStreamInstrumented<T>(
963
968
  attributes: dispatcherSpanAttributes(type, "stream", user, registry.getHandlerFeature(type)),
964
969
  });
965
970
  const it = inner();
971
+ // Each pull re-enters both scopes: AsyncLocalStorage does not survive a
972
+ // generator suspension, so wrapping inner() once would leave every event a
973
+ // stream writes unattributed.
974
+ const inScope = <R>(fn: () => R): R =>
975
+ runWithOrigin({ handler: type, feature: registry.getHandlerFeature(type) }, () =>
976
+ observabilityContext.run({ activeSpan: span }, fn),
977
+ );
966
978
  try {
967
- let next = await observabilityContext.run({ activeSpan: span }, () => it.next());
979
+ let next = await inScope(() => it.next());
968
980
  while (!next.done) {
969
981
  yield next.value;
970
- next = await observabilityContext.run({ activeSpan: span }, () => it.next());
982
+ next = await inScope(() => it.next());
971
983
  }
972
984
  completedNormally = true;
973
985
  return next.value;
@@ -980,7 +992,7 @@ export async function* runStreamInstrumented<T>(
980
992
  } finally {
981
993
  // forward close so inner()'s finally still fires — yield* did this for free
982
994
  try {
983
- await observabilityContext.run({ activeSpan: span }, () => it.return?.(undefined));
995
+ await inScope(() => it.return?.(undefined));
984
996
  } catch (closeError) {
985
997
  // Only fold this in when nothing has already failed — a close-time
986
998
  // error while an earlier error is in flight would mask the real
@@ -14,6 +14,7 @@ import {
14
14
  } from "../db/queries/event-consumer";
15
15
  import { selectEventsHeadId } from "../db/queries/event-store";
16
16
  import { coerceRow, extractTableInfo, selectMany } from "../db/query";
17
+ import { qnScope } from "../engine/qualified-name";
17
18
  import type { AppContext } from "../engine/types";
18
19
  import { eventsTable, toStoredEvent as rowToStoredEvent } from "../event-store";
19
20
  import {
@@ -240,9 +241,20 @@ export async function deliverEvents(
240
241
  const correlationId = stored.metadata.correlationId ?? requestContext.generateId();
241
242
  const causationId = String(stored.id);
242
243
  const requestId = requestContext.generateId();
243
- await requestContext.run({ requestId, correlationId, causationId }, async () => {
244
- await consumer.handler(stored, context);
245
- });
244
+ // #3043 — an event this apply writes is attributed to the consumer, not
245
+ // to whatever wrote the triggering event; causationId already links back.
246
+ await requestContext.run(
247
+ {
248
+ requestId,
249
+ correlationId,
250
+ causationId,
251
+ handler: consumer.name,
252
+ feature: consumer.featureName ?? qnScope(consumer.name),
253
+ },
254
+ async () => {
255
+ await consumer.handler(stored, context);
256
+ },
257
+ );
246
258
  cursor = row.id;
247
259
  attempts = 0;
248
260
  lastError = null;
@@ -9,6 +9,7 @@ import {
9
9
  import {
10
10
  assertLiveColumnsMatchMeta,
11
11
  assertLiveTableHasNoRowLevelSecurity,
12
+ assertNoBlindIndexLoss,
12
13
  assertNoUnreachableLiveRows,
13
14
  buildShadowTable,
14
15
  type ColumnDriftResult,
@@ -358,6 +359,12 @@ export async function rebuildProjection(
358
359
  if (skipped.length > 0) {
359
360
  await recordRebuildDeadLetters(tx, projectionName, skipped);
360
361
  }
362
+ // Guard the swap: abort if the process has no blind-index key configured
363
+ // but the live table already has populated bidx columns — the rebuild
364
+ // would otherwise silently null them out (fw#3091). Applies to every
365
+ // projection, not just implicit ones: the shadow always gets a fresh
366
+ // computeBlindIndexValues() pass regardless of projection kind.
367
+ await assertNoBlindIndexLoss(tx, meta.tableName, meta, projectionName);
361
368
  // Guard the swap: abort if the live table holds a row no event can
362
369
  // reconstruct (#498 ghost — direct-inserted without a .created event),
363
370
  // which the swap would silently drop. Implicit projections only.
package/src/schema-cli.ts CHANGED
@@ -10,6 +10,12 @@
10
10
 
11
11
  import { existsSync, mkdirSync, readdirSync, writeFileSync } from "node:fs";
12
12
  import { join, resolve as resolvePath } from "node:path";
13
+ import {
14
+ configureBlindIndexKey,
15
+ configurePiiSubjectKms,
16
+ type KmsWiring,
17
+ resolveKmsWiringAsync,
18
+ } from "./crypto";
13
19
  import {
14
20
  assertValidMigrationName,
15
21
  baselineMigrations,
@@ -316,6 +322,7 @@ export async function runSchemaCli(
316
322
  return 1;
317
323
  }
318
324
  const { db, close } = createDbConnection(dbUrl);
325
+ let wiring: KmsWiring | undefined;
319
326
  try {
320
327
  const result = await runMigrationsFromDir(db, migrationsDir);
321
328
  // Framework-Infra-Tabellen (event-store + pipeline-state) — die erfasst
@@ -346,6 +353,19 @@ export async function runSchemaCli(
346
353
  // retry it — otherwise a failed rebuild is silently never retried,
347
354
  // since the migration itself is already tracked applied (#2464).
348
355
  if (options.features) {
356
+ // A rebuild replays applyEntityEvent, which computes bidx columns from
357
+ // configureBlindIndexKey/configurePiiSubjectKms — both boot-injected by
358
+ // run{Prod,Dev}App but never by this standalone CLI (the migrate-db
359
+ // initContainer). Without wiring them here, a rebuild would silently
360
+ // null out bidx columns and, for ciphertext, throw only once it hits
361
+ // the row (fw#3091).
362
+ wiring = await resolveKmsWiringAsync(process.env, {
363
+ logPrefix: "[kumiko schema apply]",
364
+ });
365
+ if ("kms" in wiring) {
366
+ configurePiiSubjectKms(wiring.kms);
367
+ configureBlindIndexKey(wiring.blindIndexKey);
368
+ }
349
369
  const thisRunTables = await queueRebuildsFromMarkers(db, {
350
370
  migrationsDir,
351
371
  appliedIds: result.applied,
@@ -376,6 +396,7 @@ export async function runSchemaCli(
376
396
  out.err("");
377
397
  return 1;
378
398
  } finally {
399
+ await wiring?.close();
379
400
  await close();
380
401
  }
381
402
  }
@@ -23,9 +23,11 @@
23
23
  //
24
24
  // Usage: bun scripts/codemod/pii-personal-migration.ts <targetDir> [--dry-run]
25
25
 
26
+ import { readFileSync } from "node:fs";
26
27
  import { relative, resolve } from "node:path";
27
28
  import { Glob } from "bun";
28
29
  import {
30
+ type CallExpression,
29
31
  Node,
30
32
  type ObjectLiteralExpression,
31
33
  Project,
@@ -33,6 +35,11 @@ import {
33
35
  type SourceFile,
34
36
  SyntaxKind,
35
37
  } from "ts-morph";
38
+ import {
39
+ PII_DIRECT_NAME_HINTS,
40
+ PII_USER_OWNED_NAME_HINTS,
41
+ PII_USER_REFERENCE_NAME_HINTS,
42
+ } from "../../engine/boot-validator/entity-handler";
36
43
 
37
44
  const SUBJECT_FLAG_NAMES = [
38
45
  "pii",
@@ -431,11 +438,242 @@ function findTargetFiles(rootDir: string): string[] {
431
438
  return files.sort();
432
439
  }
433
440
 
441
+ export type StanceClass = "direct" | "user-owned" | "user-reference" | "near-miss" | "unclassified";
442
+
443
+ export type StanceSite = {
444
+ readonly line: number;
445
+ readonly field: string;
446
+ readonly entity: string | null;
447
+ readonly callee: string;
448
+ readonly stance: StanceClass;
449
+ readonly hint: string | undefined;
450
+ };
451
+
452
+ const ALL_PII_NAME_HINTS: readonly string[] = [
453
+ ...PII_DIRECT_NAME_HINTS,
454
+ ...PII_USER_OWNED_NAME_HINTS,
455
+ ...PII_USER_REFERENCE_NAME_HINTS,
456
+ ];
457
+
458
+ const REPORT_STANCE_CALLEES = new Set(["createTextField", "createLongTextField"]);
459
+
460
+ // Mirrors guard-text-field-stance.ts's hasPersonalStance exactly.
461
+ function reportStanceHasPersonalStance(obj: ObjectLiteralExpression): boolean {
462
+ const prop = obj.getProperty("personal");
463
+ if (!prop || !Node.isPropertyAssignment(prop)) return false;
464
+ const init = prop.getInitializer();
465
+ return (
466
+ init !== undefined &&
467
+ init.getKind() !== SyntaxKind.UndefinedKeyword &&
468
+ !(Node.isIdentifier(init) && init.getText() === "undefined") &&
469
+ init.getKind() !== SyntaxKind.NullKeyword
470
+ );
471
+ }
472
+
473
+ function reportStanceEnclosingFieldName(call: CallExpression): string | undefined {
474
+ let node = call.getParent();
475
+ while (node) {
476
+ if (Node.isPropertyAssignment(node)) return node.getName();
477
+ node = node.getParent();
478
+ }
479
+ return undefined;
480
+ }
481
+
482
+ function reportStanceResolveEntity(call: CallExpression): string | null {
483
+ let node: Node | undefined = call.getParent();
484
+ let entityCall: CallExpression | undefined;
485
+ while (node) {
486
+ if (Node.isCallExpression(node)) {
487
+ const expr = node.getExpression();
488
+ if (Node.isIdentifier(expr) && expr.getText() === "createEntity") {
489
+ entityCall = node;
490
+ break;
491
+ }
492
+ }
493
+ node = node.getParent();
494
+ }
495
+ if (!entityCall) return null;
496
+
497
+ const firstArg = entityCall.getArguments()[0];
498
+ if (firstArg && Node.isObjectLiteralExpression(firstArg)) {
499
+ const tableProp = firstArg.getProperty("table");
500
+ if (tableProp && Node.isPropertyAssignment(tableProp)) {
501
+ const init = tableProp.getInitializer();
502
+ if (init && Node.isStringLiteral(init)) return init.getLiteralText();
503
+ }
504
+ }
505
+ const varDecl = entityCall.getParentIfKind(SyntaxKind.VariableDeclaration);
506
+ return varDecl ? varDecl.getName() : null;
507
+ }
508
+
509
+ // A hint only counts at a segment boundary (index 0, an uppercase letter in
510
+ // the original, or preceded by `_`) — otherwise a coincidental substring
511
+ // like "text" inside "contextId" would false-positive.
512
+ function hintOccursAtBoundary(fieldLower: string, fieldOriginal: string, hint: string): boolean {
513
+ let searchFrom = 0;
514
+ for (;;) {
515
+ const index = fieldLower.indexOf(hint, searchFrom);
516
+ if (index === -1) return false;
517
+ const atBoundary =
518
+ index === 0 || /[A-Z]/.test(fieldOriginal[index] ?? "") || fieldOriginal[index - 1] === "_";
519
+ if (atBoundary) return true;
520
+ searchFrom = index + 1;
521
+ }
522
+ }
523
+
524
+ function findLongestBoundaryHint(fieldLower: string, fieldOriginal: string): string | undefined {
525
+ let best: string | undefined;
526
+ for (const hint of ALL_PII_NAME_HINTS) {
527
+ if (best && hint.length <= best.length) continue;
528
+ if (hintOccursAtBoundary(fieldLower, fieldOriginal, hint)) best = hint;
529
+ }
530
+ return best;
531
+ }
532
+
533
+ function segmentAlignedSuffixes(fieldOriginal: string): string[] {
534
+ const fieldLower = fieldOriginal.toLowerCase();
535
+ const suffixes: string[] = [];
536
+ for (let index = 0; index < fieldOriginal.length; index++) {
537
+ const atBoundary =
538
+ index === 0 || /[A-Z]/.test(fieldOriginal[index] ?? "") || fieldOriginal[index - 1] === "_";
539
+ if (atBoundary) suffixes.push(fieldLower.slice(index));
540
+ }
541
+ return suffixes;
542
+ }
543
+
544
+ // The hint sets only carry exact full names, so a suffix variant of one
545
+ // (e.g. "...UserId" of "assigneeUserId") otherwise slips through undetected.
546
+ function findShortestHintContainingSuffix(fieldOriginal: string): string | undefined {
547
+ let best: string | undefined;
548
+ for (const suffix of segmentAlignedSuffixes(fieldOriginal)) {
549
+ if (suffix.length < 5) continue;
550
+ for (const hint of ALL_PII_NAME_HINTS) {
551
+ if (!hint.includes(suffix)) continue;
552
+ if (!best || hint.length < best.length) best = hint;
553
+ }
554
+ }
555
+ return best;
556
+ }
557
+
558
+ function classifyFieldStance(field: string): { stance: StanceClass; hint: string | undefined } {
559
+ const fieldLower = field.toLowerCase();
560
+ if (PII_DIRECT_NAME_HINTS.has(fieldLower)) return { stance: "direct", hint: fieldLower };
561
+ if (PII_USER_OWNED_NAME_HINTS.has(fieldLower)) return { stance: "user-owned", hint: fieldLower };
562
+ if (PII_USER_REFERENCE_NAME_HINTS.has(fieldLower))
563
+ return { stance: "user-reference", hint: fieldLower };
564
+ const containmentHint = findLongestBoundaryHint(fieldLower, field);
565
+ if (containmentHint) return { stance: "near-miss", hint: containmentHint };
566
+ const suffixHint = findShortestHintContainingSuffix(field);
567
+ if (suffixHint) return { stance: "near-miss", hint: suffixHint };
568
+ return { stance: "unclassified", hint: undefined };
569
+ }
570
+
571
+ export function reportStanceForSource(source: string, filePath: string): StanceSite[] {
572
+ const project = new Project({ useInMemoryFileSystem: true, skipFileDependencyResolution: true });
573
+ const sourceFile = project.createSourceFile(filePath, source);
574
+
575
+ const sites: StanceSite[] = [];
576
+ for (const call of sourceFile.getDescendantsOfKind(SyntaxKind.CallExpression)) {
577
+ const exprNode = call.getExpression();
578
+ if (!Node.isIdentifier(exprNode)) continue;
579
+ const callee = exprNode.getText();
580
+ if (!REPORT_STANCE_CALLEES.has(callee)) continue;
581
+
582
+ const args = call.getArguments();
583
+ if (args.length > 0) {
584
+ const options = args[0];
585
+ if (!options || !Node.isObjectLiteralExpression(options)) continue;
586
+ if (options.getProperties().some((p) => p.isKind(SyntaxKind.SpreadAssignment))) continue;
587
+ if (reportStanceHasPersonalStance(options)) continue;
588
+ }
589
+
590
+ const enclosingField = reportStanceEnclosingFieldName(call);
591
+ // A call with no enclosing field (e.g. a bare createTextField() at top
592
+ // level) has no real name to classify — `createTextField(...)` would
593
+ // otherwise false-positive as a near-miss on its own "Text".
594
+ const { stance, hint } = enclosingField
595
+ ? classifyFieldStance(enclosingField)
596
+ : { stance: "unclassified" as const, hint: undefined };
597
+
598
+ sites.push({
599
+ line: call.getStartLineNumber(),
600
+ field: enclosingField ?? `${callee}(...)`,
601
+ entity: reportStanceResolveEntity(call),
602
+ callee,
603
+ stance,
604
+ hint,
605
+ });
606
+ }
607
+ return sites;
608
+ }
609
+
610
+ function findReportStanceFiles(rootDir: string): string[] {
611
+ const glob = new Glob("**/*.{ts,tsx}");
612
+ const EXCLUDE = ["/node_modules/", "/dist/", "/build/"];
613
+ const files: string[] = [];
614
+ for (const file of glob.scanSync({ cwd: rootDir, dot: false })) {
615
+ if (file.endsWith(".d.ts")) continue;
616
+ const abs = resolve(rootDir, file);
617
+ if (EXCLUDE.some((p) => abs.includes(p))) continue;
618
+ files.push(abs);
619
+ }
620
+ return files.sort();
621
+ }
622
+
623
+ function reportStance(rootDir: string): void {
624
+ console.log(
625
+ `Scanning every .ts/.tsx under ${rootDir} except node_modules/dist/build/*.d.ts — guard-text-field-stance scans packages/*/src/** only, so counts can differ outside that scope.`,
626
+ );
627
+
628
+ const totals: Record<StanceClass, number> = {
629
+ direct: 0,
630
+ "user-owned": 0,
631
+ "user-reference": 0,
632
+ "near-miss": 0,
633
+ unclassified: 0,
634
+ };
635
+ let total = 0;
636
+
637
+ for (const file of findReportStanceFiles(rootDir)) {
638
+ const sites = reportStanceForSource(readFileSync(file, "utf8"), file);
639
+ if (sites.length === 0) continue;
640
+
641
+ console.log(`\n${relative(rootDir, file)}`);
642
+ for (const site of sites) {
643
+ totals[site.stance]++;
644
+ total++;
645
+ const entity = site.entity ?? "<unresolved>";
646
+ const hintSuffix = site.hint ? ` (hint: ${site.hint})` : "";
647
+ console.log(
648
+ ` ${site.line} ${site.field} entity=${entity} ${site.callee} ${site.stance}${hintSuffix}`,
649
+ );
650
+ }
651
+ }
652
+
653
+ console.log("\nBy stance:");
654
+ for (const [stance, count] of Object.entries(totals)) {
655
+ if (count > 0) console.log(` ${stance}: ${count}`);
656
+ }
657
+ console.log(`Total: ${total}`);
658
+
659
+ if (totals["near-miss"] > 0) {
660
+ console.log(
661
+ "\nHint sets in entity-handler.ts are exact name matches — near-miss field names slip past the boot heuristic.",
662
+ );
663
+ }
664
+ }
665
+
434
666
  async function main(): Promise<void> {
435
667
  const positional = process.argv.slice(2).filter((a) => !a.startsWith("--"));
436
- const dryRun = process.argv.includes("--dry-run");
437
668
  const rootDir = resolve(positional[0] ?? process.cwd());
438
669
 
670
+ if (process.argv.includes("--report-stance")) {
671
+ reportStance(rootDir);
672
+ // skip: report mode never rewrites, so the transform path below must not run
673
+ return;
674
+ }
675
+
676
+ const dryRun = process.argv.includes("--dry-run");
439
677
  const files = findTargetFiles(rootDir);
440
678
  const project = new Project({
441
679
  skipAddingFilesFromTsConfig: true,
@@ -477,4 +715,6 @@ async function main(): Promise<void> {
477
715
  }
478
716
  }
479
717
 
480
- await main();
718
+ if (import.meta.main) {
719
+ await main();
720
+ }
@@ -37,7 +37,7 @@ function memoEntity(access: Parameters<typeof createEntity>[0]["access"]) {
37
37
  return createEntity({
38
38
  table: "fwbootguard_memos",
39
39
  fields: {
40
- ownerId: createTextField({ required: true }),
40
+ ownerId: createTextField({ personal: false, reason: "test_fixture", required: true }),
41
41
  title: createTextField({ personal: false, reason: "test_fixture", required: true }),
42
42
  },
43
43
  ...(access ? { access } : {}),
@@ -97,6 +97,56 @@ describe("generateE2ESpec", () => {
97
97
  ]);
98
98
  });
99
99
 
100
+ test("groups-only sections liefern Required-Felder, Fill-Ops und Text-Assertion", () => {
101
+ const feature = defineFeature("tasks", (r) => {
102
+ r.systemScope();
103
+ r.entity("task", taskEntity);
104
+ r.writeHandler(
105
+ defineEntityCreateHandler("task", taskEntity, {
106
+ access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
107
+ }),
108
+ );
109
+ r.screen({
110
+ id: "task-list",
111
+ type: "entityList",
112
+ entity: "task",
113
+ columns: ["title", "status", "done"],
114
+ });
115
+ r.screen({
116
+ id: "task-edit",
117
+ type: "entityEdit",
118
+ entity: "task",
119
+ layout: {
120
+ sections: [
121
+ {
122
+ title: "tasks:section.basics",
123
+ fields: [],
124
+ groups: [
125
+ { title: "tasks:group.core", fields: ["title", "status"] },
126
+ { title: "tasks:group.state", fields: ["done"] },
127
+ ],
128
+ },
129
+ ],
130
+ },
131
+ });
132
+ });
133
+ const specs = generateE2ESpec(createRegistry([feature]));
134
+ const editSpecs = specs.filter((s) => s.screenQn === "tasks:screen:task-edit");
135
+
136
+ const validates = editSpecs.find((s) => s.kind === "edit-validates-required");
137
+ if (validates?.kind !== "edit-validates-required") throw new Error("unreachable");
138
+ expect(validates.requiredFields).toEqual(["title"]);
139
+
140
+ const persists = editSpecs.find((s) => s.kind === "edit-save-persists");
141
+ if (persists?.kind !== "edit-save-persists") throw new Error("unreachable");
142
+ expect(persists.fills).toEqual([
143
+ { kind: "fill", field: "title", value: "e2e title" },
144
+ { kind: "select", field: "status", value: "todo" },
145
+ { kind: "check", field: "done", value: true },
146
+ ]);
147
+ expect(persists.identifyingField).toBe("title");
148
+ });
149
+
100
150
  test("date emittiert fill, timestamp wird übersprungen (zwei Inputs seit #369)", () => {
101
151
  const entity = createEntity({
102
152
  table: "events",