@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
@@ -1,16 +1,28 @@
1
+ import { BOOT_CHECK_RULES } from "./boot-check-rules.js";
2
+ import { BootRuleFailures, BootRuleViolation, bootRuleError } from "./boot-rule.js";
1
3
  // r.bootCheck(fn) lets a feature declare its own mount-invariant instead of
2
4
  // relying on framework-internal knowledge (the gdpr-storage.ts guards are
3
5
  // the framework-owned version of this same idea). Each registered fn gets
4
6
  // the full mounted-feature set and throws to fail the boot; we wrap the
5
7
  // message with the owning feature's name so the error text points back at
6
8
  // the feature that declared the check.
9
+ function prefixFailure(failure, prefix) {
10
+ const rule = failure instanceof BootRuleViolation ? failure.rule : BOOT_CHECK_RULES.failed;
11
+ return new BootRuleViolation(rule, prefix + failure.message);
12
+ }
7
13
  export function runFeatureBootCheck(feature, check, features) {
8
14
  const ctx = { features };
15
+ const prefix = `[Feature ${feature.name}] r.bootCheck failed: `;
9
16
  try {
10
17
  check(ctx);
11
18
  }
12
19
  catch (err) {
20
+ if (err instanceof BootRuleViolation)
21
+ throw prefixFailure(err, prefix);
22
+ if (err instanceof BootRuleFailures) {
23
+ throw new BootRuleFailures(err.failures.map((failure) => prefixFailure(failure, prefix)));
24
+ }
13
25
  const message = err instanceof Error ? err.message : String(err);
14
- throw new Error(`[Feature ${feature.name}] r.bootCheck failed: ${message}`);
26
+ throw bootRuleError(BOOT_CHECK_RULES.failed, { message: prefix + message });
15
27
  }
16
28
  }
@@ -5,6 +5,7 @@ import { validateProjectionAllowlist } from "../validate-projection-allowlist.js
5
5
  import { runFeatureBootCheck } from "./boot-check.js";
6
6
  import { validateCardMetaOverflow } from "./card-meta-overflow.js";
7
7
  import { validateConfigReads } from "./config-deps.js";
8
+ import { validateDashboardStatNavigate } from "./dashboard-stat-navigate.js";
8
9
  import { validateExtensionPreSaveWiring } from "./entity-handler.js";
9
10
  import { validateEntityListScreens } from "./entity-list-screens.js";
10
11
  import { validateGdprStoragePersistence } from "./gdpr-storage.js";
@@ -45,6 +46,10 @@ const APP_CHECKS_AFTER_I18N = [
45
46
  check: "validateProjectionListScreens",
46
47
  run: (c) => validateProjectionListScreens(c.features),
47
48
  },
49
+ {
50
+ check: "validateDashboardStatNavigate",
51
+ run: (c) => validateDashboardStatNavigate(c.features),
52
+ },
48
53
  {
49
54
  check: "validateRelatedListSectionQueries",
50
55
  run: (c) => validateRelatedListSectionQueries(c.features),
@@ -4,3 +4,4 @@ export type BootReport = {
4
4
  finish(): void;
5
5
  };
6
6
  export declare function createBootReport(): BootReport;
7
+ export declare function collectBootRuleViolations<T>(items: Iterable<T>, run: (item: T) => void): void;
@@ -1,4 +1,4 @@
1
- import { BootValidationError } from "./boot-rule.js";
1
+ import { BootRuleFailures, BootRuleViolation, BootValidationError, } from "./boot-rule.js";
2
2
  // A runtime crash is a bug in a validator (or in the input it assumed), not a
3
3
  // rule violation: it must surface as itself instead of masquerading as a finding.
4
4
  function isRuntimeCrash(thrown) {
@@ -10,6 +10,33 @@ function isRuntimeCrash(thrown) {
10
10
  thrown instanceof EvalError ||
11
11
  thrown instanceof URIError);
12
12
  }
13
+ function violationsOf(failure, checkName, feature) {
14
+ if (failure instanceof BootRuleFailures) {
15
+ return failure.failures.flatMap((nested) => violationsOf(nested, checkName, feature));
16
+ }
17
+ if (failure instanceof BootValidationError)
18
+ return failure.violations;
19
+ const featurePart = feature === undefined ? {} : { feature };
20
+ if (failure instanceof BootRuleViolation) {
21
+ return [
22
+ {
23
+ ruleId: failure.rule.id,
24
+ check: checkName,
25
+ ...featurePart,
26
+ message: failure.message,
27
+ fix: failure.rule.fix,
28
+ },
29
+ ];
30
+ }
31
+ return [
32
+ {
33
+ ruleId: `unclassified/${checkName}`,
34
+ check: checkName,
35
+ ...featurePart,
36
+ message: failure.message,
37
+ },
38
+ ];
39
+ }
13
40
  export function createBootReport() {
14
41
  const violations = [];
15
42
  let firstCrash;
@@ -31,12 +58,7 @@ export function createBootReport() {
31
58
  firstCrash ??= { thrown };
32
59
  }
33
60
  else if (thrown instanceof Error) {
34
- violations.push({
35
- ruleId: `unclassified/${checkName}`,
36
- check: checkName,
37
- ...(feature === undefined ? {} : { feature }),
38
- message: thrown.message,
39
- });
61
+ violations.push(...violationsOf(thrown, checkName, feature));
40
62
  }
41
63
  return false;
42
64
  }
@@ -45,3 +67,32 @@ export function createBootReport() {
45
67
  finish: endPhase,
46
68
  };
47
69
  }
70
+ // Boundary per item: every item runs, so one boot reports all independent violations
71
+ // instead of only the first. Crashes behave as in endPhase: dropped next to failures.
72
+ export function collectBootRuleViolations(items, run) {
73
+ const failures = [];
74
+ let firstCrash;
75
+ for (const item of items) {
76
+ try {
77
+ run(item);
78
+ }
79
+ catch (thrown) {
80
+ if (isRuntimeCrash(thrown)) {
81
+ firstCrash ??= { thrown };
82
+ }
83
+ else if (thrown instanceof BootRuleFailures) {
84
+ failures.push(...thrown.failures);
85
+ }
86
+ else if (thrown instanceof Error) {
87
+ failures.push(thrown);
88
+ }
89
+ }
90
+ }
91
+ const [onlyFailure] = failures;
92
+ if (onlyFailure !== undefined && failures.length === 1)
93
+ throw onlyFailure;
94
+ if (failures.length > 1)
95
+ throw new BootRuleFailures(failures);
96
+ if (firstCrash !== undefined)
97
+ throw firstCrash.thrown;
98
+ }
@@ -5,6 +5,26 @@ export type BootViolation = {
5
5
  readonly message: string;
6
6
  readonly fix?: string;
7
7
  };
8
+ export type BootRule = {
9
+ readonly id: string;
10
+ readonly summary: string;
11
+ readonly fix: string;
12
+ };
13
+ export type BootRuleTable = Readonly<Record<string, BootRule>>;
14
+ export declare class BootRuleViolation extends Error {
15
+ readonly rule: BootRule;
16
+ readonly name = "BootRuleViolation";
17
+ constructor(rule: BootRule, message: string);
18
+ }
19
+ export declare function bootRuleError(rule: BootRule, details: {
20
+ readonly message: string;
21
+ }): BootRuleViolation;
22
+ export declare class BootRuleFailures extends Error {
23
+ readonly failures: readonly Error[];
24
+ readonly name = "BootRuleFailures";
25
+ constructor(failures: readonly Error[]);
26
+ }
27
+ export declare function bootRuleDocLink(ruleId: string): string | undefined;
8
28
  export declare class BootValidationError extends Error {
9
29
  readonly violations: readonly BootViolation[];
10
30
  readonly name = "BootValidationError";
@@ -1,6 +1,37 @@
1
+ export class BootRuleViolation extends Error {
2
+ rule;
3
+ name = "BootRuleViolation";
4
+ constructor(rule, message) {
5
+ super(message);
6
+ this.rule = rule;
7
+ }
8
+ }
9
+ export function bootRuleError(rule, details) {
10
+ return new BootRuleViolation(rule, details.message);
11
+ }
12
+ export class BootRuleFailures extends Error {
13
+ failures;
14
+ name = "BootRuleFailures";
15
+ constructor(failures) {
16
+ super(failures.map((failure) => failure.message).join("\n"));
17
+ this.failures = failures;
18
+ }
19
+ }
20
+ const UNCLASSIFIED_RULE_PREFIX = "unclassified/";
21
+ // Must match anchorOf in packages/guards/src/_lib/agent-docs-boot-rules.ts.
22
+ export function bootRuleDocLink(ruleId) {
23
+ if (ruleId.startsWith(UNCLASSIFIED_RULE_PREFIX))
24
+ return undefined;
25
+ return `agents/boot-rules.md#${ruleId.replaceAll("/", "-")}`;
26
+ }
1
27
  function describeViolation(violation) {
2
- const line = `- [${violation.ruleId}] ${violation.message}`;
3
- return violation.fix === undefined ? line : `${line}\n Fix: ${violation.fix}`;
28
+ const lines = [`- [${violation.ruleId}] ${violation.message}`];
29
+ if (violation.fix !== undefined)
30
+ lines.push(` Fix: ${violation.fix}`);
31
+ const docLink = bootRuleDocLink(violation.ruleId);
32
+ if (docLink !== undefined)
33
+ lines.push(` Rule: ${docLink}`);
34
+ return lines.join("\n");
4
35
  }
5
36
  export class BootValidationError extends Error {
6
37
  violations;
@@ -0,0 +1,246 @@
1
+ import type { BootRule } from "./boot-rule.ts";
2
+ export declare const FRAMEWORK_BOOT_RULE_TABLES: readonly [{
3
+ readonly multipleSubjectAnnotations: {
4
+ readonly id: "pii/multiple-subject-annotations";
5
+ readonly summary: "A field carries at most one subject-key annotation.";
6
+ readonly fix: "Keep a single personal annotation on the field.";
7
+ };
8
+ readonly textWithoutPersonalStance: {
9
+ readonly id: "pii/text-without-personal-stance";
10
+ readonly summary: "Every text and longText field declares whether it holds personal data.";
11
+ readonly fix: "Declare a personal stance, or personal: false with a reason stating why the value is not personal data.";
12
+ };
13
+ readonly plaintextUserReference: {
14
+ readonly id: "pii/plaintext-user-reference";
15
+ readonly summary: "A reference to the user entity is never plaintext.";
16
+ readonly fix: 'Use the default reference field or personal: "ref" instead of personal: false.';
17
+ };
18
+ readonly lookupableNonText: {
19
+ readonly id: "pii/lookupable-non-text";
20
+ readonly summary: "Blind-index lookups apply to text fields only.";
21
+ readonly fix: 'Use a text field or set find: "none".';
22
+ };
23
+ readonly lookupableWithoutSubject: {
24
+ readonly id: "pii/lookupable-without-subject";
25
+ readonly summary: "A blind-index lookup requires a subject annotation on the field.";
26
+ readonly fix: 'Add a personal annotation or set find: "none".';
27
+ };
28
+ readonly sensitiveWithoutCiphertext: {
29
+ readonly id: "pii/sensitive-without-ciphertext";
30
+ readonly summary: "A sensitive field is stored as ciphertext at rest.";
31
+ readonly fix: "Add a subject annotation or encrypted: true.";
32
+ };
33
+ readonly writeOnlyEmbeddedSubField: {
34
+ readonly id: "pii/write-only-embedded-sub-field";
35
+ readonly summary: "writeOnly is only supported on top-level text fields, not on embedded sub-fields.";
36
+ readonly fix: "Remove writeOnly from the sub-field or declare it on a top-level text field.";
37
+ };
38
+ readonly writeOnlyNonText: {
39
+ readonly id: "pii/write-only-non-text";
40
+ readonly summary: "writeOnly applies to text fields only.";
41
+ readonly fix: "Use a text field or remove writeOnly.";
42
+ };
43
+ readonly writeOnlyWithoutSensitive: {
44
+ readonly id: "pii/write-only-without-sensitive";
45
+ readonly summary: "A writeOnly field is also sensitive.";
46
+ readonly fix: 'Declare the field with find: "secret".';
47
+ };
48
+ readonly writeOnlyConflictingFlag: {
49
+ readonly id: "pii/write-only-conflicting-flag";
50
+ readonly summary: "A writeOnly field cannot be defaulted, searched, sorted, filtered or looked up.";
51
+ readonly fix: "Remove either writeOnly or the conflicting flag.";
52
+ };
53
+ readonly subjectKeySortable: {
54
+ readonly id: "pii/subject-key-sortable";
55
+ readonly summary: "A field with a subject-key annotation is not sortable, its column is ciphertext.";
56
+ readonly fix: 'Drop sortable, use find: "exact" for equality lookups, or keep the field plaintext.';
57
+ };
58
+ readonly sensitiveSearchable: {
59
+ readonly id: "pii/sensitive-searchable";
60
+ readonly summary: "A sensitive field is never searchable.";
61
+ readonly fix: "Drop searchable, or add writeOnly to never return the value.";
62
+ };
63
+ readonly personalJsonbFlag: {
64
+ readonly id: "pii/personal-jsonb-flag";
65
+ readonly summary: "A personal jsonb field cannot be sortable, filterable, searchable or lookupable, its value is a ciphertext string.";
66
+ readonly fix: "Remove the flag or the personal annotation.";
67
+ };
68
+ readonly personalJsonbIndex: {
69
+ readonly id: "pii/personal-jsonb-index";
70
+ readonly summary: "A personal jsonb field is not part of an entity index.";
71
+ readonly fix: "Drop the index or the personal annotation.";
72
+ };
73
+ readonly personalJsonbCustomFields: {
74
+ readonly id: "pii/personal-jsonb-custom-fields";
75
+ readonly summary: "The custom-fields jsonb column cannot carry a personal annotation.";
76
+ readonly fix: "Drop the personal annotation.";
77
+ };
78
+ readonly personalOfMissingOwnerName: {
79
+ readonly id: "pii/personal-of-missing-owner-name";
80
+ readonly summary: "personal: { of } names the owner field.";
81
+ readonly fix: 'Pass the name of the owner field: personal: { of: "<ownerField>" }.';
82
+ };
83
+ readonly personalOfUnknownOwner: {
84
+ readonly id: "pii/personal-of-unknown-owner";
85
+ readonly summary: "The owner field named by personal: { of } exists on the entity.";
86
+ readonly fix: "Point personal.of at an existing field of the entity.";
87
+ };
88
+ readonly personalOfOwnerType: {
89
+ readonly id: "pii/personal-of-owner-type";
90
+ readonly summary: "The owner field named by personal: { of } is a reference or text field.";
91
+ readonly fix: "Use a reference or text field as owner.";
92
+ };
93
+ readonly personalOfInvalidWhenAbsent: {
94
+ readonly id: "pii/personal-of-invalid-when-absent";
95
+ readonly summary: 'personal.whenAbsent is either "tenant" or "self".';
96
+ readonly fix: 'Remove whenAbsent or set it to "tenant" or "self".';
97
+ };
98
+ readonly personalOfOptionalOwner: {
99
+ readonly id: "pii/personal-of-optional-owner";
100
+ readonly summary: "An optional owner field of personal: { of } declares a whenAbsent fallback.";
101
+ readonly fix: 'Set whenAbsent to "tenant" or "self", or make the owner field required.';
102
+ };
103
+ readonly retentionUnknownReference: {
104
+ readonly id: "retention/unknown-reference";
105
+ readonly summary: "retention.reference names an existing field or a framework timestamp.";
106
+ readonly fix: "Point retention.reference at an existing field or a framework-managed timestamp.";
107
+ };
108
+ }, {
109
+ readonly searchableFalse: {
110
+ readonly id: "entity-list/searchable-false";
111
+ readonly summary: "Operator lists are searchable unless they are on the searchable-false whitelist.";
112
+ readonly fix: "Set searchable: true or add the screen id to the whitelist.";
113
+ };
114
+ readonly defaultSortRequired: {
115
+ readonly id: "entity-list/default-sort-required";
116
+ readonly summary: "A list with active search or filterable fields declares a defaultSort.";
117
+ readonly fix: "Set defaultSort: { field, direction } on the screen.";
118
+ };
119
+ readonly defaultSortNotListed: {
120
+ readonly id: "entity-list/default-sort-not-listed";
121
+ readonly summary: "defaultSort.field is one of the listed columns.";
122
+ readonly fix: "Pick a field from columns or add it there.";
123
+ };
124
+ readonly defaultSortNotSortable: {
125
+ readonly id: "entity-list/default-sort-not-sortable";
126
+ readonly summary: "The defaultSort column is sortable.";
127
+ readonly fix: "Mark the field sortable or pick another defaultSort field.";
128
+ };
129
+ readonly detailWithoutNavigate: {
130
+ readonly id: "entity-list/detail-without-navigate";
131
+ readonly summary: "A list whose entity has a detail screen offers a navigate row action.";
132
+ readonly fix: 'Add a navigate rowAction with id "view" or "edit".';
133
+ };
134
+ readonly unknownNamedFilter: {
135
+ readonly id: "entity-list/unknown-named-filter";
136
+ readonly summary: "Every screen namedFilters id is declared in the entity's namedFilters.";
137
+ readonly fix: "Declare the rule in EntityDefinition.namedFilters or remove the id from the screen.";
138
+ };
139
+ readonly unknownColumn: {
140
+ readonly id: "entity-list/unknown-column";
141
+ readonly summary: "Every column without a label names an entity field, derived field or row-meta column.";
142
+ readonly fix: "Fix the column field name or give the column a label.";
143
+ };
144
+ }, {
145
+ readonly projectionQueryAcceptsNamedFilter: {
146
+ readonly id: "named-filter/projection-query-accepts-named-filter";
147
+ readonly summary: "A projectionList with namedFilters binds a query whose schema has a namedFilter key.";
148
+ readonly fix: "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.";
149
+ };
150
+ readonly statNavigateTarget: {
151
+ readonly id: "named-filter/stat-navigate-target";
152
+ readonly summary: "A stat navigate.screen resolves to an entityList or projectionList screen.";
153
+ readonly fix: "Point navigate.screen at a registered list screen.";
154
+ };
155
+ readonly statNavigateFilterDeclared: {
156
+ readonly id: "named-filter/stat-navigate-filter-declared";
157
+ readonly summary: "A stat navigate.namedFilter is declared in the target screen's namedFilters.";
158
+ readonly fix: "Add { id, label } to namedFilters of the target screen.";
159
+ };
160
+ readonly statNavigateSameQuery: {
161
+ readonly id: "named-filter/stat-navigate-same-query";
162
+ readonly summary: "A stat with navigate counts with the target list's own query.";
163
+ readonly fix: "Set the stat query to the target list's query.";
164
+ };
165
+ readonly statNavigateSameNamedFilter: {
166
+ readonly id: "named-filter/stat-navigate-same-named-filter";
167
+ readonly summary: "A stat's params.namedFilter equals navigate.namedFilter.";
168
+ readonly fix: "Set params.namedFilter to the same id as navigate.namedFilter.";
169
+ };
170
+ readonly statNavigateStaticFilter: {
171
+ readonly id: "named-filter/stat-navigate-static-filter";
172
+ readonly summary: "A stat repeats the target screen's static filter in params.filter.";
173
+ readonly fix: "Set params.filter to the target screen's filter.";
174
+ };
175
+ }, {
176
+ readonly missingFeatureKey: {
177
+ readonly id: "i18n/missing-feature-key";
178
+ readonly summary: "Every translation key a feature surface requires is defined.";
179
+ readonly fix: "Add the key via r.translations(...) in the feature.";
180
+ };
181
+ readonly missingSettingsHubKey: {
182
+ readonly id: "i18n/missing-settings-hub-key";
183
+ readonly summary: "Every translation key the generated settings hub requires is defined.";
184
+ readonly fix: "Add the key via r.translations(...) in the owning feature.";
185
+ };
186
+ readonly localeInconsistent: {
187
+ readonly id: "i18n/locale-inconsistent";
188
+ readonly summary: "Every locale a feature uses is maintained for every one of its translation keys.";
189
+ readonly fix: "Add the missing locale to the key, or move that locale into a locale package.";
190
+ };
191
+ readonly missingRequiredLocale: {
192
+ readonly id: "i18n/missing-required-locale";
193
+ readonly summary: "Every translation key exists in every locale the app requires.";
194
+ readonly fix: "Load the matching locale package or add the missing translations.";
195
+ };
196
+ readonly missingFieldValueTranslation: {
197
+ readonly id: "i18n/missing-field-value-translation";
198
+ readonly summary: "With requireFieldValueTranslations, select options and embedded cells are translated.";
199
+ readonly fix: "Add the keys via r.translations(...) in the owning feature.";
200
+ };
201
+ }, {
202
+ readonly overflow: {
203
+ readonly id: "list/card-meta-overflow";
204
+ readonly summary: "A card list shows at most the maximum number of meta columns.";
205
+ readonly fix: "Reduce each list to the maximum number of meta columns or mark the others hideOnNarrow: true.";
206
+ };
207
+ }, {
208
+ readonly actionIcon: {
209
+ readonly id: "screen/action-icon";
210
+ readonly summary: "Every screen action resolves to a registered icon.";
211
+ readonly fix: "Set icon to a registered NAV_ICONS key or use an action id that resolves one via the default icon map.";
212
+ };
213
+ }, {
214
+ readonly unknownApi: {
215
+ readonly id: "api/unknown-api";
216
+ readonly summary: "Every r.usesApi name is exposed by some mounted feature.";
217
+ readonly fix: "Mount the feature that exposes the API or fix the API name.";
218
+ };
219
+ readonly providerNotRequired: {
220
+ readonly id: "api/provider-not-required";
221
+ readonly summary: "A feature using an API lists the exposing feature in requires or optionalRequires.";
222
+ readonly fix: "Add the exposing feature via r.requires({ features: [...] }).";
223
+ };
224
+ readonly providerNotMounted: {
225
+ readonly id: "api/provider-not-mounted";
226
+ readonly summary: "The feature exposing a used API is part of the app's feature list.";
227
+ readonly fix: "Register the provider feature in the app's feature list.";
228
+ };
229
+ readonly unknownExtension: {
230
+ readonly id: "extension/unknown-extension";
231
+ readonly summary: "Every extension a feature uses is defined by some mounted feature.";
232
+ readonly fix: "Register the feature that defines the extension or fix the extension name.";
233
+ };
234
+ readonly extensionProviderNotRequired: {
235
+ readonly id: "extension/provider-not-required";
236
+ readonly summary: "A feature using an extension lists the defining feature in requires or optionalRequires.";
237
+ readonly fix: "Add the defining feature via r.requires({ features: [...] }).";
238
+ };
239
+ }, {
240
+ readonly failed: {
241
+ readonly id: "boot-check/failed";
242
+ readonly summary: "A feature's r.bootCheck invariant holds for the mounted feature set.";
243
+ readonly fix: "Satisfy the invariant named in the message or unmount the feature that declares it.";
244
+ };
245
+ }];
246
+ export declare const FRAMEWORK_BOOT_RULES: readonly BootRule[];
@@ -0,0 +1,19 @@
1
+ import { API_EXT_RULES } from "./api-ext-rules.js";
2
+ import { BOOT_CHECK_RULES } from "./boot-check-rules.js";
3
+ import { CARD_META_OVERFLOW_RULES } from "./card-meta-overflow-rules.js";
4
+ import { ENTITY_LIST_RULES } from "./entity-list-screens-rules.js";
5
+ import { I18N_RULES } from "./i18n-keys-rules.js";
6
+ import { NAMED_FILTER_RULES } from "./named-filter-rules.js";
7
+ import { PII_RULES } from "./pii-retention-rules.js";
8
+ import { SCREEN_ACTION_RULES } from "./screens-actions-rules.js";
9
+ export const FRAMEWORK_BOOT_RULE_TABLES = [
10
+ PII_RULES,
11
+ ENTITY_LIST_RULES,
12
+ NAMED_FILTER_RULES,
13
+ I18N_RULES,
14
+ CARD_META_OVERFLOW_RULES,
15
+ SCREEN_ACTION_RULES,
16
+ API_EXT_RULES,
17
+ BOOT_CHECK_RULES,
18
+ ];
19
+ export const FRAMEWORK_BOOT_RULES = FRAMEWORK_BOOT_RULE_TABLES.flatMap((table) => Object.values(table));
@@ -0,0 +1,7 @@
1
+ export declare const CARD_META_OVERFLOW_RULES: {
2
+ readonly overflow: {
3
+ readonly id: "list/card-meta-overflow";
4
+ readonly summary: "A card list shows at most the maximum number of meta columns.";
5
+ readonly fix: "Reduce each list to the maximum number of meta columns or mark the others hideOnNarrow: true.";
6
+ };
7
+ };
@@ -0,0 +1,7 @@
1
+ export const CARD_META_OVERFLOW_RULES = {
2
+ overflow: {
3
+ id: "list/card-meta-overflow",
4
+ summary: "A card list shows at most the maximum number of meta columns.",
5
+ fix: "Reduce each list to the maximum number of meta columns or mark the others hideOnNarrow: true.",
6
+ },
7
+ };
@@ -2,6 +2,8 @@ import { CARD_META_MAX, cardColumnRoles, cardFileDisplayKind, } from "../../ui-t
2
2
  import { LIST_ROW_META_COLUMNS } from "../../ui-types/list-row-meta.js";
3
3
  import { parseRefTarget } from "../parse-ref-target.js";
4
4
  import { normalizeListColumn } from "../screen-helpers.js";
5
+ import { bootRuleError } from "./boot-rule.js";
6
+ import { CARD_META_OVERFLOW_RULES } from "./card-meta-overflow-rules.js";
5
7
  // Mirrors the type resolution of headless computeListViewModel; only the
6
8
  // "select" outcome matters for the card status role.
7
9
  function resolveColumnType(spec, storedType, entity) {
@@ -94,5 +96,7 @@ export function validateCardMetaOverflow(features) {
94
96
  if (overflows.length === 0)
95
97
  return;
96
98
  const lines = overflows.map((o) => ` - Feature "${o.feature}" screen "${o.screen}"${o.location !== undefined ? ` ${o.location}` : ""}: ${o.count} meta columns (${o.fields.join(", ")})`);
97
- throw new Error(`List cards show at most ${CARD_META_MAX} meta columns (all columns except title, status select and hideOnNarrow ones); more would be cut off on narrow screens:\n${lines.join("\n")}\nReduce each to ${CARD_META_MAX} columns or mark the others hideOnNarrow: true.`);
99
+ throw bootRuleError(CARD_META_OVERFLOW_RULES.overflow, {
100
+ message: `List cards show at most ${CARD_META_MAX} meta columns (all columns except title, status select and hideOnNarrow ones); more would be cut off on narrow screens:\n${lines.join("\n")}\nReduce each to ${CARD_META_MAX} columns or mark the others hideOnNarrow: true.`,
101
+ });
98
102
  }
@@ -0,0 +1,2 @@
1
+ import type { FeatureDefinition } from "../types/index.ts";
2
+ export declare function validateDashboardStatNavigate(features: readonly FeatureDefinition[]): void;
@@ -0,0 +1,77 @@
1
+ import { isDeepStrictEqual } from "node:util";
2
+ import { toKebab } from "../qualified-name.js";
3
+ import { bootRuleError } from "./boot-rule.js";
4
+ import { NAMED_FILTER_RULES } from "./named-filter-rules.js";
5
+ import { resolveScreenTargetQn } from "./screens-actions.js";
6
+ function entityListQueryName(feature, screen) {
7
+ return `${toKebab(feature.name)}:query:${toKebab(screen.entity)}:list`;
8
+ }
9
+ function listQueryOf(target) {
10
+ return target.screen.type === "entityList"
11
+ ? entityListQueryName(target.feature, target.screen)
12
+ : target.screen.query;
13
+ }
14
+ function declaredNamedFilterIds(target) {
15
+ return new Set((target.screen.namedFilters ?? []).map((ref) => ref.id));
16
+ }
17
+ function resolveListTarget(featureName, screenRef, featureMap) {
18
+ const qn = resolveScreenTargetQn(featureName, screenRef);
19
+ const [targetFeatureName = "", , targetShortId = ""] = qn.split(":");
20
+ const feature = featureMap.get(targetFeatureName);
21
+ const screen = feature?.screens[targetShortId];
22
+ if (feature === undefined || screen === undefined)
23
+ return { qn };
24
+ if (screen.type !== "entityList" && screen.type !== "projectionList")
25
+ return { qn };
26
+ return { feature, screen };
27
+ }
28
+ function validateStatNavigate(context, featureName, stat, featureMap) {
29
+ const navigate = stat.navigate;
30
+ // skip: stat has no navigate target to validate
31
+ if (navigate === undefined)
32
+ return;
33
+ const target = resolveListTarget(featureName, navigate.screen, featureMap);
34
+ if ("qn" in target) {
35
+ throw bootRuleError(NAMED_FILTER_RULES.statNavigateTarget, {
36
+ message: `${context} navigate.screen "${navigate.screen}" does not resolve to a registered entityList or projectionList screen (checked "${target.qn}").`,
37
+ });
38
+ }
39
+ if (navigate.namedFilter !== undefined &&
40
+ !declaredNamedFilterIds(target).has(navigate.namedFilter)) {
41
+ throw bootRuleError(NAMED_FILTER_RULES.statNavigateFilterDeclared, {
42
+ message: `${context} navigate.namedFilter "${navigate.namedFilter}" is not declared in namedFilters of screen "${target.screen.id}". Add { id, label } to that screen.`,
43
+ });
44
+ }
45
+ const listQuery = listQueryOf(target);
46
+ if (stat.query !== listQuery) {
47
+ throw bootRuleError(NAMED_FILTER_RULES.statNavigateSameQuery, {
48
+ message: `${context} navigate targets screen "${target.screen.id}" whose list query is "${listQuery}", but the stat counts with query "${stat.query}". Count with the list's own query so tile and list cannot drift.`,
49
+ });
50
+ }
51
+ if (stat.params?.["namedFilter"] !== navigate.namedFilter) {
52
+ throw bootRuleError(NAMED_FILTER_RULES.statNavigateSameNamedFilter, {
53
+ message: `${context} params.namedFilter (${JSON.stringify(stat.params?.["namedFilter"])}) must equal navigate.namedFilter (${JSON.stringify(navigate.namedFilter)}), otherwise the tile counts other rows than the list shows.`,
54
+ });
55
+ }
56
+ const staticFilter = target.screen.filter;
57
+ if (staticFilter !== undefined && !isDeepStrictEqual(stat.params?.["filter"], staticFilter)) {
58
+ throw bootRuleError(NAMED_FILTER_RULES.statNavigateStaticFilter, {
59
+ message: `${context} target screen "${target.screen.id}" has a static filter the tile does not apply. Set params.filter to the same filter on the stat.`,
60
+ });
61
+ }
62
+ }
63
+ export function validateDashboardStatNavigate(features) {
64
+ const featureMap = new Map(features.map((f) => [f.name, f]));
65
+ for (const feature of features) {
66
+ for (const screen of Object.values(feature.screens)) {
67
+ if (screen.type !== "dashboard")
68
+ continue;
69
+ for (const panel of screen.panels) {
70
+ const stats = panel.kind === "stat" ? [panel] : panel.kind === "stat-group" ? panel.stats : [];
71
+ for (const stat of stats) {
72
+ validateStatNavigate(`[Feature ${feature.name}] Screen "${screen.id}" (dashboard) stat "${stat.id}"`, feature.name, stat, featureMap);
73
+ }
74
+ }
75
+ }
76
+ }
77
+ }
@@ -0,0 +1,37 @@
1
+ export declare const ENTITY_LIST_RULES: {
2
+ readonly searchableFalse: {
3
+ readonly id: "entity-list/searchable-false";
4
+ readonly summary: "Operator lists are searchable unless they are on the searchable-false whitelist.";
5
+ readonly fix: "Set searchable: true or add the screen id to the whitelist.";
6
+ };
7
+ readonly defaultSortRequired: {
8
+ readonly id: "entity-list/default-sort-required";
9
+ readonly summary: "A list with active search or filterable fields declares a defaultSort.";
10
+ readonly fix: "Set defaultSort: { field, direction } on the screen.";
11
+ };
12
+ readonly defaultSortNotListed: {
13
+ readonly id: "entity-list/default-sort-not-listed";
14
+ readonly summary: "defaultSort.field is one of the listed columns.";
15
+ readonly fix: "Pick a field from columns or add it there.";
16
+ };
17
+ readonly defaultSortNotSortable: {
18
+ readonly id: "entity-list/default-sort-not-sortable";
19
+ readonly summary: "The defaultSort column is sortable.";
20
+ readonly fix: "Mark the field sortable or pick another defaultSort field.";
21
+ };
22
+ readonly detailWithoutNavigate: {
23
+ readonly id: "entity-list/detail-without-navigate";
24
+ readonly summary: "A list whose entity has a detail screen offers a navigate row action.";
25
+ readonly fix: 'Add a navigate rowAction with id "view" or "edit".';
26
+ };
27
+ readonly unknownNamedFilter: {
28
+ readonly id: "entity-list/unknown-named-filter";
29
+ readonly summary: "Every screen namedFilters id is declared in the entity's namedFilters.";
30
+ readonly fix: "Declare the rule in EntityDefinition.namedFilters or remove the id from the screen.";
31
+ };
32
+ readonly unknownColumn: {
33
+ readonly id: "entity-list/unknown-column";
34
+ readonly summary: "Every column without a label names an entity field, derived field or row-meta column.";
35
+ readonly fix: "Fix the column field name or give the column a label.";
36
+ };
37
+ };