@cosmicdrift/kumiko-framework 0.401.0 → 0.403.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 (53) hide show
  1. package/AGENTS.md +4 -2
  2. package/agents/api/framework-engine.md +29 -1
  3. package/agents/api/framework-ui-types.md +4 -0
  4. package/agents/api/renderer-web.md +1 -1
  5. package/agents/api/types.md +34 -0
  6. package/agents/api.md +1 -1
  7. package/agents/boot-rules.md +110 -0
  8. package/agents/examples/dashboard-stats.md +58 -3
  9. package/dist/db/replay-migration-sql.js +1 -1
  10. package/dist/engine/boot-validator/api-ext-rules.d.ts +27 -0
  11. package/dist/engine/boot-validator/api-ext-rules.js +27 -0
  12. package/dist/engine/boot-validator/api-ext.js +25 -12
  13. package/dist/engine/boot-validator/boot-check-rules.d.ts +7 -0
  14. package/dist/engine/boot-validator/boot-check-rules.js +7 -0
  15. package/dist/engine/boot-validator/boot-check.js +13 -1
  16. package/dist/engine/boot-validator/boot-phases-app.js +5 -0
  17. package/dist/engine/boot-validator/boot-report.d.ts +1 -0
  18. package/dist/engine/boot-validator/boot-report.js +58 -7
  19. package/dist/engine/boot-validator/boot-rule.d.ts +20 -0
  20. package/dist/engine/boot-validator/boot-rule.js +33 -2
  21. package/dist/engine/boot-validator/boot-rules.d.ts +246 -0
  22. package/dist/engine/boot-validator/boot-rules.js +19 -0
  23. package/dist/engine/boot-validator/card-meta-overflow-rules.d.ts +7 -0
  24. package/dist/engine/boot-validator/card-meta-overflow-rules.js +7 -0
  25. package/dist/engine/boot-validator/card-meta-overflow.js +5 -1
  26. package/dist/engine/boot-validator/dashboard-stat-navigate.d.ts +2 -0
  27. package/dist/engine/boot-validator/dashboard-stat-navigate.js +77 -0
  28. package/dist/engine/boot-validator/entity-list-screens-rules.d.ts +37 -0
  29. package/dist/engine/boot-validator/entity-list-screens-rules.js +37 -0
  30. package/dist/engine/boot-validator/entity-list-screens.js +86 -41
  31. package/dist/engine/boot-validator/i18n-keys-rules.d.ts +27 -0
  32. package/dist/engine/boot-validator/i18n-keys-rules.js +27 -0
  33. package/dist/engine/boot-validator/i18n-keys.js +27 -16
  34. package/dist/engine/boot-validator/named-filter-rules.d.ts +32 -0
  35. package/dist/engine/boot-validator/named-filter-rules.js +32 -0
  36. package/dist/engine/boot-validator/pii-retention-rules.d.ts +107 -0
  37. package/dist/engine/boot-validator/pii-retention-rules.js +107 -0
  38. package/dist/engine/boot-validator/pii-retention.js +76 -33
  39. package/dist/engine/boot-validator/projection-list-screens.js +13 -0
  40. package/dist/engine/boot-validator/screens-actions-rules.d.ts +7 -0
  41. package/dist/engine/boot-validator/screens-actions-rules.js +7 -0
  42. package/dist/engine/boot-validator/screens-actions.js +7 -3
  43. package/dist/engine/entity-handlers.d.ts +1 -0
  44. package/dist/engine/entity-handlers.js +13 -1
  45. package/dist/engine/factories.d.ts +2 -0
  46. package/dist/engine/index.d.ts +3 -0
  47. package/dist/engine/index.js +3 -0
  48. package/dist/engine/resolve-named-list-filters.d.ts +3 -0
  49. package/dist/engine/resolve-named-list-filters.js +30 -0
  50. package/dist/i18n/required-surface-keys.js +3 -0
  51. package/dist/schema-cli/validate-command.js +4 -1
  52. package/package.json +3 -3
  53. package/src/changes.json +18 -0
package/AGENTS.md CHANGED
@@ -15,6 +15,7 @@ not exist in your version:
15
15
  | `agents/api/<entry>.md` | Signatures of one entry point; options and screen types as property lists |
16
16
  | `agents/api/types.md` | Types the entry points use but do not export, such as nested option shapes |
17
17
  | `agents/widgets.md` | Every value export of `@cosmicdrift/kumiko-renderer/web` with its short description |
18
+ | `agents/boot-rules.md` | Every boot rule by id: summary, fix and source file; a boot violation links its entry |
18
19
  | `agents/examples.md` | Index of the worked examples, one per concept |
19
20
  | `agents/examples/<name>.md` | One tested sample app: README plus every source and test file |
20
21
 
@@ -33,7 +34,7 @@ Order of work for a new feature:
33
34
  `agents/api.md` instead of `dist/*.d.ts`.
34
35
  4. Build the feature with the declarative building blocks below.
35
36
  5. Write an integration test against the real stack.
36
- 6. Run boot validation and the tests. Boot errors name the fix; apply it.
37
+ 6. Run boot validation and the tests. Boot errors name the fix; apply it. Each violation carries a rule id whose entry is in `agents/boot-rules.md`.
37
38
 
38
39
  ## Mental model
39
40
 
@@ -293,7 +294,8 @@ produces code that compiles and then fails in production.
293
294
  unknown field in a column, a nav pointing at a missing screen, an
294
295
  encrypted field marked searchable. The message names the
295
296
  feature, the place and the fix. Apply the fix the message names instead
296
- of working around the check.
297
+ of working around the check. Each violation shows a rule id and, for
298
+ classified rules, a `Rule:` link to its entry in `agents/boot-rules.md`.
297
299
 
298
300
  ## I need X, use Y
299
301
 
@@ -11,6 +11,7 @@ function assertNotificationVariables(registry: Pick<Registry, "getAllNotificatio
11
11
  function assignableAppRolesFromUsages(usages: { entityName: string; options?: Record<string, unknown> | undefined; }[]): AssignableAppRoles;
12
12
  function assignableAppRolesOf(registry: Pick<Registry, "getExtensionUsages">): AssignableAppRoles;
13
13
  function attachOutsideTransaction<T extends object, O extends object | undefined>(db: T, outsideTransaction: O): T & { outsideTransaction: O; };
14
+ function bootRuleError(rule: BootRule, details: { message: string; }): BootRuleViolation;
14
15
  function bridgeStub(opts?: { user?: SessionUser; log?: HandlerObservability["log"]; dispatch?: Partial<HandlerDispatch>; services?: Partial<ContextServices>; }): Pick<HandlerContext, "dispatch" | "events" | "auth" | "obs" | "hasFeature" | "tz" | "locale" | "user" | "services" | "lifecycle" | "tenants">;
15
16
  function buildAppSchema(registry: Registry, options?: BuildAppSchemaOptions): AppSchema;
16
17
  function buildConfigFeatureSchema(registry: Registry): ConfigFeatureSchema;
@@ -20,6 +21,7 @@ function buildPipelineSteps<TPayload, TAwaits extends Record<string, AwaitedEven
20
21
  function buildSessionRoles(globalRoles: string[], membershipRoles: string[]): string[];
21
22
  function buildUpdateSchema(entity: EntityDefinition, currencies?: string[], excludedFields?: string[]): z.ZodObject<Record<string, z.ZodTypeAny>>;
22
23
  function canActorAssignRole(actorRoles: string[], role: string, assignableAppRoles?: AssignableAppRoles): boolean;
24
+ function collectBootRuleViolations<T>(items: Iterable<T>, run: (item: T) => void): void;
23
25
  function collectCardMetaOverflow(features: FeatureDefinition[]): CardMetaOverflow[];
24
26
  function collectWriteHandlerQns(features: FeatureDefinition[]): Set<string>;
25
27
  function compareVersions(a: string, b: string): number;
@@ -33,7 +35,7 @@ function createDecimalField<R extends true | false = false>(config: { precision:
33
35
  function createDerivedField(spec: DerivedFieldDef): DerivedFieldDef; // Read-time computed field for `EntityDefinition.derivedFields` (NOT `fields`), never persisted.
34
36
  function createEmbeddedField(schema: EmbeddedFieldDef["schema"], overrides?: Partial<Omit<EmbeddedFieldDef, "type" | "schema" | keyof ResolvedPiiFlags>> & PersonalAnnotationsNoFind): EmbeddedFieldDef;
35
37
  function createEmbeddedListField(schema: EmbeddedFieldDef["schema"], overrides?: Partial<Omit<EmbeddedFieldDef, "type" | "schema" | "multiple" | keyof ResolvedPiiFlags>> & PersonalAnnotationsNoFind): EmbeddedFieldDef & { multiple: true; };
36
- function createEntity<const F, const T extends EntityTenancy = "tenant">(def: { table?: string; fields: F; description?: string; softDelete?: boolean; systemStream?: boolean; eventVersion?: number; eventMigrations?: EntityDefinition["eventMigrations"]; searchWeight?: number; defaultCurrency?: string; transitions?: Readonly<Record<string, Readonly<Record<string, string[]>>>>; lockedWhen?: EntityDefinition["lockedWhen"]; indexes?: EntityIndexDef[]; idType?: "serial" | "uuid"; access?: EntityDefinition["access"]; parentRef?: EntityDefinition["parentRef"]; readGate?: EntityDefinition["readGate"]; retention?: RetentionDef; derivedFields?: EntityDefinition["derivedFields"]; tenancy?: T; transferable?: boolean; attachedFileAccess?: EntityDefinition["attachedFileAccess"]; }): F extends FieldsMap ? EntityDefinition<F, T> : never;
38
+ function createEntity<const F, const T extends EntityTenancy = "tenant">(def: { table?: string; fields: F; description?: string; softDelete?: boolean; systemStream?: boolean; eventVersion?: number; eventMigrations?: EntityDefinition["eventMigrations"]; searchWeight?: number; defaultCurrency?: string; transitions?: Readonly<Record<string, Readonly<Record<string, string[]>>>>; lockedWhen?: EntityDefinition["lockedWhen"]; indexes?: EntityIndexDef[]; idType?: "serial" | "uuid"; access?: EntityDefinition["access"]; parentRef?: EntityDefinition["parentRef"]; readGate?: EntityDefinition["readGate"]; retention?: RetentionDef; derivedFields?: EntityDefinition["derivedFields"]; namedFilters?: EntityDefinition["namedFilters"]; tenancy?: T; transferable?: boolean; attachedFileAccess?: EntityDefinition["attachedFileAccess"]; }): F extends FieldsMap ? EntityDefinition<F, T> : never;
37
39
  function createEntityExecutor<E extends EntityDefinition>(entityName: string, entity: E): { table: EntityTable; executor: EventStoreExecutor<EntityRow<E>>; };
38
40
  function createFileField(overrides?: Partial<Omit<FileFieldDef, "type" | keyof ResolvedPiiFlags>> & PersonalAnnotationsNoFind): FileFieldDef;
39
41
  function createFilesField(overrides?: Partial<Omit<FilesFieldDef, "type" | keyof ResolvedPiiFlags>> & PersonalAnnotationsNoFind): FilesFieldDef;
@@ -135,6 +137,7 @@ function referenceGroupLabels(ctx: HandlerContext, user: SessionUser, sourceTabl
135
137
  function requireTierResolverPlugin(usage: RegistrarExtensionRegistration): TierResolverPlugin;
136
138
  function resolveAgentExposure(def: { description?: string; agent?: AgentHandlerHints; }, kind: "query" | "write"): AgentExposure; // Fail-closed: a handler without a `description` stays invisible to the agent unless it opts in explicitly.
137
139
  function resolveEntityTransferAdjacency(entities: ReadonlyMap<string, EntityDefinition>, rootEntityType?: string): TransferAdjacency;
140
+ function resolveNamedListFilters(ctx: HandlerContext, user: SessionUser, entityName: string, name: string): Promise<NamedListFilterCondition[]>;
138
141
  function runPipeline<TPayload, TData, TMap extends object = KumikoEventTypeMap>(pipelineDef: PipelineDef<TPayload, TData>, event: WriteEvent<TPayload>, handlerCtx: HandlerContext<TMap>, workflow?: PipelineCtx["workflow"], resumeFrom?: number): Promise<WriteResult<TData>>;
139
142
  function runStepList<TPayload, TMap extends object = KumikoEventTypeMap>(steps: StepInstance[], event: WriteEvent<TPayload>, handlerCtx: HandlerContext<TMap>, stepsAcc: Record<string, unknown>, scopeAcc: Record<string, unknown>, workflow?: PipelineCtx["workflow"], resumeFrom?: number): Promise<StepListOutcome>; // Walk a step-list.
140
143
  function sectionFieldSpecs(section: FieldsOrGroupsSection): EditFieldSpec[];
@@ -225,6 +228,14 @@ const templateVariable: { text; link; richText; money; date; percent; images; li
225
228
  ## Classes
226
229
 
227
230
  ```ts
231
+ class BootRuleViolation extends Error {
232
+ constructor(rule: BootRule, message: string);
233
+ name: unknown;
234
+ }
235
+ class BootValidationError extends Error {
236
+ constructor(violations: BootViolation[]);
237
+ name: unknown;
238
+ }
228
239
  class ProbeFailure extends Error {
229
240
  constructor(text: ProbeText);
230
241
  }
@@ -315,6 +326,19 @@ type BeforeTransactionResult<TPrepared = unknown> = // Outcome of a write handle
315
326
  | WriteFailure
316
327
  ;
317
328
  type BootOptionName = "mail" | "kmsMode" | "fileStorage" | "billing" | "featureToggles" | "rateLimit"; // runProdApp options a stack can depend on; runProdApp checks the set at boot.
329
+ type BootRule = {
330
+ id: string;
331
+ summary: string;
332
+ fix: string;
333
+ };
334
+ type BootRuleTable = Readonly<Record<string, BootRule>>;
335
+ type BootViolation = {
336
+ ruleId: string;
337
+ check: string;
338
+ feature?: string;
339
+ message: string;
340
+ fix?: string;
341
+ };
318
342
  type BundledListsOptions = { // One `table` block for every entityList, projectionList and relatedList of the bundled features.
319
343
  table: ListTableOptions;
320
344
  pinFirstColumn?: boolean; // Pins the first column left on every bundled list where no column is pinned.
@@ -427,6 +451,7 @@ type ContentCollectionDefinition = {
427
451
  variableSchema?: Readonly<Record<string, string>>;
428
452
  nav: {
429
453
  label: string;
454
+ entryLabel?: string;
430
455
  icon?: NavIconKey;
431
456
  parent?: string;
432
457
  order?: number;
@@ -540,6 +565,7 @@ type EntityDefinition<F extends FieldsMap = FieldsMap, T extends EntityTenancy =
540
565
  readGate?: ReadGateDef; // Declarative read gate for rows readable only through other rows (a link to a visible host, a file whose owner is readable).
541
566
  retention?: RetentionDef; // Default retention policy for this entity.
542
567
  derivedFields?: DerivedFieldsMap; // Read-time computed fields, keyed by name.
568
+ namedFilters?: Readonly<Record<string, NamedListFilterDef>>; // Named work-queue rules keyed by id, selected via `namedFilter`; function-valued, never in the client schema.
543
569
  description?: string; // Optional free-text semantics for the AI agent manifest — see TextFieldDef.description.
544
570
  transferable?: boolean; // Opts the rows into `tenant-handover`, a grant-legitimated move from an anonymous tenant to the signed-up account's tenant.
545
571
  attachedFileAccess?: "record-only"; // Attached files are readable only through the record's detail query: the files feature's privilegedRoles do not bypass it, the uploader check stays.
@@ -604,6 +630,7 @@ type EntityListScreenDefinition = {
604
630
  defaultSort?: ListSortSpec;
605
631
  defaultFilters?: Readonly<Record<string, string[] | boolean>>; // Initial facet selection, per filterable field: option values (string facets) or a boolean.
606
632
  facets?: Readonly<Record<string, EntityListFacetConfig | false>>; // Per-facet display options for the entity's `filterable` fields, keyed by field name.
633
+ namedFilters?: NamedListFilterRef[]; // Named work-queue rules offered on this list: ids declared in `EntityDefinition.namedFilters`, `label` an i18n key.
607
634
  emptyState?: ListEmptyStateSpec; // Replaces the default empty-list title/hint; the create button stays when the list can create.
608
635
  createLabel?: string; // i18n key for the primary create button label (default `kumiko.actions.create`).
609
636
  searchPlaceholder?: string; // i18n key for the search field placeholder (default `kumiko.list.search-placeholder`).
@@ -1327,6 +1354,7 @@ type ProjectionListScreenDefinition = {
1327
1354
  paginated?: boolean; // Derived by buildAppSchema from the query handler's Zod schema (`cursor` or `offset` param present).
1328
1355
  filter?: ScreenFilter; // Server-side filter, fixed on the screen — see `ScreenFilter` doc above (entityList).
1329
1356
  facets?: ListFacetSpec[]; // User-toggleable facet dropdowns — see `ListFacetSpec` doc.
1357
+ namedFilters?: NamedListFilterRef[]; // Named work-queue rules the bound query understands: its Zod schema must have a `namedFilter` key (boot-checked) and is sent the selected id in every list and count query.
1330
1358
  slots?: ScreenSlots;
1331
1359
  access?: AccessRule;
1332
1360
  };
@@ -252,6 +252,10 @@ type DashboardStatPanel = DashboardPanelQueryOptions & DashboardPanelGate & {
252
252
  deltaToneField?: string;
253
253
  icon?: PlatformComponent; // Static icon next to the label.
254
254
  accentColor?: string; // Static CSS color value (e.g. "var(--color-debt)") for the icon chip, passed straight to the tile without a registry lookup.
255
+ navigate?: {
256
+ screen: string;
257
+ namedFilter?: string;
258
+ }; // Makes the tile a link to a list screen (qualified screen id).
255
259
  };
256
260
  type DashboardText = string | DashboardI18nText; // Plain string is displayed as-is; DashboardI18nText is translated by the renderer.
257
261
  type DashboardTextBlock =
@@ -85,7 +85,7 @@ function SidebarProvider(props: React.ComponentProps<"div"> & { defaultOpen?: bo
85
85
  function SidebarUser(props: SidebarUserProps): ReactNode;
86
86
  function Sparkline(props: { points: number[]; className?: string; }): ReactNode;
87
87
  function Stack(props: StackProps): ReactNode;
88
- function StatCard(props: { icon?: ReactNode; label: string; value: string; sub?: string; tone?: StatTone; accentColor?: string; delta?: StatDelta; trend?: string; spark?: number[]; testId?: string; children?: ReactNode; }): ReactNode; // Icon-bearing metric tile with an optional delta chip, trend line, and sparkline.
88
+ function StatCard(props: { icon?: ReactNode; label: string; value: string; sub?: string; tone?: StatTone; accentColor?: string; delta?: StatDelta; trend?: string; spark?: number[]; testId?: string; link?: StatLink; children?: ReactNode; }): ReactNode; // Icon-bearing metric tile with an optional delta chip, trend line, and sparkline.
89
89
  function StatusBadge(props: { tone: StatusBadgeTone; children: ReactNode; className?: string; testId?: string; }): ReactNode; // Pill badge with a status dot.
90
90
  function StatusBarChart(props: { entries: StatusBarEntry[]; ariaLabel: string; startLabel?: string; endLabel?: string; highlightLast?: boolean; dense?: boolean; testId?: string; }): ReactNode; // Status bar strip (e.g. 90-day uptime): variable-height bars with gradient fade + tick line on top; the last entry gets a "now" accent stripe.
91
91
  function StepBar(props: { steps: string[]; currentIndex: number; compactLabel: string; onStepSelect?: (index: number) => void; doneSteps?: boolean[]; selectableSteps?: "done" | "all"; narrowLayout?: "label" | "steps"; orientation?: "horizontal" | "vertical"; heading?: string; description?: string; subtitles?: (string | undefined)[]; upNext?: { heading: string; title: string; subtitle?: string; }; testId?: string; compactTestId?: string; }): ReactNode; // Wizard step overview — numbered chips with a connector line between them.
@@ -2676,6 +2676,15 @@ interface ResultTableFooterRow {
2676
2676
  }
2677
2677
  ```
2678
2678
 
2679
+ ## packages/renderer/src/web/widgets/stat.tsx
2680
+
2681
+ ```ts
2682
+ type StatLink = { // Makes a tile a link; `onPress` is the SPA navigation, `href` serves modified clicks and a11y.
2683
+ href: string;
2684
+ onPress: () => void;
2685
+ };
2686
+ ```
2687
+
2679
2688
  ## packages/testing/src/bundled-injection.ts
2680
2689
 
2681
2690
  ```ts
@@ -4377,6 +4386,30 @@ type MultiStreamApplyContext<TMap extends object = KumikoEventTypeMap> = {
4377
4386
  };
4378
4387
  ```
4379
4388
 
4389
+ ## packages/types/src/named-list-filters.ts
4390
+
4391
+ ```ts
4392
+ type NamedListFilterCondition = {
4393
+ field: string;
4394
+ op: NamedListFilterOp;
4395
+ value: unknown;
4396
+ };
4397
+ type NamedListFilterDef = { // A work-queue rule defined once on the entity: the list and every count query resolve the same conditions.
4398
+ access?: AccessRule;
4399
+ filters: (scope: NamedListFilterScope) => NamedListFilterCondition[] | Promise<NamedListFilterCondition[]>;
4400
+ };
4401
+ type NamedListFilterOp = "eq" | "ne" | "lt" | "gt" | "lte" | "gte" | "in";
4402
+ type NamedListFilterRef = { // Screen-side reference to an entity-defined named filter; `label` is an i18n key.
4403
+ id: string;
4404
+ label: string;
4405
+ };
4406
+ type NamedListFilterScope = {
4407
+ ctx: HandlerContext;
4408
+ user: SessionUser;
4409
+ now: Temporal.Instant;
4410
+ };
4411
+ ```
4412
+
4380
4413
  ## packages/types/src/nav.ts
4381
4414
 
4382
4415
  ```ts
@@ -4783,6 +4816,7 @@ type MetricSpec = {
4783
4816
  navigate?: MetricNavigate;
4784
4817
  tone?: DashboardStatTone; // Tile color as on dashboard stat panels; not applied while the value is 0.
4785
4818
  icon?: NavIconKey; // Icon chip of the tile; only drawn with `metricsStyle: "tiles"`.
4819
+ format?: FormatSpec; // Formats the displayed value (e.g. `date`, `enumOption`); the tone still resolves on the raw value.
4786
4820
  };
4787
4821
  type RecordHeaderSpec = {
4788
4822
  title: string;
package/agents/api.md CHANGED
@@ -8,7 +8,7 @@ Format: one line per function or constant, object and options types as property
8
8
 
9
9
  | Import path | File | Exports |
10
10
  | --- | --- | --- |
11
- | `@cosmicdrift/kumiko-framework/engine` | `api/framework-engine.md` | 412 |
11
+ | `@cosmicdrift/kumiko-framework/engine` | `api/framework-engine.md` | 420 |
12
12
  | `@cosmicdrift/kumiko-framework/errors` | `api/framework-errors.md` | 25 |
13
13
  | `@cosmicdrift/kumiko-framework/ui-types` | `api/framework-ui-types.md` | 154 |
14
14
  | `@cosmicdrift/kumiko-framework/db` | `api/framework-db.md` | 150 |
@@ -0,0 +1,110 @@
1
+ <!-- Generated by `bun run gen:agent-docs` from boot rule tables (`*-rules.ts`, `boot-rules.ts`). Do not edit. -->
2
+
3
+ # Boot rules
4
+
5
+ `validateBoot` reports every violation as `- [<id>] <message>`, followed by a `Fix:` and a `Rule:` line that links the entry below. Violations with an `unclassified/<check>` id have no entry yet. `kumiko-schema validate --json` returns the same ids.
6
+
7
+ ## api
8
+
9
+ | Rule | Summary | Fix | Source |
10
+ | --- | --- | --- | --- |
11
+ | <a id="api-provider-not-mounted"></a>`api/provider-not-mounted` | The feature exposing a used API is part of the app's feature list. | Register the provider feature in the app's feature list. | `packages/framework/src/engine/boot-validator/api-ext-rules.ts` |
12
+ | <a id="api-provider-not-required"></a>`api/provider-not-required` | A feature using an API lists the exposing feature in requires or optionalRequires. | Add the exposing feature via r.requires({ features: [...] }). | `packages/framework/src/engine/boot-validator/api-ext-rules.ts` |
13
+ | <a id="api-unknown-api"></a>`api/unknown-api` | Every r.usesApi name is exposed by some mounted feature. | Mount the feature that exposes the API or fix the API name. | `packages/framework/src/engine/boot-validator/api-ext-rules.ts` |
14
+
15
+ ## boot-check
16
+
17
+ | Rule | Summary | Fix | Source |
18
+ | --- | --- | --- | --- |
19
+ | <a id="boot-check-failed"></a>`boot-check/failed` | A feature's r.bootCheck invariant holds for the mounted feature set. | Satisfy the invariant named in the message or unmount the feature that declares it. | `packages/framework/src/engine/boot-validator/boot-check-rules.ts` |
20
+
21
+ ## entity-list
22
+
23
+ | Rule | Summary | Fix | Source |
24
+ | --- | --- | --- | --- |
25
+ | <a id="entity-list-default-sort-not-listed"></a>`entity-list/default-sort-not-listed` | defaultSort.field is one of the listed columns. | Pick a field from columns or add it there. | `packages/framework/src/engine/boot-validator/entity-list-screens-rules.ts` |
26
+ | <a id="entity-list-default-sort-not-sortable"></a>`entity-list/default-sort-not-sortable` | The defaultSort column is sortable. | Mark the field sortable or pick another defaultSort field. | `packages/framework/src/engine/boot-validator/entity-list-screens-rules.ts` |
27
+ | <a id="entity-list-default-sort-required"></a>`entity-list/default-sort-required` | A list with active search or filterable fields declares a defaultSort. | Set defaultSort: { field, direction } on the screen. | `packages/framework/src/engine/boot-validator/entity-list-screens-rules.ts` |
28
+ | <a id="entity-list-detail-without-navigate"></a>`entity-list/detail-without-navigate` | A list whose entity has a detail screen offers a navigate row action. | Add a navigate rowAction with id "view" or "edit". | `packages/framework/src/engine/boot-validator/entity-list-screens-rules.ts` |
29
+ | <a id="entity-list-searchable-false"></a>`entity-list/searchable-false` | Operator lists are searchable unless they are on the searchable-false whitelist. | Set searchable: true or add the screen id to the whitelist. | `packages/framework/src/engine/boot-validator/entity-list-screens-rules.ts` |
30
+ | <a id="entity-list-unknown-column"></a>`entity-list/unknown-column` | Every column without a label names an entity field, derived field or row-meta column. | Fix the column field name or give the column a label. | `packages/framework/src/engine/boot-validator/entity-list-screens-rules.ts` |
31
+ | <a id="entity-list-unknown-named-filter"></a>`entity-list/unknown-named-filter` | Every screen namedFilters id is declared in the entity's namedFilters. | Declare the rule in EntityDefinition.namedFilters or remove the id from the screen. | `packages/framework/src/engine/boot-validator/entity-list-screens-rules.ts` |
32
+
33
+ ## extension
34
+
35
+ | Rule | Summary | Fix | Source |
36
+ | --- | --- | --- | --- |
37
+ | <a id="extension-provider-not-required"></a>`extension/provider-not-required` | A feature using an extension lists the defining feature in requires or optionalRequires. | Add the defining feature via r.requires({ features: [...] }). | `packages/framework/src/engine/boot-validator/api-ext-rules.ts` |
38
+ | <a id="extension-unknown-extension"></a>`extension/unknown-extension` | Every extension a feature uses is defined by some mounted feature. | Register the feature that defines the extension or fix the extension name. | `packages/framework/src/engine/boot-validator/api-ext-rules.ts` |
39
+
40
+ ## i18n
41
+
42
+ | Rule | Summary | Fix | Source |
43
+ | --- | --- | --- | --- |
44
+ | <a id="i18n-locale-inconsistent"></a>`i18n/locale-inconsistent` | Every locale a feature uses is maintained for every one of its translation keys. | Add the missing locale to the key, or move that locale into a locale package. | `packages/framework/src/engine/boot-validator/i18n-keys-rules.ts` |
45
+ | <a id="i18n-missing-feature-key"></a>`i18n/missing-feature-key` | Every translation key a feature surface requires is defined. | Add the key via r.translations(...) in the feature. | `packages/framework/src/engine/boot-validator/i18n-keys-rules.ts` |
46
+ | <a id="i18n-missing-field-value-translation"></a>`i18n/missing-field-value-translation` | With requireFieldValueTranslations, select options and embedded cells are translated. | Add the keys via r.translations(...) in the owning feature. | `packages/framework/src/engine/boot-validator/i18n-keys-rules.ts` |
47
+ | <a id="i18n-missing-required-locale"></a>`i18n/missing-required-locale` | Every translation key exists in every locale the app requires. | Load the matching locale package or add the missing translations. | `packages/framework/src/engine/boot-validator/i18n-keys-rules.ts` |
48
+ | <a id="i18n-missing-settings-hub-key"></a>`i18n/missing-settings-hub-key` | Every translation key the generated settings hub requires is defined. | Add the key via r.translations(...) in the owning feature. | `packages/framework/src/engine/boot-validator/i18n-keys-rules.ts` |
49
+
50
+ ## list
51
+
52
+ | Rule | Summary | Fix | Source |
53
+ | --- | --- | --- | --- |
54
+ | <a id="list-card-meta-overflow"></a>`list/card-meta-overflow` | A card list shows at most the maximum number of meta columns. | Reduce each list to the maximum number of meta columns or mark the others hideOnNarrow: true. | `packages/framework/src/engine/boot-validator/card-meta-overflow-rules.ts` |
55
+
56
+ ## named-filter
57
+
58
+ | Rule | Summary | Fix | Source |
59
+ | --- | --- | --- | --- |
60
+ | <a id="named-filter-projection-query-accepts-named-filter"></a>`named-filter/projection-query-accepts-named-filter` | A projectionList with namedFilters binds a query whose schema has a namedFilter key. | Add namedFilter: z.string().optional() to the query schema, resolve it with resolveNamedListFilters and pass the conditions into executor.list filters, the only path that applies field-read and PII guards. | `packages/framework/src/engine/boot-validator/named-filter-rules.ts` |
61
+ | <a id="named-filter-stat-navigate-filter-declared"></a>`named-filter/stat-navigate-filter-declared` | A stat navigate.namedFilter is declared in the target screen's namedFilters. | Add { id, label } to namedFilters of the target screen. | `packages/framework/src/engine/boot-validator/named-filter-rules.ts` |
62
+ | <a id="named-filter-stat-navigate-same-named-filter"></a>`named-filter/stat-navigate-same-named-filter` | A stat's params.namedFilter equals navigate.namedFilter. | Set params.namedFilter to the same id as navigate.namedFilter. | `packages/framework/src/engine/boot-validator/named-filter-rules.ts` |
63
+ | <a id="named-filter-stat-navigate-same-query"></a>`named-filter/stat-navigate-same-query` | A stat with navigate counts with the target list's own query. | Set the stat query to the target list's query. | `packages/framework/src/engine/boot-validator/named-filter-rules.ts` |
64
+ | <a id="named-filter-stat-navigate-static-filter"></a>`named-filter/stat-navigate-static-filter` | A stat repeats the target screen's static filter in params.filter. | Set params.filter to the target screen's filter. | `packages/framework/src/engine/boot-validator/named-filter-rules.ts` |
65
+ | <a id="named-filter-stat-navigate-target"></a>`named-filter/stat-navigate-target` | A stat navigate.screen resolves to an entityList or projectionList screen. | Point navigate.screen at a registered list screen. | `packages/framework/src/engine/boot-validator/named-filter-rules.ts` |
66
+
67
+ ## pii
68
+
69
+ | Rule | Summary | Fix | Source |
70
+ | --- | --- | --- | --- |
71
+ | <a id="pii-lookupable-non-text"></a>`pii/lookupable-non-text` | Blind-index lookups apply to text fields only. | Use a text field or set find: "none". | `packages/framework/src/engine/boot-validator/pii-retention-rules.ts` |
72
+ | <a id="pii-lookupable-without-subject"></a>`pii/lookupable-without-subject` | A blind-index lookup requires a subject annotation on the field. | Add a personal annotation or set find: "none". | `packages/framework/src/engine/boot-validator/pii-retention-rules.ts` |
73
+ | <a id="pii-multiple-subject-annotations"></a>`pii/multiple-subject-annotations` | A field carries at most one subject-key annotation. | Keep a single personal annotation on the field. | `packages/framework/src/engine/boot-validator/pii-retention-rules.ts` |
74
+ | <a id="pii-personal-jsonb-custom-fields"></a>`pii/personal-jsonb-custom-fields` | The custom-fields jsonb column cannot carry a personal annotation. | Drop the personal annotation. | `packages/framework/src/engine/boot-validator/pii-retention-rules.ts` |
75
+ | <a id="pii-personal-jsonb-flag"></a>`pii/personal-jsonb-flag` | A personal jsonb field cannot be sortable, filterable, searchable or lookupable, its value is a ciphertext string. | Remove the flag or the personal annotation. | `packages/framework/src/engine/boot-validator/pii-retention-rules.ts` |
76
+ | <a id="pii-personal-jsonb-index"></a>`pii/personal-jsonb-index` | A personal jsonb field is not part of an entity index. | Drop the index or the personal annotation. | `packages/framework/src/engine/boot-validator/pii-retention-rules.ts` |
77
+ | <a id="pii-personal-of-invalid-when-absent"></a>`pii/personal-of-invalid-when-absent` | personal.whenAbsent is either "tenant" or "self". | Remove whenAbsent or set it to "tenant" or "self". | `packages/framework/src/engine/boot-validator/pii-retention-rules.ts` |
78
+ | <a id="pii-personal-of-missing-owner-name"></a>`pii/personal-of-missing-owner-name` | personal: { of } names the owner field. | Pass the name of the owner field: personal: { of: "<ownerField>" }. | `packages/framework/src/engine/boot-validator/pii-retention-rules.ts` |
79
+ | <a id="pii-personal-of-optional-owner"></a>`pii/personal-of-optional-owner` | An optional owner field of personal: { of } declares a whenAbsent fallback. | Set whenAbsent to "tenant" or "self", or make the owner field required. | `packages/framework/src/engine/boot-validator/pii-retention-rules.ts` |
80
+ | <a id="pii-personal-of-owner-type"></a>`pii/personal-of-owner-type` | The owner field named by personal: { of } is a reference or text field. | Use a reference or text field as owner. | `packages/framework/src/engine/boot-validator/pii-retention-rules.ts` |
81
+ | <a id="pii-personal-of-unknown-owner"></a>`pii/personal-of-unknown-owner` | The owner field named by personal: { of } exists on the entity. | Point personal.of at an existing field of the entity. | `packages/framework/src/engine/boot-validator/pii-retention-rules.ts` |
82
+ | <a id="pii-plaintext-user-reference"></a>`pii/plaintext-user-reference` | A reference to the user entity is never plaintext. | Use the default reference field or personal: "ref" instead of personal: false. | `packages/framework/src/engine/boot-validator/pii-retention-rules.ts` |
83
+ | <a id="pii-sensitive-searchable"></a>`pii/sensitive-searchable` | A sensitive field is never searchable. | Drop searchable, or add writeOnly to never return the value. | `packages/framework/src/engine/boot-validator/pii-retention-rules.ts` |
84
+ | <a id="pii-sensitive-without-ciphertext"></a>`pii/sensitive-without-ciphertext` | A sensitive field is stored as ciphertext at rest. | Add a subject annotation or encrypted: true. | `packages/framework/src/engine/boot-validator/pii-retention-rules.ts` |
85
+ | <a id="pii-subject-key-sortable"></a>`pii/subject-key-sortable` | A field with a subject-key annotation is not sortable, its column is ciphertext. | Drop sortable, use find: "exact" for equality lookups, or keep the field plaintext. | `packages/framework/src/engine/boot-validator/pii-retention-rules.ts` |
86
+ | <a id="pii-text-without-personal-stance"></a>`pii/text-without-personal-stance` | Every text and longText field declares whether it holds personal data. | Declare a personal stance, or personal: false with a reason stating why the value is not personal data. | `packages/framework/src/engine/boot-validator/pii-retention-rules.ts` |
87
+ | <a id="pii-write-only-conflicting-flag"></a>`pii/write-only-conflicting-flag` | A writeOnly field cannot be defaulted, searched, sorted, filtered or looked up. | Remove either writeOnly or the conflicting flag. | `packages/framework/src/engine/boot-validator/pii-retention-rules.ts` |
88
+ | <a id="pii-write-only-embedded-sub-field"></a>`pii/write-only-embedded-sub-field` | writeOnly is only supported on top-level text fields, not on embedded sub-fields. | Remove writeOnly from the sub-field or declare it on a top-level text field. | `packages/framework/src/engine/boot-validator/pii-retention-rules.ts` |
89
+ | <a id="pii-write-only-non-text"></a>`pii/write-only-non-text` | writeOnly applies to text fields only. | Use a text field or remove writeOnly. | `packages/framework/src/engine/boot-validator/pii-retention-rules.ts` |
90
+ | <a id="pii-write-only-without-sensitive"></a>`pii/write-only-without-sensitive` | A writeOnly field is also sensitive. | Declare the field with find: "secret". | `packages/framework/src/engine/boot-validator/pii-retention-rules.ts` |
91
+
92
+ ## retention
93
+
94
+ | Rule | Summary | Fix | Source |
95
+ | --- | --- | --- | --- |
96
+ | <a id="retention-unknown-reference"></a>`retention/unknown-reference` | retention.reference names an existing field or a framework timestamp. | Point retention.reference at an existing field or a framework-managed timestamp. | `packages/framework/src/engine/boot-validator/pii-retention-rules.ts` |
97
+
98
+ ## screen
99
+
100
+ | Rule | Summary | Fix | Source |
101
+ | --- | --- | --- | --- |
102
+ | <a id="screen-action-icon"></a>`screen/action-icon` | Every screen action resolves to a registered icon. | Set icon to a registered NAV_ICONS key or use an action id that resolves one via the default icon map. | `packages/framework/src/engine/boot-validator/screens-actions-rules.ts` |
103
+
104
+ ## user-data-rights
105
+
106
+ | Rule | Summary | Fix | Source |
107
+ | --- | --- | --- | --- |
108
+ | <a id="user-data-rights-empty-hook"></a>`user-data-rights/empty-hook` | An EXT_USER_DATA registration declares at least one of an export or a delete hook. | Register at least one hook. | `packages/bundled-features/src/user-data-rights/boot-rules.ts` |
109
+ | <a id="user-data-rights-export-without-delete"></a>`user-data-rights/export-without-delete` | An entity exported via EXT_USER_DATA also registers a delete hook. | Add a delete hook, or a documented no-op delete if erasure is handled elsewhere. | `packages/bundled-features/src/user-data-rights/boot-rules.ts` |
110
+ | <a id="user-data-rights-missing-hook"></a>`user-data-rights/missing-hook` | An entity with user-subject fields registers an EXT_USER_DATA hook. | Register r.useExtension(EXT_USER_DATA, ...) for the entity, or a documented no-op hook if it is covered elsewhere. | `packages/bundled-features/src/user-data-rights/boot-rules.ts` |
@@ -36,6 +36,11 @@ query and the fields to read, the query does the counting.
36
36
  - **Fallback by reason**: `fallback: { forbidden, notFound }` renders a screen on
37
37
  403/404 of the gate query; other reasons get the default notice or an error
38
38
  banner with retry.
39
+ - **Named filter shared by tile and list**: the entity declares `namedFilters`,
40
+ the `ticket-queue` entityList lists the id in `namedFilters`, and the
41
+ `open-work` stat counts through the list's own query with `params.namedFilter`
42
+ and `navigate`. A click opens the queue filtered by the same rule, so tile and
43
+ list cannot drift.
39
44
 
40
45
  ## When to reach for it
41
46
 
@@ -73,6 +78,7 @@ import {
73
78
  } from "@cosmicdrift/kumiko-testing/stack";
74
79
  import {
75
80
  TICKET_CAPABILITIES_QUERY,
81
+ TICKET_LIST_QUERY,
76
82
  TICKET_STATS_QUERY,
77
83
  ticketEntity,
78
84
  ticketFeature,
@@ -100,11 +106,11 @@ describe("dashboard screen", () => {
100
106
  const stats = screen.panels.flatMap((panel) =>
101
107
  panel.kind === "stat-group" ? panel.stats : panel.kind === "stat" ? [panel] : [],
102
108
  );
103
- expect(stats.map((s) => s.valueField)).toEqual(["open", "closed", "total"]);
104
- for (const stat of stats) expect(stat.query).toBe(TICKET_STATS_QUERY);
109
+ expect(stats.map((s) => s.valueField)).toEqual(["open", "closed", "total", "total"]);
110
+ const statsQueryStats = stats.filter((s) => s.query === TICKET_STATS_QUERY);
105
111
 
106
112
  const result = await stack.http.queryOk<Record<string, unknown>>(TICKET_STATS_QUERY, {}, admin);
107
- for (const stat of stats) expect(result).toHaveProperty(stat.valueField);
113
+ for (const stat of statsQueryStats) expect(result).toHaveProperty(stat.valueField);
108
114
  });
109
115
  });
110
116
 
@@ -165,6 +171,28 @@ describe("record-bound dashboard with a gated detail panel", () => {
165
171
  });
166
172
  });
167
173
  });
174
+
175
+ describe("named filter shared by the dashboard tile and the list", () => {
176
+ test("the tile total equals the filtered list total over HTTP", async () => {
177
+ const dashboard = ticketFeature.screens["ticket-dashboard"];
178
+ if (dashboard?.type !== "dashboard") throw new Error("ticket-dashboard missing");
179
+ const tile = dashboard.panels.find(
180
+ (panel) => panel.kind === "stat" && panel.id === "open-work",
181
+ );
182
+ if (tile?.kind !== "stat") throw new Error("open-work tile missing");
183
+ expect(tile.navigate).toEqual({ screen: "ticket-queue", namedFilter: "open-work" });
184
+
185
+ const counted = await stack.http.queryOk<{ total: number }>(tile.query, tile.params, admin);
186
+ const listed = await stack.http.queryOk<{ rows: unknown[]; total: number }>(
187
+ TICKET_LIST_QUERY,
188
+ { namedFilter: "open-work", totalCount: true, limit: 50 },
189
+ admin,
190
+ );
191
+ expect(counted.total).toBe(listed.total);
192
+ expect(listed.rows).toHaveLength(listed.total);
193
+ expect(listed.total).toBeGreaterThan(0);
194
+ });
195
+ });
168
196
  ```
169
197
 
170
198
  ### src/feature.ts
@@ -193,10 +221,15 @@ export const ticketEntity = createEntity({
193
221
  title: createTextField({ personal: false, reason: "is_business_data", required: true }),
194
222
  status: createSelectField({ options: TICKET_STATUSES, default: "open", required: true }),
195
223
  },
224
+ // Defined once: the queue list and the dashboard tile both read it.
225
+ namedFilters: {
226
+ "open-work": { filters: () => [{ field: "status", op: "eq", value: "open" }] },
227
+ },
196
228
  });
197
229
 
198
230
  export const TICKET_STATS_QUERY = "tickets:query:ticket:stats";
199
231
  export const TICKET_CAPABILITIES_QUERY = "tickets:query:ticket:capabilities";
232
+ export const TICKET_LIST_QUERY = "tickets:query:ticket:list";
200
233
 
201
234
  const readerRoles = { roles: ["Admin", "User"] } as const;
202
235
 
@@ -281,6 +314,9 @@ export const ticketFeature = defineFeature("tickets", (r) => {
281
314
  "tickets.dashboard.open": { en: "Open" },
282
315
  "tickets.dashboard.closed": { en: "Closed" },
283
316
  "tickets.dashboard.total": { en: "All tickets" },
317
+ "tickets.dashboard.open-work": { en: "Open work" },
318
+ "tickets.filter.open-work": { en: "Open work" },
319
+ "screen:ticket-queue.title": { en: "Ticket queue" },
284
320
  },
285
321
  });
286
322
 
@@ -316,6 +352,16 @@ export const ticketFeature = defineFeature("tickets", (r) => {
316
352
  query: TICKET_STATS_QUERY,
317
353
  valueField: "total",
318
354
  },
355
+ // Counts through the list's own query with the same named filter, then links to that list.
356
+ {
357
+ kind: "stat",
358
+ id: "open-work",
359
+ label: "tickets.dashboard.open-work",
360
+ query: TICKET_LIST_QUERY,
361
+ valueField: "total",
362
+ params: { namedFilter: "open-work", limit: 1, totalCount: true },
363
+ navigate: { screen: "ticket-queue", namedFilter: "open-work" },
364
+ },
319
365
  ],
320
366
  access: readerRoles,
321
367
  });
@@ -324,6 +370,15 @@ export const ticketFeature = defineFeature("tickets", (r) => {
324
370
  // button, and a fallback chosen by reason.
325
371
  const gate = { query: TICKET_CAPABILITIES_QUERY, field: "canClose", eq: true } as const;
326
372
 
373
+ r.screen({
374
+ id: "ticket-queue",
375
+ type: "entityList",
376
+ entity: "ticket",
377
+ columns: ["title", "status"],
378
+ namedFilters: [{ id: "open-work", label: "tickets.filter.open-work" }],
379
+ access: readerRoles,
380
+ });
381
+
327
382
  // The list doubles as the fallback target: it has no visibleWhen of its own.
328
383
  r.screen({
329
384
  id: "ticket-list",
@@ -151,7 +151,7 @@ function applyStatement(schema, statement, context) {
151
151
  // skip: DO block fully handled above
152
152
  return;
153
153
  }
154
- const createAs = statement.match(new RegExp(`^CREATE\\s+(?:UNLOGGED\\s+)?TABLE\\s+(IF NOT EXISTS\\s+)?${IDENT}\\s*(?:\\(([^)]*)\\)\\s*)?AS\\b([\\s\\S]*)$`, "i"));
154
+ const createAs = statement.match(new RegExp(`^CREATE\\s+(?:UNLOGGED\\s+)?TABLE\\s+(IF NOT EXISTS\\s+)?${IDENT}(?:\\s*\\(([^)]*)\\)\\s*|\\s+)AS\\b([\\s\\S]*)$`, "i"));
155
155
  if (createAs?.[2] !== undefined && createAs[4] !== undefined) {
156
156
  if (!(createAs[1] !== undefined && schema.has(createAs[2]))) {
157
157
  const explicitColumns = createAs[3] === undefined ? undefined : parseColumnNames(createAs[3]);
@@ -0,0 +1,27 @@
1
+ export declare const API_EXT_RULES: {
2
+ readonly unknownApi: {
3
+ readonly id: "api/unknown-api";
4
+ readonly summary: "Every r.usesApi name is exposed by some mounted feature.";
5
+ readonly fix: "Mount the feature that exposes the API or fix the API name.";
6
+ };
7
+ readonly providerNotRequired: {
8
+ readonly id: "api/provider-not-required";
9
+ readonly summary: "A feature using an API lists the exposing feature in requires or optionalRequires.";
10
+ readonly fix: "Add the exposing feature via r.requires({ features: [...] }).";
11
+ };
12
+ readonly providerNotMounted: {
13
+ readonly id: "api/provider-not-mounted";
14
+ readonly summary: "The feature exposing a used API is part of the app's feature list.";
15
+ readonly fix: "Register the provider feature in the app's feature list.";
16
+ };
17
+ readonly unknownExtension: {
18
+ readonly id: "extension/unknown-extension";
19
+ readonly summary: "Every extension a feature uses is defined by some mounted feature.";
20
+ readonly fix: "Register the feature that defines the extension or fix the extension name.";
21
+ };
22
+ readonly extensionProviderNotRequired: {
23
+ readonly id: "extension/provider-not-required";
24
+ readonly summary: "A feature using an extension lists the defining feature in requires or optionalRequires.";
25
+ readonly fix: "Add the defining feature via r.requires({ features: [...] }).";
26
+ };
27
+ };
@@ -0,0 +1,27 @@
1
+ export const API_EXT_RULES = {
2
+ unknownApi: {
3
+ id: "api/unknown-api",
4
+ summary: "Every r.usesApi name is exposed by some mounted feature.",
5
+ fix: "Mount the feature that exposes the API or fix the API name.",
6
+ },
7
+ providerNotRequired: {
8
+ id: "api/provider-not-required",
9
+ summary: "A feature using an API lists the exposing feature in requires or optionalRequires.",
10
+ fix: "Add the exposing feature via r.requires({ features: [...] }).",
11
+ },
12
+ providerNotMounted: {
13
+ id: "api/provider-not-mounted",
14
+ summary: "The feature exposing a used API is part of the app's feature list.",
15
+ fix: "Register the provider feature in the app's feature list.",
16
+ },
17
+ unknownExtension: {
18
+ id: "extension/unknown-extension",
19
+ summary: "Every extension a feature uses is defined by some mounted feature.",
20
+ fix: "Register the feature that defines the extension or fix the extension name.",
21
+ },
22
+ extensionProviderNotRequired: {
23
+ id: "extension/provider-not-required",
24
+ summary: "A feature using an extension lists the defining feature in requires or optionalRequires.",
25
+ fix: "Add the defining feature via r.requires({ features: [...] }).",
26
+ },
27
+ };
@@ -1,4 +1,7 @@
1
1
  import { createFallbackLogger } from "../../logging/utils.js";
2
+ import { API_EXT_RULES } from "./api-ext-rules.js";
3
+ import { collectBootRuleViolations } from "./boot-report.js";
4
+ import { bootRuleError } from "./boot-rule.js";
2
5
  const log = createFallbackLogger("kumiko:boot");
3
6
  // --- Cross-feature API exposure / usage matching ---
4
7
  //
@@ -13,42 +16,52 @@ const log = createFallbackLogger("kumiko:boot");
13
16
  // apiNames must be globally unique across features; validateBoot() checks
14
17
  // this before the per-feature walk.
15
18
  export function validateApiExposureMatching(feature, allExposedApis, featureMap) {
16
- for (const apiName of feature.usedApis) {
19
+ collectBootRuleViolations(feature.usedApis, (apiName) => {
17
20
  const providerFeature = allExposedApis.get(apiName);
18
21
  if (!providerFeature) {
19
22
  const known = [...allExposedApis.keys()].sort().join(", ") || "(none)";
20
- throw new Error(`[Feature ${feature.name}] r.usesApi("${apiName}") but no feature exposes that API. Known exposed APIs: ${known}`);
23
+ throw bootRuleError(API_EXT_RULES.unknownApi, {
24
+ message: `[Feature ${feature.name}] r.usesApi("${apiName}") but no feature exposes that API. Known exposed APIs: ${known}`,
25
+ });
21
26
  }
22
27
  if (providerFeature === feature.name) {
23
28
  log.warn(`[Feature ${feature.name}] r.usesApi("${apiName}") on its own r.exposesApi — typically a refactor leftover. Call the impl directly instead.`);
24
- continue;
29
+ return;
25
30
  }
26
31
  const allDeps = [...feature.requires, ...feature.optionalRequires];
27
32
  if (!allDeps.includes(providerFeature)) {
28
- throw new Error(`[Feature ${feature.name}] r.usesApi("${apiName}") is exposed by "${providerFeature}" but feature is not in requires/optionalRequires. Add r.requires({ features: ["${providerFeature}"] }).`);
33
+ throw bootRuleError(API_EXT_RULES.providerNotRequired, {
34
+ message: `[Feature ${feature.name}] r.usesApi("${apiName}") is exposed by "${providerFeature}" but feature is not in requires/optionalRequires. Add r.requires({ features: ["${providerFeature}"] }).`,
35
+ });
29
36
  }
30
37
  // Sanity: the provider feature must exist in this app's feature set.
31
38
  // This should always hold when allExposedApis was built from `features`,
32
39
  // but remains defensive for unclear constructor paths.
33
40
  if (!featureMap.has(providerFeature)) {
34
- throw new Error(`[Feature ${feature.name}] internal: r.usesApi("${apiName}") points to provider "${providerFeature}" which is not in feature map. Register the provider feature in the app's feature list.`);
41
+ throw bootRuleError(API_EXT_RULES.providerNotMounted, {
42
+ message: `[Feature ${feature.name}] internal: r.usesApi("${apiName}") points to provider "${providerFeature}" which is not in feature map. Register the provider feature in the app's feature list.`,
43
+ });
35
44
  }
36
- }
45
+ });
37
46
  }
38
47
  // --- Extension usage validation ---
39
48
  export function validateExtensionUsages(feature, extensionProviders) {
40
- for (const usage of feature.extensionUsages) {
49
+ collectBootRuleViolations(feature.extensionUsages, (usage) => {
41
50
  const providerFeature = extensionProviders.get(usage.extensionName);
42
51
  if (!providerFeature) {
43
- throw new Error(`Feature "${feature.name}" uses extension "${usage.extensionName}" on entity "${usage.entityName}" but no feature defines that extension. Register the feature that defines it or fix the extension name.`);
52
+ throw bootRuleError(API_EXT_RULES.unknownExtension, {
53
+ message: `Feature "${feature.name}" uses extension "${usage.extensionName}" on entity "${usage.entityName}" but no feature defines that extension. Register the feature that defines it or fix the extension name.`,
54
+ });
44
55
  }
45
- // self-extension is legitimate: requires(self) would be circular.
46
56
  if (providerFeature === feature.name) {
47
- continue;
57
+ // skip: self-extension is legitimate, requires(self) would be circular
58
+ return;
48
59
  }
49
60
  const allDeps = [...feature.requires, ...feature.optionalRequires];
50
61
  if (!allDeps.includes(providerFeature)) {
51
- throw new Error(`Feature "${feature.name}" uses extension "${usage.extensionName}" but missing requires("${providerFeature}"). Add r.requires({ features: ["${providerFeature}"] }).`);
62
+ throw bootRuleError(API_EXT_RULES.extensionProviderNotRequired, {
63
+ message: `Feature "${feature.name}" uses extension "${usage.extensionName}" but missing requires("${providerFeature}"). Add r.requires({ features: ["${providerFeature}"] }).`,
64
+ });
52
65
  }
53
- }
66
+ });
54
67
  }
@@ -0,0 +1,7 @@
1
+ export declare const BOOT_CHECK_RULES: {
2
+ readonly failed: {
3
+ readonly id: "boot-check/failed";
4
+ readonly summary: "A feature's r.bootCheck invariant holds for the mounted feature set.";
5
+ readonly fix: "Satisfy the invariant named in the message or unmount the feature that declares it.";
6
+ };
7
+ };
@@ -0,0 +1,7 @@
1
+ export const BOOT_CHECK_RULES = {
2
+ failed: {
3
+ id: "boot-check/failed",
4
+ summary: "A feature's r.bootCheck invariant holds for the mounted feature set.",
5
+ fix: "Satisfy the invariant named in the message or unmount the feature that declares it.",
6
+ },
7
+ };