@cosmicdrift/kumiko-framework 0.319.0 → 0.321.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 +8 -4
- package/src/__tests__/schema-cli.integration.test.ts +11 -0
- package/src/__tests__/upgrade-cli.test.ts +50 -3
- package/src/api/__tests__/auth-routes-mfa-preauth-enable-start.test.ts +6 -0
- package/src/api/__tests__/auth-routes-mfa-verify.test.ts +11 -0
- package/src/api/__tests__/auth-routes-trusted-proxy.test.ts +8 -4
- package/src/api/__tests__/client-ip.test.ts +164 -0
- package/src/api/__tests__/extra-routes.integration.test.ts +50 -1
- package/src/api/__tests__/http-route-rate-limit.integration.test.ts +73 -2
- package/src/api/__tests__/pat-route-guard.integration.test.ts +126 -0
- package/src/api/__tests__/post-auth-landing.test.ts +100 -0
- package/src/api/__tests__/request-id-middleware.test.ts +35 -2
- package/src/api/__tests__/schema-route.integration.test.ts +157 -0
- package/src/api/api-constants.ts +2 -0
- package/src/api/auth-routes.ts +92 -95
- package/src/api/client-ip.ts +118 -0
- package/src/api/extra-route.ts +3 -0
- package/src/api/index.ts +17 -0
- package/src/api/pat-route-guard.ts +51 -0
- package/src/api/post-auth-landing.ts +105 -0
- package/src/api/request-context.ts +4 -2
- package/src/api/request-id-middleware.ts +44 -14
- package/src/api/server.ts +130 -40
- package/src/changes.json +121 -0
- package/src/db/queries/event-store-transfer.ts +73 -0
- package/src/engine/__tests__/boot-validator-pii-retention.test.ts +47 -0
- package/src/engine/__tests__/metric-navigate-tab.test.ts +167 -0
- package/src/engine/__tests__/project-app-schema-for-roles.test.ts +500 -0
- package/src/engine/__tests__/steps-webhook-send.test.ts +2 -2
- package/src/engine/__tests__/toolbar-navigate-tab.test.ts +233 -0
- package/src/engine/boot-validator/pii-retention.ts +18 -2
- package/src/engine/boot-validator/screens.ts +75 -18
- package/src/engine/feature-ast/__tests__/fixtures/resolve-import/alias-reexport.ts +1 -0
- package/src/engine/feature-ast/__tests__/fixtures/resolve-import/cycle-a.ts +1 -0
- package/src/engine/feature-ast/__tests__/fixtures/resolve-import/cycle-b.ts +1 -0
- package/src/engine/feature-ast/__tests__/fixtures/resolve-import/entry.ts +3 -0
- package/src/engine/feature-ast/__tests__/fixtures/resolve-import/local-rebind.ts +3 -0
- package/src/engine/feature-ast/__tests__/fixtures/resolve-import/origin.ts +1 -0
- package/src/engine/feature-ast/__tests__/fixtures/resolve-import/star-barrel.ts +1 -0
- package/src/engine/feature-ast/__tests__/parse.test.ts +4 -4
- package/src/engine/feature-ast/__tests__/resolve-import.test.ts +138 -0
- package/src/engine/feature-ast/extractors/index.ts +4 -0
- package/src/engine/feature-ast/extractors/resolve-import.ts +187 -0
- package/src/engine/feature-ast/extractors/shared.ts +26 -19
- package/src/engine/feature-ast/parse.ts +13 -20
- package/src/engine/index.ts +1 -0
- package/src/engine/project-app-schema-for-roles.ts +950 -0
- package/src/engine/steps/webhook-send.ts +4 -2
- package/src/engine/types/handlers.ts +1 -1
- package/src/engine/types/index.ts +5 -1
- package/src/entrypoint/index.ts +14 -1
- package/src/event-store/__tests__/transfer.integration.test.ts +236 -0
- package/src/event-store/index.ts +6 -0
- package/src/event-store/transfer.ts +84 -0
- package/src/files/__tests__/files.integration.test.ts +105 -1
- package/src/files/file-routes.ts +54 -2
- package/src/files/index.ts +1 -1
- package/src/http/index.ts +10 -2
- package/src/jobs/__tests__/jobs.integration.test.ts +105 -3
- package/src/jobs/job-runner.ts +6 -1
- package/src/pipeline/multi-stream-apply-context.ts +4 -0
- package/src/rate-limit/__tests__/middleware.integration.test.ts +24 -7
- package/src/rate-limit/middleware.ts +32 -8
- package/src/schema-cli.ts +5 -1
- package/src/stack/__tests__/push-entity-projection-tables-column-sync.integration.test.ts +107 -0
- package/src/stack/push-entity-projection-tables.ts +52 -7
- package/src/stack/table-helpers.ts +17 -9
- package/src/stack/test-stack.ts +28 -1
- package/src/testing/real-providers.ts +2 -0
- package/src/ui-types/index.ts +1 -1
- package/src/upgrade-cli.ts +58 -18
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import * as z from "zod";
|
|
3
|
+
import { withBootValidatorFixture } from "../../testing/boot-validator-fixture";
|
|
4
|
+
import { validateBoot as validateBootRaw } from "../boot-validator";
|
|
5
|
+
import { defineFeature } from "../define-feature";
|
|
6
|
+
import { createEntity, createTextField } from "../factories";
|
|
7
|
+
|
|
8
|
+
function validateBoot(features: Parameters<typeof validateBootRaw>[0]): void {
|
|
9
|
+
validateBootRaw(withBootValidatorFixture(features));
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
describe("validateBoot — ToolbarAction.tab", () => {
|
|
13
|
+
test("entityList toolbar navigate + unknown tab throws with the action id", () => {
|
|
14
|
+
const feature = defineFeature("app", (r) => {
|
|
15
|
+
r.queryHandler("vehicle:detail", z.object({}), async () => ({ name: "x" }), {
|
|
16
|
+
access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
|
|
17
|
+
});
|
|
18
|
+
r.screen({
|
|
19
|
+
id: "vehicle-detail",
|
|
20
|
+
type: "projectionDetail",
|
|
21
|
+
query: "app:query:vehicle:detail",
|
|
22
|
+
layout: {
|
|
23
|
+
mode: "tabs",
|
|
24
|
+
sections: [
|
|
25
|
+
{ id: "overview", title: "Overview", fields: ["name"] },
|
|
26
|
+
{ id: "channels", title: "Channels", fields: ["name"] },
|
|
27
|
+
],
|
|
28
|
+
},
|
|
29
|
+
});
|
|
30
|
+
r.entity(
|
|
31
|
+
"car",
|
|
32
|
+
createEntity({
|
|
33
|
+
fields: { name: createTextField({ personal: false, reason: "test_fixture" }) },
|
|
34
|
+
}),
|
|
35
|
+
);
|
|
36
|
+
r.screen({
|
|
37
|
+
id: "car-list",
|
|
38
|
+
type: "entityList",
|
|
39
|
+
entity: "car",
|
|
40
|
+
columns: ["name"],
|
|
41
|
+
toolbarActions: [
|
|
42
|
+
{
|
|
43
|
+
kind: "navigate",
|
|
44
|
+
id: "view-channels",
|
|
45
|
+
label: "app.actions.viewChannels",
|
|
46
|
+
screen: "vehicle-detail",
|
|
47
|
+
tab: "unknown",
|
|
48
|
+
},
|
|
49
|
+
],
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
expect(() => validateBoot([feature])).toThrow(
|
|
53
|
+
/toolbarAction "view-channels".*navigates to tab "unknown", which is not a section id on target screen/,
|
|
54
|
+
);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("entityList toolbar navigate + known tab boots cleanly", () => {
|
|
58
|
+
const feature = defineFeature("app", (r) => {
|
|
59
|
+
r.queryHandler("vehicle:detail", z.object({}), async () => ({ name: "x" }), {
|
|
60
|
+
access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
|
|
61
|
+
});
|
|
62
|
+
r.screen({
|
|
63
|
+
id: "vehicle-detail",
|
|
64
|
+
type: "projectionDetail",
|
|
65
|
+
query: "app:query:vehicle:detail",
|
|
66
|
+
layout: {
|
|
67
|
+
mode: "tabs",
|
|
68
|
+
sections: [
|
|
69
|
+
{ id: "overview", title: "Overview", fields: ["name"] },
|
|
70
|
+
{ id: "channels", title: "Channels", fields: ["name"] },
|
|
71
|
+
],
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
r.entity(
|
|
75
|
+
"car",
|
|
76
|
+
createEntity({
|
|
77
|
+
fields: { name: createTextField({ personal: false, reason: "test_fixture" }) },
|
|
78
|
+
}),
|
|
79
|
+
);
|
|
80
|
+
r.screen({
|
|
81
|
+
id: "car-list",
|
|
82
|
+
type: "entityList",
|
|
83
|
+
entity: "car",
|
|
84
|
+
columns: ["name"],
|
|
85
|
+
toolbarActions: [
|
|
86
|
+
{
|
|
87
|
+
kind: "navigate",
|
|
88
|
+
id: "view-channels",
|
|
89
|
+
label: "app.actions.viewChannels",
|
|
90
|
+
screen: "vehicle-detail",
|
|
91
|
+
tab: "channels",
|
|
92
|
+
},
|
|
93
|
+
],
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
expect(() => validateBoot([feature])).not.toThrow();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("entityList toolbar navigate targeting a non-tabs projectionDetail throws", () => {
|
|
100
|
+
const feature = defineFeature("app", (r) => {
|
|
101
|
+
r.queryHandler("vehicle:detail", z.object({}), async () => ({ name: "x" }), {
|
|
102
|
+
access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
|
|
103
|
+
});
|
|
104
|
+
r.screen({
|
|
105
|
+
id: "vehicle-detail",
|
|
106
|
+
type: "projectionDetail",
|
|
107
|
+
query: "app:query:vehicle:detail",
|
|
108
|
+
layout: { sections: [{ title: "Overview", fields: ["name"] }] },
|
|
109
|
+
});
|
|
110
|
+
r.entity(
|
|
111
|
+
"car",
|
|
112
|
+
createEntity({
|
|
113
|
+
fields: { name: createTextField({ personal: false, reason: "test_fixture" }) },
|
|
114
|
+
}),
|
|
115
|
+
);
|
|
116
|
+
r.screen({
|
|
117
|
+
id: "car-list",
|
|
118
|
+
type: "entityList",
|
|
119
|
+
entity: "car",
|
|
120
|
+
columns: ["name"],
|
|
121
|
+
toolbarActions: [
|
|
122
|
+
{
|
|
123
|
+
kind: "navigate",
|
|
124
|
+
id: "view-channels",
|
|
125
|
+
label: "app.actions.viewChannels",
|
|
126
|
+
screen: "vehicle-detail",
|
|
127
|
+
tab: "overview",
|
|
128
|
+
},
|
|
129
|
+
],
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
expect(() => validateBoot([feature])).toThrow(
|
|
133
|
+
/is not a projectionDetail with layout.mode "tabs"/,
|
|
134
|
+
);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("projectionList toolbar navigate + unknown tab throws", () => {
|
|
138
|
+
const feature = defineFeature("app", (r) => {
|
|
139
|
+
r.queryHandler("vehicle:detail", z.object({}), async () => ({ name: "x" }), {
|
|
140
|
+
access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
|
|
141
|
+
});
|
|
142
|
+
r.screen({
|
|
143
|
+
id: "vehicle-detail",
|
|
144
|
+
type: "projectionDetail",
|
|
145
|
+
query: "app:query:vehicle:detail",
|
|
146
|
+
layout: {
|
|
147
|
+
mode: "tabs",
|
|
148
|
+
sections: [
|
|
149
|
+
{ id: "overview", title: "Overview", fields: ["name"] },
|
|
150
|
+
{ id: "channels", title: "Channels", fields: ["name"] },
|
|
151
|
+
],
|
|
152
|
+
},
|
|
153
|
+
});
|
|
154
|
+
r.queryHandler("car:list", z.object({}), async () => ({ rows: [], nextCursor: null }), {
|
|
155
|
+
access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
|
|
156
|
+
});
|
|
157
|
+
r.screen({
|
|
158
|
+
id: "car-projection-list",
|
|
159
|
+
type: "projectionList",
|
|
160
|
+
query: "app:query:car:list",
|
|
161
|
+
columns: ["name"],
|
|
162
|
+
toolbarActions: [
|
|
163
|
+
{
|
|
164
|
+
kind: "navigate",
|
|
165
|
+
id: "view-channels",
|
|
166
|
+
label: "app.actions.viewChannels",
|
|
167
|
+
screen: "vehicle-detail",
|
|
168
|
+
tab: "unknown",
|
|
169
|
+
},
|
|
170
|
+
],
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
expect(() => validateBoot([feature])).toThrow(
|
|
174
|
+
/toolbarAction "view-channels".*navigates to tab "unknown", which is not a section id on target screen/,
|
|
175
|
+
);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test("relatedList section toolbar navigate + unknown tab throws", () => {
|
|
179
|
+
const feature = defineFeature("app", (r) => {
|
|
180
|
+
r.queryHandler("vehicle:detail", z.object({}), async () => ({ name: "x" }), {
|
|
181
|
+
access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
|
|
182
|
+
});
|
|
183
|
+
r.screen({
|
|
184
|
+
id: "vehicle-detail",
|
|
185
|
+
type: "projectionDetail",
|
|
186
|
+
query: "app:query:vehicle:detail",
|
|
187
|
+
layout: {
|
|
188
|
+
mode: "tabs",
|
|
189
|
+
sections: [
|
|
190
|
+
{ id: "overview", title: "Overview", fields: ["name"] },
|
|
191
|
+
{ id: "channels", title: "Channels", fields: ["name"] },
|
|
192
|
+
],
|
|
193
|
+
},
|
|
194
|
+
});
|
|
195
|
+
r.queryHandler("lease:detail", z.object({}), async () => ({ id: "1" }), {
|
|
196
|
+
access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
|
|
197
|
+
});
|
|
198
|
+
r.queryHandler(
|
|
199
|
+
"lease:positions",
|
|
200
|
+
z.object({}),
|
|
201
|
+
async () => ({ rows: [], nextCursor: null }),
|
|
202
|
+
{ access: { openToAll: { reason: "test handler callable by any signed-in test user" } } },
|
|
203
|
+
);
|
|
204
|
+
r.screen({
|
|
205
|
+
id: "lease-detail",
|
|
206
|
+
type: "projectionDetail",
|
|
207
|
+
query: "app:query:lease:detail",
|
|
208
|
+
layout: {
|
|
209
|
+
sections: [
|
|
210
|
+
{
|
|
211
|
+
kind: "relatedList",
|
|
212
|
+
title: "Positions",
|
|
213
|
+
query: "app:query:lease:positions",
|
|
214
|
+
columns: ["id"],
|
|
215
|
+
toolbarActions: [
|
|
216
|
+
{
|
|
217
|
+
kind: "navigate",
|
|
218
|
+
id: "view-channels",
|
|
219
|
+
label: "app.actions.viewChannels",
|
|
220
|
+
screen: "vehicle-detail",
|
|
221
|
+
tab: "unknown",
|
|
222
|
+
},
|
|
223
|
+
],
|
|
224
|
+
},
|
|
225
|
+
],
|
|
226
|
+
},
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
expect(() => validateBoot([feature])).toThrow(
|
|
230
|
+
/toolbarAction "view-channels".*navigates to tab "unknown", which is not a section id on target screen/,
|
|
231
|
+
);
|
|
232
|
+
});
|
|
233
|
+
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { FeatureDefinition } from "../types";
|
|
2
|
-
import type { ResolvedPiiFlags } from "../types/fields";
|
|
2
|
+
import type { EntityDefinition, ResolvedPiiFlags } from "../types/fields";
|
|
3
3
|
import {
|
|
4
4
|
PII_DIRECT_NAME_HINTS,
|
|
5
5
|
PII_USER_OWNED_NAME_HINTS,
|
|
@@ -27,6 +27,17 @@ function hasAnonymizableSubjectField(annot: ResolvedPiiFlags): boolean {
|
|
|
27
27
|
return Boolean(annot.pii || annot.userOwned || annot.tenantOwned || annot.recordOwned);
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
function ownerFieldNamesReferencedByPersonalOf(
|
|
31
|
+
fieldsByName: EntityDefinition["fields"],
|
|
32
|
+
): ReadonlySet<string> {
|
|
33
|
+
const ownerFieldNames = new Set<string>();
|
|
34
|
+
for (const field of Object.values(fieldsByName)) {
|
|
35
|
+
const ownerFieldName = (field as ResolvedPiiFlags).userOwned?.ownerField; // @cast-boundary schema-walk
|
|
36
|
+
if (typeof ownerFieldName === "string") ownerFieldNames.add(ownerFieldName);
|
|
37
|
+
}
|
|
38
|
+
return ownerFieldNames;
|
|
39
|
+
}
|
|
40
|
+
|
|
30
41
|
// --- PII / Subject-Key Annotations + Retention validation ---
|
|
31
42
|
//
|
|
32
43
|
// Drei Klassen von Checks:
|
|
@@ -54,6 +65,7 @@ function hasAnonymizableSubjectField(annot: ResolvedPiiFlags): boolean {
|
|
|
54
65
|
export function validatePiiAndRetention(feature: FeatureDefinition): void {
|
|
55
66
|
for (const [entityName, entity] of Object.entries(feature.entities ?? {})) {
|
|
56
67
|
const fieldsByName = entity.fields;
|
|
68
|
+
const personalOfOwnerFieldNames = ownerFieldNamesReferencedByPersonalOf(fieldsByName);
|
|
57
69
|
|
|
58
70
|
for (const [fieldName, field] of Object.entries(fieldsByName)) {
|
|
59
71
|
// ResolvedPiiFlags properties are type-level optional. On field defs
|
|
@@ -192,7 +204,11 @@ export function validatePiiAndRetention(feature: FeatureDefinition): void {
|
|
|
192
204
|
console.warn(
|
|
193
205
|
`[kumiko:boot] [Feature ${feature.name}] Field "${fieldName}" on entity "${entityName}" has a user-content-typical name but no personal annotation. If this contains user-generated content, mark it { personal: { of: "<authorIdField>" }, find: ... }. If business data, set { personal: false, reason: "..." } to silence.`,
|
|
194
206
|
);
|
|
195
|
-
} else if (
|
|
207
|
+
} else if (
|
|
208
|
+
PII_USER_REFERENCE_NAME_HINTS.has(lower) &&
|
|
209
|
+
!annot.subjectRef &&
|
|
210
|
+
!personalOfOwnerFieldNames.has(fieldName)
|
|
211
|
+
) {
|
|
196
212
|
// biome-ignore lint/suspicious/noConsole: boot-time dev hint, no logger available yet
|
|
197
213
|
console.warn(
|
|
198
214
|
`[kumiko:boot] [Feature ${feature.name}] Field "${fieldName}" on entity "${entityName}" has a user-reference-typical name but no personal annotation — a foreign key into \`user\` carries Art.17 obligations even with no annotated content on the entity. Mark it { personal: "ref" } AND register r.useExtension(EXT_USER_DATA, "${entityName}", …) for Art.17 coverage — this warning is the only boot-time check for that, registering the hook is not enforced. Or { personal: { of: "${fieldName}" } } on the field it owns. If business data, set { personal: false, reason: "..." } to silence.`,
|
|
@@ -450,7 +450,7 @@ export function validateScreenShortIdCollisions(
|
|
|
450
450
|
// cross-feature screen QN (`<feature>:screen:<id>`) given verbatim — a
|
|
451
451
|
// short id can never itself be a valid QN (QN_SEGMENT forbids colons), so
|
|
452
452
|
// the two forms don't collide.
|
|
453
|
-
function resolveScreenTargetQn(featureName: string, target: string): string {
|
|
453
|
+
export function resolveScreenTargetQn(featureName: string, target: string): string {
|
|
454
454
|
return isValidQn(target) ? target : qualifyEntityName(featureName, "screen", target);
|
|
455
455
|
}
|
|
456
456
|
|
|
@@ -1158,9 +1158,9 @@ export function validateScreens(
|
|
|
1158
1158
|
}
|
|
1159
1159
|
validateAtMostOneRowClick(feature.name, screenId, "projectionList", screen.rowActions);
|
|
1160
1160
|
}
|
|
1161
|
-
// Only drawer-kind
|
|
1162
|
-
//
|
|
1163
|
-
//
|
|
1161
|
+
// Only drawer-kind and navigate actions that set tab are validated
|
|
1162
|
+
// here — plain navigate (no tab) and writeHandler toolbarActions on
|
|
1163
|
+
// projectionList have no boot check yet (pre-existing gap).
|
|
1164
1164
|
if (screen.toolbarActions !== undefined) {
|
|
1165
1165
|
for (const action of screen.toolbarActions) {
|
|
1166
1166
|
if (action.kind === "drawer") {
|
|
@@ -1173,6 +1173,19 @@ export function validateScreens(
|
|
|
1173
1173
|
feature.screens,
|
|
1174
1174
|
);
|
|
1175
1175
|
}
|
|
1176
|
+
if (action.kind === "navigate" && action.tab !== undefined) {
|
|
1177
|
+
resolveRowActionNavigateTarget(
|
|
1178
|
+
feature.name,
|
|
1179
|
+
screenId,
|
|
1180
|
+
"projectionList",
|
|
1181
|
+
"toolbarAction",
|
|
1182
|
+
action,
|
|
1183
|
+
allScreenQns,
|
|
1184
|
+
navTargetShortIds,
|
|
1185
|
+
screensByShortId,
|
|
1186
|
+
detailForScreens,
|
|
1187
|
+
);
|
|
1188
|
+
}
|
|
1176
1189
|
}
|
|
1177
1190
|
}
|
|
1178
1191
|
continue;
|
|
@@ -1239,18 +1252,35 @@ export function validateScreens(
|
|
|
1239
1252
|
);
|
|
1240
1253
|
}
|
|
1241
1254
|
const navigate = typeof metric === "string" ? undefined : metric.navigate;
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1255
|
+
if (navigate?.tab !== undefined) {
|
|
1256
|
+
if (navigate.screen === undefined && navigate.entity === undefined) {
|
|
1257
|
+
if (!screen.layout.sections.some((section) => section.id === navigate.tab)) {
|
|
1258
|
+
throw new Error(
|
|
1259
|
+
`[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) metric "${field}" ` +
|
|
1260
|
+
`navigates to tab "${navigate.tab}", which is not a section id on this screen.`,
|
|
1261
|
+
);
|
|
1262
|
+
}
|
|
1263
|
+
} else {
|
|
1264
|
+
resolveRowActionNavigateTarget(
|
|
1265
|
+
feature.name,
|
|
1266
|
+
screenId,
|
|
1267
|
+
"projectionDetail",
|
|
1268
|
+
"metric",
|
|
1269
|
+
{
|
|
1270
|
+
kind: "navigate",
|
|
1271
|
+
id: field,
|
|
1272
|
+
label: field,
|
|
1273
|
+
...(navigate.screen !== undefined ? { screen: navigate.screen } : {}),
|
|
1274
|
+
...(navigate.entity !== undefined ? { entity: navigate.entity } : {}),
|
|
1275
|
+
...(navigate.entityId !== undefined ? { entityId: navigate.entityId } : {}),
|
|
1276
|
+
tab: navigate.tab,
|
|
1277
|
+
},
|
|
1278
|
+
allScreenQns,
|
|
1279
|
+
navTargetShortIds,
|
|
1280
|
+
screensByShortId,
|
|
1281
|
+
detailForScreens,
|
|
1282
|
+
);
|
|
1283
|
+
}
|
|
1254
1284
|
}
|
|
1255
1285
|
}
|
|
1256
1286
|
}
|
|
@@ -1459,8 +1489,9 @@ export function validateScreens(
|
|
|
1459
1489
|
featureMap,
|
|
1460
1490
|
);
|
|
1461
1491
|
}
|
|
1462
|
-
// Only drawer-kind
|
|
1463
|
-
//
|
|
1492
|
+
// Only drawer-kind and navigate actions that set tab are validated
|
|
1493
|
+
// here — plain navigate (no tab) and writeHandler toolbarActions
|
|
1494
|
+
// have no boot check yet, same gap as above.
|
|
1464
1495
|
if (section.toolbarActions !== undefined) {
|
|
1465
1496
|
for (const action of section.toolbarActions) {
|
|
1466
1497
|
if (action.kind === "drawer") {
|
|
@@ -1473,6 +1504,19 @@ export function validateScreens(
|
|
|
1473
1504
|
feature.screens,
|
|
1474
1505
|
);
|
|
1475
1506
|
}
|
|
1507
|
+
if (action.kind === "navigate" && action.tab !== undefined) {
|
|
1508
|
+
resolveRowActionNavigateTarget(
|
|
1509
|
+
feature.name,
|
|
1510
|
+
screenId,
|
|
1511
|
+
"projectionDetail",
|
|
1512
|
+
"toolbarAction",
|
|
1513
|
+
action,
|
|
1514
|
+
allScreenQns,
|
|
1515
|
+
navTargetShortIds,
|
|
1516
|
+
screensByShortId,
|
|
1517
|
+
detailForScreens,
|
|
1518
|
+
);
|
|
1519
|
+
}
|
|
1476
1520
|
}
|
|
1477
1521
|
}
|
|
1478
1522
|
continue;
|
|
@@ -2002,6 +2046,19 @@ export function validateScreens(
|
|
|
2002
2046
|
`navigate-target "${action.screen}" does not resolve to a registered screen in any feature.`,
|
|
2003
2047
|
);
|
|
2004
2048
|
}
|
|
2049
|
+
if (action.tab !== undefined) {
|
|
2050
|
+
resolveRowActionNavigateTarget(
|
|
2051
|
+
feature.name,
|
|
2052
|
+
screenId,
|
|
2053
|
+
"entityList",
|
|
2054
|
+
"toolbarAction",
|
|
2055
|
+
action,
|
|
2056
|
+
allScreenQns,
|
|
2057
|
+
navTargetShortIds,
|
|
2058
|
+
screensByShortId,
|
|
2059
|
+
detailForScreens,
|
|
2060
|
+
);
|
|
2061
|
+
}
|
|
2005
2062
|
} else if (action.kind === "drawer") {
|
|
2006
2063
|
validateDrawerTargetAction(
|
|
2007
2064
|
feature.name,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { ORIGIN_VALUE as ALIASED } from "./origin";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./cycle-b";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./cycle-a";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const ORIGIN_VALUE = "origin-value" as const;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./alias-reexport";
|
|
@@ -2799,10 +2799,10 @@ defineFeature("showpony", (r) => {
|
|
|
2799
2799
|
|
|
2800
2800
|
test("an imported registrar-wrapper stays invisible when its file can't be resolved (in-memory project, #1008)", () => {
|
|
2801
2801
|
// parseInline uses an in-memory ts-morph Project — "./register/screens"
|
|
2802
|
-
// isn't a real file, so
|
|
2803
|
-
//
|
|
2804
|
-
//
|
|
2805
|
-
//
|
|
2802
|
+
// isn't a real file, so resolveModuleFile() returns undefined and the
|
|
2803
|
+
// wrapper is skipped like any other unresolvable call. Real cross-file
|
|
2804
|
+
// resolution against on-disk files is covered by the parseFeatureFile
|
|
2805
|
+
// fixture tests below.
|
|
2806
2806
|
expect(result.patterns.some((p) => p.kind === "screen" || p.kind === "nav")).toBe(false);
|
|
2807
2807
|
});
|
|
2808
2808
|
});
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import { Project, type SourceFile } from "ts-morph";
|
|
4
|
+
import {
|
|
5
|
+
findImportBindingForLocalName,
|
|
6
|
+
resolveExportedVariable,
|
|
7
|
+
resolveModuleFile,
|
|
8
|
+
} from "../extractors/resolve-import";
|
|
9
|
+
import { parseFeatureFile, parseSourceFile } from "../parse";
|
|
10
|
+
|
|
11
|
+
function loadFixture(relPath: string): SourceFile {
|
|
12
|
+
const project = new Project({
|
|
13
|
+
skipAddingFilesFromTsConfig: true,
|
|
14
|
+
skipFileDependencyResolution: true,
|
|
15
|
+
});
|
|
16
|
+
return project.addSourceFileAtPath(resolve(__dirname, relPath));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe("resolveModuleFile", () => {
|
|
20
|
+
test("resolves a relative specifier to the real source file", () => {
|
|
21
|
+
const entry = loadFixture("fixtures/resolve-import/entry.ts");
|
|
22
|
+
const resolved = resolveModuleFile(entry, "./star-barrel");
|
|
23
|
+
expect(String(resolved?.getFilePath())).toBe(
|
|
24
|
+
resolve(__dirname, "fixtures/resolve-import/star-barrel.ts"),
|
|
25
|
+
);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("returns undefined for an unresolvable package", () => {
|
|
29
|
+
const entry = loadFixture("fixtures/resolve-import/entry.ts");
|
|
30
|
+
const resolved = resolveModuleFile(entry, "@totally-unresolvable/not-a-real-package");
|
|
31
|
+
expect(resolved).toBeUndefined();
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe("findImportBindingForLocalName", () => {
|
|
36
|
+
test("follows an aliased named import to the module's original export name", () => {
|
|
37
|
+
const entry = loadFixture("fixtures/resolve-import/entry.ts");
|
|
38
|
+
const binding = findImportBindingForLocalName(entry, "RENAMED");
|
|
39
|
+
expect(binding).toEqual({ moduleSpecifier: "./star-barrel", importedName: "ALIASED" });
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("returns undefined for a name with no import declaration", () => {
|
|
43
|
+
const entry = loadFixture("fixtures/resolve-import/entry.ts");
|
|
44
|
+
expect(findImportBindingForLocalName(entry, "NOT_IMPORTED")).toBeUndefined();
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe("resolveExportedVariable", () => {
|
|
49
|
+
test("follows a cross-file aliased re-export (export { X as Y } from ...)", () => {
|
|
50
|
+
const aliasFile = loadFixture("fixtures/resolve-import/alias-reexport.ts");
|
|
51
|
+
const decl = resolveExportedVariable(aliasFile, "ALIASED");
|
|
52
|
+
expect(decl?.getName()).toBe("ORIGIN_VALUE");
|
|
53
|
+
expect(decl?.getInitializer()?.getText()).toContain("origin-value");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("follows an export * barrel to a nested aliased re-export", () => {
|
|
57
|
+
const barrelFile = loadFixture("fixtures/resolve-import/star-barrel.ts");
|
|
58
|
+
const decl = resolveExportedVariable(barrelFile, "ALIASED");
|
|
59
|
+
expect(decl?.getName()).toBe("ORIGIN_VALUE");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("follows a local re-export of an imported binding (import + export {} with no from)", () => {
|
|
63
|
+
const localRebindFile = loadFixture("fixtures/resolve-import/local-rebind.ts");
|
|
64
|
+
const decl = resolveExportedVariable(localRebindFile, "LOCAL_EXPORT");
|
|
65
|
+
expect(decl?.getName()).toBe("ORIGIN_VALUE");
|
|
66
|
+
expect(String(decl?.getSourceFile().getFilePath())).toBe(
|
|
67
|
+
resolve(__dirname, "fixtures/resolve-import/origin.ts"),
|
|
68
|
+
);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("terminates instead of looping forever on a mutual export * cycle", () => {
|
|
72
|
+
const cycleFile = loadFixture("fixtures/resolve-import/cycle-a.ts");
|
|
73
|
+
const decl = resolveExportedVariable(cycleFile, "NOTHING_EXPORTED_HERE");
|
|
74
|
+
expect(decl).toBeUndefined();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("returns undefined for a name that is never exported anywhere in the chain", () => {
|
|
78
|
+
const barrelFile = loadFixture("fixtures/resolve-import/star-barrel.ts");
|
|
79
|
+
expect(resolveExportedVariable(barrelFile, "DOES_NOT_EXIST")).toBeUndefined();
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe("same-file shadowing (regression for the getSymbol()/getDefinitionNodes() removal)", () => {
|
|
84
|
+
function makeSourceFile(content: string): SourceFile {
|
|
85
|
+
const project = new Project({
|
|
86
|
+
skipAddingFilesFromTsConfig: true,
|
|
87
|
+
skipFileDependencyResolution: true,
|
|
88
|
+
useInMemoryFileSystem: true,
|
|
89
|
+
});
|
|
90
|
+
return project.createSourceFile("shadow.ts", content);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
test("an inner const declared inside setup() shadows a same-named outer const", () => {
|
|
94
|
+
const sf = makeSourceFile(`
|
|
95
|
+
import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
|
|
96
|
+
|
|
97
|
+
const NAME = "outer";
|
|
98
|
+
|
|
99
|
+
defineFeature("f", (r) => {
|
|
100
|
+
const NAME = "inner";
|
|
101
|
+
r.extendsRegistrar(NAME, {});
|
|
102
|
+
});
|
|
103
|
+
`);
|
|
104
|
+
const result = parseSourceFile(sf);
|
|
105
|
+
expect(result.errors).toEqual([]);
|
|
106
|
+
expect(result.patterns).toMatchObject([{ kind: "extendsRegistrar", extensionName: "inner" }]);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("a function parameter shadowing an outer const yields an unresolvable reference, not the outer value", () => {
|
|
110
|
+
const sf = makeSourceFile(`
|
|
111
|
+
import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
|
|
112
|
+
|
|
113
|
+
const NAME = "outer";
|
|
114
|
+
|
|
115
|
+
defineFeature("f", (r) => {
|
|
116
|
+
function wrap(NAME) {
|
|
117
|
+
r.extendsRegistrar(NAME, {});
|
|
118
|
+
}
|
|
119
|
+
wrap("shadowed-by-param");
|
|
120
|
+
});
|
|
121
|
+
`);
|
|
122
|
+
const result = parseSourceFile(sf);
|
|
123
|
+
expect(result.patterns).toEqual([]);
|
|
124
|
+
expect(result.errors).toEqual([expect.objectContaining({ methodName: "extendsRegistrar" })]);
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
describe("parseFeatureFile resolves cross-file constants without a Program", () => {
|
|
129
|
+
test("sessions' imported event-name constant resolves to its real value", () => {
|
|
130
|
+
const result = parseFeatureFile(
|
|
131
|
+
resolve(__dirname, "../../../../../bundled-features/src/sessions/feature.ts"),
|
|
132
|
+
);
|
|
133
|
+
const defineEvent = result.patterns.find((p) => p.kind === "defineEvent");
|
|
134
|
+
// SESSION_REVOKED_EVENT_SHORT is imported from ./session-revoked-event —
|
|
135
|
+
// only resolves through the cross-file resolver this change replaces.
|
|
136
|
+
expect(defineEvent).toMatchObject({ eventName: "session-revoked" });
|
|
137
|
+
});
|
|
138
|
+
});
|