@cosmicdrift/kumiko-framework 0.210.0 → 0.212.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.
@@ -3192,6 +3192,228 @@ describe("boot-validator", () => {
3192
3192
  });
3193
3193
  });
3194
3194
 
3195
+ // --- fw#2228: rowAction navigate-target as an entity instead of a screen ---
3196
+ describe("entityList rowAction kind=navigate entity-target (fw#2228)", () => {
3197
+ test("entity-target resolving via detailFor → kein Throw", () => {
3198
+ const feature = defineFeature("shop", (r) => {
3199
+ r.entity("product", createEntity({ fields: { name: createTextField() } }));
3200
+ r.screen({
3201
+ id: "product-list",
3202
+ type: "entityList",
3203
+ entity: "product",
3204
+ columns: ["name"],
3205
+ rowActions: [{ kind: "navigate", id: "view", label: "actions.view", entity: "product" }],
3206
+ });
3207
+ r.screen({
3208
+ id: "product-detail",
3209
+ type: "custom",
3210
+ renderer: { react: "stub" },
3211
+ detailFor: "product",
3212
+ });
3213
+ });
3214
+ expect(() => validateBoot([feature])).not.toThrow();
3215
+ });
3216
+
3217
+ test("entity-target with no detailFor screen anywhere → Throw mit klarer Message", () => {
3218
+ const feature = defineFeature("shop", (r) => {
3219
+ r.entity("product", createEntity({ fields: { name: createTextField() } }));
3220
+ r.screen({
3221
+ id: "product-list",
3222
+ type: "entityList",
3223
+ entity: "product",
3224
+ columns: ["name"],
3225
+ rowActions: [{ kind: "navigate", id: "view", label: "actions.view", entity: "product" }],
3226
+ });
3227
+ });
3228
+ expect(() => validateBoot([feature])).toThrow(
3229
+ /rowAction "view" navigate-target entity "product" has no screen declaring detailFor: "product"/,
3230
+ );
3231
+ });
3232
+
3233
+ test("both screen and entity set → Throw", () => {
3234
+ const feature = defineFeature("shop", (r) => {
3235
+ r.entity("product", createEntity({ fields: { name: createTextField() } }));
3236
+ r.screen({
3237
+ id: "product-list",
3238
+ type: "entityList",
3239
+ entity: "product",
3240
+ columns: ["name"],
3241
+ rowActions: [
3242
+ {
3243
+ kind: "navigate",
3244
+ id: "view",
3245
+ label: "actions.view",
3246
+ screen: "product-detail",
3247
+ entity: "product",
3248
+ },
3249
+ ],
3250
+ });
3251
+ r.screen({
3252
+ id: "product-detail",
3253
+ type: "custom",
3254
+ renderer: { react: "stub" },
3255
+ detailFor: "product",
3256
+ });
3257
+ });
3258
+ expect(() => validateBoot([feature])).toThrow(
3259
+ /rowAction "view" sets both "screen" and "entity"/,
3260
+ );
3261
+ });
3262
+
3263
+ test("neither screen nor entity set → Throw", () => {
3264
+ const feature = defineFeature("shop", (r) => {
3265
+ r.entity("product", createEntity({ fields: { name: createTextField() } }));
3266
+ r.screen({
3267
+ id: "product-list",
3268
+ type: "entityList",
3269
+ entity: "product",
3270
+ columns: ["name"],
3271
+ rowActions: [{ kind: "navigate", id: "view", label: "actions.view" }],
3272
+ });
3273
+ });
3274
+ expect(() => validateBoot([feature])).toThrow(
3275
+ /rowAction "view" sets neither "screen" nor "entity"/,
3276
+ );
3277
+ });
3278
+
3279
+ test("entity-target resolving cross-feature to a NON-entityEdit detailFor screen → kein Throw", () => {
3280
+ // Mirrors the existing cross-feature screen-target case above, but via
3281
+ // detailFor resolution — the owning feature's list navigates to a
3282
+ // detail screen a consumer app registers for the entity.
3283
+ const list = defineFeature("shop", (r) => {
3284
+ r.entity("product", createEntity({ fields: { name: createTextField() } }));
3285
+ r.screen({
3286
+ id: "product-list",
3287
+ type: "entityList",
3288
+ entity: "product",
3289
+ columns: ["name"],
3290
+ rowActions: [{ kind: "navigate", id: "view", label: "actions.view", entity: "product" }],
3291
+ });
3292
+ });
3293
+ const consumer = defineFeature("app", (r) => {
3294
+ r.screen({
3295
+ id: "product-detail",
3296
+ type: "custom",
3297
+ renderer: { react: "stub" },
3298
+ detailFor: "product",
3299
+ });
3300
+ });
3301
+ expect(() => validateBoot([list, consumer])).not.toThrow();
3302
+ });
3303
+
3304
+ test("entity-target resolving cross-feature to an entityEdit detailFor screen WITHOUT entityId → kein Throw", () => {
3305
+ // Regression guard: the cross-feature-entityEdit-needs-explicit-entityId
3306
+ // check (screen-target branch) must NOT fire for entity-targets — the
3307
+ // renderer always supplies an id for those (explicit entityId, else
3308
+ // row["id"]), so the same-feature-fallback gap that check guards
3309
+ // against doesn't exist here.
3310
+ const list = defineFeature("shop", (r) => {
3311
+ r.entity("product", createEntity({ fields: { name: createTextField() } }));
3312
+ r.screen({
3313
+ id: "product-list",
3314
+ type: "entityList",
3315
+ entity: "product",
3316
+ columns: ["name"],
3317
+ rowActions: [{ kind: "navigate", id: "edit", label: "actions.edit", entity: "invoice" }],
3318
+ });
3319
+ });
3320
+ const consumer = defineFeature("billing", (r) => {
3321
+ r.entity("invoice", createEntity({ fields: { name: createTextField() } }));
3322
+ r.screen({
3323
+ id: "invoice-edit",
3324
+ type: "entityEdit",
3325
+ entity: "invoice",
3326
+ layout: { sections: [{ columns: 1, fields: ["name"] }] },
3327
+ detailFor: "invoice",
3328
+ });
3329
+ });
3330
+ expect(() => validateBoot([list, consumer])).not.toThrow();
3331
+ });
3332
+
3333
+ test("projectionList rowAction entity-target without explicit entityId → Throw", () => {
3334
+ // projectionList rows come from an arbitrary query projection with no
3335
+ // guaranteed "id" field, unlike entityList rows — an entity-target
3336
+ // there must name the field to read the id from.
3337
+ const feature = defineFeature("shop", (r) => {
3338
+ r.entity("product", createEntity({ fields: { name: createTextField() } }));
3339
+ r.queryHandler("products", z.object({}), async () => ({ rows: [], nextCursor: null }), {
3340
+ access: { openToAll: true },
3341
+ });
3342
+ r.screen({
3343
+ id: "product-projection",
3344
+ type: "projectionList",
3345
+ query: "shop:query:products",
3346
+ columns: ["name"],
3347
+ rowActions: [{ kind: "navigate", id: "view", label: "actions.view", entity: "product" }],
3348
+ });
3349
+ r.screen({
3350
+ id: "product-detail",
3351
+ type: "custom",
3352
+ renderer: { react: "stub" },
3353
+ detailFor: "product",
3354
+ });
3355
+ });
3356
+ expect(() => validateBoot([feature])).toThrow(
3357
+ /rowAction "view" navigate-target entity "product" needs an explicit "entityId"/,
3358
+ );
3359
+ });
3360
+
3361
+ test("projectionList rowAction entity-target with explicit entityId → kein Throw", () => {
3362
+ const feature = defineFeature("shop", (r) => {
3363
+ r.entity("product", createEntity({ fields: { name: createTextField() } }));
3364
+ r.queryHandler("products", z.object({}), async () => ({ rows: [], nextCursor: null }), {
3365
+ access: { openToAll: true },
3366
+ });
3367
+ r.screen({
3368
+ id: "product-projection",
3369
+ type: "projectionList",
3370
+ query: "shop:query:products",
3371
+ columns: ["name"],
3372
+ rowActions: [
3373
+ {
3374
+ kind: "navigate",
3375
+ id: "view",
3376
+ label: "actions.view",
3377
+ entity: "product",
3378
+ entityId: "productId",
3379
+ },
3380
+ ],
3381
+ });
3382
+ r.screen({
3383
+ id: "product-detail",
3384
+ type: "custom",
3385
+ renderer: { react: "stub" },
3386
+ detailFor: "product",
3387
+ });
3388
+ });
3389
+ expect(() => validateBoot([feature])).not.toThrow();
3390
+ });
3391
+
3392
+ test("projectionDetail action entity-target without explicit entityId → Throw", () => {
3393
+ const feature = defineFeature("shop", (r) => {
3394
+ r.entity("product", createEntity({ fields: { name: createTextField() } }));
3395
+ r.screen({
3396
+ id: "order-detail",
3397
+ type: "projectionDetail",
3398
+ query: "shop:query:order-detail",
3399
+ layout: { sections: [{ fields: ["total"] }] },
3400
+ actions: [
3401
+ { kind: "navigate", id: "view-product", label: "actions.view", entity: "product" },
3402
+ ],
3403
+ });
3404
+ r.screen({
3405
+ id: "product-detail",
3406
+ type: "custom",
3407
+ renderer: { react: "stub" },
3408
+ detailFor: "product",
3409
+ });
3410
+ });
3411
+ expect(() => validateBoot([feature])).toThrow(
3412
+ /action "view-product" navigate-target entity "product" needs an explicit "entityId"/,
3413
+ );
3414
+ });
3415
+ });
3416
+
3195
3417
  // --- Screen short-id collision across features ---
3196
3418
  describe("screen short-id collisions across features", () => {
3197
3419
  test("two features registering the same short screen-id → Throw", () => {
@@ -169,3 +169,50 @@ describe("requiredKeysFromNav / requiredKeysFromWorkspace", () => {
169
169
  ).toEqual(["bmc:workspace.disposition"]);
170
170
  });
171
171
  });
172
+
173
+ // fw#2260: isI18nKey's colon-only check silently drops dot-form labels like
174
+ // `${feature}.settings` — the Settings-Hub generator's own convention (see
175
+ // buildConfigFeatureSchema). requiredKeysFromScreen/requiredKeysFromNav take
176
+ // an opt-in `treatDotFormAsKey` option so the boot-validator can register
177
+ // those generated keys directly instead of relying on isI18nKey to recognize
178
+ // them.
179
+ describe("dot-form labels + treatDotFormAsKey (fw#2260)", () => {
180
+ const configScreen: ConfigEditScreenDefinition = {
181
+ id: "billing-tenant",
182
+ type: "configEdit",
183
+ scope: "tenant",
184
+ configKeys: { apiKey: "billing:config:api-key" },
185
+ fieldLabels: { apiKey: "billing.api-key" },
186
+ fields: { apiKey: { type: "text" } },
187
+ layout: { sections: [{ title: "billing.settings", fields: ["apiKey"] }] },
188
+ };
189
+
190
+ test("configEdit section title + fieldLabels override: dot-form is dropped by default", () => {
191
+ const keys = requiredKeysFromScreen("config", configScreen);
192
+ expect(keys).not.toContain("billing.settings");
193
+ expect(keys).not.toContain("billing.api-key");
194
+ });
195
+
196
+ test("configEdit section title + fieldLabels override: treatDotFormAsKey surfaces the dot-form keys", () => {
197
+ const keys = requiredKeysFromScreen("config", configScreen, { treatDotFormAsKey: true });
198
+ expect(keys).toContain("billing.settings");
199
+ expect(keys).toContain("billing.api-key");
200
+ });
201
+
202
+ test("colon-form keys are still required with treatDotFormAsKey (no regression for the normal path)", () => {
203
+ const screen: ConfigEditScreenDefinition = {
204
+ ...configScreen,
205
+ fieldLabels: { apiKey: "billing:override.apiKey" },
206
+ layout: { sections: [{ title: "billing:section.basics", fields: ["apiKey"] }] },
207
+ };
208
+ const keys = requiredKeysFromScreen("config", screen, { treatDotFormAsKey: true });
209
+ expect(keys).toContain("billing:override.apiKey");
210
+ expect(keys).toContain("billing:section.basics");
211
+ });
212
+
213
+ test("nav label: dot-form is dropped by default, treatDotFormAsKey surfaces it", () => {
214
+ const nav = { id: "billing-tenant", label: "billing.settings" };
215
+ expect(requiredKeysFromNav(nav)).not.toContain("billing.settings");
216
+ expect(requiredKeysFromNav(nav, { treatDotFormAsKey: true })).toContain("billing.settings");
217
+ });
218
+ });
@@ -0,0 +1,64 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { access, createTenantConfig } from "../../config-helpers";
3
+ import { defineFeature } from "../../define-feature";
4
+ import { validateBoot } from "../index";
5
+
6
+ // fw#2260: the Settings-Hub generator (buildConfigFeatureSchema) labels a
7
+ // masked config key's generated nav entry / configEdit section with the
8
+ // dot-form key `${feature}.settings` — never colon-form. isI18nKey's
9
+ // colon-only check dropped that label from the required-keys set, so a
10
+ // feature could ship a generated Settings screen whose label was never
11
+ // translated and boot validation stayed silent.
12
+ describe("validateI18nSurfaceKeys — Settings-Hub generated dot-form label (fw#2260)", () => {
13
+ // Mirrors packages/bundled-features/src/config/i18n.ts — the audience-parent
14
+ // labels every masked config key's generated hub requires regardless of
15
+ // which feature owns the key.
16
+ const configHub = defineFeature("config", (r) => {
17
+ r.translations({
18
+ keys: {
19
+ "config.settings.title": { en: "Settings" },
20
+ "config.settings.system": { en: "Platform" },
21
+ "config.settings.tenant": { en: "Organization" },
22
+ "config.settings.user": { en: "Personal" },
23
+ },
24
+ });
25
+ });
26
+
27
+ function billingFeature(translationKeys: Record<string, { readonly en: string }>) {
28
+ return defineFeature("billing", (r) => {
29
+ r.config({
30
+ keys: {
31
+ // write restricted to a non-elevated role (see ELEVATED_ROLES in
32
+ // build-config-feature-schema.ts) so this stays a single
33
+ // tenant-scope screen — no SystemAdmin cascade to a second one.
34
+ apiKey: createTenantConfig("text", {
35
+ write: access.roles("TenantAdmin"),
36
+ mask: { title: "billing.api-key" },
37
+ }),
38
+ },
39
+ });
40
+ if (Object.keys(translationKeys).length > 0) {
41
+ r.translations({ keys: translationKeys });
42
+ }
43
+ });
44
+ }
45
+
46
+ test("generated nav label 'billing.settings' left untranslated fails boot", () => {
47
+ const billing = billingFeature({
48
+ "billing.api-key": { en: "API Key" },
49
+ "screen:billing-tenant.title": { en: "Billing Settings" },
50
+ });
51
+ expect(() => validateBoot([configHub, billing])).toThrow(
52
+ /Settings-Hub: required translation key missing: "billing\.settings"/,
53
+ );
54
+ });
55
+
56
+ test("translating the generated dot-form label lets boot pass", () => {
57
+ const billing = billingFeature({
58
+ "billing.api-key": { en: "API Key" },
59
+ "screen:billing-tenant.title": { en: "Billing Settings" },
60
+ "billing.settings": { en: "Billing Settings" },
61
+ });
62
+ expect(() => validateBoot([configHub, billing])).not.toThrow();
63
+ });
64
+ });
@@ -1,12 +1,22 @@
1
1
  import { qualifyEntityName } from "../qualified-name";
2
2
  import type { FeatureDefinition } from "../types";
3
+ import type { ScreenDefinition } from "../types/screen";
3
4
  import { findEntityFeature } from "./screens";
4
5
 
5
- export function validateDetailForScreens(
6
+ // Entity name → the one screen that declares detailFor: "<entity>". Built
7
+ // once, up front (before the per-feature validateScreens loop) so both this
8
+ // module's own uniqueness check AND rowAction entity-targets (fw#2228,
9
+ // screens.ts) resolve against the same map instead of re-walking every
10
+ // feature's screens twice.
11
+ export function collectDetailForScreens(
6
12
  features: readonly FeatureDefinition[],
7
13
  featureMap: ReadonlyMap<string, FeatureDefinition>,
8
- ): void {
14
+ ): Map<string, { readonly featureName: string; readonly screen: ScreenDefinition }> {
9
15
  const screenQnByEntity = new Map<string, string>();
16
+ const result = new Map<
17
+ string,
18
+ { readonly featureName: string; readonly screen: ScreenDefinition }
19
+ >();
10
20
 
11
21
  for (const feature of features) {
12
22
  for (const [screenId, screen] of Object.entries(feature.screens)) {
@@ -30,6 +40,10 @@ export function validateDetailForScreens(
30
40
  `but no feature registers an entity with that name.`,
31
41
  );
32
42
  }
43
+
44
+ result.set(detailFor, { featureName: feature.name, screen });
33
45
  }
34
46
  }
47
+
48
+ return result;
35
49
  }
@@ -16,11 +16,18 @@ function requiredKeysFromGeneratedConfigHub(registry: Registry): readonly string
16
16
  if (schema.navs.length === 0) return [];
17
17
  const out = new Set<string>();
18
18
 
19
+ // The generator's section titles + mask-title field-label overrides are
20
+ // dot-form i18n references (`${feature}.settings`), never literal display
21
+ // text, so treatDotFormAsKey bypasses isI18nKey's colon-only check (fw#2260).
19
22
  for (const screen of schema.screens) {
20
- for (const key of requiredKeysFromScreen(SETTINGS_HUB_FEATURE, screen)) out.add(key);
23
+ for (const key of requiredKeysFromScreen(SETTINGS_HUB_FEATURE, screen, {
24
+ treatDotFormAsKey: true,
25
+ })) {
26
+ out.add(key);
27
+ }
21
28
  }
22
29
  for (const nav of schema.navs) {
23
- for (const key of requiredKeysFromNav(nav)) out.add(key);
30
+ for (const key of requiredKeysFromNav(nav, { treatDotFormAsKey: true })) out.add(key);
24
31
  }
25
32
  if (schema.workspace) {
26
33
  for (const key of requiredKeysFromWorkspace(schema.workspace.definition)) out.add(key);
@@ -16,7 +16,7 @@ import {
16
16
  validateConfigReads,
17
17
  warnOnToggleableDependencies,
18
18
  } from "./config-deps";
19
- import { validateDetailForScreens } from "./detail-screens";
19
+ import { collectDetailForScreens } from "./detail-screens";
20
20
  import {
21
21
  validateDerivedFieldCollisions,
22
22
  validateEmbeddedFields,
@@ -142,6 +142,11 @@ export function validateBoot(
142
142
  const allWriteHandlerQns = collectWriteHandlerQns(features);
143
143
  const screensByShortId = collectScreensByShortId(features);
144
144
  validateScreenShortIdCollisions(screensByShortId);
145
+ // Cross-feature entity → detailFor-screen map — built (+ uniqueness-
146
+ // validated) up front so per-feature rowAction entity-targets (fw#2228)
147
+ // resolve against it inside the loop below, instead of at the very end
148
+ // where this used to run.
149
+ const detailForScreens = collectDetailForScreens(features, featureMap);
145
150
 
146
151
  // Cross-feature API exposure-map — jedes Feature deklariert Marker via
147
152
  // r.exposesApi(name). Per-feature validateApiExposureMatching walkt
@@ -202,6 +207,7 @@ export function validateBoot(
202
207
  allScreenQns,
203
208
  allConfigKeyQns,
204
209
  screensByShortId,
210
+ detailForScreens,
205
211
  );
206
212
  validateNavs(feature, allScreenQns, allNavQns, allWorkspaceQns);
207
213
  validateWorkspaces(feature, allNavQns);
@@ -211,7 +217,6 @@ export function validateBoot(
211
217
  validateDefaultWorkspaceUniqueness(allWorkspaceQns);
212
218
  validateI18nSurfaceKeys(features);
213
219
  validateEntityListScreens(features);
214
- validateDetailForScreens(features, featureMap);
215
220
  // Must run before validateProjectionListScreens: an unresolvable query
216
221
  // there is silently treated as "capability absent" and surfaces as a
217
222
  // misleading "no search parameter in its Zod schema" error instead of
@@ -20,6 +20,7 @@ import type {
20
20
  EditLayout,
21
21
  FieldCondition,
22
22
  RowAction,
23
+ RowActionNavigate,
23
24
  RowFieldExtractor,
24
25
  ScreenDefinition,
25
26
  ToolbarAction,
@@ -118,9 +119,11 @@ function validateRowActionNavigateParams(
118
119
  : `same entity "${screenEntity}" auto-fills row["id"]`
119
120
  })`
120
121
  : `screen type "${target.screen.type}"`;
122
+ const targetDescriptor =
123
+ action.screen !== undefined ? `"${action.screen}"` : `entity "${action.entity}"`;
121
124
  throw new Error(
122
125
  `[Feature ${featureName}] Screen "${screenId}" (${screenType}) rowAction "${action.id}" ` +
123
- `sets params on navigate-target "${action.screen}" which ${reason} — only actionForm ` +
126
+ `sets params on navigate-target ${targetDescriptor} which ${reason} — only actionForm ` +
124
127
  `and entityEdit-create targets read URL search params as initial values. Remove the ` +
125
128
  `params extractor or retarget to an actionForm / cross-entity entityEdit-create screen.`,
126
129
  );
@@ -336,6 +339,74 @@ function validateToolbarDrawerAction(
336
339
  }
337
340
  }
338
341
 
342
+ // fw#2228: a navigate rowAction (or projectionDetail header action, which
343
+ // reuses the same RowActionNavigate shape) names its target as either a
344
+ // screen (existing) or an entity (new) — exactly one. Shared by all three
345
+ // call sites so the mutual-exclusivity check and the entity→detailFor
346
+ // resolution don't drift between them (same drift risk
347
+ // validateRowActionNavigateParams above is already shared to avoid).
348
+ function resolveRowActionNavigateTarget(
349
+ featureName: string,
350
+ screenId: string,
351
+ screenType: "entityList" | "projectionList" | "projectionDetail",
352
+ actionLabel: "rowAction" | "action",
353
+ action: RowActionNavigate,
354
+ allScreenQns: ReadonlySet<string>,
355
+ navTargetShortIds: ReadonlySet<string>,
356
+ screensByShortId: ReadonlyMap<
357
+ string,
358
+ ReadonlyArray<{ readonly featureName: string; readonly screen: ScreenDefinition }>
359
+ >,
360
+ detailForScreens: ReadonlyMap<
361
+ string,
362
+ { readonly featureName: string; readonly screen: ScreenDefinition }
363
+ >,
364
+ ): { readonly featureName: string; readonly screen: ScreenDefinition } | undefined {
365
+ if (action.entity !== undefined) {
366
+ if (action.screen !== undefined) {
367
+ throw new Error(
368
+ `[Feature ${featureName}] Screen "${screenId}" (${screenType}) ${actionLabel} "${action.id}" ` +
369
+ `sets both "screen" and "entity" — exactly one navigate-target form is allowed.`,
370
+ );
371
+ }
372
+ if (screenType !== "entityList" && action.entityId === undefined) {
373
+ // entityList rows are always a real entity record, so row["id"] is a
374
+ // safe implicit default. projectionList/projectionDetail rows come from
375
+ // an arbitrary query projection with no guaranteed "id" field — an
376
+ // entity-target there needs an explicit entityId, or navigation silently
377
+ // opens the detail screen with no entity context at runtime.
378
+ throw new Error(
379
+ `[Feature ${featureName}] Screen "${screenId}" (${screenType}) ${actionLabel} "${action.id}" ` +
380
+ `navigate-target entity "${action.entity}" needs an explicit "entityId" — ${screenType} rows ` +
381
+ `come from a query projection with no guaranteed "id" field.`,
382
+ );
383
+ }
384
+ const detail = detailForScreens.get(action.entity);
385
+ if (detail === undefined) {
386
+ throw new Error(
387
+ `[Feature ${featureName}] Screen "${screenId}" (${screenType}) ${actionLabel} "${action.id}" ` +
388
+ `navigate-target entity "${action.entity}" has no screen declaring ` +
389
+ `detailFor: "${action.entity}".`,
390
+ );
391
+ }
392
+ return detail;
393
+ }
394
+ if (action.screen === undefined) {
395
+ throw new Error(
396
+ `[Feature ${featureName}] Screen "${screenId}" (${screenType}) ${actionLabel} "${action.id}" ` +
397
+ `sets neither "screen" nor "entity" — exactly one navigate-target form is required.`,
398
+ );
399
+ }
400
+ const candidateQn = qualifyEntityName(featureName, "screen", action.screen);
401
+ if (!allScreenQns.has(candidateQn) && !navTargetShortIds.has(action.screen)) {
402
+ throw new Error(
403
+ `[Feature ${featureName}] Screen "${screenId}" (${screenType}) ${actionLabel} "${action.id}" ` +
404
+ `navigate-target "${action.screen}" does not resolve to a registered screen in any feature.`,
405
+ );
406
+ }
407
+ return screensByShortId.get(action.screen)?.[0];
408
+ }
409
+
339
410
  export function validateScreens(
340
411
  feature: FeatureDefinition,
341
412
  featureMap: ReadonlyMap<string, FeatureDefinition>,
@@ -346,6 +417,10 @@ export function validateScreens(
346
417
  string,
347
418
  ReadonlyArray<{ readonly featureName: string; readonly screen: ScreenDefinition }>
348
419
  >,
420
+ detailForScreens: ReadonlyMap<
421
+ string,
422
+ { readonly featureName: string; readonly screen: ScreenDefinition }
423
+ >,
349
424
  ): void {
350
425
  // navigate-Targets (rowAction/toolbarAction) dürfen cross-feature zeigen —
351
426
  // der Runtime-Router (create-app) löst eine bare screenId app-weit über ALLE
@@ -433,14 +508,17 @@ export function validateScreens(
433
508
  if (screen.rowActions !== undefined) {
434
509
  for (const action of screen.rowActions) {
435
510
  if (action.kind === "navigate") {
436
- const candidateQn = qualifyEntityName(feature.name, "screen", action.screen);
437
- if (!allScreenQns.has(candidateQn) && !navTargetShortIds.has(action.screen)) {
438
- throw new Error(
439
- `[Feature ${feature.name}] Screen "${screenId}" (projectionList) rowAction "${action.id}" ` +
440
- `navigate-target "${action.screen}" does not resolve to a registered screen in any feature.`,
441
- );
442
- }
443
- const target = screensByShortId.get(action.screen)?.[0];
511
+ const target = resolveRowActionNavigateTarget(
512
+ feature.name,
513
+ screenId,
514
+ "projectionList",
515
+ "rowAction",
516
+ action,
517
+ allScreenQns,
518
+ navTargetShortIds,
519
+ screensByShortId,
520
+ detailForScreens,
521
+ );
444
522
  validateRowActionNavigateParams(
445
523
  feature.name,
446
524
  screenId,
@@ -545,14 +623,17 @@ export function validateScreens(
545
623
  );
546
624
  }
547
625
  if (action.kind === "navigate") {
548
- const candidateQn = qualifyEntityName(feature.name, "screen", action.screen);
549
- if (!allScreenQns.has(candidateQn) && !navTargetShortIds.has(action.screen)) {
550
- throw new Error(
551
- `[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) action "${action.id}" ` +
552
- `navigate-target "${action.screen}" does not resolve to a registered screen in any feature.`,
553
- );
554
- }
555
- const target = screensByShortId.get(action.screen)?.[0];
626
+ const target = resolveRowActionNavigateTarget(
627
+ feature.name,
628
+ screenId,
629
+ "projectionDetail",
630
+ "action",
631
+ action,
632
+ allScreenQns,
633
+ navTargetShortIds,
634
+ screensByShortId,
635
+ detailForScreens,
636
+ );
556
637
  validateRowActionNavigateParams(
557
638
  feature.name,
558
639
  screenId,
@@ -934,20 +1015,28 @@ export function validateScreens(
934
1015
  if (screen.rowActions !== undefined) {
935
1016
  for (const action of screen.rowActions) {
936
1017
  if (action.kind === "navigate") {
937
- const candidateQn = qualifyEntityName(feature.name, "screen", action.screen);
938
- if (!allScreenQns.has(candidateQn) && !navTargetShortIds.has(action.screen)) {
939
- throw new Error(
940
- `[Feature ${feature.name}] Screen "${screenId}" (entityList) rowAction "${action.id}" ` +
941
- `navigate-target "${action.screen}" does not resolve to a registered screen in any feature.`,
942
- );
943
- }
1018
+ const target = resolveRowActionNavigateTarget(
1019
+ feature.name,
1020
+ screenId,
1021
+ "entityList",
1022
+ "rowAction",
1023
+ action,
1024
+ allScreenQns,
1025
+ navTargetShortIds,
1026
+ screensByShortId,
1027
+ detailForScreens,
1028
+ );
944
1029
  // The renderer's default-entityId fallback (row["id"]) only fires
945
1030
  // for a same-feature entityEdit target — it can't safely guess
946
1031
  // the id for a screen owned by a different feature. Cross-feature
947
1032
  // + entityEdit therefore MUST set an explicit entityId, or the
948
1033
  // edit screen silently opens with no entity context at runtime.
949
- const target = screensByShortId.get(action.screen)?.[0];
1034
+ // Entity-targets (fw#2228) are exempt: the renderer always
1035
+ // supplies an id for them (explicit entityId, else row["id"]),
1036
+ // regardless of which feature the resolved detailFor screen
1037
+ // belongs to.
950
1038
  if (
1039
+ action.screen !== undefined &&
951
1040
  target !== undefined &&
952
1041
  target.featureName !== feature.name &&
953
1042
  target.screen.type === "entityEdit" &&
@@ -15,6 +15,8 @@ export type ChangelogEntry = {
15
15
  readonly detail?: string;
16
16
  /** Required when type=breaking. Shown in `kumiko upgrade` output. */
17
17
  readonly migration?: string;
18
+ /** Path (repo-root-relative, under scripts/codemod/) run by `kumiko upgrade --apply`. */
19
+ readonly codemod?: string;
18
20
  };
19
21
 
20
22
  export type FeatureChangelog = {
@@ -323,6 +323,14 @@ describe("UnprocessableError", () => {
323
323
  expect(err.details).toMatchObject({ reason: "order.already_cancelled", orderId: 7 });
324
324
  expect(err.i18nKey).toBe("orders.errors.alreadyCancelled");
325
325
  });
326
+
327
+ test("positional reason survives a conflicting details.reason from the caller", () => {
328
+ const err = new UnprocessableError("order.already_cancelled", {
329
+ details: { reason: "some unrelated cause text", orderId: 7 },
330
+ });
331
+ expect(err.details).toEqual({ reason: "order.already_cancelled", orderId: 7 });
332
+ expect(err.docsUrl).toBe("https://docs.kumiko.rocks/errors/order.already_cancelled");
333
+ });
326
334
  });
327
335
 
328
336
  describe("InternalError", () => {