@cosmicdrift/kumiko-framework 0.2.2 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +54 -0
- package/package.json +124 -38
- package/src/__tests__/full-stack.integration.ts +2 -2
- package/src/api/auth-routes.ts +5 -5
- package/src/api/jwt.ts +2 -2
- package/src/api/route-registrars.ts +1 -1
- package/src/api/routes.ts +3 -3
- package/src/api/server.ts +6 -7
- package/src/auth/__tests__/roles.test.ts +24 -0
- package/src/auth/index.ts +7 -0
- package/src/auth/roles.ts +42 -0
- package/src/compliance/__tests__/duration-spec.test.ts +72 -0
- package/src/compliance/__tests__/profiles.test.ts +308 -0
- package/src/compliance/__tests__/sub-processors.test.ts +139 -0
- package/src/compliance/duration-spec.ts +44 -0
- package/src/compliance/index.ts +31 -0
- package/src/compliance/override-schema.ts +136 -0
- package/src/compliance/profiles.ts +427 -0
- package/src/compliance/sub-processors.ts +152 -0
- package/src/db/__tests__/big-int-field.test.ts +131 -0
- package/src/db/assert-exists-in.ts +2 -2
- package/src/db/cursor.ts +3 -3
- package/src/db/event-store-executor.ts +19 -13
- package/src/db/located-timestamp.ts +1 -1
- package/src/db/money.ts +12 -2
- package/src/db/pg-error.ts +1 -1
- package/src/db/row-helpers.ts +1 -1
- package/src/db/table-builder.ts +20 -5
- package/src/db/tenant-db.ts +9 -9
- package/src/engine/__tests__/_pipeline-test-utils.ts +23 -0
- package/src/engine/__tests__/boot-validator-api-exposure.test.ts +142 -0
- package/src/engine/__tests__/boot-validator-pii-retention.test.ts +570 -0
- package/src/engine/__tests__/boot-validator-s0-integration.test.ts +160 -0
- package/src/engine/__tests__/build-target.test.ts +135 -0
- package/src/engine/__tests__/codemod-pipeline.test.ts +551 -0
- package/src/engine/__tests__/entity-handlers.test.ts +3 -3
- package/src/engine/__tests__/event-helpers.test.ts +4 -4
- package/src/engine/__tests__/pipeline-engine.test.ts +215 -0
- package/src/engine/__tests__/pipeline-handler.integration.ts +894 -0
- package/src/engine/__tests__/pipeline-observability.integration.ts +142 -0
- package/src/engine/__tests__/pipeline-performance.integration.ts +152 -0
- package/src/engine/__tests__/pipeline-sub-pipelines.test.ts +288 -0
- package/src/engine/__tests__/raw-table.test.ts +2 -2
- package/src/engine/__tests__/steps-aggregate-append-event.test.ts +115 -0
- package/src/engine/__tests__/steps-aggregate-create.test.ts +92 -0
- package/src/engine/__tests__/steps-aggregate-update.test.ts +127 -0
- package/src/engine/__tests__/steps-call-feature.test.ts +123 -0
- package/src/engine/__tests__/steps-mail-send.test.ts +136 -0
- package/src/engine/__tests__/steps-read.test.ts +142 -0
- package/src/engine/__tests__/steps-resolver-utils.test.ts +50 -0
- package/src/engine/__tests__/steps-unsafe-projection-delete.test.ts +69 -0
- package/src/engine/__tests__/steps-unsafe-projection-upsert.test.ts +117 -0
- package/src/engine/__tests__/steps-webhook-send.test.ts +135 -0
- package/src/engine/__tests__/steps-workflow.test.ts +198 -0
- package/src/engine/__tests__/validate-projection-allowlist.test.ts +491 -0
- package/src/engine/__tests__/visual-tree-patterns.test.ts +251 -0
- package/src/engine/boot-validator/api-ext.ts +77 -0
- package/src/engine/boot-validator/config-deps.ts +163 -0
- package/src/engine/boot-validator/entity-handler.ts +466 -0
- package/src/engine/boot-validator/index.ts +159 -0
- package/src/engine/boot-validator/ownership.ts +198 -0
- package/src/engine/boot-validator/pii-retention.ts +155 -0
- package/src/engine/boot-validator/screens-nav.ts +624 -0
- package/src/engine/boot-validator.ts +1 -1528
- package/src/engine/build-app-schema.ts +1 -1
- package/src/engine/build-target.ts +99 -0
- package/src/engine/codemod/index.ts +15 -0
- package/src/engine/codemod/pipeline-codemod.ts +641 -0
- package/src/engine/config-helpers.ts +9 -19
- package/src/engine/constants.ts +1 -1
- package/src/engine/define-feature.ts +127 -9
- package/src/engine/define-handler.ts +89 -3
- package/src/engine/define-roles.ts +2 -2
- package/src/engine/define-step.ts +28 -0
- package/src/engine/define-workflow.ts +110 -0
- package/src/engine/entity-handlers.ts +10 -9
- package/src/engine/event-helpers.ts +4 -4
- package/src/engine/extension-names.ts +105 -0
- package/src/engine/extensions/user-data.ts +106 -0
- package/src/engine/factories.ts +26 -16
- package/src/engine/feature-ast/__tests__/visual-tree-parse.test.ts +184 -0
- package/src/engine/feature-ast/extractors/index.ts +74 -0
- package/src/engine/feature-ast/extractors/round1.ts +110 -0
- package/src/engine/feature-ast/extractors/round2.ts +253 -0
- package/src/engine/feature-ast/extractors/round3.ts +471 -0
- package/src/engine/feature-ast/extractors/round4.ts +1365 -0
- package/src/engine/feature-ast/extractors/round5.ts +72 -0
- package/src/engine/feature-ast/extractors/round6.ts +66 -0
- package/src/engine/feature-ast/extractors/shared.ts +177 -0
- package/src/engine/feature-ast/parse.ts +13 -0
- package/src/engine/feature-ast/patch.ts +9 -1
- package/src/engine/feature-ast/patcher.ts +10 -3
- package/src/engine/feature-ast/patterns.ts +71 -1
- package/src/engine/feature-ast/render.ts +31 -1
- package/src/engine/index.ts +66 -2
- package/src/engine/pattern-library/__tests__/library.test.ts +11 -0
- package/src/engine/pattern-library/library.ts +78 -2
- package/src/engine/pipeline.ts +88 -0
- package/src/engine/projection-helpers.ts +1 -1
- package/src/engine/read-claim.ts +1 -1
- package/src/engine/registry.ts +30 -2
- package/src/engine/resolve-config-or-param.ts +4 -0
- package/src/engine/run-pipeline.ts +162 -0
- package/src/engine/schema-builder.ts +10 -4
- package/src/engine/state-machine.ts +1 -1
- package/src/engine/steps/_drizzle-boundary.ts +19 -0
- package/src/engine/steps/_duration-utils.ts +33 -0
- package/src/engine/steps/_no-return-guard.ts +21 -0
- package/src/engine/steps/_resolver-utils.ts +42 -0
- package/src/engine/steps/_step-dispatch-constants.ts +38 -0
- package/src/engine/steps/aggregate-append-event.ts +56 -0
- package/src/engine/steps/aggregate-create.ts +56 -0
- package/src/engine/steps/aggregate-update.ts +68 -0
- package/src/engine/steps/branch.ts +84 -0
- package/src/engine/steps/call-feature.ts +49 -0
- package/src/engine/steps/compute.ts +41 -0
- package/src/engine/steps/for-each.ts +111 -0
- package/src/engine/steps/mail-send.ts +44 -0
- package/src/engine/steps/read-find-many.ts +51 -0
- package/src/engine/steps/read-find-one.ts +58 -0
- package/src/engine/steps/retry.ts +87 -0
- package/src/engine/steps/return.ts +34 -0
- package/src/engine/steps/unsafe-projection-delete.ts +46 -0
- package/src/engine/steps/unsafe-projection-upsert.ts +69 -0
- package/src/engine/steps/wait-for-event.ts +71 -0
- package/src/engine/steps/wait.ts +69 -0
- package/src/engine/steps/webhook-send.ts +71 -0
- package/src/engine/system-user.ts +1 -1
- package/src/engine/types/feature.ts +143 -1
- package/src/engine/types/fields.ts +134 -10
- package/src/engine/types/handlers.ts +18 -10
- package/src/engine/types/identifiers.ts +1 -0
- package/src/engine/types/index.ts +15 -1
- package/src/engine/types/step.ts +334 -0
- package/src/engine/types/target-ref.ts +21 -0
- package/src/engine/types/tree-node.ts +130 -0
- package/src/engine/types/workspace.ts +7 -0
- package/src/engine/validate-projection-allowlist.ts +161 -0
- package/src/event-store/snapshot.ts +1 -1
- package/src/event-store/upcaster-dead-letter.ts +1 -1
- package/src/event-store/upcaster.ts +1 -1
- package/src/files/__tests__/read-stream.test.ts +105 -0
- package/src/files/__tests__/write-stream.test.ts +233 -0
- package/src/files/__tests__/zip-stream.test.ts +357 -0
- package/src/files/file-routes.ts +1 -1
- package/src/files/in-memory-provider.ts +38 -0
- package/src/files/index.ts +3 -0
- package/src/files/local-provider.ts +58 -1
- package/src/files/types.ts +36 -8
- package/src/files/zip-stream.ts +251 -0
- package/src/jobs/job-runner.ts +10 -10
- package/src/lifecycle/lifecycle.ts +0 -3
- package/src/logging/index.ts +1 -0
- package/src/logging/pino-logger.ts +11 -7
- package/src/logging/utils.ts +24 -0
- package/src/observability/prometheus-meter.ts +7 -5
- package/src/pipeline/__tests__/archive-stream.integration.ts +1 -1
- package/src/pipeline/__tests__/causation-chain.integration.ts +1 -1
- package/src/pipeline/__tests__/domain-events-projections.integration.ts +3 -3
- package/src/pipeline/__tests__/event-define-event-strict.integration.ts +4 -4
- package/src/pipeline/__tests__/load-aggregate-query.integration.ts +1 -1
- package/src/pipeline/__tests__/msp-multi-hop.integration.ts +3 -3
- package/src/pipeline/__tests__/msp-rebuild.integration.ts +3 -3
- package/src/pipeline/__tests__/multi-stream-projection.integration.ts +2 -2
- package/src/pipeline/__tests__/query-projection.integration.ts +5 -5
- package/src/pipeline/append-event-core.ts +22 -6
- package/src/pipeline/dispatcher-utils.ts +188 -0
- package/src/pipeline/dispatcher.ts +63 -283
- package/src/pipeline/distributed-lock.ts +1 -1
- package/src/pipeline/entity-cache.ts +2 -2
- package/src/pipeline/event-consumer-state.ts +0 -13
- package/src/pipeline/event-dispatcher.ts +4 -4
- package/src/pipeline/index.ts +0 -2
- package/src/pipeline/lifecycle-pipeline.ts +6 -12
- package/src/pipeline/msp-rebuild.ts +5 -5
- package/src/pipeline/multi-stream-apply-context.ts +6 -7
- package/src/pipeline/projection-rebuild.ts +2 -2
- package/src/pipeline/projection-state.ts +0 -12
- package/src/rate-limit/__tests__/resolver.integration.ts +8 -4
- package/src/rate-limit/resolver.ts +1 -1
- package/src/search/in-memory-adapter.ts +1 -1
- package/src/search/meilisearch-adapter.ts +3 -3
- package/src/search/types.ts +1 -1
- package/src/secrets/leak-guard.ts +2 -2
- package/src/stack/request-helper.ts +9 -5
- package/src/stack/test-stack.ts +1 -1
- package/src/testing/handler-context.ts +4 -4
- package/src/testing/http-cookies.ts +1 -1
- package/src/time/tz-context.ts +1 -2
- package/src/ui-types/index.ts +4 -0
- package/src/engine/feature-ast/extractors.ts +0 -2562
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest";
|
|
2
|
+
import { buildTarget, createRegistry, defineFeature } from "../index";
|
|
3
|
+
import type { TreeChildrenSubscribe, TreeNode } from "../types/tree-node";
|
|
4
|
+
|
|
5
|
+
// Stub-Provider für Tests. Form: (ctx) => (emit) => unsubscribe.
|
|
6
|
+
// Emittet einmal initial, kein Cleanup nötig (no-op unsubscribe).
|
|
7
|
+
function makeStubProvider(nodes: readonly TreeNode[]): TreeChildrenSubscribe {
|
|
8
|
+
return () => (emit) => {
|
|
9
|
+
emit(nodes);
|
|
10
|
+
return () => {};
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
describe("r.treeActions — registrar slot", () => {
|
|
15
|
+
test("feature without r.treeActions leaves the slot undefined", () => {
|
|
16
|
+
const feature = defineFeature("empty", () => {});
|
|
17
|
+
expect(feature.treeActions).toBeUndefined();
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("single r.treeActions call stores the map on the FeatureDefinition", () => {
|
|
21
|
+
const feature = defineFeature("text-content", (r) => {
|
|
22
|
+
r.treeActions({
|
|
23
|
+
edit: { args: { slug: "" as string } },
|
|
24
|
+
list: {},
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
expect(feature.treeActions).toEqual({
|
|
28
|
+
edit: { args: { slug: "" } },
|
|
29
|
+
list: {},
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("returns a typed handle carrying id + literal-typed treeActions", () => {
|
|
34
|
+
const feature = defineFeature("text-content", (r) => {
|
|
35
|
+
const handle = r.treeActions({
|
|
36
|
+
edit: { args: { slug: "" as string } },
|
|
37
|
+
list: {},
|
|
38
|
+
});
|
|
39
|
+
return { handle };
|
|
40
|
+
});
|
|
41
|
+
expect(feature.exports.handle.id).toBe("text-content");
|
|
42
|
+
expect(feature.exports.handle.treeActions).toEqual({
|
|
43
|
+
edit: { args: { slug: "" } },
|
|
44
|
+
list: {},
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("second r.treeActions call throws — only-once-guard", () => {
|
|
49
|
+
expect(() =>
|
|
50
|
+
defineFeature("dupe", (r) => {
|
|
51
|
+
r.treeActions({ edit: { args: { slug: "" as string } } });
|
|
52
|
+
r.treeActions({ list: {} });
|
|
53
|
+
}),
|
|
54
|
+
).toThrow(/r\.treeActions\(\) already called/);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("returned handle is frozen — cannot mutate id or actions after creation", () => {
|
|
58
|
+
const feature = defineFeature("text-content", (r) => {
|
|
59
|
+
const handle = r.treeActions({ list: {} });
|
|
60
|
+
return { handle };
|
|
61
|
+
});
|
|
62
|
+
expect(Object.isFrozen(feature.exports.handle)).toBe(true);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe("r.tree — registrar slot", () => {
|
|
67
|
+
test("feature without r.tree leaves the slot undefined", () => {
|
|
68
|
+
const feature = defineFeature("empty", () => {});
|
|
69
|
+
expect(feature.treeProvider).toBeUndefined();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("single r.tree call stores the provider function on the FeatureDefinition", () => {
|
|
73
|
+
const provider = makeStubProvider([{ label: "Marketing" }]);
|
|
74
|
+
const feature = defineFeature("text-content", (r) => {
|
|
75
|
+
r.tree(provider);
|
|
76
|
+
});
|
|
77
|
+
expect(feature.treeProvider).toBe(provider);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("second r.tree call throws — only-once-guard", () => {
|
|
81
|
+
expect(() =>
|
|
82
|
+
defineFeature("dupe", (r) => {
|
|
83
|
+
r.tree(makeStubProvider([]));
|
|
84
|
+
r.tree(makeStubProvider([]));
|
|
85
|
+
}),
|
|
86
|
+
).toThrow(/r\.tree\(\) already called/);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("treeActions and tree are independent slots — can declare one without the other", () => {
|
|
90
|
+
const onlyActions = defineFeature("a", (r) => {
|
|
91
|
+
r.treeActions({ list: {} });
|
|
92
|
+
});
|
|
93
|
+
expect(onlyActions.treeActions).toBeDefined();
|
|
94
|
+
expect(onlyActions.treeProvider).toBeUndefined();
|
|
95
|
+
|
|
96
|
+
const onlyProvider = defineFeature("b", (r) => {
|
|
97
|
+
r.tree(makeStubProvider([]));
|
|
98
|
+
});
|
|
99
|
+
expect(onlyProvider.treeActions).toBeUndefined();
|
|
100
|
+
expect(onlyProvider.treeProvider).toBeDefined();
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
describe("Registry.getTreeProviders + getTreeActions", () => {
|
|
105
|
+
test("empty registry returns empty providers map and undefined actions", () => {
|
|
106
|
+
const reg = createRegistry([]);
|
|
107
|
+
expect(reg.getTreeProviders().size).toBe(0);
|
|
108
|
+
expect(reg.getTreeActions("nonexistent")).toBeUndefined();
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("aggregates providers from multiple features keyed by feature name", () => {
|
|
112
|
+
const providerA = makeStubProvider([{ label: "A-root" }]);
|
|
113
|
+
const providerB = makeStubProvider([{ label: "B-root" }]);
|
|
114
|
+
const featureA = defineFeature("text-content", (r) => {
|
|
115
|
+
r.tree(providerA);
|
|
116
|
+
});
|
|
117
|
+
const featureB = defineFeature("legal-pages", (r) => {
|
|
118
|
+
r.tree(providerB);
|
|
119
|
+
});
|
|
120
|
+
const reg = createRegistry([featureA, featureB]);
|
|
121
|
+
|
|
122
|
+
const providers = reg.getTreeProviders();
|
|
123
|
+
expect(providers.size).toBe(2);
|
|
124
|
+
expect(providers.get("text-content")).toBe(providerA);
|
|
125
|
+
expect(providers.get("legal-pages")).toBe(providerB);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("features without r.tree are absent from the providers map (Zero-Whitelist-Filter)", () => {
|
|
129
|
+
const featureWithProvider = defineFeature("text-content", (r) => {
|
|
130
|
+
r.tree(makeStubProvider([]));
|
|
131
|
+
});
|
|
132
|
+
const featureWithoutProvider = defineFeature("schema-editor", () => {});
|
|
133
|
+
const reg = createRegistry([featureWithProvider, featureWithoutProvider]);
|
|
134
|
+
|
|
135
|
+
const providers = reg.getTreeProviders();
|
|
136
|
+
expect(providers.has("text-content")).toBe(true);
|
|
137
|
+
expect(providers.has("schema-editor")).toBe(false);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("getTreeActions returns the erased map for a feature that declared r.treeActions", () => {
|
|
141
|
+
const feature = defineFeature("text-content", (r) => {
|
|
142
|
+
r.treeActions({
|
|
143
|
+
edit: { args: { slug: "" as string } },
|
|
144
|
+
list: {},
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
const reg = createRegistry([feature]);
|
|
148
|
+
|
|
149
|
+
expect(reg.getTreeActions("text-content")).toEqual({
|
|
150
|
+
edit: { args: { slug: "" } },
|
|
151
|
+
list: {},
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test("getTreeActions returns undefined for a feature without r.treeActions", () => {
|
|
156
|
+
const feature = defineFeature("no-actions", (r) => {
|
|
157
|
+
r.tree(makeStubProvider([]));
|
|
158
|
+
});
|
|
159
|
+
const reg = createRegistry([feature]);
|
|
160
|
+
|
|
161
|
+
expect(reg.getTreeActions("no-actions")).toBeUndefined();
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
// =============================================================================
|
|
166
|
+
// Schicht-1↔Schicht-2-Bridge: typed handle survives module-boundary.
|
|
167
|
+
//
|
|
168
|
+
// Diese Tests sind die kritische Architektur-Verification: r.treeActions
|
|
169
|
+
// returnt einen typed Handle, der via setup-export durch FeatureDefinition
|
|
170
|
+
// fließt und compile-time-typisiert vom Schicht-1-buildTarget konsumiert
|
|
171
|
+
// wird. Ohne diese Tests würde ein Type-Drift in einer der zwei Schichten
|
|
172
|
+
// erst in V.1.1 sichtbar — siehe advisor-Verdict + Memory `[EventDef-
|
|
173
|
+
// Exports-Pattern]`.
|
|
174
|
+
// =============================================================================
|
|
175
|
+
|
|
176
|
+
describe("Schicht-1↔Schicht-2 Bridge — buildTarget against real defineFeature handle", () => {
|
|
177
|
+
test("buildTarget compiles + runs against handle from feature.exports", () => {
|
|
178
|
+
const textContent = defineFeature("text-content", (r) => {
|
|
179
|
+
const handle = r.treeActions({
|
|
180
|
+
edit: { args: { slug: "" as string } },
|
|
181
|
+
list: {},
|
|
182
|
+
});
|
|
183
|
+
return { handle };
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
// Der Beweis: dieser Call typecheckt OHNE Casts/unknowns.
|
|
187
|
+
const ref = buildTarget({
|
|
188
|
+
target: textContent.exports.handle,
|
|
189
|
+
action: "edit",
|
|
190
|
+
args: { slug: "imprint" },
|
|
191
|
+
});
|
|
192
|
+
expect(ref.featureId).toBe("text-content");
|
|
193
|
+
expect(ref.action).toBe("edit");
|
|
194
|
+
expect(ref.args).toEqual({ slug: "imprint" });
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test("NoArgs-Action durch den Bridge — list ohne args", () => {
|
|
198
|
+
const textContent = defineFeature("text-content", (r) => {
|
|
199
|
+
const handle = r.treeActions({
|
|
200
|
+
edit: { args: { slug: "" as string } },
|
|
201
|
+
list: {},
|
|
202
|
+
});
|
|
203
|
+
return { handle };
|
|
204
|
+
});
|
|
205
|
+
const ref = buildTarget({ target: textContent.exports.handle, action: "list" });
|
|
206
|
+
expect(ref).toEqual({ featureId: "text-content", action: "list" });
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test("Compile-Time-Pinning — typed handle rejects falsche Calls (@ts-expect-error)", () => {
|
|
210
|
+
const textContent = defineFeature("text-content", (r) => {
|
|
211
|
+
const handle = r.treeActions({
|
|
212
|
+
edit: { args: { slug: "" as string } },
|
|
213
|
+
list: {},
|
|
214
|
+
});
|
|
215
|
+
return { handle };
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
// @ts-expect-error — "delet" ist keine Action im Handle
|
|
219
|
+
buildTarget({ target: textContent.exports.handle, action: "delet", args: { slug: "x" } });
|
|
220
|
+
|
|
221
|
+
// @ts-expect-error — slug muss string sein, nicht number
|
|
222
|
+
buildTarget({
|
|
223
|
+
target: textContent.exports.handle,
|
|
224
|
+
action: "edit",
|
|
225
|
+
args: { slug: 42 },
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
buildTarget({
|
|
229
|
+
target: textContent.exports.handle,
|
|
230
|
+
action: "list",
|
|
231
|
+
// @ts-expect-error — list hat keine args, args-Feld nicht erlaubt
|
|
232
|
+
args: { x: 1 },
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
// @ts-expect-error — edit braucht args, fehlt
|
|
236
|
+
buildTarget({ target: textContent.exports.handle, action: "edit" });
|
|
237
|
+
|
|
238
|
+
// Runtime-Coverage (Memory `[Keine Fake-Tests]`): der korrespondierende
|
|
239
|
+
// Happy-Path über denselben typed Handle liefert valid TargetRef.
|
|
240
|
+
// Beweist dass die Bridge nicht nur compile-time funktioniert sondern
|
|
241
|
+
// runtime den richtigen TargetRef konstruiert.
|
|
242
|
+
const validRef = buildTarget({
|
|
243
|
+
target: textContent.exports.handle,
|
|
244
|
+
action: "edit",
|
|
245
|
+
args: { slug: "imprint" },
|
|
246
|
+
});
|
|
247
|
+
expect(validRef.featureId).toBe("text-content");
|
|
248
|
+
expect(validRef.action).toBe("edit");
|
|
249
|
+
expect(validRef.args).toEqual({ slug: "imprint" });
|
|
250
|
+
});
|
|
251
|
+
});
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import type { FeatureDefinition } from "../types";
|
|
2
|
+
|
|
3
|
+
// --- Cross-feature API exposure / usage matching ---
|
|
4
|
+
//
|
|
5
|
+
// `r.exposesApi(name, impl)` registers a callable; `r.usesApi(name)`
|
|
6
|
+
// declares a caller. Boot-Validator prüft drei Invarianten:
|
|
7
|
+
// 1. Jeder usesApi(name) findet einen exposesApi(name) in irgendeinem
|
|
8
|
+
// Feature.
|
|
9
|
+
// 2. Das exposing-Feature ist in requires/optionalRequires des callers
|
|
10
|
+
// gelisted (sonst klappt die Cross-Feature-Aufruf-Reihenfolge nicht).
|
|
11
|
+
// 3. Self-exposure ist erlaubt (Feature ruft eigene API), wird aber
|
|
12
|
+
// mit Warning markiert weil es typisch ein Refactor-Restbestand ist.
|
|
13
|
+
//
|
|
14
|
+
// Globale Eindeutigkeit der apiNames (kein Dublicate über Features)
|
|
15
|
+
// wird in validateBoot() vor dem Per-Feature-Walk geprüft.
|
|
16
|
+
export function validateApiExposureMatching(
|
|
17
|
+
feature: FeatureDefinition,
|
|
18
|
+
allExposedApis: ReadonlyMap<string, string>,
|
|
19
|
+
featureMap: ReadonlyMap<string, FeatureDefinition>,
|
|
20
|
+
): void {
|
|
21
|
+
for (const apiName of feature.usedApis) {
|
|
22
|
+
const providerFeature = allExposedApis.get(apiName);
|
|
23
|
+
if (!providerFeature) {
|
|
24
|
+
const known = [...allExposedApis.keys()].sort().join(", ") || "(none)";
|
|
25
|
+
throw new Error(
|
|
26
|
+
`[Feature ${feature.name}] r.usesApi("${apiName}") but no feature exposes that API. Known exposed APIs: ${known}`,
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (providerFeature === feature.name) {
|
|
31
|
+
// biome-ignore lint/suspicious/noConsole: boot-time dev hint, no logger available yet
|
|
32
|
+
console.warn(
|
|
33
|
+
`[kumiko:boot] [Feature ${feature.name}] r.usesApi("${apiName}") on its own r.exposesApi — typically a refactor leftover. Call the impl directly instead.`,
|
|
34
|
+
);
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const allDeps = [...feature.requires, ...feature.optionalRequires];
|
|
39
|
+
if (!allDeps.includes(providerFeature)) {
|
|
40
|
+
throw new Error(
|
|
41
|
+
`[Feature ${feature.name}] r.usesApi("${apiName}") is exposed by "${providerFeature}" but feature is not in requires/optionalRequires. Add r.requires("${providerFeature}").`,
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Sanity: provider feature actually exists in this app's feature set.
|
|
46
|
+
// Should always be true if allExposedApis was built from `features`,
|
|
47
|
+
// aber defensiv für unklare Constructor-Pfade.
|
|
48
|
+
if (!featureMap.has(providerFeature)) {
|
|
49
|
+
throw new Error(
|
|
50
|
+
`[Feature ${feature.name}] internal: r.usesApi("${apiName}") points to provider "${providerFeature}" which is not in feature map`,
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// --- Extension usage validation ---
|
|
57
|
+
|
|
58
|
+
export function validateExtensionUsages(
|
|
59
|
+
feature: FeatureDefinition,
|
|
60
|
+
extensionProviders: ReadonlyMap<string, string>,
|
|
61
|
+
): void {
|
|
62
|
+
for (const usage of feature.extensionUsages) {
|
|
63
|
+
const providerFeature = extensionProviders.get(usage.extensionName);
|
|
64
|
+
if (!providerFeature) {
|
|
65
|
+
throw new Error(
|
|
66
|
+
`Feature "${feature.name}" uses extension "${usage.extensionName}" on entity "${usage.entityName}" but no feature defines that extension`,
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const allDeps = [...feature.requires, ...feature.optionalRequires];
|
|
71
|
+
if (!allDeps.includes(providerFeature)) {
|
|
72
|
+
throw new Error(
|
|
73
|
+
`Feature "${feature.name}" uses extension "${usage.extensionName}" but missing requires("${providerFeature}")`,
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import type { FeatureDefinition } from "../types";
|
|
2
|
+
|
|
3
|
+
// --- Toggleable-dependency warnings ---
|
|
4
|
+
//
|
|
5
|
+
// When feature A declares r.requires("B") and B is toggleable with
|
|
6
|
+
// default=false, A is effectively disabled out-of-the-box until someone
|
|
7
|
+
// flips B on globally. That's usually an oversight — the dev either meant
|
|
8
|
+
// optionalRequires, or forgot to ship B with default=true. We warn (not
|
|
9
|
+
// fail) because the combination is legal: an app might intentionally
|
|
10
|
+
// require an opt-in feature to make it explicit that B must be activated.
|
|
11
|
+
export function warnOnToggleableDependencies(
|
|
12
|
+
features: readonly FeatureDefinition[],
|
|
13
|
+
featureMap: ReadonlyMap<string, FeatureDefinition>,
|
|
14
|
+
): void {
|
|
15
|
+
for (const f of features) {
|
|
16
|
+
for (const dep of f.requires) {
|
|
17
|
+
const depFeature = featureMap.get(dep);
|
|
18
|
+
if (!depFeature) continue; // requires-target-missing is handled elsewhere
|
|
19
|
+
if (depFeature.toggleableDefault === false) {
|
|
20
|
+
// biome-ignore lint/suspicious/noConsole: boot-time dev hint, no logger available yet
|
|
21
|
+
console.warn(
|
|
22
|
+
`[kumiko:boot] Feature "${f.name}" requires "${dep}", which is toggleable(default=false). ` +
|
|
23
|
+
`"${f.name}" will be effectively disabled until "${dep}" is enabled globally via the feature-toggles feature. ` +
|
|
24
|
+
`If this is intentional, ignore this warning; otherwise consider r.optionalRequires() or default=true.`,
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// --- Config key bounds consistency ---
|
|
32
|
+
|
|
33
|
+
export function validateConfigKeyBounds(feature: FeatureDefinition): void {
|
|
34
|
+
for (const [keyName, keyDef] of Object.entries(feature.configKeys)) {
|
|
35
|
+
const bounds = keyDef.bounds;
|
|
36
|
+
// skip: no bounds declared, nothing to validate
|
|
37
|
+
if (!bounds) continue;
|
|
38
|
+
|
|
39
|
+
// Bounds on non-number keys are nonsensical — the call-site type-guard
|
|
40
|
+
// already rejects this, but catch it at boot as defence in depth (e.g.
|
|
41
|
+
// a hand-rolled key definition that bypasses createTenantConfig).
|
|
42
|
+
if (keyDef.type !== "number") {
|
|
43
|
+
throw new Error(
|
|
44
|
+
`[Feature ${feature.name}] Config key "${keyName}" has bounds but type is "${keyDef.type}" — bounds are only valid for type="number"`,
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const { min, max } = bounds;
|
|
49
|
+
|
|
50
|
+
if (min !== undefined && max !== undefined && min > max) {
|
|
51
|
+
throw new Error(
|
|
52
|
+
`[Feature ${feature.name}] Config key "${keyName}" has bounds.min (${min}) > bounds.max (${max})`,
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (keyDef.default !== undefined) {
|
|
57
|
+
const defaultNum = keyDef.default as number; // @cast-boundary engine-payload
|
|
58
|
+
if (min !== undefined && defaultNum < min) {
|
|
59
|
+
throw new Error(
|
|
60
|
+
`[Feature ${feature.name}] Config key "${keyName}" default (${defaultNum}) is below bounds.min (${min})`,
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
if (max !== undefined && defaultNum > max) {
|
|
64
|
+
throw new Error(
|
|
65
|
+
`[Feature ${feature.name}] Config key "${keyName}" default (${defaultNum}) is above bounds.max (${max})`,
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// --- Config key computed + encrypted exclusivity ---
|
|
73
|
+
|
|
74
|
+
export function validateConfigKeyComputed(feature: FeatureDefinition): void {
|
|
75
|
+
for (const [keyName, keyDef] of Object.entries(feature.configKeys)) {
|
|
76
|
+
if (!keyDef.computed) continue;
|
|
77
|
+
|
|
78
|
+
// computed + encrypted mix two paradigms that shouldn't meet: computed
|
|
79
|
+
// returns a plain value, encrypted expects cipher-text in the row. The
|
|
80
|
+
// cascade doesn't know which one to prefer on write. Rejecting at boot
|
|
81
|
+
// is cheaper than surprising behaviour at runtime.
|
|
82
|
+
if (keyDef.encrypted) {
|
|
83
|
+
throw new Error(
|
|
84
|
+
`[Feature ${feature.name}] Config key "${keyName}" has both encrypted=true and a computed resolver — these are mutually exclusive paradigms`,
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// --- Config key allowPerRequest compatibility ---
|
|
91
|
+
|
|
92
|
+
export function validateConfigKeyAllowPerRequest(feature: FeatureDefinition): void {
|
|
93
|
+
for (const [keyName, keyDef] of Object.entries(feature.configKeys)) {
|
|
94
|
+
if (!keyDef.allowPerRequest) continue;
|
|
95
|
+
|
|
96
|
+
// text is hard-locked against per-request — the helper refuses
|
|
97
|
+
// anyway, but declaring allowPerRequest on a text key is a
|
|
98
|
+
// misconfiguration that should fail loudly at boot.
|
|
99
|
+
if (keyDef.type === "text") {
|
|
100
|
+
throw new Error(
|
|
101
|
+
`[Feature ${feature.name}] Config key "${keyName}" has allowPerRequest=true but type="text" — text keys are permanently ineligible for per-request overrides (XSS/injection risk)`,
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// encrypted + per-request would expose a cipher-text interpretation
|
|
106
|
+
// to query-strings. The secret-value shouldn't be transported this
|
|
107
|
+
// way — reject as a paradigm-mismatch.
|
|
108
|
+
if (keyDef.encrypted) {
|
|
109
|
+
throw new Error(
|
|
110
|
+
`[Feature ${feature.name}] Config key "${keyName}" has allowPerRequest=true but encrypted=true — secret values may not be set via query-params`,
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// --- Config key cross-feature reference validation ---
|
|
117
|
+
|
|
118
|
+
export function validateConfigReads(
|
|
119
|
+
features: readonly FeatureDefinition[],
|
|
120
|
+
allConfigKeys: ReadonlySet<string>,
|
|
121
|
+
): void {
|
|
122
|
+
for (const feature of features) {
|
|
123
|
+
for (const key of feature.configReads) {
|
|
124
|
+
if (!allConfigKeys.has(key)) {
|
|
125
|
+
throw new Error(
|
|
126
|
+
`Feature "${feature.name}" reads config "${key}" but no feature defines that key`,
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// --- Circular dependency detection ---
|
|
134
|
+
|
|
135
|
+
export function validateCircularDeps(
|
|
136
|
+
featureName: string,
|
|
137
|
+
featureMap: ReadonlyMap<string, FeatureDefinition>,
|
|
138
|
+
): void {
|
|
139
|
+
const visited = new Set<string>();
|
|
140
|
+
const stack = new Set<string>();
|
|
141
|
+
|
|
142
|
+
function visit(name: string, path: string[]): void {
|
|
143
|
+
if (stack.has(name)) {
|
|
144
|
+
throw new Error(`Circular dependency: ${[...path, name].join(" → ")}`);
|
|
145
|
+
}
|
|
146
|
+
// skip: node already visited in DFS traversal
|
|
147
|
+
if (visited.has(name)) return;
|
|
148
|
+
|
|
149
|
+
visited.add(name);
|
|
150
|
+
stack.add(name);
|
|
151
|
+
|
|
152
|
+
const feature = featureMap.get(name);
|
|
153
|
+
if (feature) {
|
|
154
|
+
for (const dep of feature.requires) {
|
|
155
|
+
visit(dep, [...path, name]);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
stack.delete(name);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
visit(featureName, []);
|
|
163
|
+
}
|