@objectstack/lint 16.0.0 → 17.0.0-rc.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/README.md +5 -2
- package/dist/index.cjs +2925 -75
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +755 -33
- package/dist/index.d.ts +755 -33
- package/dist/index.js +2882 -77
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
package/dist/index.cjs
CHANGED
|
@@ -20,22 +20,53 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
|
|
|
20
20
|
// src/index.ts
|
|
21
21
|
var index_exports = {};
|
|
22
22
|
__export(index_exports, {
|
|
23
|
+
ACTION_NAME_UNDEFINED: () => ACTION_NAME_UNDEFINED,
|
|
24
|
+
AGENT_AUTHORING_WITHDRAWN: () => AGENT_AUTHORING_WITHDRAWN,
|
|
25
|
+
AI_SKILL_SURFACE_MISMATCH: () => AI_SKILL_SURFACE_MISMATCH,
|
|
26
|
+
AI_SKILL_TOOL_UNRESOLVED: () => AI_SKILL_TOOL_UNRESOLVED,
|
|
27
|
+
APPROVAL_APPROVERS_MAY_RESOLVE_EMPTY: () => APPROVAL_APPROVERS_MAY_RESOLVE_EMPTY,
|
|
28
|
+
APPROVAL_APPROVER_CROSS_ORG_UNSUPPORTED: () => APPROVAL_APPROVER_CROSS_ORG_UNSUPPORTED,
|
|
23
29
|
APPROVAL_APPROVER_NOT_MEMBERSHIP_TIER: () => APPROVAL_APPROVER_NOT_MEMBERSHIP_TIER,
|
|
24
30
|
APPROVAL_APPROVER_TYPE_DEPRECATED: () => APPROVAL_APPROVER_TYPE_DEPRECATED,
|
|
25
31
|
APPROVAL_APPROVER_TYPE_UNKNOWN: () => APPROVAL_APPROVER_TYPE_UNKNOWN,
|
|
32
|
+
APPROVAL_DECISION_OUTPUTS_RESERVED: () => APPROVAL_DECISION_OUTPUTS_RESERVED,
|
|
26
33
|
APPROVAL_ESCALATION_REASSIGN_NO_TARGET: () => APPROVAL_ESCALATION_REASSIGN_NO_TARGET,
|
|
34
|
+
APPROVAL_EXPRESSION_INVALID: () => APPROVAL_EXPRESSION_INVALID,
|
|
35
|
+
APPROVAL_EXPRESSION_NO_EMPTY_POLICY: () => APPROVAL_EXPRESSION_NO_EMPTY_POLICY,
|
|
27
36
|
CAPABILITY_REFERENCE_UNKNOWN: () => CAPABILITY_REFERENCE_UNKNOWN,
|
|
37
|
+
CHART_AXIS_NOT_SELECTED: () => CHART_AXIS_NOT_SELECTED,
|
|
28
38
|
CHART_CONFIG_MISSING: () => CHART_CONFIG_MISSING,
|
|
39
|
+
CHART_DATASET_UNKNOWN: () => CHART_DATASET_UNKNOWN,
|
|
40
|
+
CHART_DIMENSION_UNKNOWN: () => CHART_DIMENSION_UNKNOWN,
|
|
29
41
|
CHART_FIELD_UNKNOWN: () => CHART_FIELD_UNKNOWN,
|
|
42
|
+
CHART_MEASURE_UNKNOWN: () => CHART_MEASURE_UNKNOWN,
|
|
43
|
+
DASHBOARD_ACTION_ROUTE_UNRESOLVED: () => DASHBOARD_ACTION_ROUTE_UNRESOLVED,
|
|
44
|
+
DASHBOARD_ACTION_TARGET_UNDEFINED: () => DASHBOARD_ACTION_TARGET_UNDEFINED,
|
|
45
|
+
DASHBOARD_FILTER_FIELD_UNKNOWN: () => DASHBOARD_FILTER_FIELD_UNKNOWN,
|
|
30
46
|
FIELD_GROUP_EMPTY: () => FIELD_GROUP_EMPTY,
|
|
31
47
|
FIELD_GROUP_UNDECLARED: () => FIELD_GROUP_UNDECLARED,
|
|
48
|
+
FILTER_TOKEN_UNKNOWN: () => FILTER_TOKEN_UNKNOWN,
|
|
32
49
|
FLOW_DRAFT_STATUS_AMBIGUOUS: () => FLOW_DRAFT_STATUS_AMBIGUOUS,
|
|
50
|
+
FLOW_TEMPLATE_LOOKUP_TRAVERSAL: () => FLOW_TEMPLATE_LOOKUP_TRAVERSAL,
|
|
51
|
+
FLOW_TEMPLATE_UNKNOWN_FIELD: () => FLOW_TEMPLATE_UNKNOWN_FIELD,
|
|
33
52
|
FLOW_TRIGGER_UNKNOWN_OBJECT: () => FLOW_TRIGGER_UNKNOWN_OBJECT,
|
|
53
|
+
FLOW_UPDATE_READONLY_FIELD: () => FLOW_UPDATE_READONLY_FIELD,
|
|
54
|
+
FLOW_UPDATE_READONLY_WHEN_FIELD: () => FLOW_UPDATE_READONLY_WHEN_FIELD,
|
|
34
55
|
FORM_COLSPAN_ABSOLUTE: () => FORM_COLSPAN_ABSOLUTE,
|
|
35
56
|
FORM_FIELD_UNKNOWN: () => FORM_FIELD_UNKNOWN,
|
|
36
57
|
LIST_VIEW_FILTERS_IN_VIEWS_MODE: () => LIST_VIEW_FILTERS_IN_VIEWS_MODE,
|
|
37
58
|
MEASURE_AGGREGATE_INCOHERENT: () => MEASURE_AGGREGATE_INCOHERENT,
|
|
59
|
+
NAV_OBJECT_UNGRANTED: () => NAV_OBJECT_UNGRANTED,
|
|
60
|
+
OBJECT_REFERENCE_UNKNOWN: () => OBJECT_REFERENCE_UNKNOWN,
|
|
61
|
+
OBJECT_REFERENCE_UNREGISTERED_PLATFORM: () => OBJECT_REFERENCE_UNREGISTERED_PLATFORM,
|
|
62
|
+
ORG_AXIS_CROSS_ORG_BU_GRANT: () => ORG_AXIS_CROSS_ORG_BU_GRANT,
|
|
63
|
+
ORG_AXIS_PERMISSION_INHERITANCE: () => ORG_AXIS_PERMISSION_INHERITANCE,
|
|
64
|
+
PAGE_FIELD_UNKNOWN: () => PAGE_FIELD_UNKNOWN,
|
|
38
65
|
PAGE_SOURCE_CLASSNAME: () => PAGE_SOURCE_CLASSNAME,
|
|
66
|
+
REACT_CHART_AGGREGATE_INVALID: () => REACT_CHART_AGGREGATE_INVALID,
|
|
67
|
+
REACT_CHART_AXIS_UNKNOWN: () => REACT_CHART_AXIS_UNKNOWN,
|
|
68
|
+
REACT_CHART_FIELD_UNKNOWN: () => REACT_CHART_FIELD_UNKNOWN,
|
|
69
|
+
REFERENCE_INTEGRITY_RULES: () => REFERENCE_INTEGRITY_RULES,
|
|
39
70
|
SECURITY_ANCHOR_HIGH_PRIVILEGE: () => SECURITY_ANCHOR_HIGH_PRIVILEGE,
|
|
40
71
|
SECURITY_BOOK_AUDIENCE_UNKNOWN_SET: () => SECURITY_BOOK_AUDIENCE_UNKNOWN_SET,
|
|
41
72
|
SECURITY_DELEGATION_MISSING_REASON: () => SECURITY_DELEGATION_MISSING_REASON,
|
|
@@ -47,6 +78,8 @@ __export(index_exports, {
|
|
|
47
78
|
SECURITY_PRIVATE_NO_READSCOPE: () => SECURITY_PRIVATE_NO_READSCOPE,
|
|
48
79
|
SECURITY_ROLE_WORD: () => SECURITY_ROLE_WORD,
|
|
49
80
|
SECURITY_WILDCARD_VAMA: () => SECURITY_WILDCARD_VAMA,
|
|
81
|
+
SEED_INSERT_MODE_DUPLICATES_ON_REPLAY: () => SEED_INSERT_MODE_DUPLICATES_ON_REPLAY,
|
|
82
|
+
SEED_VALUE_OUTSIDE_STATE_MACHINE: () => SEED_VALUE_OUTSIDE_STATE_MACHINE,
|
|
50
83
|
SEMANTIC_ROLE_FIELD_UNKNOWN: () => SEMANTIC_ROLE_FIELD_UNKNOWN,
|
|
51
84
|
STYLE_CLASSNAME_TAILWIND: () => STYLE_CLASSNAME_TAILWIND,
|
|
52
85
|
STYLE_NODE_MISSING_ID: () => STYLE_NODE_MISSING_ID,
|
|
@@ -56,6 +89,8 @@ __export(index_exports, {
|
|
|
56
89
|
TABLE_COUNT_ONLY: () => TABLE_COUNT_ONLY,
|
|
57
90
|
TITLE_FORMAT_RETIRED: () => TITLE_FORMAT_RETIRED,
|
|
58
91
|
TITLE_UNRESOLVABLE: () => TITLE_UNRESOLVABLE,
|
|
92
|
+
TRANSLATION_OPTION_KEY_UNKNOWN: () => TRANSLATION_OPTION_KEY_UNKNOWN,
|
|
93
|
+
TRANSLATION_TARGET_UNKNOWN: () => TRANSLATION_TARGET_UNKNOWN,
|
|
59
94
|
VIEW_CONTAINER_SHAPE: () => VIEW_CONTAINER_SHAPE,
|
|
60
95
|
VISIBILITY_ALIAS_DEPRECATED: () => VISIBILITY_ALIAS_DEPRECATED,
|
|
61
96
|
VISIBILITY_ROOT_MISLAYERED: () => VISIBILITY_ROOT_MISLAYERED,
|
|
@@ -64,20 +99,37 @@ __export(index_exports, {
|
|
|
64
99
|
WIDGET_MEASURE_UNKNOWN: () => WIDGET_MEASURE_UNKNOWN,
|
|
65
100
|
buildAccessMatrix: () => buildAccessMatrix,
|
|
66
101
|
diffAccessMatrix: () => diffAccessMatrix,
|
|
102
|
+
validateActionNameRefs: () => validateActionNameRefs,
|
|
103
|
+
validateAiAgentAuthoring: () => validateAiAgentAuthoring,
|
|
104
|
+
validateAiSurfaceAffinity: () => validateAiSurfaceAffinity,
|
|
105
|
+
validateAiToolReferences: () => validateAiToolReferences,
|
|
67
106
|
validateApprovalApprovers: () => validateApprovalApprovers,
|
|
68
107
|
validateCapabilityReferences: () => validateCapabilityReferences,
|
|
108
|
+
validateChartBindings: () => validateChartBindings,
|
|
109
|
+
validateDashboardActionRefs: () => validateDashboardActionRefs,
|
|
110
|
+
validateFilterTokens: () => validateFilterTokens,
|
|
111
|
+
validateFlowTemplatePaths: () => validateFlowTemplatePaths,
|
|
69
112
|
validateFlowTriggerReadiness: () => validateFlowTriggerReadiness,
|
|
70
113
|
validateFormLayout: () => validateFormLayout,
|
|
71
114
|
validateJsxPages: () => validateJsxPages,
|
|
72
115
|
validateListViewMode: () => validateListViewMode,
|
|
116
|
+
validateNavAccess: () => validateNavAccess,
|
|
117
|
+
validateObjectReferences: () => validateObjectReferences,
|
|
118
|
+
validateOrgAxisRedLines: () => validateOrgAxisRedLines,
|
|
119
|
+
validatePageFieldBindings: () => validatePageFieldBindings,
|
|
73
120
|
validatePageSourceStyling: () => validatePageSourceStyling,
|
|
74
121
|
validateReactPageProps: () => validateReactPageProps,
|
|
75
122
|
validateReactPages: () => validateReactPages,
|
|
123
|
+
validateReadonlyFlowWrites: () => validateReadonlyFlowWrites,
|
|
76
124
|
validateRecordTitle: () => validateRecordTitle,
|
|
125
|
+
validateReferenceIntegrity: () => validateReferenceIntegrity,
|
|
77
126
|
validateResponsiveStyles: () => validateResponsiveStyles,
|
|
78
127
|
validateSecurityPosture: () => validateSecurityPosture,
|
|
128
|
+
validateSeedReplaySafety: () => validateSeedReplaySafety,
|
|
129
|
+
validateSeedStateMachine: () => validateSeedStateMachine,
|
|
79
130
|
validateSemanticRoles: () => validateSemanticRoles,
|
|
80
131
|
validateStackExpressions: () => validateStackExpressions,
|
|
132
|
+
validateTranslationReferences: () => validateTranslationReferences,
|
|
81
133
|
validateViewContainers: () => validateViewContainers,
|
|
82
134
|
validateVisibilityPredicates: () => validateVisibilityPredicates,
|
|
83
135
|
validateWidgetBindings: () => validateWidgetBindings
|
|
@@ -95,6 +147,7 @@ var TABLE_COUNT_ONLY = "table-count-only";
|
|
|
95
147
|
var MEASURE_AGGREGATE_INCOHERENT = "measure-aggregate-incoherent";
|
|
96
148
|
var WIDGET_LEGACY_ANALYTICS_SHAPE = "widget-legacy-analytics-shape";
|
|
97
149
|
var WIDGET_LEGACY_ANALYTICS_UNRENDERABLE = "widget-legacy-analytics-unrenderable";
|
|
150
|
+
var DASHBOARD_FILTER_FIELD_UNKNOWN = "dashboard-filter-field-unknown";
|
|
98
151
|
var LEGACY_ANALYTICS_KEYS = [
|
|
99
152
|
"categoryField",
|
|
100
153
|
"valueField",
|
|
@@ -172,6 +225,47 @@ function list(names) {
|
|
|
172
225
|
const arr = [...names];
|
|
173
226
|
return arr.length > 0 ? arr.join(", ") : "(none)";
|
|
174
227
|
}
|
|
228
|
+
var DATE_RANGE_FILTER_NAME = "dateRange";
|
|
229
|
+
var DATE_RANGE_DEFAULT_FIELD = "created_at";
|
|
230
|
+
var SYSTEM_FIELDS = /* @__PURE__ */ new Set([
|
|
231
|
+
"id",
|
|
232
|
+
"created_at",
|
|
233
|
+
"created_by",
|
|
234
|
+
"updated_at",
|
|
235
|
+
"updated_by",
|
|
236
|
+
"owner_id",
|
|
237
|
+
"organization_id",
|
|
238
|
+
"tenant_id",
|
|
239
|
+
"user_id",
|
|
240
|
+
"deleted_at"
|
|
241
|
+
]);
|
|
242
|
+
function dashboardFilterDefs(dash) {
|
|
243
|
+
const byName = /* @__PURE__ */ new Map();
|
|
244
|
+
const dateRange = dash.dateRange;
|
|
245
|
+
if (dateRange && typeof dateRange === "object") {
|
|
246
|
+
const declared = dateRange.field;
|
|
247
|
+
const field = typeof declared === "string" && declared ? declared : DATE_RANGE_DEFAULT_FIELD;
|
|
248
|
+
byName.set(DATE_RANGE_FILTER_NAME, { name: DATE_RANGE_FILTER_NAME, field });
|
|
249
|
+
}
|
|
250
|
+
for (const f of asArray(dash.globalFilters)) {
|
|
251
|
+
if (typeof f.field !== "string" || !f.field) continue;
|
|
252
|
+
const name = typeof f.name === "string" && f.name ? f.name : f.field;
|
|
253
|
+
const targetWidgets = Array.isArray(f.targetWidgets) ? f.targetWidgets.filter((w) => typeof w === "string") : void 0;
|
|
254
|
+
byName.set(name, { name, field: f.field, targetWidgets });
|
|
255
|
+
}
|
|
256
|
+
return [...byName.values()];
|
|
257
|
+
}
|
|
258
|
+
function effectiveFilterField(widget, def) {
|
|
259
|
+
const bindings = widget.filterBindings;
|
|
260
|
+
const binding = bindings && typeof bindings === "object" ? bindings[def.name] : void 0;
|
|
261
|
+
if (binding === false) return void 0;
|
|
262
|
+
if (typeof binding === "string" && binding) return { field: binding, explicit: true };
|
|
263
|
+
if (def.targetWidgets && def.targetWidgets.length > 0) {
|
|
264
|
+
const id = typeof widget.id === "string" ? widget.id : void 0;
|
|
265
|
+
if (!id || !def.targetWidgets.includes(id)) return void 0;
|
|
266
|
+
}
|
|
267
|
+
return { field: def.field, explicit: false };
|
|
268
|
+
}
|
|
175
269
|
function validateWidgetBindings(stack) {
|
|
176
270
|
const findings = [];
|
|
177
271
|
const datasets = /* @__PURE__ */ new Map();
|
|
@@ -216,6 +310,7 @@ function validateWidgetBindings(stack) {
|
|
|
216
310
|
const dash = dashboards[i];
|
|
217
311
|
const dashName = typeof dash.name === "string" ? dash.name : `(dashboard ${i})`;
|
|
218
312
|
const widgets = Array.isArray(dash.widgets) ? dash.widgets : [];
|
|
313
|
+
const dashFilterDefs = dashboardFilterDefs(dash);
|
|
219
314
|
for (let j = 0; j < widgets.length; j++) {
|
|
220
315
|
const w = widgets[j];
|
|
221
316
|
const widgetId = typeof w.id === "string" ? w.id : `(widget ${j})`;
|
|
@@ -259,7 +354,35 @@ function validateWidgetBindings(stack) {
|
|
|
259
354
|
hint: `Declared datasets: ${list(datasets.keys())}.${suggest(dsName, datasets.keys())} Define the dataset with defineDataset() or fix the reference (ADR-0021).`
|
|
260
355
|
});
|
|
261
356
|
}
|
|
357
|
+
if (!dsName) {
|
|
358
|
+
push({
|
|
359
|
+
severity: "error",
|
|
360
|
+
rule: WIDGET_DATASET_UNKNOWN,
|
|
361
|
+
message: `binds no \`dataset\` \u2014 the ADR-0021 widget shape requires one, so this widget resolves no data and renders empty.`,
|
|
362
|
+
hint: `Set \`dataset: '<name>'\` (plus \`values\`, and \`dimensions\` where the chart family needs them). Declared datasets: ${list(datasets.keys())}.`
|
|
363
|
+
});
|
|
364
|
+
continue;
|
|
365
|
+
}
|
|
262
366
|
if (!dataset) continue;
|
|
367
|
+
if (dashFilterDefs.length > 0) {
|
|
368
|
+
const datasetObject = typeof dataset.object === "string" ? dataset.object : void 0;
|
|
369
|
+
const objectFields = datasetObject ? objectFieldTypes.get(datasetObject) : void 0;
|
|
370
|
+
if (objectFields) {
|
|
371
|
+
for (const def of dashFilterDefs) {
|
|
372
|
+
const eff = effectiveFilterField(w, def);
|
|
373
|
+
if (!eff) continue;
|
|
374
|
+
const field = eff.field;
|
|
375
|
+
if (field.includes(".")) continue;
|
|
376
|
+
if (objectFields.has(field) || SYSTEM_FIELDS.has(field)) continue;
|
|
377
|
+
push({
|
|
378
|
+
severity: "error",
|
|
379
|
+
rule: DASHBOARD_FILTER_FIELD_UNKNOWN,
|
|
380
|
+
message: eff.explicit ? `binds dashboard filter \`${def.name}\` to field \`${field}\` (via filterBindings), but object \`${datasetObject}\` (dataset "${dsName}") has no field \`${field}\`.` : `inherits dashboard filter \`${def.name}(${field})\`, but object \`${datasetObject}\` (dataset "${dsName}") has no field \`${field}\`.`,
|
|
381
|
+
hint: eff.explicit ? `Point filterBindings: { ${def.name}: '<field>' } at a field that exists on \`${datasetObject}\`, or opt out with filterBindings: { ${def.name}: false }.${suggest(field, objectFields.keys())} Object fields: ${list(objectFields.keys())}.` : `Set filterBindings: { ${def.name}: false } on this widget to opt out, or re-target to an existing field with filterBindings: { ${def.name}: '<field>' }.${suggest(field, objectFields.keys())} Object fields: ${list(objectFields.keys())}.`
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
}
|
|
263
386
|
const dimensionNames = /* @__PURE__ */ new Set();
|
|
264
387
|
for (const d of asArray(dataset.dimensions)) {
|
|
265
388
|
if (typeof d.name === "string") dimensionNames.add(d.name);
|
|
@@ -301,13 +424,13 @@ function validateWidgetBindings(stack) {
|
|
|
301
424
|
hint: `Point xAxis.field at a dataset dimension name.${suggest(xAxis.field, dimensionNames)}`
|
|
302
425
|
});
|
|
303
426
|
}
|
|
304
|
-
const measureField = (
|
|
427
|
+
const measureField = (label2, field) => {
|
|
305
428
|
if (values.includes(field)) return;
|
|
306
429
|
const declaredButUnselected = measures.has(field);
|
|
307
430
|
push({
|
|
308
431
|
severity: "error",
|
|
309
432
|
rule: CHART_FIELD_UNKNOWN,
|
|
310
|
-
message: declaredButUnselected ? `chartConfig.${
|
|
433
|
+
message: declaredButUnselected ? `chartConfig.${label2} "${field}" is a measure of dataset "${dsName}" but is not selected in the widget's values (${list(values)}), so the query result will not contain it.` : `chartConfig.${label2} "${field}" does not resolve to a measure of dataset "${dsName}" (declared measures: ${list(measures.keys())}).`,
|
|
311
434
|
hint: declaredButUnselected ? `Add "${field}" to the widget's values, or bind the chart to a selected measure.` : `Post-cutover data is keyed by the dataset's measure NAME, not the base column.${suggest(field, selectedValues.size > 0 ? selectedValues : measures.keys())}`
|
|
312
435
|
});
|
|
313
436
|
};
|
|
@@ -498,8 +621,33 @@ function validateStackExpressions(stack) {
|
|
|
498
621
|
check(where, rule.condition ?? rule.criteria ?? rule.predicate, ruleObj, "record");
|
|
499
622
|
}
|
|
500
623
|
for (const hook of asArray2(stack.hooks)) {
|
|
501
|
-
const
|
|
502
|
-
|
|
624
|
+
const hookName = hook.name ?? "?";
|
|
625
|
+
if (typeof hook.object === "string") {
|
|
626
|
+
check(`hook '${hookName}' (${hook.object}) condition`, hook.condition, hook.object, "record");
|
|
627
|
+
continue;
|
|
628
|
+
}
|
|
629
|
+
const targets = Array.isArray(hook.object) ? hook.object.filter((o) => typeof o === "string" && o !== "*") : [];
|
|
630
|
+
if (targets.length === 0) {
|
|
631
|
+
check(`hook '${hookName}' condition`, hook.condition, void 0, "record");
|
|
632
|
+
continue;
|
|
633
|
+
}
|
|
634
|
+
const before = issues.length;
|
|
635
|
+
const seen = /* @__PURE__ */ new Set();
|
|
636
|
+
const kept = [];
|
|
637
|
+
for (const target of targets) {
|
|
638
|
+
const mark = issues.length;
|
|
639
|
+
check(`hook '${hookName}' (${target}) condition`, hook.condition, target, "record");
|
|
640
|
+
for (let i = mark; i < issues.length; i++) {
|
|
641
|
+
const issue = issues[i];
|
|
642
|
+
const key = `${issue.message}\0${issue.source ?? ""}`;
|
|
643
|
+
if (!seen.has(key)) {
|
|
644
|
+
seen.add(key);
|
|
645
|
+
kept.push(issue);
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
issues.length = before;
|
|
650
|
+
issues.push(...kept);
|
|
503
651
|
}
|
|
504
652
|
return issues;
|
|
505
653
|
}
|
|
@@ -558,14 +706,14 @@ function scanListViews(listViews, wherePrefix, pathPrefix, out) {
|
|
|
558
706
|
function validateListViewMode(stack) {
|
|
559
707
|
const out = [];
|
|
560
708
|
asArray3(stack.objects).forEach((obj, i) => {
|
|
561
|
-
const
|
|
562
|
-
scanListViews(obj.listViews,
|
|
709
|
+
const label2 = typeof obj.name === "string" ? `object "${obj.name}"` : `objects[${i}]`;
|
|
710
|
+
scanListViews(obj.listViews, label2, `objects[${i}]`, out);
|
|
563
711
|
});
|
|
564
712
|
asArray3(stack.views).forEach((view, i) => {
|
|
565
713
|
const named = typeof view.objectName === "string" ? view.objectName : typeof view.name === "string" ? view.name : void 0;
|
|
566
|
-
const
|
|
567
|
-
scanView(view.list, `${
|
|
568
|
-
scanListViews(view.listViews,
|
|
714
|
+
const label2 = named ? `view "${named}"` : `views[${i}]`;
|
|
715
|
+
scanView(view.list, `${label2} \u203A list`, `views[${i}].list`, out);
|
|
716
|
+
scanListViews(view.listViews, label2, `views[${i}]`, out);
|
|
569
717
|
});
|
|
570
718
|
return out;
|
|
571
719
|
}
|
|
@@ -573,6 +721,8 @@ function validateListViewMode(stack) {
|
|
|
573
721
|
// src/validate-flow-trigger-readiness.ts
|
|
574
722
|
var FLOW_TRIGGER_UNKNOWN_OBJECT = "flow-trigger-unknown-object";
|
|
575
723
|
var FLOW_DRAFT_STATUS_AMBIGUOUS = "flow-draft-status-ambiguous";
|
|
724
|
+
var FLOW_TRIGGER_UNKNOWN_EVENT = "flow-trigger-unknown-event";
|
|
725
|
+
var VALID_RECORD_TRIGGER = /^record-(?:before|after)-(?:create|insert|update|delete|write)$/;
|
|
576
726
|
function asArray4(v) {
|
|
577
727
|
if (Array.isArray(v)) return v;
|
|
578
728
|
if (v && typeof v === "object") {
|
|
@@ -600,10 +750,11 @@ function validateFlowTriggerReadiness(stack) {
|
|
|
600
750
|
const start = startNodeOf(flow);
|
|
601
751
|
const config = start?.node.config ?? {};
|
|
602
752
|
const triggerType = typeof config.triggerType === "string" ? config.triggerType : void 0;
|
|
603
|
-
const
|
|
753
|
+
const isRecordTriggered2 = !!triggerType && triggerType.startsWith("record-");
|
|
754
|
+
const isArrayRecordTriggered = Array.isArray(config.triggerType) && config.triggerType.some((t) => typeof t === "string" && t.startsWith("record-"));
|
|
604
755
|
const isTimeRelative = config.timeRelative != null && typeof config.timeRelative === "object";
|
|
605
|
-
const isAutoTriggered =
|
|
606
|
-
if (
|
|
756
|
+
const isAutoTriggered = isRecordTriggered2 || triggerType === "api" || config.schedule != null || isTimeRelative || flow.type === "schedule" || flow.type === "api";
|
|
757
|
+
if (isRecordTriggered2 && start) {
|
|
607
758
|
const objectName = typeof config.objectName === "string" ? config.objectName : void 0;
|
|
608
759
|
if (objectName && !objectNames.has(objectName) && !objectName.startsWith("sys_")) {
|
|
609
760
|
findings.push({
|
|
@@ -630,6 +781,26 @@ function validateFlowTriggerReadiness(stack) {
|
|
|
630
781
|
});
|
|
631
782
|
}
|
|
632
783
|
}
|
|
784
|
+
if (start && isRecordTriggered2 && !VALID_RECORD_TRIGGER.test((triggerType ?? "").trim())) {
|
|
785
|
+
findings.push({
|
|
786
|
+
severity: "warning",
|
|
787
|
+
rule: FLOW_TRIGGER_UNKNOWN_EVENT,
|
|
788
|
+
where: `flow "${flowName}" \u203A start node`,
|
|
789
|
+
path: `flows[${flowIndex}].nodes[${start.index}].config.triggerType`,
|
|
790
|
+
message: `triggerType '${triggerType}' is not a recognized record trigger \u2014 the flow binds to the record-change trigger but never fires (the runtime stays silent about it).`,
|
|
791
|
+
hint: `Use record-{before,after}-{create,update,delete,write}. 'write' fires on create OR update in one flow (#3427); create/insert are synonyms. There is no "any change" token \u2014 pick the specific event(s).`
|
|
792
|
+
});
|
|
793
|
+
}
|
|
794
|
+
if (start && isArrayRecordTriggered) {
|
|
795
|
+
findings.push({
|
|
796
|
+
severity: "warning",
|
|
797
|
+
rule: FLOW_TRIGGER_UNKNOWN_EVENT,
|
|
798
|
+
where: `flow "${flowName}" \u203A start node`,
|
|
799
|
+
path: `flows[${flowIndex}].nodes[${start.index}].config.triggerType`,
|
|
800
|
+
message: `triggerType is an array (${JSON.stringify(config.triggerType)}), which is not supported \u2014 a start node takes a single trigger event, so the flow binds to nothing and never fires (the runtime stays silent about it).`,
|
|
801
|
+
hint: `Use one triggerType string. For "created or updated" use record-after-write (one flow, both events, #3427). For any other combination, author one flow per event \u2014 multi-event arrays are deferred (#3457).`
|
|
802
|
+
});
|
|
803
|
+
}
|
|
633
804
|
if (isAutoTriggered && (flow.status == null || flow.status === "draft")) {
|
|
634
805
|
findings.push({
|
|
635
806
|
severity: "warning",
|
|
@@ -644,6 +815,303 @@ function validateFlowTriggerReadiness(stack) {
|
|
|
644
815
|
return findings;
|
|
645
816
|
}
|
|
646
817
|
|
|
818
|
+
// src/validate-flow-template-paths.ts
|
|
819
|
+
var FLOW_TEMPLATE_UNKNOWN_FIELD = "flow-template-unknown-field";
|
|
820
|
+
var FLOW_TEMPLATE_LOOKUP_TRAVERSAL = "flow-template-lookup-traversal";
|
|
821
|
+
function asArray5(v) {
|
|
822
|
+
if (Array.isArray(v)) return v;
|
|
823
|
+
if (v && typeof v === "object") {
|
|
824
|
+
return Object.entries(v).map(([name, def]) => ({
|
|
825
|
+
name,
|
|
826
|
+
...def
|
|
827
|
+
}));
|
|
828
|
+
}
|
|
829
|
+
return [];
|
|
830
|
+
}
|
|
831
|
+
var SYSTEM_FIELDS2 = /* @__PURE__ */ new Set([
|
|
832
|
+
"id",
|
|
833
|
+
"name",
|
|
834
|
+
"owner",
|
|
835
|
+
"owner_id",
|
|
836
|
+
"created_at",
|
|
837
|
+
"created_by",
|
|
838
|
+
"updated_at",
|
|
839
|
+
"updated_by",
|
|
840
|
+
"organization_id",
|
|
841
|
+
"tenant_id",
|
|
842
|
+
"is_deleted",
|
|
843
|
+
"deleted_at",
|
|
844
|
+
"record_type"
|
|
845
|
+
]);
|
|
846
|
+
var RELATION_TYPES = /* @__PURE__ */ new Set([
|
|
847
|
+
"lookup",
|
|
848
|
+
"master_detail",
|
|
849
|
+
"user",
|
|
850
|
+
"tree"
|
|
851
|
+
]);
|
|
852
|
+
var FILTER_GUARDED_NODE_TYPES = /* @__PURE__ */ new Set([
|
|
853
|
+
"get_record",
|
|
854
|
+
"update_record",
|
|
855
|
+
"delete_record"
|
|
856
|
+
]);
|
|
857
|
+
function fieldTypesOf(obj) {
|
|
858
|
+
const types = /* @__PURE__ */ new Map();
|
|
859
|
+
for (const f of asArray5(obj.fields)) {
|
|
860
|
+
if (typeof f.name === "string") {
|
|
861
|
+
types.set(f.name, typeof f.type === "string" ? f.type : "");
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
return types;
|
|
865
|
+
}
|
|
866
|
+
function recordRefsIn(text) {
|
|
867
|
+
const refs = [];
|
|
868
|
+
const tokenRe = /\{([^{}]+)\}/g;
|
|
869
|
+
let m;
|
|
870
|
+
while ((m = tokenRe.exec(text)) !== null) {
|
|
871
|
+
const body = m[1].trim();
|
|
872
|
+
if (!/^[A-Za-z_$][\w$]*(?:\.(?:[A-Za-z_$][\w$]*|\d+))*$/.test(body)) continue;
|
|
873
|
+
const segments = body.split(".");
|
|
874
|
+
if (segments[0] !== "record") continue;
|
|
875
|
+
const rest = segments.slice(1);
|
|
876
|
+
if (rest.length > 0) refs.push(rest);
|
|
877
|
+
}
|
|
878
|
+
return refs;
|
|
879
|
+
}
|
|
880
|
+
function stringLeaves(value, out) {
|
|
881
|
+
if (typeof value === "string") {
|
|
882
|
+
if (value.includes("{")) out.push(value);
|
|
883
|
+
return;
|
|
884
|
+
}
|
|
885
|
+
if (Array.isArray(value)) {
|
|
886
|
+
for (const v of value) stringLeaves(v, out);
|
|
887
|
+
return;
|
|
888
|
+
}
|
|
889
|
+
if (value && typeof value === "object") {
|
|
890
|
+
for (const v of Object.values(value)) stringLeaves(v, out);
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
var NODE_CONFIG_KEYS = [
|
|
894
|
+
"config",
|
|
895
|
+
"notify",
|
|
896
|
+
"update_record",
|
|
897
|
+
"create_record",
|
|
898
|
+
"http",
|
|
899
|
+
"script",
|
|
900
|
+
"screen",
|
|
901
|
+
"wait",
|
|
902
|
+
"approval",
|
|
903
|
+
"connector_action",
|
|
904
|
+
"subflow",
|
|
905
|
+
"decision",
|
|
906
|
+
"start"
|
|
907
|
+
];
|
|
908
|
+
function collectNodeLeaves(node, guarded) {
|
|
909
|
+
const filterLeaves = [];
|
|
910
|
+
const otherLeaves = [];
|
|
911
|
+
for (const key of NODE_CONFIG_KEYS) {
|
|
912
|
+
if (!(key in node)) continue;
|
|
913
|
+
const block = node[key];
|
|
914
|
+
const splitFilter = guarded && !!block && typeof block === "object" && !Array.isArray(block);
|
|
915
|
+
if (splitFilter) {
|
|
916
|
+
const { filter, ...rest } = block;
|
|
917
|
+
const inFilter = [];
|
|
918
|
+
stringLeaves(filter, inFilter);
|
|
919
|
+
for (const text of inFilter) filterLeaves.push({ text, inFilter: true });
|
|
920
|
+
const outside = [];
|
|
921
|
+
stringLeaves(rest, outside);
|
|
922
|
+
for (const text of outside) otherLeaves.push({ text, inFilter: false });
|
|
923
|
+
continue;
|
|
924
|
+
}
|
|
925
|
+
const plain = [];
|
|
926
|
+
stringLeaves(block, plain);
|
|
927
|
+
for (const text of plain) otherLeaves.push({ text, inFilter: false });
|
|
928
|
+
}
|
|
929
|
+
return [...filterLeaves, ...otherLeaves];
|
|
930
|
+
}
|
|
931
|
+
function isRecordTriggered(flow, startConfig) {
|
|
932
|
+
if (flow.type === "record_change") return true;
|
|
933
|
+
const triggerType = typeof startConfig.triggerType === "string" ? startConfig.triggerType : void 0;
|
|
934
|
+
return !!triggerType && triggerType.startsWith("record-");
|
|
935
|
+
}
|
|
936
|
+
function boundObjectOf(flow) {
|
|
937
|
+
const nodes = Array.isArray(flow.nodes) ? flow.nodes : [];
|
|
938
|
+
const start = nodes.find((n) => n?.type === "start");
|
|
939
|
+
if (!start) return void 0;
|
|
940
|
+
const config = start.config ?? {};
|
|
941
|
+
const typed = start.start ?? {};
|
|
942
|
+
const fromConfig = typeof config.objectName === "string" ? config.objectName : void 0;
|
|
943
|
+
const fromTyped = typeof typed.objectName === "string" ? typed.objectName : void 0;
|
|
944
|
+
return fromConfig ?? fromTyped;
|
|
945
|
+
}
|
|
946
|
+
function declaredExpandOf(flow) {
|
|
947
|
+
const nodes = Array.isArray(flow.nodes) ? flow.nodes : [];
|
|
948
|
+
const start = nodes.find((n) => n?.type === "start");
|
|
949
|
+
const raw = (start?.config ?? {}).expand;
|
|
950
|
+
if (typeof raw === "string") return new Set(raw ? [raw] : []);
|
|
951
|
+
if (Array.isArray(raw)) return new Set(raw.filter((r) => typeof r === "string" && r.length > 0));
|
|
952
|
+
return /* @__PURE__ */ new Set();
|
|
953
|
+
}
|
|
954
|
+
function validateFlowTemplatePaths(stack) {
|
|
955
|
+
const findings = [];
|
|
956
|
+
const flows = asArray5(stack.flows);
|
|
957
|
+
if (flows.length === 0) return findings;
|
|
958
|
+
const objectsByName = /* @__PURE__ */ new Map();
|
|
959
|
+
for (const obj of asArray5(stack.objects)) {
|
|
960
|
+
if (typeof obj.name === "string") objectsByName.set(obj.name, obj);
|
|
961
|
+
}
|
|
962
|
+
flows.forEach((flow, flowIndex) => {
|
|
963
|
+
const flowName = typeof flow.name === "string" ? flow.name : `#${flowIndex}`;
|
|
964
|
+
const nodes = Array.isArray(flow.nodes) ? flow.nodes : [];
|
|
965
|
+
const start = nodes.find((n) => n?.type === "start")?.config ?? {};
|
|
966
|
+
if (!isRecordTriggered(flow, start)) return;
|
|
967
|
+
const objectName = boundObjectOf(flow);
|
|
968
|
+
if (!objectName) return;
|
|
969
|
+
const obj = objectsByName.get(objectName);
|
|
970
|
+
if (!obj) return;
|
|
971
|
+
const fieldTypes = fieldTypesOf(obj);
|
|
972
|
+
const expandSet = declaredExpandOf(flow);
|
|
973
|
+
nodes.forEach((node, nodeIndex) => {
|
|
974
|
+
if (typeof node !== "object" || !node) return;
|
|
975
|
+
const nodeLabel = typeof node.type === "string" ? node.type : typeof node.id === "string" ? node.id : `#${nodeIndex}`;
|
|
976
|
+
const nodeType = typeof node.type === "string" ? node.type : "";
|
|
977
|
+
const guarded = FILTER_GUARDED_NODE_TYPES.has(nodeType);
|
|
978
|
+
const leaves = collectNodeLeaves(node, guarded);
|
|
979
|
+
if (leaves.length === 0) return;
|
|
980
|
+
const seenUnknown = /* @__PURE__ */ new Set();
|
|
981
|
+
const seenTraversal = /* @__PURE__ */ new Set();
|
|
982
|
+
for (const leaf of leaves) {
|
|
983
|
+
const inFilter = leaf.inFilter;
|
|
984
|
+
for (const rest of recordRefsIn(leaf.text)) {
|
|
985
|
+
const head = rest[0];
|
|
986
|
+
const hasSubPath = rest.length > 1;
|
|
987
|
+
const nextIsIdentifier = hasSubPath && !/^\d+$/.test(rest[1]);
|
|
988
|
+
const isKnown = fieldTypes.has(head) || SYSTEM_FIELDS2.has(head);
|
|
989
|
+
if (!isKnown) {
|
|
990
|
+
if (seenUnknown.has(head)) continue;
|
|
991
|
+
seenUnknown.add(head);
|
|
992
|
+
findings.push({
|
|
993
|
+
severity: inFilter ? "error" : "warning",
|
|
994
|
+
rule: FLOW_TEMPLATE_UNKNOWN_FIELD,
|
|
995
|
+
where: `flow "${flowName}" node "${nodeLabel}"`,
|
|
996
|
+
path: `flows[${flowIndex}].nodes[${nodeIndex}]`,
|
|
997
|
+
message: inFilter ? `${nodeType} filter references '{record.${rest.join(".")}}', but '${head}' is not a field on object '${objectName}' \u2014 the token resolves to nothing, which DROPS the condition from the query instead of narrowing it. The node refuses to run at execution time (#3810).` : `template references '{record.${rest.join(".")}}', but '${head}' is not a field on object '${objectName}' \u2014 it resolves to an empty string at runtime (silently).`,
|
|
998
|
+
hint: inFilter ? `Check the field name against the object's field definitions (e.g. '{record.full_name}', not '{record.full_naem}'); system columns like id/created_at/owner are also addressable. This gates the build rather than warning: an absent condition WIDENS the query, so the runtime has already decided to refuse this node.` : `Check the field name against the object's field definitions (e.g. '{record.full_name}', not '{record.full_naem}'). System columns like id/created_at/owner are also addressable.`
|
|
999
|
+
});
|
|
1000
|
+
continue;
|
|
1001
|
+
}
|
|
1002
|
+
if (nextIsIdentifier) {
|
|
1003
|
+
const headType = fieldTypes.get(head) ?? "";
|
|
1004
|
+
if (RELATION_TYPES.has(headType) && !expandSet.has(head)) {
|
|
1005
|
+
const key = rest.join(".");
|
|
1006
|
+
if (seenTraversal.has(key)) continue;
|
|
1007
|
+
seenTraversal.add(key);
|
|
1008
|
+
findings.push({
|
|
1009
|
+
severity: inFilter ? "error" : "warning",
|
|
1010
|
+
rule: FLOW_TEMPLATE_LOOKUP_TRAVERSAL,
|
|
1011
|
+
where: `flow "${flowName}" node "${nodeLabel}"`,
|
|
1012
|
+
path: `flows[${flowIndex}].nodes[${nodeIndex}]`,
|
|
1013
|
+
message: inFilter ? `${nodeType} filter references '{record.${key}}', a cross-object hop through the ${headType} field '${head}' \u2014 the flow record carries '${head}' as a scalar id, not an expanded object, so the token resolves to nothing and the condition is DROPPED from the query instead of narrowing it. The node refuses to run at execution time (#3810).` : `template references '{record.${key}}', a cross-object hop through the ${headType} field '${head}' \u2014 the flow record carries '${head}' as a scalar id, not an expanded object, so this resolves to an empty string at runtime (silently).`,
|
|
1014
|
+
hint: inFilter ? `Opt in to resolve it: add '${head}' to the start node's config.expand (#3475) and the engine re-reads it as the run's identity. Otherwise filter on the foreign-key id directly ('{record.${head}}'), or project the value via a formula field on '${objectName}'. This gates the build rather than warning: an absent condition WIDENS the query.` : `Opt in to resolve it: add '${head}' to the start node's config.expand (#3475) and the engine re-reads it as the run's identity. Otherwise reference the foreign-key id directly ('{record.${head}}'), or project the value via a formula field on '${objectName}'.`
|
|
1015
|
+
});
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
1020
|
+
});
|
|
1021
|
+
});
|
|
1022
|
+
return findings;
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
// src/validate-readonly-flow-writes.ts
|
|
1026
|
+
var FLOW_UPDATE_READONLY_FIELD = "flow-update-readonly-field";
|
|
1027
|
+
var FLOW_UPDATE_READONLY_WHEN_FIELD = "flow-update-readonly-when-field";
|
|
1028
|
+
function asArray6(v) {
|
|
1029
|
+
if (Array.isArray(v)) return v;
|
|
1030
|
+
if (v && typeof v === "object") {
|
|
1031
|
+
return Object.entries(v).map(([name, def]) => ({
|
|
1032
|
+
name,
|
|
1033
|
+
...def
|
|
1034
|
+
}));
|
|
1035
|
+
}
|
|
1036
|
+
return [];
|
|
1037
|
+
}
|
|
1038
|
+
function buildReadonlyIndex(objects) {
|
|
1039
|
+
const idx = /* @__PURE__ */ new Map();
|
|
1040
|
+
for (const obj of objects) {
|
|
1041
|
+
const name = typeof obj.name === "string" ? obj.name : void 0;
|
|
1042
|
+
if (!name) continue;
|
|
1043
|
+
const fieldMap = /* @__PURE__ */ new Map();
|
|
1044
|
+
const collect = (fieldName, def) => {
|
|
1045
|
+
const rw = def?.readonlyWhen;
|
|
1046
|
+
const readonlyWhen = rw != null && !(typeof rw === "string" && rw.trim() === "");
|
|
1047
|
+
fieldMap.set(fieldName, { readonly: def?.readonly === true, readonlyWhen });
|
|
1048
|
+
};
|
|
1049
|
+
const fields = obj.fields;
|
|
1050
|
+
if (Array.isArray(fields)) {
|
|
1051
|
+
for (const f of fields) {
|
|
1052
|
+
const fn = f?.name;
|
|
1053
|
+
if (typeof fn === "string") collect(fn, f);
|
|
1054
|
+
}
|
|
1055
|
+
} else if (fields && typeof fields === "object") {
|
|
1056
|
+
for (const [fn, def] of Object.entries(fields)) collect(fn, def);
|
|
1057
|
+
}
|
|
1058
|
+
idx.set(name, fieldMap);
|
|
1059
|
+
}
|
|
1060
|
+
return idx;
|
|
1061
|
+
}
|
|
1062
|
+
function readLiteralObjectName(config) {
|
|
1063
|
+
const raw = config.objectName ?? config.object;
|
|
1064
|
+
if (typeof raw !== "string" || raw.includes("{")) return void 0;
|
|
1065
|
+
return raw || void 0;
|
|
1066
|
+
}
|
|
1067
|
+
function validateReadonlyFlowWrites(stack) {
|
|
1068
|
+
const findings = [];
|
|
1069
|
+
const flows = asArray6(stack.flows);
|
|
1070
|
+
if (flows.length === 0) return findings;
|
|
1071
|
+
const roIndex = buildReadonlyIndex(asArray6(stack.objects));
|
|
1072
|
+
flows.forEach((flow, flowIndex) => {
|
|
1073
|
+
if (flow.runAs === "system") return;
|
|
1074
|
+
const runAs = flow.runAs === "user" || flow.runAs === "system" ? flow.runAs : "user";
|
|
1075
|
+
const flowName = typeof flow.name === "string" ? flow.name : `#${flowIndex}`;
|
|
1076
|
+
const nodes = Array.isArray(flow.nodes) ? flow.nodes : [];
|
|
1077
|
+
nodes.forEach((node, nodeIndex) => {
|
|
1078
|
+
if (node?.type !== "update_record") return;
|
|
1079
|
+
const config = node.config ?? {};
|
|
1080
|
+
const objectName = readLiteralObjectName(config);
|
|
1081
|
+
if (!objectName) return;
|
|
1082
|
+
const fieldMap = roIndex.get(objectName);
|
|
1083
|
+
if (!fieldMap) return;
|
|
1084
|
+
const fields = config.fields;
|
|
1085
|
+
if (!fields || typeof fields !== "object" || Array.isArray(fields)) return;
|
|
1086
|
+
const nodeName = typeof node.label === "string" && node.label ? node.label : typeof node.id === "string" && node.id ? node.id : `#${nodeIndex}`;
|
|
1087
|
+
for (const fieldName of Object.keys(fields)) {
|
|
1088
|
+
const meta = fieldMap.get(fieldName);
|
|
1089
|
+
if (!meta) continue;
|
|
1090
|
+
if (meta.readonly) {
|
|
1091
|
+
findings.push({
|
|
1092
|
+
severity: "error",
|
|
1093
|
+
rule: FLOW_UPDATE_READONLY_FIELD,
|
|
1094
|
+
where: `flow "${flowName}" \u203A node "${nodeName}"`,
|
|
1095
|
+
path: `flows[${flowIndex}].nodes[${nodeIndex}].config.fields.${fieldName}`,
|
|
1096
|
+
message: `writes field '${fieldName}', which object '${objectName}' declares readonly:true. Under runAs:'${runAs}' the engine silently strips readonly fields from the UPDATE payload (#2948), so this write never lands \u2014 while the step still reports success.`,
|
|
1097
|
+
hint: `If automation is meant to maintain this field, declare the flow runAs:'system' (the intended channel \u2014 readonly governs the end-user/API surface, not trusted system writers). Otherwise remove '${fieldName}' from this update_record node.`
|
|
1098
|
+
});
|
|
1099
|
+
} else if (meta.readonlyWhen) {
|
|
1100
|
+
findings.push({
|
|
1101
|
+
severity: "warning",
|
|
1102
|
+
rule: FLOW_UPDATE_READONLY_WHEN_FIELD,
|
|
1103
|
+
where: `flow "${flowName}" \u203A node "${nodeName}"`,
|
|
1104
|
+
path: `flows[${flowIndex}].nodes[${nodeIndex}].config.fields.${fieldName}`,
|
|
1105
|
+
message: `writes field '${fieldName}', which object '${objectName}' declares readonlyWhen. On records where that predicate is TRUE, a runAs:'${runAs}' UPDATE strips the field (#3042), so this write may silently not land depending on the record's state.`,
|
|
1106
|
+
hint: `If automation must maintain this field regardless of record state, run the flow runAs:'system'. Otherwise confirm this node only targets records whose readonlyWhen predicate is FALSE.`
|
|
1107
|
+
});
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
1110
|
+
});
|
|
1111
|
+
});
|
|
1112
|
+
return findings;
|
|
1113
|
+
}
|
|
1114
|
+
|
|
647
1115
|
// src/validate-view-containers.ts
|
|
648
1116
|
var VIEW_CONTAINER_SHAPE = "view-container-shape";
|
|
649
1117
|
var CONTAINER_SLOT_KEYS = ["list", "form", "listViews", "formViews"];
|
|
@@ -666,13 +1134,13 @@ function validateViewContainers(stack) {
|
|
|
666
1134
|
const rec = value;
|
|
667
1135
|
if (rec.viewKind != null) continue;
|
|
668
1136
|
if (containerViewCount(rec) > 0) continue;
|
|
669
|
-
const
|
|
1137
|
+
const label2 = typeof rec.name === "string" ? ` ("${rec.name}")` : "";
|
|
670
1138
|
const hasContainerSlot = CONTAINER_SLOT_KEYS.some((k) => k in rec);
|
|
671
1139
|
const looksFlat = !hasContainerSlot && ["type", "columns", "data", "filter", "sort"].some((k) => k in rec);
|
|
672
1140
|
out.push({
|
|
673
1141
|
severity: "error",
|
|
674
1142
|
rule: VIEW_CONTAINER_SHAPE,
|
|
675
|
-
where: `views${key}${
|
|
1143
|
+
where: `views${key}${label2}`,
|
|
676
1144
|
path: `views${key}`,
|
|
677
1145
|
message: looksFlat ? "Flat list-view object is not a view container: `ViewSchema` strips its keys, so it parses to an EMPTY container \u2014 zero views register and the Console renders no view for it." : "View container defines no views \u2014 all of `list` / `form` / `listViews` / `formViews` are absent or empty, so nothing registers.",
|
|
678
1146
|
hint: "Wrap every view in a defineView container: defineView({ list: { type, data, columns, ... }, listViews: { ... }, formViews: { ... } }). See examples/app-showcase/src/ui/views/task.view.ts."
|
|
@@ -884,7 +1352,7 @@ function looksLikeTailwind(className) {
|
|
|
884
1352
|
return false;
|
|
885
1353
|
});
|
|
886
1354
|
}
|
|
887
|
-
function
|
|
1355
|
+
function asArray7(v) {
|
|
888
1356
|
if (Array.isArray(v)) return v;
|
|
889
1357
|
if (v && typeof v === "object") {
|
|
890
1358
|
return Object.entries(v).map(([name, def]) => ({ name, ...def }));
|
|
@@ -977,13 +1445,13 @@ function checkNode(node, pageName, path, findings) {
|
|
|
977
1445
|
}
|
|
978
1446
|
function validateResponsiveStyles(stack) {
|
|
979
1447
|
const findings = [];
|
|
980
|
-
const pages =
|
|
1448
|
+
const pages = asArray7(stack.pages);
|
|
981
1449
|
for (let p = 0; p < pages.length; p++) {
|
|
982
1450
|
const page = pages[p];
|
|
983
1451
|
const pageName = typeof page.name === "string" ? page.name : `pages[${p}]`;
|
|
984
|
-
const regions =
|
|
1452
|
+
const regions = asArray7(page.regions);
|
|
985
1453
|
for (let r = 0; r < regions.length; r++) {
|
|
986
|
-
const components =
|
|
1454
|
+
const components = asArray7(regions[r].components);
|
|
987
1455
|
for (let c = 0; c < components.length; c++) {
|
|
988
1456
|
checkNode(components[c], pageName, `pages[${p}].regions[${r}].components[${c}]`, findings);
|
|
989
1457
|
}
|
|
@@ -994,10 +1462,10 @@ function validateResponsiveStyles(stack) {
|
|
|
994
1462
|
|
|
995
1463
|
// src/validate-jsx-pages.ts
|
|
996
1464
|
var import_sdui_parser = require("@objectstack/sdui-parser");
|
|
997
|
-
var
|
|
1465
|
+
var asArray8 = (v) => Array.isArray(v) ? v : [];
|
|
998
1466
|
function validateJsxPages(stack, opts = {}) {
|
|
999
1467
|
const findings = [];
|
|
1000
|
-
const pages =
|
|
1468
|
+
const pages = asArray8(stack.pages);
|
|
1001
1469
|
for (let p = 0; p < pages.length; p++) {
|
|
1002
1470
|
const page = pages[p];
|
|
1003
1471
|
if (!page || page.kind !== "html" && page.kind !== "jsx") continue;
|
|
@@ -1045,10 +1513,10 @@ function loadSucraseTransform() {
|
|
|
1045
1513
|
}
|
|
1046
1514
|
return cachedTransform;
|
|
1047
1515
|
}
|
|
1048
|
-
var
|
|
1516
|
+
var asArray9 = (v) => Array.isArray(v) ? v : [];
|
|
1049
1517
|
function validateReactPages(stack) {
|
|
1050
1518
|
const findings = [];
|
|
1051
|
-
const pages =
|
|
1519
|
+
const pages = asArray9(stack.pages);
|
|
1052
1520
|
for (let p = 0; p < pages.length; p++) {
|
|
1053
1521
|
const page = pages[p];
|
|
1054
1522
|
if (!page || page.kind !== "react") continue;
|
|
@@ -1100,7 +1568,7 @@ function loadTypeScript() {
|
|
|
1100
1568
|
}
|
|
1101
1569
|
return cachedTs;
|
|
1102
1570
|
}
|
|
1103
|
-
var
|
|
1571
|
+
var asArray10 = (v) => Array.isArray(v) ? v : [];
|
|
1104
1572
|
var BLOCKS = new Map(
|
|
1105
1573
|
import_ui.REACT_BLOCKS.map((b) => [
|
|
1106
1574
|
b.tag,
|
|
@@ -1137,9 +1605,173 @@ function nearestKnown(prop, known) {
|
|
|
1137
1605
|
}
|
|
1138
1606
|
return bestD <= 2 ? best : null;
|
|
1139
1607
|
}
|
|
1608
|
+
var NOT_STATIC = /* @__PURE__ */ Symbol("not-static");
|
|
1609
|
+
function staticValue(tsc, sf, node) {
|
|
1610
|
+
if (!node) return NOT_STATIC;
|
|
1611
|
+
if (tsc.isParenthesizedExpression(node)) return staticValue(tsc, sf, node.expression);
|
|
1612
|
+
if (tsc.isStringLiteral(node) || tsc.isNoSubstitutionTemplateLiteral(node)) return node.text;
|
|
1613
|
+
if (tsc.isNumericLiteral(node)) return Number(node.text);
|
|
1614
|
+
if (node.kind === tsc.SyntaxKind.TrueKeyword) return true;
|
|
1615
|
+
if (node.kind === tsc.SyntaxKind.FalseKeyword) return false;
|
|
1616
|
+
if (node.kind === tsc.SyntaxKind.NullKeyword) return null;
|
|
1617
|
+
if (tsc.isArrayLiteralExpression(node)) {
|
|
1618
|
+
const out = [];
|
|
1619
|
+
for (const el of node.elements) {
|
|
1620
|
+
const v = staticValue(tsc, sf, el);
|
|
1621
|
+
if (v === NOT_STATIC) return NOT_STATIC;
|
|
1622
|
+
out.push(v);
|
|
1623
|
+
}
|
|
1624
|
+
return out;
|
|
1625
|
+
}
|
|
1626
|
+
if (tsc.isObjectLiteralExpression(node)) {
|
|
1627
|
+
const out = {};
|
|
1628
|
+
for (const p of node.properties) {
|
|
1629
|
+
if (!tsc.isPropertyAssignment(p)) return NOT_STATIC;
|
|
1630
|
+
const key = tsc.isIdentifier(p.name) || tsc.isStringLiteral(p.name) ? p.name.text : null;
|
|
1631
|
+
if (key === null) return NOT_STATIC;
|
|
1632
|
+
const v = staticValue(tsc, sf, p.initializer);
|
|
1633
|
+
if (v === NOT_STATIC) return NOT_STATIC;
|
|
1634
|
+
out[key] = v;
|
|
1635
|
+
}
|
|
1636
|
+
return out;
|
|
1637
|
+
}
|
|
1638
|
+
return NOT_STATIC;
|
|
1639
|
+
}
|
|
1640
|
+
function attrValue(tsc, sf, attr) {
|
|
1641
|
+
const init = attr.initializer;
|
|
1642
|
+
if (!init) return true;
|
|
1643
|
+
if (tsc.isStringLiteral(init)) return init.text;
|
|
1644
|
+
if (tsc.isJsxExpression(init)) return staticValue(tsc, sf, init.expression);
|
|
1645
|
+
return NOT_STATIC;
|
|
1646
|
+
}
|
|
1647
|
+
var REACT_CHART_FIELD_UNKNOWN = "react-chart-field-unknown";
|
|
1648
|
+
var REACT_CHART_AGGREGATE_INVALID = "react-chart-aggregate-invalid";
|
|
1649
|
+
var REACT_CHART_AXIS_UNKNOWN = "react-chart-axis-unknown";
|
|
1650
|
+
var CHART_FUNCTIONS = ["count", "sum", "avg", "min", "max"];
|
|
1651
|
+
var SYSTEM_FIELDS3 = /* @__PURE__ */ new Set([
|
|
1652
|
+
"id",
|
|
1653
|
+
"created_at",
|
|
1654
|
+
"created_by",
|
|
1655
|
+
"updated_at",
|
|
1656
|
+
"updated_by",
|
|
1657
|
+
"owner_id",
|
|
1658
|
+
"organization_id",
|
|
1659
|
+
"tenant_id",
|
|
1660
|
+
"user_id",
|
|
1661
|
+
"deleted_at"
|
|
1662
|
+
]);
|
|
1663
|
+
function namedArray(v) {
|
|
1664
|
+
if (Array.isArray(v)) return v.filter((x) => !!x && typeof x === "object");
|
|
1665
|
+
if (v && typeof v === "object") {
|
|
1666
|
+
return Object.entries(v).map(([name, def]) => ({
|
|
1667
|
+
name,
|
|
1668
|
+
...def && typeof def === "object" ? def : {}
|
|
1669
|
+
}));
|
|
1670
|
+
}
|
|
1671
|
+
return [];
|
|
1672
|
+
}
|
|
1673
|
+
function indexObjectFields(stack) {
|
|
1674
|
+
const out = /* @__PURE__ */ new Map();
|
|
1675
|
+
for (const obj of namedArray(stack.objects)) {
|
|
1676
|
+
const name = typeof obj.name === "string" ? obj.name : void 0;
|
|
1677
|
+
if (!name) continue;
|
|
1678
|
+
const names = /* @__PURE__ */ new Set();
|
|
1679
|
+
for (const f of namedArray(obj.fields)) {
|
|
1680
|
+
if (typeof f.name === "string" && f.name) names.add(f.name);
|
|
1681
|
+
}
|
|
1682
|
+
out.set(name, names);
|
|
1683
|
+
}
|
|
1684
|
+
return out;
|
|
1685
|
+
}
|
|
1686
|
+
var isRec = (v) => !!v && typeof v === "object" && !Array.isArray(v);
|
|
1687
|
+
var strOf = (v) => typeof v === "string" && v.length > 0 ? v : void 0;
|
|
1688
|
+
function checkObjectChart(attrs, objectFields, findings) {
|
|
1689
|
+
const { values, where, path } = attrs;
|
|
1690
|
+
const push = (severity, rule, message, hint) => findings.push({ severity, rule, where, path, message, hint });
|
|
1691
|
+
if (values.has("data")) return;
|
|
1692
|
+
const aggregate = values.get("aggregate");
|
|
1693
|
+
if (aggregate === void 0 || aggregate === NOT_STATIC) return;
|
|
1694
|
+
if (!isRec(aggregate)) return;
|
|
1695
|
+
const fn = strOf(aggregate.function);
|
|
1696
|
+
const field = strOf(aggregate.field);
|
|
1697
|
+
const groupBy = aggregate.groupBy;
|
|
1698
|
+
const groupByField = strOf(groupBy) ?? (isRec(groupBy) ? strOf(groupBy.field) : void 0);
|
|
1699
|
+
if (fn && !CHART_FUNCTIONS.includes(fn)) {
|
|
1700
|
+
push(
|
|
1701
|
+
"error",
|
|
1702
|
+
REACT_CHART_AGGREGATE_INVALID,
|
|
1703
|
+
`aggregate.function "${fn}" is not an aggregation this chart can run.`,
|
|
1704
|
+
`Use one of: ${CHART_FUNCTIONS.join(", ")}.`
|
|
1705
|
+
);
|
|
1706
|
+
} else if (fn && fn !== "count" && !field) {
|
|
1707
|
+
push(
|
|
1708
|
+
"error",
|
|
1709
|
+
REACT_CHART_AGGREGATE_INVALID,
|
|
1710
|
+
`aggregate.function "${fn}" has no "field" to aggregate.`,
|
|
1711
|
+
'Add aggregate.field, or use function "count" (the only one that may omit it).'
|
|
1712
|
+
);
|
|
1713
|
+
}
|
|
1714
|
+
const objectName = strOf(values.get("objectName"));
|
|
1715
|
+
const known = objectName ? objectFields.get(objectName) : void 0;
|
|
1716
|
+
if (objectName && known) {
|
|
1717
|
+
const fieldRef = (name, prop) => {
|
|
1718
|
+
if (!name) return;
|
|
1719
|
+
if (name.includes(".")) return;
|
|
1720
|
+
if (known.has(name) || SYSTEM_FIELDS3.has(name)) return;
|
|
1721
|
+
push(
|
|
1722
|
+
"error",
|
|
1723
|
+
REACT_CHART_FIELD_UNKNOWN,
|
|
1724
|
+
`aggregate.${prop} "${name}" is not a field on object "${objectName}" \u2014 the aggregate query has nothing to ${prop === "groupBy" ? "group by" : "aggregate"}, so the chart comes back empty.`,
|
|
1725
|
+
`Fix the field name, or add "${name}" to ${objectName}.` + (known.size > 0 ? ` Object fields: ${[...known].sort().join(", ")}.` : "")
|
|
1726
|
+
);
|
|
1727
|
+
};
|
|
1728
|
+
fieldRef(field, "field");
|
|
1729
|
+
fieldRef(groupByField, "groupBy");
|
|
1730
|
+
}
|
|
1731
|
+
const keys = (0, import_ui.chartAggregateResultKeys)({ field, function: fn, groupBy });
|
|
1732
|
+
const columns = [keys.category, keys.value].filter((k) => !!k);
|
|
1733
|
+
if (columns.length === 0) return;
|
|
1734
|
+
const axisRef = (name, prop) => {
|
|
1735
|
+
if (!name) return;
|
|
1736
|
+
if (columns.includes(name)) return;
|
|
1737
|
+
if (keys.comparison && name === keys.comparison) return;
|
|
1738
|
+
push(
|
|
1739
|
+
"error",
|
|
1740
|
+
REACT_CHART_AXIS_UNKNOWN,
|
|
1741
|
+
`"${name}" is not a column this aggregate returns, so the axis plots nothing. Object-bound aggregate rows are keyed by the RAW FIELD NAMES (unlike a dataset, whose rows are keyed by measure name).`,
|
|
1742
|
+
`Result columns: ${columns.join(", ")}` + (keys.comparison ? ` (plus "${keys.comparison}" with a comparison overlay)` : "") + `. Bind ${prop} to one of them.`
|
|
1743
|
+
);
|
|
1744
|
+
};
|
|
1745
|
+
const xAxisRaw = values.get("xAxis");
|
|
1746
|
+
const categoryAxis = strOf(values.get("xAxisKey")) ?? strOf(xAxisRaw) ?? (isRec(xAxisRaw) ? strOf(xAxisRaw.field) : void 0);
|
|
1747
|
+
const categoryProp = values.has("xAxisKey") ? "xAxisKey" : "xAxis.field";
|
|
1748
|
+
axisRef(categoryAxis, categoryProp);
|
|
1749
|
+
const yAxisRaw = values.get("yAxis");
|
|
1750
|
+
const yAxisList = Array.isArray(yAxisRaw) ? yAxisRaw : yAxisRaw !== void 0 ? [yAxisRaw] : [];
|
|
1751
|
+
for (const a of yAxisList) {
|
|
1752
|
+
axisRef(strOf(a) ?? (isRec(a) ? strOf(a.field) : void 0), "yAxis[].field");
|
|
1753
|
+
}
|
|
1754
|
+
const series = values.get("series");
|
|
1755
|
+
if (Array.isArray(series)) {
|
|
1756
|
+
for (const s of series) {
|
|
1757
|
+
if (!isRec(s)) continue;
|
|
1758
|
+
const dataKey = strOf(s.dataKey);
|
|
1759
|
+
axisRef(dataKey ?? strOf(s.name), dataKey ? "series[].dataKey" : "series[].name");
|
|
1760
|
+
}
|
|
1761
|
+
}
|
|
1762
|
+
if (categoryAxis && keys.category && categoryAxis !== keys.category && categoryAxis === keys.value) {
|
|
1763
|
+
push(
|
|
1764
|
+
"error",
|
|
1765
|
+
REACT_CHART_AXIS_UNKNOWN,
|
|
1766
|
+
`${categoryProp} "${categoryAxis}" is the aggregate's VALUE column, not its category column.`,
|
|
1767
|
+
`The category axis is keyed by groupBy \u2014 bind it to "${keys.category}".`
|
|
1768
|
+
);
|
|
1769
|
+
}
|
|
1770
|
+
}
|
|
1140
1771
|
function validateReactPageProps(stack) {
|
|
1141
1772
|
const findings = [];
|
|
1142
|
-
const
|
|
1773
|
+
const objectFields = indexObjectFields(stack);
|
|
1774
|
+
const pages = asArray10(stack.pages);
|
|
1143
1775
|
for (let p = 0; p < pages.length; p++) {
|
|
1144
1776
|
const page = pages[p];
|
|
1145
1777
|
if (!page || page.kind !== "react") continue;
|
|
@@ -1160,12 +1792,17 @@ function validateReactPageProps(stack) {
|
|
|
1160
1792
|
if (block) {
|
|
1161
1793
|
let hasSpread = false;
|
|
1162
1794
|
const used = /* @__PURE__ */ new Set();
|
|
1795
|
+
const values = /* @__PURE__ */ new Map();
|
|
1163
1796
|
for (const a of node.attributes.properties) {
|
|
1164
1797
|
if (tsc.isJsxSpreadAttribute(a)) {
|
|
1165
1798
|
hasSpread = true;
|
|
1166
1799
|
continue;
|
|
1167
1800
|
}
|
|
1168
|
-
if (tsc.isJsxAttribute(a))
|
|
1801
|
+
if (tsc.isJsxAttribute(a)) {
|
|
1802
|
+
const propName = a.name.getText(sf);
|
|
1803
|
+
used.add(propName);
|
|
1804
|
+
values.set(propName, attrValue(tsc, sf, a));
|
|
1805
|
+
}
|
|
1169
1806
|
}
|
|
1170
1807
|
const where = `page "${name}" \u203A <${tag}>`;
|
|
1171
1808
|
const path = `pages[${p}].source`;
|
|
@@ -1196,6 +1833,9 @@ function validateReactPageProps(stack) {
|
|
|
1196
1833
|
});
|
|
1197
1834
|
}
|
|
1198
1835
|
}
|
|
1836
|
+
if (tag === "ObjectChart" && !hasSpread) {
|
|
1837
|
+
checkObjectChart({ values, where, path }, objectFields, findings);
|
|
1838
|
+
}
|
|
1199
1839
|
}
|
|
1200
1840
|
}
|
|
1201
1841
|
tsc.forEachChild(node, visit);
|
|
@@ -1207,11 +1847,11 @@ function validateReactPageProps(stack) {
|
|
|
1207
1847
|
|
|
1208
1848
|
// src/validate-page-source-styling.ts
|
|
1209
1849
|
var PAGE_SOURCE_CLASSNAME = "page-source-className-tailwind";
|
|
1210
|
-
var
|
|
1850
|
+
var asArray11 = (v) => Array.isArray(v) ? v : [];
|
|
1211
1851
|
var CLASSNAME_ATTR = /\bclassName\s*=\s*["'{]/g;
|
|
1212
1852
|
function validatePageSourceStyling(stack) {
|
|
1213
1853
|
const findings = [];
|
|
1214
|
-
const pages =
|
|
1854
|
+
const pages = asArray11(stack.pages);
|
|
1215
1855
|
for (let p = 0; p < pages.length; p++) {
|
|
1216
1856
|
const page = pages[p];
|
|
1217
1857
|
if (!page) continue;
|
|
@@ -1240,7 +1880,7 @@ function validatePageSourceStyling(stack) {
|
|
|
1240
1880
|
var import_data2 = require("@objectstack/spec/data");
|
|
1241
1881
|
var TITLE_FORMAT_RETIRED = "title-format-retired";
|
|
1242
1882
|
var TITLE_UNRESOLVABLE = "title-unresolvable";
|
|
1243
|
-
function
|
|
1883
|
+
function asArray12(v) {
|
|
1244
1884
|
if (Array.isArray(v)) return v;
|
|
1245
1885
|
if (v && typeof v === "object") {
|
|
1246
1886
|
return Object.entries(v).map(([name, def]) => ({ name, ...def }));
|
|
@@ -1249,7 +1889,7 @@ function asArray10(v) {
|
|
|
1249
1889
|
}
|
|
1250
1890
|
function validateRecordTitle(stack) {
|
|
1251
1891
|
const findings = [];
|
|
1252
|
-
const objects =
|
|
1892
|
+
const objects = asArray12(stack.objects);
|
|
1253
1893
|
for (let i = 0; i < objects.length; i++) {
|
|
1254
1894
|
const obj = objects[i];
|
|
1255
1895
|
const objName = typeof obj.name === "string" ? obj.name : `(object ${i})`;
|
|
@@ -1285,7 +1925,7 @@ var FIELD_GROUP_UNDECLARED = "field-group-undeclared";
|
|
|
1285
1925
|
var FIELD_GROUP_EMPTY = "field-group-empty";
|
|
1286
1926
|
var FIELD_GROUP_SHADOWED = "field-group-shadowed";
|
|
1287
1927
|
var SEMANTIC_ROLE_FIELD_UNKNOWN = "semantic-role-field-unknown";
|
|
1288
|
-
function
|
|
1928
|
+
function asArray13(v) {
|
|
1289
1929
|
if (Array.isArray(v)) return v;
|
|
1290
1930
|
if (v && typeof v === "object") {
|
|
1291
1931
|
return Object.entries(v).map(([name, def]) => ({ name, ...def }));
|
|
@@ -1294,7 +1934,7 @@ function asArray11(v) {
|
|
|
1294
1934
|
}
|
|
1295
1935
|
function validateSemanticRoles(stack) {
|
|
1296
1936
|
const findings = [];
|
|
1297
|
-
const objects =
|
|
1937
|
+
const objects = asArray13(stack.objects);
|
|
1298
1938
|
for (let i = 0; i < objects.length; i++) {
|
|
1299
1939
|
const obj = objects[i];
|
|
1300
1940
|
if (!obj || typeof obj !== "object") continue;
|
|
@@ -1389,7 +2029,7 @@ function validateSemanticRoles(stack) {
|
|
|
1389
2029
|
// src/validate-form-layout.ts
|
|
1390
2030
|
var FORM_FIELD_UNKNOWN = "form-field-unknown";
|
|
1391
2031
|
var FORM_COLSPAN_ABSOLUTE = "absolute-colspan-discouraged";
|
|
1392
|
-
function
|
|
2032
|
+
function asArray14(v) {
|
|
1393
2033
|
if (Array.isArray(v)) return v;
|
|
1394
2034
|
if (v && typeof v === "object") {
|
|
1395
2035
|
return Object.entries(v).map(([name, def]) => ({ name, ...def }));
|
|
@@ -1414,13 +2054,13 @@ function boundObject(view) {
|
|
|
1414
2054
|
function validateFormLayout(stack) {
|
|
1415
2055
|
const findings = [];
|
|
1416
2056
|
const objectFields = /* @__PURE__ */ new Map();
|
|
1417
|
-
for (const obj of
|
|
2057
|
+
for (const obj of asArray14(stack.objects)) {
|
|
1418
2058
|
const name = typeof obj.name === "string" ? obj.name : void 0;
|
|
1419
2059
|
if (!name) continue;
|
|
1420
2060
|
const fields = obj.fields && typeof obj.fields === "object" && !Array.isArray(obj.fields) ? Object.keys(obj.fields) : [];
|
|
1421
2061
|
objectFields.set(name, new Set(fields));
|
|
1422
2062
|
}
|
|
1423
|
-
const views =
|
|
2063
|
+
const views = asArray14(stack.views);
|
|
1424
2064
|
for (let i = 0; i < views.length; i++) {
|
|
1425
2065
|
const view = views[i];
|
|
1426
2066
|
if (!view || typeof view !== "object") continue;
|
|
@@ -1470,7 +2110,7 @@ var VISIBILITY_ALIAS_DEPRECATED = "visibility-alias-deprecated";
|
|
|
1470
2110
|
var VISIBILITY_ROOT_MISLAYERED = "visibility-root-mislayered";
|
|
1471
2111
|
var CANONICAL = "visibleWhen";
|
|
1472
2112
|
var ALIASES = ["visibleOn", "visibility"];
|
|
1473
|
-
function
|
|
2113
|
+
function asArray15(v) {
|
|
1474
2114
|
if (Array.isArray(v)) return v;
|
|
1475
2115
|
if (v && typeof v === "object") {
|
|
1476
2116
|
return Object.entries(v).map(([name, def]) => ({ name, ...def }));
|
|
@@ -1532,7 +2172,7 @@ function isFieldObject(entry) {
|
|
|
1532
2172
|
function validateVisibilityPredicates(stack, opts = {}) {
|
|
1533
2173
|
const layer = opts.layer ?? "runtime";
|
|
1534
2174
|
const findings = [];
|
|
1535
|
-
const views =
|
|
2175
|
+
const views = asArray15(stack.views);
|
|
1536
2176
|
for (let i = 0; i < views.length; i++) {
|
|
1537
2177
|
const view = views[i];
|
|
1538
2178
|
if (!view || typeof view !== "object") continue;
|
|
@@ -1555,7 +2195,7 @@ function validateVisibilityPredicates(stack, opts = {}) {
|
|
|
1555
2195
|
}
|
|
1556
2196
|
}
|
|
1557
2197
|
}
|
|
1558
|
-
const pages =
|
|
2198
|
+
const pages = asArray15(stack.pages);
|
|
1559
2199
|
for (let i = 0; i < pages.length; i++) {
|
|
1560
2200
|
const page = pages[i];
|
|
1561
2201
|
if (!page || typeof page !== "object") continue;
|
|
@@ -1579,7 +2219,7 @@ function validateVisibilityPredicates(stack, opts = {}) {
|
|
|
1579
2219
|
// src/validate-capability-references.ts
|
|
1580
2220
|
var import_security = require("@objectstack/spec/security");
|
|
1581
2221
|
var CAPABILITY_REFERENCE_UNKNOWN = "capability-reference-unknown";
|
|
1582
|
-
function
|
|
2222
|
+
function asArray16(v) {
|
|
1583
2223
|
if (Array.isArray(v)) return v;
|
|
1584
2224
|
if (v && typeof v === "object") {
|
|
1585
2225
|
return Object.entries(v).map(([name, def]) => ({ name, ...def }));
|
|
@@ -1604,13 +2244,13 @@ function validateCapabilityReferences(stack) {
|
|
|
1604
2244
|
const findings = [];
|
|
1605
2245
|
if (!stack || typeof stack !== "object") return findings;
|
|
1606
2246
|
const known = new Set(import_security.PLATFORM_CAPABILITY_NAMES);
|
|
1607
|
-
for (const cap of
|
|
2247
|
+
for (const cap of asArray16(stack.capabilities)) {
|
|
1608
2248
|
if (typeof cap.name === "string" && cap.name.length > 0) known.add(cap.name);
|
|
1609
2249
|
}
|
|
1610
|
-
for (const ps of
|
|
2250
|
+
for (const ps of asArray16(stack.permissions)) {
|
|
1611
2251
|
for (const cap of asCapArray(ps.systemPermissions)) known.add(cap);
|
|
1612
2252
|
}
|
|
1613
|
-
for (const seed of
|
|
2253
|
+
for (const seed of asArray16(stack.data)) {
|
|
1614
2254
|
if (seed.object !== "sys_capability") continue;
|
|
1615
2255
|
for (const rec of Array.isArray(seed.records) ? seed.records : []) {
|
|
1616
2256
|
const name = rec?.name;
|
|
@@ -1629,7 +2269,7 @@ function validateCapabilityReferences(stack) {
|
|
|
1629
2269
|
hint
|
|
1630
2270
|
});
|
|
1631
2271
|
};
|
|
1632
|
-
const objects =
|
|
2272
|
+
const objects = asArray16(stack.objects);
|
|
1633
2273
|
for (let i = 0; i < objects.length; i++) {
|
|
1634
2274
|
const obj = objects[i];
|
|
1635
2275
|
if (!obj || typeof obj !== "object") continue;
|
|
@@ -1638,27 +2278,27 @@ function validateCapabilityReferences(stack) {
|
|
|
1638
2278
|
for (const { cap, key } of flattenObjectRequired(obj.requiredPermissions)) {
|
|
1639
2279
|
flag(cap, `object "${objName}"`, `${objPath}.requiredPermissions${key ? `.${key}` : ""}`);
|
|
1640
2280
|
}
|
|
1641
|
-
const fields =
|
|
2281
|
+
const fields = asArray16(obj.fields);
|
|
1642
2282
|
for (const f of fields) {
|
|
1643
2283
|
const fname = typeof f.name === "string" ? f.name : "(field)";
|
|
1644
2284
|
for (const cap of asCapArray(f.requiredPermissions)) {
|
|
1645
2285
|
flag(cap, `field "${objName}.${fname}"`, `${objPath}.fields.${fname}.requiredPermissions`);
|
|
1646
2286
|
}
|
|
1647
2287
|
}
|
|
1648
|
-
for (const [ai, action] of
|
|
2288
|
+
for (const [ai, action] of asArray16(obj.actions).entries()) {
|
|
1649
2289
|
const aName = typeof action.name === "string" ? action.name : `(action ${ai})`;
|
|
1650
2290
|
for (const cap of asCapArray(action.requiredPermissions)) {
|
|
1651
2291
|
flag(cap, `action "${objName}.${aName}"`, `${objPath}.actions[${ai}].requiredPermissions`);
|
|
1652
2292
|
}
|
|
1653
2293
|
}
|
|
1654
2294
|
}
|
|
1655
|
-
for (const [i, action] of
|
|
2295
|
+
for (const [i, action] of asArray16(stack.actions).entries()) {
|
|
1656
2296
|
const aName = typeof action.name === "string" ? action.name : `(action ${i})`;
|
|
1657
2297
|
for (const cap of asCapArray(action.requiredPermissions)) {
|
|
1658
2298
|
flag(cap, `action "${aName}"`, `actions[${i}].requiredPermissions`);
|
|
1659
2299
|
}
|
|
1660
2300
|
}
|
|
1661
|
-
const apps =
|
|
2301
|
+
const apps = asArray16(stack.apps);
|
|
1662
2302
|
for (let i = 0; i < apps.length; i++) {
|
|
1663
2303
|
const app = apps[i];
|
|
1664
2304
|
if (!app || typeof app !== "object") continue;
|
|
@@ -1686,16 +2326,28 @@ function validateCapabilityReferences(stack) {
|
|
|
1686
2326
|
|
|
1687
2327
|
// src/validate-approval-approvers.ts
|
|
1688
2328
|
var import_automation = require("@objectstack/spec/automation");
|
|
2329
|
+
var import_spec = require("@objectstack/spec");
|
|
2330
|
+
var import_formula2 = require("@objectstack/formula");
|
|
1689
2331
|
var APPROVAL_APPROVER_NOT_MEMBERSHIP_TIER = "approval-approver-not-membership-tier";
|
|
1690
2332
|
var APPROVAL_APPROVER_TYPE_DEPRECATED = "approval-approver-type-deprecated";
|
|
1691
2333
|
var APPROVAL_APPROVER_TYPE_UNKNOWN = "approval-approver-type-unknown";
|
|
2334
|
+
var APPROVAL_APPROVER_TYPE_UNSUPPORTED = "approval-approver-type-unsupported";
|
|
1692
2335
|
var APPROVAL_ESCALATION_REASSIGN_NO_TARGET = "approval-escalation-reassign-no-target";
|
|
1693
|
-
var
|
|
2336
|
+
var APPROVAL_APPROVERS_MAY_RESOLVE_EMPTY = "approval-approvers-may-resolve-empty";
|
|
2337
|
+
var APPROVAL_EXPRESSION_INVALID = "approval-expression-invalid";
|
|
2338
|
+
var APPROVAL_EXPRESSION_NO_EMPTY_POLICY = "approval-expression-no-empty-policy";
|
|
2339
|
+
var APPROVAL_DECISION_OUTPUTS_RESERVED = "approval-decision-outputs-reserved";
|
|
2340
|
+
var APPROVAL_APPROVER_CROSS_ORG_UNSUPPORTED = "approval-approver-cross-org-unsupported";
|
|
2341
|
+
var EXPRESSION_ROOTS = /* @__PURE__ */ new Set(["current", "trigger", "vars"]);
|
|
2342
|
+
var RESERVED_OUTPUT_KEYS = /* @__PURE__ */ new Set(["decision", "requestId"]);
|
|
2343
|
+
var GROUP_ROUTED_TYPES = /* @__PURE__ */ new Set(["position", "team", "department"]);
|
|
2344
|
+
var MEMBERSHIP_TIERS = new Set(import_spec.BUILTIN_MEMBERSHIP_ROLES);
|
|
2345
|
+
var MEMBERSHIP_TIER_LIST = import_spec.BUILTIN_MEMBERSHIP_ROLES.join("/");
|
|
1694
2346
|
var TYPE_FIX = {
|
|
1695
2347
|
business_unit: "department",
|
|
1696
2348
|
bu: "department"
|
|
1697
2349
|
};
|
|
1698
|
-
function
|
|
2350
|
+
function asArray17(v) {
|
|
1699
2351
|
if (Array.isArray(v)) return v;
|
|
1700
2352
|
if (v && typeof v === "object") {
|
|
1701
2353
|
return Object.entries(v).map(([name, def]) => ({ name, ...def }));
|
|
@@ -1705,7 +2357,7 @@ function asArray15(v) {
|
|
|
1705
2357
|
function validateApprovalApprovers(stack) {
|
|
1706
2358
|
const findings = [];
|
|
1707
2359
|
if (!stack || typeof stack !== "object") return findings;
|
|
1708
|
-
const flows =
|
|
2360
|
+
const flows = asArray17(stack.flows);
|
|
1709
2361
|
const validTypes = new Set(import_automation.ApproverType.options);
|
|
1710
2362
|
for (let fi = 0; fi < flows.length; fi++) {
|
|
1711
2363
|
const flow = flows[fi];
|
|
@@ -1738,14 +2390,61 @@ function validateApprovalApprovers(stack) {
|
|
|
1738
2390
|
continue;
|
|
1739
2391
|
}
|
|
1740
2392
|
const canonical = (0, import_automation.canonicalApproverType)(type);
|
|
2393
|
+
if (canonical === "expression") {
|
|
2394
|
+
const source = value.trim();
|
|
2395
|
+
if (!source) {
|
|
2396
|
+
findings.push({
|
|
2397
|
+
severity: "error",
|
|
2398
|
+
rule: APPROVAL_EXPRESSION_INVALID,
|
|
2399
|
+
where,
|
|
2400
|
+
path: `${path}.value`,
|
|
2401
|
+
message: `expression approver has an empty expression \u2014 the node fails at entry.`,
|
|
2402
|
+
hint: `Write a CEL expression over current.* (the record's live state at node entry), trigger.* (the submit-time snapshot) or vars.* (flow variables), e.g. current.approvers_dynamic or vars.approval_lead.picked_departments.`
|
|
2403
|
+
});
|
|
2404
|
+
} else {
|
|
2405
|
+
const parsed = (0, import_formula2.collectCelRootIdentifiers)(source);
|
|
2406
|
+
if (!parsed.ok) {
|
|
2407
|
+
findings.push({
|
|
2408
|
+
severity: "error",
|
|
2409
|
+
rule: APPROVAL_EXPRESSION_INVALID,
|
|
2410
|
+
where,
|
|
2411
|
+
path: `${path}.value`,
|
|
2412
|
+
message: `expression approver does not parse as CEL: ${parsed.error}.`,
|
|
2413
|
+
hint: `Approver expressions are bare CEL (no {\u2026} template braces), e.g. current.approvers_dynamic or vars.get_reviewers.record.owner_id.`
|
|
2414
|
+
});
|
|
2415
|
+
} else {
|
|
2416
|
+
const illegal = parsed.roots.filter((r) => !EXPRESSION_ROOTS.has(r));
|
|
2417
|
+
if (illegal.length) {
|
|
2418
|
+
const wantsRecord = illegal.includes("record") || illegal.includes("previous");
|
|
2419
|
+
findings.push({
|
|
2420
|
+
severity: "error",
|
|
2421
|
+
rule: APPROVAL_EXPRESSION_INVALID,
|
|
2422
|
+
where,
|
|
2423
|
+
path: `${path}.value`,
|
|
2424
|
+
message: `expression approver references \`${illegal.join("`, `")}\` \u2014 only current.*, trigger.* and vars.* are available, and the node fails at entry on any other root.`,
|
|
2425
|
+
hint: wantsRecord ? `\`record\`/\`previous\` are not bound here (on this platform \`record\` always means "the record at event time", which is ambiguous at an approval node). Write current.<field> for the live value at node entry, trigger.<field> for the submit-time snapshot (vars.previous carries the pre-update row).` : `Did you mean current.<field> (live record), trigger.<field> (submit snapshot), or vars.<name> (flow variable)?`
|
|
2426
|
+
});
|
|
2427
|
+
}
|
|
2428
|
+
}
|
|
2429
|
+
}
|
|
2430
|
+
} else if (a.resolveAs != null) {
|
|
2431
|
+
findings.push({
|
|
2432
|
+
severity: "info",
|
|
2433
|
+
rule: APPROVAL_EXPRESSION_INVALID,
|
|
2434
|
+
where,
|
|
2435
|
+
path: `${path}.resolveAs`,
|
|
2436
|
+
message: `resolveAs has no effect on a '${type}' approver \u2014 it only applies to type 'expression'.`,
|
|
2437
|
+
hint: `Remove it, or switch this approver to { type: 'expression', value: '<CEL>', resolveAs: '${String(a.resolveAs)}' }.`
|
|
2438
|
+
});
|
|
2439
|
+
}
|
|
1741
2440
|
if (canonical === "org_membership_level" && value && !MEMBERSHIP_TIERS.has(value.toLowerCase())) {
|
|
1742
2441
|
findings.push({
|
|
1743
2442
|
severity: "warning",
|
|
1744
2443
|
rule: APPROVAL_APPROVER_NOT_MEMBERSHIP_TIER,
|
|
1745
2444
|
where,
|
|
1746
2445
|
path: `${path}.value`,
|
|
1747
|
-
message: `approver { type: '${type}', value: '${value}' } resolves against the better-auth org-membership tier (sys_member.role:
|
|
1748
|
-
hint: `If '${value}' is an org position, author { type: 'position', value: '${value}' } (resolved via sys_user_position, ADR-0090 D3). Keep type 'org_membership_level' only for membership tiers (
|
|
2446
|
+
message: `approver { type: '${type}', value: '${value}' } resolves against the better-auth org-membership tier (sys_member.role: ${MEMBERSHIP_TIER_LIST}) \u2014 '${value}' is not a membership tier, so this approver matches nobody and the request stalls.`,
|
|
2447
|
+
hint: `If '${value}' is an org position, author { type: 'position', value: '${value}' } (resolved via sys_user_position, ADR-0090 D3). Keep type 'org_membership_level' only for membership tiers (${MEMBERSHIP_TIER_LIST}) \u2014 the vocabulary is closed (ADR-0108), so a business role is always a position.`
|
|
1749
2448
|
});
|
|
1750
2449
|
} else if (type in import_automation.DEPRECATED_APPROVER_TYPES) {
|
|
1751
2450
|
const fix = (0, import_automation.canonicalApproverType)(type);
|
|
@@ -1757,8 +2456,67 @@ function validateApprovalApprovers(stack) {
|
|
|
1757
2456
|
message: `approver type '${type}' is the deprecated spelling of '${fix}' (ADR-0090 D3) and is removed in the next major.`,
|
|
1758
2457
|
hint: `Author { type: '${fix}', value: '${value}' }. It resolves identically today.`
|
|
1759
2458
|
});
|
|
2459
|
+
} else if (import_automation.APPROVER_VALUE_BINDINGS[canonical]?.source === "unsupported") {
|
|
2460
|
+
findings.push({
|
|
2461
|
+
severity: "warning",
|
|
2462
|
+
rule: APPROVAL_APPROVER_TYPE_UNSUPPORTED,
|
|
2463
|
+
where,
|
|
2464
|
+
path: `${path}.type`,
|
|
2465
|
+
message: `approver type '${type}' is declared but not implemented by the runtime (#3508) \u2014 the slot resolves to nobody and the request stalls.`,
|
|
2466
|
+
hint: `Route to people the engine can expand: { type: 'team' | 'department' | 'position', ... }. Queue approvers need a real ownership-queue implementation before they take effect.`
|
|
2467
|
+
});
|
|
2468
|
+
}
|
|
2469
|
+
const declaredOrg = a.organization;
|
|
2470
|
+
if (typeof declaredOrg === "string" && declaredOrg.trim() !== "" && import_automation.ApproverType.options.includes(canonical) && !(0, import_automation.approverTypeIsOrgScoped)(canonical)) {
|
|
2471
|
+
findings.push({
|
|
2472
|
+
severity: "error",
|
|
2473
|
+
rule: APPROVAL_APPROVER_CROSS_ORG_UNSUPPORTED,
|
|
2474
|
+
where,
|
|
2475
|
+
path: `${path}.organization`,
|
|
2476
|
+
message: `approver type '${type}' does not resolve through an organization directory, so 'organization: ${declaredOrg}' has no effect (ADR-0105 D9) \u2014 the runtime refuses it.`,
|
|
2477
|
+
hint: `Drop 'organization' here. Cross-organization targeting applies to 'position', 'org_membership_level', 'department' and 'expression' approvers.`
|
|
2478
|
+
});
|
|
1760
2479
|
}
|
|
1761
2480
|
}
|
|
2481
|
+
const routable = approvers.filter(
|
|
2482
|
+
(a) => a && typeof a === "object" && typeof a.type === "string"
|
|
2483
|
+
);
|
|
2484
|
+
if (routable.length > 0 && routable.every((a) => GROUP_ROUTED_TYPES.has((0, import_automation.canonicalApproverType)(String(a.type))))) {
|
|
2485
|
+
const locks = cfg.lockRecord !== false;
|
|
2486
|
+
findings.push({
|
|
2487
|
+
severity: "info",
|
|
2488
|
+
rule: APPROVAL_APPROVERS_MAY_RESOLVE_EMPTY,
|
|
2489
|
+
where,
|
|
2490
|
+
path: `flows[${fi}].nodes[${ni}].config.approvers`,
|
|
2491
|
+
message: `every approver on this node routes to a group (position/team/department) whose members are runtime data \u2014 if none is staffed, the request resolves to an empty slate and waits forever` + (locks ? `, and (lockRecord) the record stays locked with no in-product recovery.` : `.`),
|
|
2492
|
+
hint: `Make sure at least one target is always staffed, or add a guaranteed-staffed fallback approver, e.g. { type: 'org_membership_level', value: 'owner' }. A request that still lands empty is recoverable only by a platform/tenant admin override (#3424).`
|
|
2493
|
+
});
|
|
2494
|
+
}
|
|
2495
|
+
const hasExpression = approvers.some(
|
|
2496
|
+
(a) => a && typeof a === "object" && (0, import_automation.canonicalApproverType)(String(a.type ?? "")) === "expression"
|
|
2497
|
+
);
|
|
2498
|
+
if (hasExpression && cfg.onEmptyApprovers == null) {
|
|
2499
|
+
findings.push({
|
|
2500
|
+
severity: "info",
|
|
2501
|
+
rule: APPROVAL_EXPRESSION_NO_EMPTY_POLICY,
|
|
2502
|
+
where,
|
|
2503
|
+
path: `flows[${fi}].nodes[${ni}].config`,
|
|
2504
|
+
message: `this node resolves approvers from an expression but declares no onEmptyApprovers \u2014 an empty result falls back to the default ('admin_rescue': request opens, only a privileged admin can act).`,
|
|
2505
|
+
hint: `Declare the empty-slate policy explicitly: onEmptyApprovers: 'admin_rescue' (hold for admin takeover), 'fail' (fail the node \u2014 config bug), or 'auto_approve' (wave through, output.autoApproved = true).`
|
|
2506
|
+
});
|
|
2507
|
+
}
|
|
2508
|
+
const declaredOutputs = (0, import_automation.normalizeDecisionOutputs)(cfg.decisionOutputs).map((d) => d.key);
|
|
2509
|
+
const reserved = declaredOutputs.filter((k) => RESERVED_OUTPUT_KEYS.has(k));
|
|
2510
|
+
if (reserved.length) {
|
|
2511
|
+
findings.push({
|
|
2512
|
+
severity: "error",
|
|
2513
|
+
rule: APPROVAL_DECISION_OUTPUTS_RESERVED,
|
|
2514
|
+
where,
|
|
2515
|
+
path: `flows[${fi}].nodes[${ni}].config.decisionOutputs`,
|
|
2516
|
+
message: `decisionOutputs declares reserved key(s) \`${reserved.join("`, `")}\` \u2014 the resume envelope owns them, so every decide carrying them is rejected.`,
|
|
2517
|
+
hint: `Rename the output key(s); any name other than 'decision'/'requestId' works.`
|
|
2518
|
+
});
|
|
2519
|
+
}
|
|
1762
2520
|
const escalation = cfg.escalation ?? null;
|
|
1763
2521
|
if (escalation && typeof escalation === "object" && escalation.action === "reassign") {
|
|
1764
2522
|
const target = typeof escalation.escalateTo === "string" ? escalation.escalateTo.trim() : "";
|
|
@@ -1778,6 +2536,96 @@ function validateApprovalApprovers(stack) {
|
|
|
1778
2536
|
return findings;
|
|
1779
2537
|
}
|
|
1780
2538
|
|
|
2539
|
+
// src/validate-seed-replay-safety.ts
|
|
2540
|
+
var SEED_INSERT_MODE_DUPLICATES_ON_REPLAY = "seed-insert-mode-duplicates-on-replay";
|
|
2541
|
+
function validateSeedReplaySafety(stack) {
|
|
2542
|
+
const out = [];
|
|
2543
|
+
const seeds = Array.isArray(stack.data) ? stack.data : [];
|
|
2544
|
+
seeds.forEach((seed, i) => {
|
|
2545
|
+
if (!seed || typeof seed !== "object") return;
|
|
2546
|
+
if (seed.mode !== "insert") return;
|
|
2547
|
+
const object = typeof seed.object === "string" ? seed.object : void 0;
|
|
2548
|
+
const where = object ? `seed "${object}"` : `data[${i}]`;
|
|
2549
|
+
out.push({
|
|
2550
|
+
severity: "warning",
|
|
2551
|
+
rule: SEED_INSERT_MODE_DUPLICATES_ON_REPLAY,
|
|
2552
|
+
where,
|
|
2553
|
+
path: `data[${i}].mode`,
|
|
2554
|
+
message: "`mode: 'insert'` re-inserts every record on each replay boot (dev-server restart, package re-publish) with no existing-row check, so the dataset duplicates the table on every restart \u2014 seeds are replayed, not applied once.",
|
|
2555
|
+
hint: "Use `mode: 'ignore'` (skip rows that already exist) or `'upsert'` (create-or-update), and declare an `externalId` to match on: a single natural-key field (e.g. `externalId: 'code'`), or a COMPOSITE list of fields for a join / junction table with no single natural key (e.g. `externalId: ['team', 'project']`)."
|
|
2556
|
+
});
|
|
2557
|
+
});
|
|
2558
|
+
return out;
|
|
2559
|
+
}
|
|
2560
|
+
|
|
2561
|
+
// src/validate-seed-state-machine.ts
|
|
2562
|
+
var SEED_VALUE_OUTSIDE_STATE_MACHINE = "seed-value-outside-state-machine";
|
|
2563
|
+
function fsmRulesByObject(objects) {
|
|
2564
|
+
const map = /* @__PURE__ */ new Map();
|
|
2565
|
+
for (const obj of objects) {
|
|
2566
|
+
if (!obj || typeof obj !== "object") continue;
|
|
2567
|
+
const name = typeof obj.name === "string" ? obj.name : void 0;
|
|
2568
|
+
if (!name) continue;
|
|
2569
|
+
const validations = Array.isArray(obj.validations) ? obj.validations : [];
|
|
2570
|
+
const rules = [];
|
|
2571
|
+
for (const v of validations) {
|
|
2572
|
+
if (!v || typeof v !== "object" || v.type !== "state_machine") continue;
|
|
2573
|
+
const field = typeof v.field === "string" ? v.field : void 0;
|
|
2574
|
+
if (!field) continue;
|
|
2575
|
+
const transitions = v.transitions && typeof v.transitions === "object" ? v.transitions : {};
|
|
2576
|
+
const states = /* @__PURE__ */ new Set();
|
|
2577
|
+
for (const s of Array.isArray(v.initialStates) ? v.initialStates : []) states.add(String(s));
|
|
2578
|
+
for (const from of Object.keys(transitions)) {
|
|
2579
|
+
states.add(String(from));
|
|
2580
|
+
const targets = transitions[from];
|
|
2581
|
+
for (const to of Array.isArray(targets) ? targets : []) states.add(String(to));
|
|
2582
|
+
}
|
|
2583
|
+
if (states.size > 0) rules.push({ field, states });
|
|
2584
|
+
}
|
|
2585
|
+
if (rules.length > 0) map.set(name, rules);
|
|
2586
|
+
}
|
|
2587
|
+
return map;
|
|
2588
|
+
}
|
|
2589
|
+
function recordLabel(record, externalId, index) {
|
|
2590
|
+
const keys = Array.isArray(externalId) ? externalId.map(String) : typeof externalId === "string" ? [externalId] : ["name"];
|
|
2591
|
+
const parts = keys.map((k) => record[k]).filter((v) => v != null && v !== "");
|
|
2592
|
+
return parts.length > 0 ? parts.map(String).join(" \xB7 ") : `#${index}`;
|
|
2593
|
+
}
|
|
2594
|
+
function validateSeedStateMachine(stack) {
|
|
2595
|
+
const out = [];
|
|
2596
|
+
const objects = Array.isArray(stack.objects) ? stack.objects : [];
|
|
2597
|
+
const seeds = Array.isArray(stack.data) ? stack.data : [];
|
|
2598
|
+
if (objects.length === 0 || seeds.length === 0) return out;
|
|
2599
|
+
const rulesByObject = fsmRulesByObject(objects);
|
|
2600
|
+
if (rulesByObject.size === 0) return out;
|
|
2601
|
+
seeds.forEach((seed, i) => {
|
|
2602
|
+
if (!seed || typeof seed !== "object") return;
|
|
2603
|
+
const objectName = typeof seed.object === "string" ? seed.object : void 0;
|
|
2604
|
+
if (!objectName) return;
|
|
2605
|
+
const rules = rulesByObject.get(objectName);
|
|
2606
|
+
if (!rules) return;
|
|
2607
|
+
const records = Array.isArray(seed.records) ? seed.records : [];
|
|
2608
|
+
records.forEach((record, j) => {
|
|
2609
|
+
if (!record || typeof record !== "object") return;
|
|
2610
|
+
for (const rule of rules) {
|
|
2611
|
+
const value = record[rule.field];
|
|
2612
|
+
if (value == null || value === "") continue;
|
|
2613
|
+
if (typeof value !== "string") continue;
|
|
2614
|
+
if (rule.states.has(value)) continue;
|
|
2615
|
+
out.push({
|
|
2616
|
+
severity: "warning",
|
|
2617
|
+
rule: SEED_VALUE_OUTSIDE_STATE_MACHINE,
|
|
2618
|
+
where: `seed "${objectName}" (${recordLabel(record, seed.externalId, j)})`,
|
|
2619
|
+
path: `data[${i}].records[${j}].${rule.field}`,
|
|
2620
|
+
message: `seeds '${rule.field}=${value}', which the '${objectName}' state machine does not declare (known states: ${[...rule.states].sort().join(", ")}). Seed writes are exempt from the state_machine rule (#3433), so this is NOT rejected at write time \u2014 a typo lands silently.`,
|
|
2621
|
+
hint: `If '${value}' is a real state, add it to the state machine (as an initial state or a transition endpoint). If it is a typo, correct it to a declared state. The exemption lets a seed be born mid-lifecycle; it is not a licence to write an unknown state.`
|
|
2622
|
+
});
|
|
2623
|
+
}
|
|
2624
|
+
});
|
|
2625
|
+
});
|
|
2626
|
+
return out;
|
|
2627
|
+
}
|
|
2628
|
+
|
|
1781
2629
|
// src/validate-security-posture.ts
|
|
1782
2630
|
var import_security2 = require("@objectstack/spec/security");
|
|
1783
2631
|
var SECURITY_OWD_UNSET = "security-owd-unset";
|
|
@@ -1804,7 +2652,7 @@ var OWD_WIDTH = {
|
|
|
1804
2652
|
public_read: 1,
|
|
1805
2653
|
public_read_write: 2
|
|
1806
2654
|
};
|
|
1807
|
-
function
|
|
2655
|
+
function asArray18(v) {
|
|
1808
2656
|
if (Array.isArray(v)) return v;
|
|
1809
2657
|
if (v && typeof v === "object") {
|
|
1810
2658
|
return Object.entries(v).map(([name, def]) => ({ name, ...def }));
|
|
@@ -1821,16 +2669,16 @@ function identifierHasRoleToken(name) {
|
|
|
1821
2669
|
if (typeof name !== "string") return false;
|
|
1822
2670
|
return name.toLowerCase().split(/[^a-z0-9]+/).some((tok) => tok === "role" || tok === "roles");
|
|
1823
2671
|
}
|
|
1824
|
-
function labelHasRoleWord(
|
|
1825
|
-
if (typeof
|
|
1826
|
-
return /\brole(s)?\b/i.test(
|
|
2672
|
+
function labelHasRoleWord(label2) {
|
|
2673
|
+
if (typeof label2 !== "string") return false;
|
|
2674
|
+
return /\brole(s)?\b/i.test(label2);
|
|
1827
2675
|
}
|
|
1828
2676
|
function refOf(def) {
|
|
1829
2677
|
const r = def.reference ?? def.reference_to;
|
|
1830
2678
|
return typeof r === "string" && r ? r : void 0;
|
|
1831
2679
|
}
|
|
1832
2680
|
function firstMasterDetailField(obj) {
|
|
1833
|
-
for (const f of
|
|
2681
|
+
for (const f of asArray18(obj.fields)) {
|
|
1834
2682
|
if (f.type === "master_detail") {
|
|
1835
2683
|
return { name: String(f.name ?? "?"), parent: refOf(f) };
|
|
1836
2684
|
}
|
|
@@ -1843,8 +2691,8 @@ function grantsObjectAccess(p) {
|
|
|
1843
2691
|
function validateSecurityPosture(stack, opts) {
|
|
1844
2692
|
const findings = [];
|
|
1845
2693
|
if (!stack || typeof stack !== "object") return findings;
|
|
1846
|
-
const objects =
|
|
1847
|
-
const permissionSets =
|
|
2694
|
+
const objects = asArray18(stack.objects);
|
|
2695
|
+
const permissionSets = asArray18(stack.permissions);
|
|
1848
2696
|
for (let i = 0; i < objects.length; i++) {
|
|
1849
2697
|
const obj = objects[i];
|
|
1850
2698
|
if (!obj || typeof obj !== "object") continue;
|
|
@@ -1947,7 +2795,7 @@ function validateSecurityPosture(stack, opts) {
|
|
|
1947
2795
|
}
|
|
1948
2796
|
}
|
|
1949
2797
|
}
|
|
1950
|
-
const flagRole = (kind, name,
|
|
2798
|
+
const flagRole = (kind, name, label2, where, path) => {
|
|
1951
2799
|
if (identifierHasRoleToken(name)) {
|
|
1952
2800
|
findings.push({
|
|
1953
2801
|
severity: "error",
|
|
@@ -1957,13 +2805,13 @@ function validateSecurityPosture(stack, opts) {
|
|
|
1957
2805
|
message: `${kind} name "${String(name)}" uses the reserved word "role" \u2014 the platform vocabulary is permission_set (capability), position (distribution), business_unit (hierarchy) (ADR-0090 D3).`,
|
|
1958
2806
|
hint: `Rename using 'position' for distribution groups or a domain word (e.g. 'function', 'duty').`
|
|
1959
2807
|
});
|
|
1960
|
-
} else if (labelHasRoleWord(
|
|
2808
|
+
} else if (labelHasRoleWord(label2)) {
|
|
1961
2809
|
findings.push({
|
|
1962
2810
|
severity: "error",
|
|
1963
2811
|
rule: SECURITY_ROLE_WORD,
|
|
1964
2812
|
where,
|
|
1965
2813
|
path: `${path.replace(/\.name$/, "")}.label`,
|
|
1966
|
-
message: `${kind} label "${String(
|
|
2814
|
+
message: `${kind} label "${String(label2)}" uses the reserved word "role" (ADR-0090 D3).`,
|
|
1967
2815
|
hint: `Relabel with 'Position' (distribution) or a domain word \u2014 admins must meet ONE vocabulary.`
|
|
1968
2816
|
});
|
|
1969
2817
|
}
|
|
@@ -1973,10 +2821,10 @@ function validateSecurityPosture(stack, opts) {
|
|
|
1973
2821
|
if (!obj || typeof obj !== "object" || isSystemObject(obj)) continue;
|
|
1974
2822
|
const objName = typeof obj.name === "string" ? obj.name : `(object ${i})`;
|
|
1975
2823
|
flagRole("object", obj.name, obj.label, `object "${objName}"`, `objects[${i}].name`);
|
|
1976
|
-
for (const f of
|
|
2824
|
+
for (const f of asArray18(obj.fields)) {
|
|
1977
2825
|
flagRole("field", f.name, f.label, `field "${objName}.${String(f.name ?? "?")}"`, `objects[${i}].fields.${String(f.name ?? "?")}.name`);
|
|
1978
2826
|
}
|
|
1979
|
-
for (const [ai, action] of
|
|
2827
|
+
for (const [ai, action] of asArray18(obj.actions).entries()) {
|
|
1980
2828
|
flagRole("action", action.name, action.label, `action "${objName}.${String(action.name ?? "?")}"`, `objects[${i}].actions[${ai}].name`);
|
|
1981
2829
|
}
|
|
1982
2830
|
}
|
|
@@ -1985,19 +2833,19 @@ function validateSecurityPosture(stack, opts) {
|
|
|
1985
2833
|
if (!ps || typeof ps !== "object") continue;
|
|
1986
2834
|
flagRole("permission set", ps.name, ps.label, `permission set "${String(ps.name ?? i)}"`, `permissions[${i}].name`);
|
|
1987
2835
|
}
|
|
1988
|
-
for (const [i, pos] of
|
|
2836
|
+
for (const [i, pos] of asArray18(stack.positions).entries()) {
|
|
1989
2837
|
flagRole("position", pos.name, pos.label, `position "${String(pos.name ?? i)}"`, `positions[${i}].name`);
|
|
1990
2838
|
}
|
|
1991
|
-
for (const [i, app] of
|
|
2839
|
+
for (const [i, app] of asArray18(stack.apps).entries()) {
|
|
1992
2840
|
flagRole("app", app.name, app.label, `app "${String(app.name ?? i)}"`, `apps[${i}].name`);
|
|
1993
2841
|
}
|
|
1994
|
-
for (const [i, book] of
|
|
2842
|
+
for (const [i, book] of asArray18(stack.books).entries()) {
|
|
1995
2843
|
flagRole("book", book.name, book.label, `book "${String(book.name ?? i)}"`, `books[${i}].name`);
|
|
1996
2844
|
}
|
|
1997
2845
|
const stackSetNames = new Set(
|
|
1998
2846
|
permissionSets.map((ps) => typeof ps.name === "string" ? ps.name : void 0).filter((n) => !!n)
|
|
1999
2847
|
);
|
|
2000
|
-
for (const [i, book] of
|
|
2848
|
+
for (const [i, book] of asArray18(stack.books).entries()) {
|
|
2001
2849
|
const audience = book.audience;
|
|
2002
2850
|
if (!audience || typeof audience !== "object") continue;
|
|
2003
2851
|
const setName = audience.permissionSet;
|
|
@@ -2075,7 +2923,7 @@ function validateSecurityPosture(stack, opts) {
|
|
|
2075
2923
|
}
|
|
2076
2924
|
const GRANT_SEED_OBJECTS = /* @__PURE__ */ new Set(["sys_user_position", "sys_user_permission_set"]);
|
|
2077
2925
|
const nowMs = opts?.nowMs ?? Date.now();
|
|
2078
|
-
for (const [i, seed] of
|
|
2926
|
+
for (const [i, seed] of asArray18(stack.data).entries()) {
|
|
2079
2927
|
const seedObject = typeof seed.object === "string" ? seed.object : "";
|
|
2080
2928
|
if (!GRANT_SEED_OBJECTS.has(seedObject)) continue;
|
|
2081
2929
|
const records = Array.isArray(seed.records) ? seed.records : [];
|
|
@@ -2115,8 +2963,1178 @@ function validateSecurityPosture(stack, opts) {
|
|
|
2115
2963
|
return findings;
|
|
2116
2964
|
}
|
|
2117
2965
|
|
|
2118
|
-
// src/
|
|
2119
|
-
|
|
2966
|
+
// src/validate-org-axis-red-lines.ts
|
|
2967
|
+
var ORG_AXIS_PERMISSION_INHERITANCE = "org-axis-permission-inheritance";
|
|
2968
|
+
var ORG_AXIS_CROSS_ORG_BU_GRANT = "org-axis-cross-org-bu-grant";
|
|
2969
|
+
var ORG_PARENT_FIELD = "parent_organization_id";
|
|
2970
|
+
function asArray19(v) {
|
|
2971
|
+
if (Array.isArray(v)) return v;
|
|
2972
|
+
if (v && typeof v === "object") {
|
|
2973
|
+
return Object.entries(v).map(([name, def]) => ({ name, ...def }));
|
|
2974
|
+
}
|
|
2975
|
+
return [];
|
|
2976
|
+
}
|
|
2977
|
+
function str(v) {
|
|
2978
|
+
return typeof v === "string" ? v : "";
|
|
2979
|
+
}
|
|
2980
|
+
function isTenancyDisabled(object) {
|
|
2981
|
+
const tenancy = object.tenancy;
|
|
2982
|
+
if (tenancy && typeof tenancy === "object" && tenancy.enabled === false) return true;
|
|
2983
|
+
const systemFields = object.systemFields;
|
|
2984
|
+
if (systemFields && typeof systemFields === "object" && systemFields.tenant === false) return true;
|
|
2985
|
+
return false;
|
|
2986
|
+
}
|
|
2987
|
+
var INHERITANCE_HINT = `Remove the ${ORG_PARENT_FIELD} reference. Cross-organization visibility comes from MEMBERSHIP: under the \`group\` tenancy posture the engine's Layer 0 wall is \`organization_id IN accessible_org_ids\`, so a user who should see several organizations is made a member of them (ADR-0105 D2). A Layer-1 policy cannot widen Layer 0 anyway, so this rule would not grant the access it appears to. For a hierarchy INSIDE one organization, use the business-unit tree (\`unit_and_subordinates\` sharing, or a depth scope anchored on \`sys_user_position\`).`;
|
|
2988
|
+
function validateOrgAxisRedLines(stack) {
|
|
2989
|
+
const findings = [];
|
|
2990
|
+
const cfg = stack ?? {};
|
|
2991
|
+
const permissionSets = asArray19(cfg.permissions ?? cfg.permissionSets);
|
|
2992
|
+
permissionSets.forEach((ps, psIndex) => {
|
|
2993
|
+
asArray19(ps.rowLevelSecurity).forEach((policy, pIndex) => {
|
|
2994
|
+
for (const clause of ["using", "check"]) {
|
|
2995
|
+
if (!str(policy[clause]).includes(ORG_PARENT_FIELD)) continue;
|
|
2996
|
+
findings.push({
|
|
2997
|
+
severity: "error",
|
|
2998
|
+
rule: ORG_AXIS_PERMISSION_INHERITANCE,
|
|
2999
|
+
where: `permission set "${str(ps.name) || psIndex}" policy "${str(policy.name) || pIndex}"`,
|
|
3000
|
+
path: `permissions[${psIndex}].rowLevelSecurity[${pIndex}].${clause}`,
|
|
3001
|
+
message: `RLS ${clause} reads \`${ORG_PARENT_FIELD}\`, which builds a permission hierarchy along the organization axis. ADR-0105 D6 forbids it: the org tree is a REPORTING dimension only.`,
|
|
3002
|
+
hint: INHERITANCE_HINT
|
|
3003
|
+
});
|
|
3004
|
+
}
|
|
3005
|
+
});
|
|
3006
|
+
});
|
|
3007
|
+
const objects = asArray19(cfg.objects);
|
|
3008
|
+
objects.forEach((object, oIndex) => {
|
|
3009
|
+
const objectName = str(object.name) || String(oIndex);
|
|
3010
|
+
asArray19(object.rowLevelSecurity ?? object.rls).forEach((policy, pIndex) => {
|
|
3011
|
+
for (const clause of ["using", "check"]) {
|
|
3012
|
+
if (!str(policy[clause]).includes(ORG_PARENT_FIELD)) continue;
|
|
3013
|
+
findings.push({
|
|
3014
|
+
severity: "error",
|
|
3015
|
+
rule: ORG_AXIS_PERMISSION_INHERITANCE,
|
|
3016
|
+
where: `object "${objectName}" policy "${str(policy.name) || pIndex}"`,
|
|
3017
|
+
path: `objects[${oIndex}].rowLevelSecurity[${pIndex}].${clause}`,
|
|
3018
|
+
message: `RLS ${clause} reads \`${ORG_PARENT_FIELD}\`, which builds a permission hierarchy along the organization axis. ADR-0105 D6 forbids it: the org tree is a REPORTING dimension only.`,
|
|
3019
|
+
hint: INHERITANCE_HINT
|
|
3020
|
+
});
|
|
3021
|
+
}
|
|
3022
|
+
});
|
|
3023
|
+
});
|
|
3024
|
+
asArray19(cfg.sharingRules ?? cfg.sharing).forEach((rule, rIndex) => {
|
|
3025
|
+
const criteria = JSON.stringify(rule.criteria ?? rule.filter ?? "");
|
|
3026
|
+
const sharedTo = JSON.stringify(rule.sharedTo ?? rule.recipient ?? "");
|
|
3027
|
+
if (criteria.includes(ORG_PARENT_FIELD) || sharedTo.includes(ORG_PARENT_FIELD)) {
|
|
3028
|
+
findings.push({
|
|
3029
|
+
severity: "error",
|
|
3030
|
+
rule: ORG_AXIS_PERMISSION_INHERITANCE,
|
|
3031
|
+
where: `sharing rule "${str(rule.name) || rIndex}"`,
|
|
3032
|
+
path: `sharingRules[${rIndex}]`,
|
|
3033
|
+
message: `Sharing rule reads \`${ORG_PARENT_FIELD}\`, granting access by walking the organization tree. ADR-0105 D6 forbids permission inheritance along the org axis.`,
|
|
3034
|
+
hint: INHERITANCE_HINT
|
|
3035
|
+
});
|
|
3036
|
+
}
|
|
3037
|
+
});
|
|
3038
|
+
const tenancyDisabledObjects = new Set(
|
|
3039
|
+
objects.filter((o) => isTenancyDisabled(o)).map((o) => str(o.name)).filter(Boolean)
|
|
3040
|
+
);
|
|
3041
|
+
asArray19(cfg.sharingRules ?? cfg.sharing).forEach((rule, rIndex) => {
|
|
3042
|
+
const target = str(rule.object ?? rule.objectName);
|
|
3043
|
+
if (!target || !tenancyDisabledObjects.has(target)) return;
|
|
3044
|
+
const sharedTo = rule.sharedTo ?? rule.recipient;
|
|
3045
|
+
const recipientType = str(sharedTo?.type);
|
|
3046
|
+
if (recipientType !== "business_unit") return;
|
|
3047
|
+
findings.push({
|
|
3048
|
+
severity: "error",
|
|
3049
|
+
rule: ORG_AXIS_CROSS_ORG_BU_GRANT,
|
|
3050
|
+
where: `sharing rule "${str(rule.name) || rIndex}" on object "${target}"`,
|
|
3051
|
+
path: `sharingRules[${rIndex}].sharedTo`,
|
|
3052
|
+
message: `A business-unit sharing rule targets "${target}", which opted out of tenancy (\`tenancy.enabled: false\`). Platform-global objects carry no organization column, so this grant spans EVERY organization \u2014 a cross-organization business-unit grant, which ADR-0105 D6 forbids (BU trees are org-internal).`,
|
|
3053
|
+
hint: `Either scope the object to organizations (drop \`tenancy.enabled: false\` so Layer 0 walls it), or share it to a position / permission-set audience instead of a business unit. A platform-global catalog that everyone should read wants an OWD of \`public_read\`, not a BU grant.`
|
|
3054
|
+
});
|
|
3055
|
+
});
|
|
3056
|
+
return findings;
|
|
3057
|
+
}
|
|
3058
|
+
|
|
3059
|
+
// src/validate-dashboard-action-refs.ts
|
|
3060
|
+
var DASHBOARD_ACTION_TARGET_UNDEFINED = "dashboard-action-target-undefined";
|
|
3061
|
+
var DASHBOARD_ACTION_ROUTE_UNRESOLVED = "dashboard-action-route-unresolved";
|
|
3062
|
+
function asArray20(v) {
|
|
3063
|
+
if (Array.isArray(v)) return v;
|
|
3064
|
+
if (v && typeof v === "object") {
|
|
3065
|
+
return Object.entries(v).map(([name, def]) => ({ name, ...def }));
|
|
3066
|
+
}
|
|
3067
|
+
return [];
|
|
3068
|
+
}
|
|
3069
|
+
function strName(v) {
|
|
3070
|
+
return typeof v === "string" && v.length > 0 ? v : void 0;
|
|
3071
|
+
}
|
|
3072
|
+
var MODAL_VERB_RE = /^(?:create|new|add|edit|update)_(.+)$/;
|
|
3073
|
+
var URL_COLLECTION_TO_STACK_KEY = {
|
|
3074
|
+
object: "objects",
|
|
3075
|
+
objects: "objects",
|
|
3076
|
+
report: "reports",
|
|
3077
|
+
reports: "reports",
|
|
3078
|
+
dashboard: "dashboards",
|
|
3079
|
+
dashboards: "dashboards",
|
|
3080
|
+
page: "pages",
|
|
3081
|
+
pages: "pages",
|
|
3082
|
+
view: "views",
|
|
3083
|
+
views: "views"
|
|
3084
|
+
};
|
|
3085
|
+
function viewContainerName(item) {
|
|
3086
|
+
return strName(item.name) ?? strName(item.id) ?? strName(item.object) ?? strName(item.list?.data && item.list.data.object) ?? strName(item.form?.data && item.form.data.object);
|
|
3087
|
+
}
|
|
3088
|
+
function collectKnownTargets(stack) {
|
|
3089
|
+
const actions = /* @__PURE__ */ new Set();
|
|
3090
|
+
const objects = /* @__PURE__ */ new Set();
|
|
3091
|
+
const reports = /* @__PURE__ */ new Set();
|
|
3092
|
+
const dashboards = /* @__PURE__ */ new Set();
|
|
3093
|
+
const pages = /* @__PURE__ */ new Set();
|
|
3094
|
+
const views = /* @__PURE__ */ new Set();
|
|
3095
|
+
const collectNames = (v, into, name) => {
|
|
3096
|
+
for (const item of asArray20(v)) {
|
|
3097
|
+
if (!item || typeof item !== "object") continue;
|
|
3098
|
+
const n = name(item);
|
|
3099
|
+
if (n) into.add(n);
|
|
3100
|
+
}
|
|
3101
|
+
};
|
|
3102
|
+
collectNames(stack.actions, actions, (a) => strName(a.name));
|
|
3103
|
+
for (const obj of asArray20(stack.objects)) {
|
|
3104
|
+
if (!obj || typeof obj !== "object") continue;
|
|
3105
|
+
const n = strName(obj.name);
|
|
3106
|
+
if (n) objects.add(n);
|
|
3107
|
+
collectNames(obj.actions, actions, (a) => strName(a.name));
|
|
3108
|
+
}
|
|
3109
|
+
collectNames(stack.reports, reports, (r) => strName(r.name));
|
|
3110
|
+
collectNames(stack.dashboards, dashboards, (d) => strName(d.name));
|
|
3111
|
+
collectNames(stack.pages, pages, (p) => strName(p.name));
|
|
3112
|
+
collectNames(stack.views, views, viewContainerName);
|
|
3113
|
+
for (const o of objects) views.add(o);
|
|
3114
|
+
return { actions, objects, reports, dashboards, pages, views };
|
|
3115
|
+
}
|
|
3116
|
+
function resolveActionTarget(actionType, target, known) {
|
|
3117
|
+
if (known.actions.has(target)) return true;
|
|
3118
|
+
if (actionType === "modal") {
|
|
3119
|
+
if (known.objects.has(target)) return true;
|
|
3120
|
+
const m = MODAL_VERB_RE.exec(target);
|
|
3121
|
+
if (m && known.objects.has(m[1])) return true;
|
|
3122
|
+
}
|
|
3123
|
+
return false;
|
|
3124
|
+
}
|
|
3125
|
+
function resolveUrlRoute(target, known) {
|
|
3126
|
+
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(target) || target.startsWith("//")) return null;
|
|
3127
|
+
if (target.includes("${")) return null;
|
|
3128
|
+
if (!target.startsWith("/")) return null;
|
|
3129
|
+
const pathPart = target.split(/[?#]/, 1)[0];
|
|
3130
|
+
const segments = pathPart.split("/").filter(Boolean);
|
|
3131
|
+
for (let i = 0; i < segments.length - 1; i++) {
|
|
3132
|
+
const stackKey = URL_COLLECTION_TO_STACK_KEY[segments[i]];
|
|
3133
|
+
if (!stackKey) continue;
|
|
3134
|
+
const name = segments[i + 1];
|
|
3135
|
+
if (known[stackKey].has(name)) return void 0;
|
|
3136
|
+
return { collection: segments[i], name };
|
|
3137
|
+
}
|
|
3138
|
+
return null;
|
|
3139
|
+
}
|
|
3140
|
+
function validateDashboardActionRefs(stack) {
|
|
3141
|
+
const findings = [];
|
|
3142
|
+
if (!stack || typeof stack !== "object") return findings;
|
|
3143
|
+
const dashboards = asArray20(stack.dashboards);
|
|
3144
|
+
if (dashboards.length === 0) return findings;
|
|
3145
|
+
const known = collectKnownTargets(stack);
|
|
3146
|
+
const checkOne = (action, where, path) => {
|
|
3147
|
+
const target = strName(action.actionUrl);
|
|
3148
|
+
if (!target) return;
|
|
3149
|
+
if (target.includes("${")) return;
|
|
3150
|
+
const actionType = strName(action.actionType) ?? "url";
|
|
3151
|
+
if (actionType === "script" || actionType === "modal") {
|
|
3152
|
+
if (resolveActionTarget(actionType, target, known)) return;
|
|
3153
|
+
const kindWord = actionType === "script" ? "script" : "modal";
|
|
3154
|
+
findings.push({
|
|
3155
|
+
severity: "error",
|
|
3156
|
+
rule: DASHBOARD_ACTION_TARGET_UNDEFINED,
|
|
3157
|
+
where,
|
|
3158
|
+
path,
|
|
3159
|
+
message: `${kindWord} action target "${target}" resolves to no defined action` + (actionType === "modal" ? " or object" : "") + `. The button renders but does nothing when clicked \u2014 a dangling reference the runtime cannot dispatch (ADR-0049: a declared reference must resolve).`,
|
|
3160
|
+
hint: actionType === "modal" ? `Define an action named "${target}" (stack.actions or the object's actions), use the "<verb>_<object>" convention against a real object (e.g. "create_<object>"), point actionUrl at an existing object, or remove the button.` : `Define a script action named "${target}" (stack.actions or the object's actions) with an inline body or a registered handler, or remove the button.`
|
|
3161
|
+
});
|
|
3162
|
+
return;
|
|
3163
|
+
}
|
|
3164
|
+
if (actionType === "url") {
|
|
3165
|
+
const route = resolveUrlRoute(target, known);
|
|
3166
|
+
if (!route) return;
|
|
3167
|
+
findings.push({
|
|
3168
|
+
severity: "warning",
|
|
3169
|
+
rule: DASHBOARD_ACTION_ROUTE_UNRESOLVED,
|
|
3170
|
+
where,
|
|
3171
|
+
path,
|
|
3172
|
+
message: `url action target "${target}" points at ${route.collection}/${route.name}, but no ${route.collection.replace(/s$/, "")} named "${route.name}" is registered in this stack \u2014 the button likely navigates to a dead route.`,
|
|
3173
|
+
hint: `Check the path for a typo, define the referenced ${route.collection.replace(/s$/, "")}, or ignore this if the route is served by another installed package or a host/console route.`
|
|
3174
|
+
});
|
|
3175
|
+
return;
|
|
3176
|
+
}
|
|
3177
|
+
};
|
|
3178
|
+
for (let di = 0; di < dashboards.length; di++) {
|
|
3179
|
+
const dash = dashboards[di];
|
|
3180
|
+
if (!dash || typeof dash !== "object") continue;
|
|
3181
|
+
const dashName = strName(dash.name) ?? `(dashboard ${di})`;
|
|
3182
|
+
const dashPath = `dashboards[${di}]`;
|
|
3183
|
+
const headerActions = asArray20(dash.header?.actions);
|
|
3184
|
+
for (let ai = 0; ai < headerActions.length; ai++) {
|
|
3185
|
+
const action = headerActions[ai];
|
|
3186
|
+
if (!action || typeof action !== "object") continue;
|
|
3187
|
+
const label2 = strName(action.label) ?? strName(action.actionUrl) ?? `#${ai}`;
|
|
3188
|
+
checkOne(
|
|
3189
|
+
action,
|
|
3190
|
+
`dashboard "${dashName}" \xB7 header action "${label2}"`,
|
|
3191
|
+
`${dashPath}.header.actions[${ai}].actionUrl`
|
|
3192
|
+
);
|
|
3193
|
+
}
|
|
3194
|
+
const widgets = asArray20(dash.widgets);
|
|
3195
|
+
for (let wi = 0; wi < widgets.length; wi++) {
|
|
3196
|
+
const widget = widgets[wi];
|
|
3197
|
+
if (!widget || typeof widget !== "object") continue;
|
|
3198
|
+
if (!strName(widget.actionUrl)) continue;
|
|
3199
|
+
const widgetId = strName(widget.id) ?? `#${wi}`;
|
|
3200
|
+
checkOne(
|
|
3201
|
+
{ actionType: widget.actionType, actionUrl: widget.actionUrl },
|
|
3202
|
+
`dashboard "${dashName}" \xB7 widget "${widgetId}" action`,
|
|
3203
|
+
`${dashPath}.widgets[${wi}].actionUrl`
|
|
3204
|
+
);
|
|
3205
|
+
}
|
|
3206
|
+
}
|
|
3207
|
+
return findings;
|
|
3208
|
+
}
|
|
3209
|
+
|
|
3210
|
+
// src/validate-filter-tokens.ts
|
|
3211
|
+
var import_data3 = require("@objectstack/spec/data");
|
|
3212
|
+
var FILTER_TOKEN_UNKNOWN = "filter-token-unknown";
|
|
3213
|
+
var FILTER_KEYS = /* @__PURE__ */ new Set(["filter", "filters", "runtimeFilter"]);
|
|
3214
|
+
function asArray21(v) {
|
|
3215
|
+
if (Array.isArray(v)) return v;
|
|
3216
|
+
if (v && typeof v === "object") {
|
|
3217
|
+
return Object.entries(v).map(([name, def]) => ({ name, ...def }));
|
|
3218
|
+
}
|
|
3219
|
+
return [];
|
|
3220
|
+
}
|
|
3221
|
+
function label(v, fallback) {
|
|
3222
|
+
return typeof v === "string" && v.length > 0 ? v : fallback;
|
|
3223
|
+
}
|
|
3224
|
+
var KNOWN_LIST = import_data3.CONTEXT_TOKENS.join("}, {");
|
|
3225
|
+
function walkFilterValues(node, path, where, out, seen) {
|
|
3226
|
+
if (node === null || node === void 0) return;
|
|
3227
|
+
if (typeof node === "string") {
|
|
3228
|
+
const cls = (0, import_data3.classifyFilterToken)(node);
|
|
3229
|
+
if (cls?.kind === "unknown") {
|
|
3230
|
+
const suggestion = cls.suggestion;
|
|
3231
|
+
out.push({
|
|
3232
|
+
severity: "error",
|
|
3233
|
+
rule: FILTER_TOKEN_UNKNOWN,
|
|
3234
|
+
where,
|
|
3235
|
+
path,
|
|
3236
|
+
message: `Filter value "${node}" is not a resolvable placeholder. It is sent to the data engine as a literal string, matches no record, and the surface renders empty.`,
|
|
3237
|
+
hint: suggestion ? `Did you mean "{${suggestion}}"? Context tokens are {${KNOWN_LIST}}; time-based values use date macros such as {today} or {30_days_ago}.` : `Resolvable placeholders are the context tokens {${KNOWN_LIST}} and the date macros (e.g. {today}, {week_start}, {30_days_ago}). To filter on a literal value that happens to look like a placeholder, this is not supported \u2014 rename the value.`
|
|
3238
|
+
});
|
|
3239
|
+
}
|
|
3240
|
+
return;
|
|
3241
|
+
}
|
|
3242
|
+
if (typeof node !== "object") return;
|
|
3243
|
+
if (seen.has(node)) return;
|
|
3244
|
+
seen.add(node);
|
|
3245
|
+
if (Array.isArray(node)) {
|
|
3246
|
+
node.forEach((v, i) => walkFilterValues(v, `${path}[${i}]`, where, out, seen));
|
|
3247
|
+
return;
|
|
3248
|
+
}
|
|
3249
|
+
for (const [k, v] of Object.entries(node)) {
|
|
3250
|
+
walkFilterValues(v, `${path}.${k}`, where, out, seen);
|
|
3251
|
+
}
|
|
3252
|
+
}
|
|
3253
|
+
function scanForFilters(node, path, where, out, seen) {
|
|
3254
|
+
if (!node || typeof node !== "object") return;
|
|
3255
|
+
if (seen.has(node)) return;
|
|
3256
|
+
seen.add(node);
|
|
3257
|
+
if (Array.isArray(node)) {
|
|
3258
|
+
node.forEach((v, i) => scanForFilters(v, `${path}[${i}]`, where, out, seen));
|
|
3259
|
+
return;
|
|
3260
|
+
}
|
|
3261
|
+
for (const [k, v] of Object.entries(node)) {
|
|
3262
|
+
const childPath = `${path}.${k}`;
|
|
3263
|
+
if (FILTER_KEYS.has(k)) {
|
|
3264
|
+
walkFilterValues(v, childPath, where, out, /* @__PURE__ */ new Set());
|
|
3265
|
+
continue;
|
|
3266
|
+
}
|
|
3267
|
+
scanForFilters(v, childPath, where, out, seen);
|
|
3268
|
+
}
|
|
3269
|
+
}
|
|
3270
|
+
function validateFilterTokens(stack) {
|
|
3271
|
+
if (!stack || typeof stack !== "object") return [];
|
|
3272
|
+
const out = [];
|
|
3273
|
+
const surfaces = [
|
|
3274
|
+
["dashboards", "dashboard"],
|
|
3275
|
+
["objects", "object"],
|
|
3276
|
+
["views", "view"],
|
|
3277
|
+
["reports", "report"],
|
|
3278
|
+
["datasets", "dataset"],
|
|
3279
|
+
["pages", "page"],
|
|
3280
|
+
["apps", "app"]
|
|
3281
|
+
];
|
|
3282
|
+
for (const [key, kind] of surfaces) {
|
|
3283
|
+
const items = asArray21(stack[key]);
|
|
3284
|
+
items.forEach((item, i) => {
|
|
3285
|
+
const name = label(item.name ?? item.id, `#${i}`);
|
|
3286
|
+
if (kind === "dashboard") {
|
|
3287
|
+
const widgets = Array.isArray(item.widgets) ? item.widgets : [];
|
|
3288
|
+
widgets.forEach((w, wi) => {
|
|
3289
|
+
const wName = label(w.id ?? w.title, `#${wi}`);
|
|
3290
|
+
scanForFilters(
|
|
3291
|
+
w,
|
|
3292
|
+
`${key}[${i}].widgets[${wi}]`,
|
|
3293
|
+
`dashboard "${name}" \xB7 widget "${wName}"`,
|
|
3294
|
+
out,
|
|
3295
|
+
/* @__PURE__ */ new Set()
|
|
3296
|
+
);
|
|
3297
|
+
});
|
|
3298
|
+
const { widgets: _skip, ...rest } = item;
|
|
3299
|
+
scanForFilters(rest, `${key}[${i}]`, `dashboard "${name}"`, out, /* @__PURE__ */ new Set());
|
|
3300
|
+
return;
|
|
3301
|
+
}
|
|
3302
|
+
scanForFilters(item, `${key}[${i}]`, `${kind} "${name}"`, out, /* @__PURE__ */ new Set());
|
|
3303
|
+
});
|
|
3304
|
+
}
|
|
3305
|
+
return out;
|
|
3306
|
+
}
|
|
3307
|
+
|
|
3308
|
+
// src/validate-object-references.ts
|
|
3309
|
+
var import_system = require("@objectstack/spec/system");
|
|
3310
|
+
var PLATFORM_NAMES = [...import_system.PLATFORM_PROVIDED_OBJECT_NAMES];
|
|
3311
|
+
var OBJECT_REFERENCE_UNKNOWN = "object-reference-unknown";
|
|
3312
|
+
var OBJECT_REFERENCE_UNREGISTERED_PLATFORM = "object-reference-unregistered-platform";
|
|
3313
|
+
function asArray22(v) {
|
|
3314
|
+
if (Array.isArray(v)) return v;
|
|
3315
|
+
if (v && typeof v === "object") {
|
|
3316
|
+
return Object.entries(v).map(([name, def]) => ({ name, ...def }));
|
|
3317
|
+
}
|
|
3318
|
+
return [];
|
|
3319
|
+
}
|
|
3320
|
+
function strName2(v) {
|
|
3321
|
+
return typeof v === "string" && v.length > 0 ? v : void 0;
|
|
3322
|
+
}
|
|
3323
|
+
function isInterpolated(target) {
|
|
3324
|
+
if (target.includes("${")) return true;
|
|
3325
|
+
const open = target.indexOf("{");
|
|
3326
|
+
return open !== -1 && target.indexOf("}", open + 2) !== -1;
|
|
3327
|
+
}
|
|
3328
|
+
function suggest2(target, known) {
|
|
3329
|
+
let best;
|
|
3330
|
+
let bestScore = Infinity;
|
|
3331
|
+
for (const candidate of known) {
|
|
3332
|
+
const d = distance(target, candidate);
|
|
3333
|
+
if (d < bestScore) {
|
|
3334
|
+
bestScore = d;
|
|
3335
|
+
best = candidate;
|
|
3336
|
+
}
|
|
3337
|
+
}
|
|
3338
|
+
const limit = Math.max(2, Math.floor(target.length / 3));
|
|
3339
|
+
return best && bestScore <= limit ? ` Did you mean "${best}"?` : "";
|
|
3340
|
+
}
|
|
3341
|
+
function distance(a, b) {
|
|
3342
|
+
const m = a.length;
|
|
3343
|
+
const n = b.length;
|
|
3344
|
+
if (m === 0) return n;
|
|
3345
|
+
if (n === 0) return m;
|
|
3346
|
+
let prev = Array.from({ length: n + 1 }, (_, j) => j);
|
|
3347
|
+
for (let i = 1; i <= m; i++) {
|
|
3348
|
+
const curr = [i, ...new Array(n).fill(0)];
|
|
3349
|
+
for (let j = 1; j <= n; j++) {
|
|
3350
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
3351
|
+
curr[j] = Math.min(curr[j - 1] + 1, prev[j] + 1, prev[j - 1] + cost);
|
|
3352
|
+
}
|
|
3353
|
+
prev = curr;
|
|
3354
|
+
}
|
|
3355
|
+
return prev[n];
|
|
3356
|
+
}
|
|
3357
|
+
function validateObjectReferences(stack) {
|
|
3358
|
+
const findings = [];
|
|
3359
|
+
if (!stack || typeof stack !== "object") return findings;
|
|
3360
|
+
const objects = asArray22(stack.objects);
|
|
3361
|
+
const ownObjects = /* @__PURE__ */ new Set();
|
|
3362
|
+
for (const obj of objects) {
|
|
3363
|
+
const n = strName2(obj.name);
|
|
3364
|
+
if (n) ownObjects.add(n);
|
|
3365
|
+
}
|
|
3366
|
+
const check = (target, where, path, subject, fix) => {
|
|
3367
|
+
const name = strName2(target);
|
|
3368
|
+
if (!name) return;
|
|
3369
|
+
if (isInterpolated(name)) return;
|
|
3370
|
+
if (ownObjects.has(name)) return;
|
|
3371
|
+
if ((0, import_system.isPlatformProvidedObjectName)(name)) return;
|
|
3372
|
+
if ((0, import_system.hasPlatformObjectPrefix)(name)) {
|
|
3373
|
+
findings.push({
|
|
3374
|
+
severity: "warning",
|
|
3375
|
+
rule: OBJECT_REFERENCE_UNREGISTERED_PLATFORM,
|
|
3376
|
+
where,
|
|
3377
|
+
path,
|
|
3378
|
+
message: `${subject} "${name}" carries a platform namespace prefix, but no platform package, official plugin, or cloud runtime object registers that name \u2014 and this stack does not define it either. If nothing provides it at runtime the reference resolves to nothing and fails silently.` + suggest2(name, PLATFORM_NAMES),
|
|
3379
|
+
hint: `Check the spelling against the object the providing package actually registers (e.g. "sys_approval_request", not "sys_approval_process" \u2014 the process object was removed when approval became a flow node, ADR-0019). If a third-party package genuinely provides it, this warning is expected. ${fix}`
|
|
3380
|
+
});
|
|
3381
|
+
return;
|
|
3382
|
+
}
|
|
3383
|
+
findings.push({
|
|
3384
|
+
severity: "error",
|
|
3385
|
+
rule: OBJECT_REFERENCE_UNKNOWN,
|
|
3386
|
+
where,
|
|
3387
|
+
path,
|
|
3388
|
+
message: `${subject} "${name}" resolves to no object defined in this stack. The reference is inert at runtime \u2014 nothing reports the miss.` + suggest2(name, ownObjects),
|
|
3389
|
+
hint: `Point it at one of this stack's objects, or at a platform object by its full name (the platform user object is "sys_user", not "user"). ${fix}` + (ownObjects.size > 0 ? ` Defined objects: ${[...ownObjects].sort().join(", ")}.` : "")
|
|
3390
|
+
});
|
|
3391
|
+
};
|
|
3392
|
+
const checkActionParams2 = (action, actionPath, actionLabel) => {
|
|
3393
|
+
const params = asArray22(action.params);
|
|
3394
|
+
for (let pi = 0; pi < params.length; pi++) {
|
|
3395
|
+
const param = params[pi];
|
|
3396
|
+
if (!param || typeof param !== "object") continue;
|
|
3397
|
+
const paramLabel = strName2(param.name) ?? strName2(param.field) ?? `#${pi}`;
|
|
3398
|
+
const where = `${actionLabel} \xB7 param "${paramLabel}"`;
|
|
3399
|
+
check(
|
|
3400
|
+
strName2(param.reference),
|
|
3401
|
+
where,
|
|
3402
|
+
`${actionPath}.params[${pi}].reference`,
|
|
3403
|
+
"record-picker target",
|
|
3404
|
+
"Without a resolvable target the picker degrades to a raw record-id text input."
|
|
3405
|
+
);
|
|
3406
|
+
check(
|
|
3407
|
+
strName2(param.objectOverride),
|
|
3408
|
+
where,
|
|
3409
|
+
`${actionPath}.params[${pi}].objectOverride`,
|
|
3410
|
+
"field-backed param object",
|
|
3411
|
+
"The param inherits type/options from a field on this object, so an unknown object leaves it untyped."
|
|
3412
|
+
);
|
|
3413
|
+
}
|
|
3414
|
+
};
|
|
3415
|
+
const globalActions = asArray22(stack.actions);
|
|
3416
|
+
for (let ai = 0; ai < globalActions.length; ai++) {
|
|
3417
|
+
const action = globalActions[ai];
|
|
3418
|
+
if (!action || typeof action !== "object") continue;
|
|
3419
|
+
checkActionParams2(action, `actions[${ai}]`, `action "${strName2(action.name) ?? `#${ai}`}"`);
|
|
3420
|
+
}
|
|
3421
|
+
for (let oi = 0; oi < objects.length; oi++) {
|
|
3422
|
+
const obj = objects[oi];
|
|
3423
|
+
if (!obj || typeof obj !== "object") continue;
|
|
3424
|
+
const objName = strName2(obj.name) ?? `#${oi}`;
|
|
3425
|
+
const objActions = asArray22(obj.actions);
|
|
3426
|
+
for (let ai = 0; ai < objActions.length; ai++) {
|
|
3427
|
+
const action = objActions[ai];
|
|
3428
|
+
if (!action || typeof action !== "object") continue;
|
|
3429
|
+
checkActionParams2(
|
|
3430
|
+
action,
|
|
3431
|
+
`objects[${oi}].actions[${ai}]`,
|
|
3432
|
+
`object "${objName}" \xB7 action "${strName2(action.name) ?? `#${ai}`}"`
|
|
3433
|
+
);
|
|
3434
|
+
}
|
|
3435
|
+
}
|
|
3436
|
+
const dashboards = asArray22(stack.dashboards);
|
|
3437
|
+
for (let di = 0; di < dashboards.length; di++) {
|
|
3438
|
+
const dash = dashboards[di];
|
|
3439
|
+
if (!dash || typeof dash !== "object") continue;
|
|
3440
|
+
const dashName = strName2(dash.name) ?? `#${di}`;
|
|
3441
|
+
const filters = asArray22(dash.globalFilters);
|
|
3442
|
+
for (let fi = 0; fi < filters.length; fi++) {
|
|
3443
|
+
const filter = filters[fi];
|
|
3444
|
+
if (!filter || typeof filter !== "object") continue;
|
|
3445
|
+
const optionsFrom = filter.optionsFrom;
|
|
3446
|
+
if (!optionsFrom || typeof optionsFrom !== "object") continue;
|
|
3447
|
+
check(
|
|
3448
|
+
strName2(optionsFrom.object),
|
|
3449
|
+
`dashboard "${dashName}" \xB7 filter "${strName2(filter.name) ?? `#${fi}`}"`,
|
|
3450
|
+
`dashboards[${di}].globalFilters[${fi}].optionsFrom.object`,
|
|
3451
|
+
"filter options source",
|
|
3452
|
+
"The dropdown fetches its options from this object; an unknown one renders an always-empty filter."
|
|
3453
|
+
);
|
|
3454
|
+
}
|
|
3455
|
+
}
|
|
3456
|
+
const apps = asArray22(stack.apps);
|
|
3457
|
+
for (let ai = 0; ai < apps.length; ai++) {
|
|
3458
|
+
const app = apps[ai];
|
|
3459
|
+
if (!app || typeof app !== "object") continue;
|
|
3460
|
+
const appName = strName2(app.name) ?? `#${ai}`;
|
|
3461
|
+
const walkNav = (items, basePath) => {
|
|
3462
|
+
const navItems = asArray22(items);
|
|
3463
|
+
for (let ni = 0; ni < navItems.length; ni++) {
|
|
3464
|
+
const nav = navItems[ni];
|
|
3465
|
+
if (!nav || typeof nav !== "object") continue;
|
|
3466
|
+
const navId = strName2(nav.id) ?? `#${ni}`;
|
|
3467
|
+
const where = `app "${appName}" \xB7 nav "${navId}"`;
|
|
3468
|
+
const navPath = `${basePath}[${ni}]`;
|
|
3469
|
+
check(
|
|
3470
|
+
strName2(nav.requiresObject),
|
|
3471
|
+
where,
|
|
3472
|
+
`${navPath}.requiresObject`,
|
|
3473
|
+
"capability gate object",
|
|
3474
|
+
"The entry is hidden unless this object is registered, so a typo hides it permanently \u2014 and it suppresses the nav cross-reference check that would have caught the target."
|
|
3475
|
+
);
|
|
3476
|
+
if (nav.requiresObject && strName2(nav.objectName)) {
|
|
3477
|
+
check(
|
|
3478
|
+
strName2(nav.objectName),
|
|
3479
|
+
where,
|
|
3480
|
+
`${navPath}.objectName`,
|
|
3481
|
+
"navigation target",
|
|
3482
|
+
"Declaring `requiresObject` exempts this target from the build-time check, so it is only verified here."
|
|
3483
|
+
);
|
|
3484
|
+
}
|
|
3485
|
+
if (Array.isArray(nav.children)) walkNav(nav.children, `${navPath}.children`);
|
|
3486
|
+
}
|
|
3487
|
+
};
|
|
3488
|
+
walkNav(app.navigation, `apps[${ai}].navigation`);
|
|
3489
|
+
const areas = asArray22(app.areas);
|
|
3490
|
+
for (let ri = 0; ri < areas.length; ri++) {
|
|
3491
|
+
walkNav(areas[ri]?.navigation, `apps[${ai}].areas[${ri}].navigation`);
|
|
3492
|
+
}
|
|
3493
|
+
}
|
|
3494
|
+
return findings;
|
|
3495
|
+
}
|
|
3496
|
+
|
|
3497
|
+
// src/page-walk.ts
|
|
3498
|
+
function isRec2(v) {
|
|
3499
|
+
return !!v && typeof v === "object" && !Array.isArray(v);
|
|
3500
|
+
}
|
|
3501
|
+
function strName3(v) {
|
|
3502
|
+
return typeof v === "string" && v.length > 0 ? v : void 0;
|
|
3503
|
+
}
|
|
3504
|
+
var SOURCE_AUTHORED_KINDS = /* @__PURE__ */ new Set(["html", "react", "jsx"]);
|
|
3505
|
+
function isSourceAuthoredPage(page) {
|
|
3506
|
+
const kind = strName3(page.kind);
|
|
3507
|
+
return kind !== void 0 && SOURCE_AUTHORED_KINDS.has(kind);
|
|
3508
|
+
}
|
|
3509
|
+
function walkPageComponents(page, pagePath) {
|
|
3510
|
+
const out = [];
|
|
3511
|
+
if (!isRec2(page) || isSourceAuthoredPage(page)) return out;
|
|
3512
|
+
const pageObject = strName3(page.object);
|
|
3513
|
+
const visit = (node, path, inheritedObject) => {
|
|
3514
|
+
if (!isRec2(node)) return;
|
|
3515
|
+
const props = isRec2(node.properties) ? node.properties : void 0;
|
|
3516
|
+
const dataSource = isRec2(node.dataSource) ? node.dataSource : void 0;
|
|
3517
|
+
const objectName = strName3(dataSource?.object) ?? strName3(props?.object) ?? inheritedObject;
|
|
3518
|
+
out.push({ component: node, path, objectName });
|
|
3519
|
+
if (!props) return;
|
|
3520
|
+
if (Array.isArray(props.items)) {
|
|
3521
|
+
for (let i = 0; i < props.items.length; i++) {
|
|
3522
|
+
const item = props.items[i];
|
|
3523
|
+
if (!isRec2(item) || !Array.isArray(item.children)) continue;
|
|
3524
|
+
for (let c = 0; c < item.children.length; c++) {
|
|
3525
|
+
visit(item.children[c], `${path}.properties.items[${i}].children[${c}]`, objectName);
|
|
3526
|
+
}
|
|
3527
|
+
}
|
|
3528
|
+
}
|
|
3529
|
+
if (Array.isArray(props.children)) {
|
|
3530
|
+
for (let i = 0; i < props.children.length; i++) {
|
|
3531
|
+
visit(props.children[i], `${path}.properties.children[${i}]`, objectName);
|
|
3532
|
+
}
|
|
3533
|
+
}
|
|
3534
|
+
for (const key of ["body", "footer"]) {
|
|
3535
|
+
const slotList = props[key];
|
|
3536
|
+
if (!Array.isArray(slotList)) continue;
|
|
3537
|
+
for (let i = 0; i < slotList.length; i++) {
|
|
3538
|
+
visit(slotList[i], `${path}.properties.${key}[${i}]`, objectName);
|
|
3539
|
+
}
|
|
3540
|
+
}
|
|
3541
|
+
};
|
|
3542
|
+
const regions = Array.isArray(page.regions) ? page.regions : [];
|
|
3543
|
+
for (let r = 0; r < regions.length; r++) {
|
|
3544
|
+
const region = regions[r];
|
|
3545
|
+
if (!isRec2(region) || !Array.isArray(region.components)) continue;
|
|
3546
|
+
for (let c = 0; c < region.components.length; c++) {
|
|
3547
|
+
visit(region.components[c], `${pagePath}.regions[${r}].components[${c}]`, pageObject);
|
|
3548
|
+
}
|
|
3549
|
+
}
|
|
3550
|
+
const slots = isRec2(page.slots) ? page.slots : void 0;
|
|
3551
|
+
if (slots) {
|
|
3552
|
+
for (const [slot, value] of Object.entries(slots)) {
|
|
3553
|
+
const list3 = Array.isArray(value) ? value : [value];
|
|
3554
|
+
const indexed = Array.isArray(value);
|
|
3555
|
+
for (let i = 0; i < list3.length; i++) {
|
|
3556
|
+
visit(list3[i], `${pagePath}.slots.${slot}${indexed ? `[${i}]` : ""}`, pageObject);
|
|
3557
|
+
}
|
|
3558
|
+
}
|
|
3559
|
+
}
|
|
3560
|
+
return out;
|
|
3561
|
+
}
|
|
3562
|
+
|
|
3563
|
+
// src/validate-action-name-refs.ts
|
|
3564
|
+
var ACTION_NAME_UNDEFINED = "action-name-undefined";
|
|
3565
|
+
function asArray23(v) {
|
|
3566
|
+
if (Array.isArray(v)) return v;
|
|
3567
|
+
if (v && typeof v === "object") {
|
|
3568
|
+
return Object.entries(v).map(([name, def]) => ({ name, ...def }));
|
|
3569
|
+
}
|
|
3570
|
+
return [];
|
|
3571
|
+
}
|
|
3572
|
+
function strName4(v) {
|
|
3573
|
+
return typeof v === "string" && v.length > 0 ? v : void 0;
|
|
3574
|
+
}
|
|
3575
|
+
function strList(v) {
|
|
3576
|
+
return Array.isArray(v) ? v.filter((x) => typeof x === "string" && x.length > 0) : [];
|
|
3577
|
+
}
|
|
3578
|
+
function distance2(a, b) {
|
|
3579
|
+
const m = a.length;
|
|
3580
|
+
const n = b.length;
|
|
3581
|
+
if (m === 0) return n;
|
|
3582
|
+
if (n === 0) return m;
|
|
3583
|
+
let prev = Array.from({ length: n + 1 }, (_, j) => j);
|
|
3584
|
+
for (let i = 1; i <= m; i++) {
|
|
3585
|
+
const curr = [i, ...new Array(n).fill(0)];
|
|
3586
|
+
for (let j = 1; j <= n; j++) {
|
|
3587
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
3588
|
+
curr[j] = Math.min(curr[j - 1] + 1, prev[j] + 1, prev[j - 1] + cost);
|
|
3589
|
+
}
|
|
3590
|
+
prev = curr;
|
|
3591
|
+
}
|
|
3592
|
+
return prev[n];
|
|
3593
|
+
}
|
|
3594
|
+
function suggest3(target, known) {
|
|
3595
|
+
let best;
|
|
3596
|
+
let bestScore = Infinity;
|
|
3597
|
+
for (const candidate of known) {
|
|
3598
|
+
const d = distance2(target, candidate);
|
|
3599
|
+
if (d < bestScore) {
|
|
3600
|
+
bestScore = d;
|
|
3601
|
+
best = candidate;
|
|
3602
|
+
}
|
|
3603
|
+
}
|
|
3604
|
+
const limit = Math.max(2, Math.floor(target.length / 3));
|
|
3605
|
+
return best && bestScore <= limit ? ` Did you mean "${best}"?` : "";
|
|
3606
|
+
}
|
|
3607
|
+
function collectActionNames(stack) {
|
|
3608
|
+
const names = /* @__PURE__ */ new Set();
|
|
3609
|
+
for (const action of asArray23(stack.actions)) {
|
|
3610
|
+
const n = strName4(action?.name);
|
|
3611
|
+
if (n) names.add(n);
|
|
3612
|
+
}
|
|
3613
|
+
for (const obj of asArray23(stack.objects)) {
|
|
3614
|
+
if (!obj || typeof obj !== "object") continue;
|
|
3615
|
+
for (const action of asArray23(obj.actions)) {
|
|
3616
|
+
const n = strName4(action?.name);
|
|
3617
|
+
if (n) names.add(n);
|
|
3618
|
+
}
|
|
3619
|
+
}
|
|
3620
|
+
return names;
|
|
3621
|
+
}
|
|
3622
|
+
function validateActionNameRefs(stack) {
|
|
3623
|
+
const findings = [];
|
|
3624
|
+
if (!stack || typeof stack !== "object") return findings;
|
|
3625
|
+
const known = collectActionNames(stack);
|
|
3626
|
+
const check = (name, where, path, surface) => {
|
|
3627
|
+
if (known.has(name)) return;
|
|
3628
|
+
findings.push({
|
|
3629
|
+
severity: "error",
|
|
3630
|
+
rule: ACTION_NAME_UNDEFINED,
|
|
3631
|
+
where,
|
|
3632
|
+
path,
|
|
3633
|
+
message: `${surface} names action "${name}", which is defined by no action in this stack (neither \`stack.actions\` nor any object's \`actions\`). The button renders and does nothing when clicked \u2014 a dead affordance the runtime cannot dispatch.` + suggest3(name, known),
|
|
3634
|
+
hint: `Define an action named "${name}" (in \`stack.actions\` or the object's \`actions\`) with the location this surface needs, remove the reference, or ignore this if the action is contributed by another installed package.` + (known.size > 0 ? ` Defined actions: ${[...known].sort().join(", ")}.` : "")
|
|
3635
|
+
});
|
|
3636
|
+
};
|
|
3637
|
+
const views = asArray23(stack.views);
|
|
3638
|
+
for (let vi = 0; vi < views.length; vi++) {
|
|
3639
|
+
const view = views[vi];
|
|
3640
|
+
if (!view || typeof view !== "object") continue;
|
|
3641
|
+
const viewName = strName4(view.name) ?? strName4(view.object) ?? `#${vi}`;
|
|
3642
|
+
const checkListContainer = (container, label2, path) => {
|
|
3643
|
+
if (!container || typeof container !== "object") return;
|
|
3644
|
+
const list3 = container;
|
|
3645
|
+
for (const key of ["rowActions", "bulkActions"]) {
|
|
3646
|
+
const names = strList(list3[key]);
|
|
3647
|
+
for (let ai = 0; ai < names.length; ai++) {
|
|
3648
|
+
check(
|
|
3649
|
+
names[ai],
|
|
3650
|
+
`view "${viewName}" \xB7 ${label2} \xB7 ${key}`,
|
|
3651
|
+
`${path}.${key}[${ai}]`,
|
|
3652
|
+
key === "bulkActions" ? "Bulk-action menu" : "Row-action menu"
|
|
3653
|
+
);
|
|
3654
|
+
}
|
|
3655
|
+
}
|
|
3656
|
+
};
|
|
3657
|
+
checkListContainer(view.list, "list", `views[${vi}].list`);
|
|
3658
|
+
const listViews = view.listViews;
|
|
3659
|
+
if (listViews && typeof listViews === "object" && !Array.isArray(listViews)) {
|
|
3660
|
+
for (const [key, lv] of Object.entries(listViews)) {
|
|
3661
|
+
checkListContainer(lv, `listViews.${key}`, `views[${vi}].listViews.${key}`);
|
|
3662
|
+
}
|
|
3663
|
+
}
|
|
3664
|
+
}
|
|
3665
|
+
const pages = asArray23(stack.pages);
|
|
3666
|
+
for (let pi = 0; pi < pages.length; pi++) {
|
|
3667
|
+
const page = pages[pi];
|
|
3668
|
+
if (!page || typeof page !== "object") continue;
|
|
3669
|
+
const pageName = strName4(page.name) ?? `#${pi}`;
|
|
3670
|
+
for (const { component, path } of walkPageComponents(page, `pages[${pi}]`)) {
|
|
3671
|
+
const props = component.properties;
|
|
3672
|
+
if (!props || typeof props !== "object") continue;
|
|
3673
|
+
const names = strList(props.actionNames);
|
|
3674
|
+
for (let ai = 0; ai < names.length; ai++) {
|
|
3675
|
+
check(
|
|
3676
|
+
names[ai],
|
|
3677
|
+
`page "${pageName}" \xB7 component "${strName4(component.type) ?? "?"}"`,
|
|
3678
|
+
`${path}.properties.actionNames[${ai}]`,
|
|
3679
|
+
"Quick-actions bar"
|
|
3680
|
+
);
|
|
3681
|
+
}
|
|
3682
|
+
}
|
|
3683
|
+
}
|
|
3684
|
+
const apps = asArray23(stack.apps);
|
|
3685
|
+
for (let ai = 0; ai < apps.length; ai++) {
|
|
3686
|
+
const app = apps[ai];
|
|
3687
|
+
if (!app || typeof app !== "object") continue;
|
|
3688
|
+
const appName = strName4(app.name) ?? `#${ai}`;
|
|
3689
|
+
const walkNav = (items, basePath) => {
|
|
3690
|
+
const navItems = asArray23(items);
|
|
3691
|
+
for (let ni = 0; ni < navItems.length; ni++) {
|
|
3692
|
+
const nav = navItems[ni];
|
|
3693
|
+
if (!nav || typeof nav !== "object") continue;
|
|
3694
|
+
const navPath = `${basePath}[${ni}]`;
|
|
3695
|
+
const actionDef = nav.actionDef;
|
|
3696
|
+
const actionName = strName4(actionDef?.actionName);
|
|
3697
|
+
if (nav.type === "action" && actionName) {
|
|
3698
|
+
check(
|
|
3699
|
+
actionName,
|
|
3700
|
+
`app "${appName}" \xB7 nav "${strName4(nav.id) ?? `#${ni}`}"`,
|
|
3701
|
+
`${navPath}.actionDef.actionName`,
|
|
3702
|
+
"Navigation action item"
|
|
3703
|
+
);
|
|
3704
|
+
}
|
|
3705
|
+
if (Array.isArray(nav.children)) walkNav(nav.children, `${navPath}.children`);
|
|
3706
|
+
}
|
|
3707
|
+
};
|
|
3708
|
+
walkNav(app.navigation, `apps[${ai}].navigation`);
|
|
3709
|
+
const areas = asArray23(app.areas);
|
|
3710
|
+
for (let ri = 0; ri < areas.length; ri++) {
|
|
3711
|
+
walkNav(areas[ri]?.navigation, `apps[${ai}].areas[${ri}].navigation`);
|
|
3712
|
+
}
|
|
3713
|
+
}
|
|
3714
|
+
return findings;
|
|
3715
|
+
}
|
|
3716
|
+
|
|
3717
|
+
// src/validate-page-field-bindings.ts
|
|
3718
|
+
var PAGE_FIELD_UNKNOWN = "page-field-unknown";
|
|
3719
|
+
var SYSTEM_FIELDS4 = /* @__PURE__ */ new Set([
|
|
3720
|
+
"id",
|
|
3721
|
+
"created_at",
|
|
3722
|
+
"created_by",
|
|
3723
|
+
"updated_at",
|
|
3724
|
+
"updated_by",
|
|
3725
|
+
"owner_id",
|
|
3726
|
+
"organization_id",
|
|
3727
|
+
"tenant_id",
|
|
3728
|
+
"user_id",
|
|
3729
|
+
"deleted_at"
|
|
3730
|
+
]);
|
|
3731
|
+
function asArray24(v) {
|
|
3732
|
+
if (Array.isArray(v)) return v;
|
|
3733
|
+
if (v && typeof v === "object") {
|
|
3734
|
+
return Object.entries(v).map(([name, def]) => ({ name, ...def }));
|
|
3735
|
+
}
|
|
3736
|
+
return [];
|
|
3737
|
+
}
|
|
3738
|
+
function strName5(v) {
|
|
3739
|
+
return typeof v === "string" && v.length > 0 ? v : void 0;
|
|
3740
|
+
}
|
|
3741
|
+
function isRec3(v) {
|
|
3742
|
+
return !!v && typeof v === "object" && !Array.isArray(v);
|
|
3743
|
+
}
|
|
3744
|
+
function fieldRefsFrom(value, basePath) {
|
|
3745
|
+
const out = [];
|
|
3746
|
+
const one = (v, path) => {
|
|
3747
|
+
const bare = strName5(v);
|
|
3748
|
+
if (bare) {
|
|
3749
|
+
out.push({ name: bare, path });
|
|
3750
|
+
return;
|
|
3751
|
+
}
|
|
3752
|
+
if (!isRec3(v)) return;
|
|
3753
|
+
const named = strName5(v.field) ?? strName5(v.name);
|
|
3754
|
+
if (named) out.push({ name: named, path: `${path}.${strName5(v.field) ? "field" : "name"}` });
|
|
3755
|
+
};
|
|
3756
|
+
if (Array.isArray(value)) {
|
|
3757
|
+
for (let i = 0; i < value.length; i++) one(value[i], `${basePath}[${i}]`);
|
|
3758
|
+
} else {
|
|
3759
|
+
one(value, basePath);
|
|
3760
|
+
}
|
|
3761
|
+
return out;
|
|
3762
|
+
}
|
|
3763
|
+
var COMPONENT_FIELD_SPECS = {
|
|
3764
|
+
"record:highlights": { props: ["fields"] },
|
|
3765
|
+
// `sections`/`hideFields` are not in RecordDetailsProps, but every real page
|
|
3766
|
+
// authors them (they survive because `properties` is unvalidated).
|
|
3767
|
+
"record:details": { props: ["fields", "hideFields"], nestedSections: ["sections"] },
|
|
3768
|
+
"record:path": { props: ["statusField"] },
|
|
3769
|
+
"element:number": { props: ["field"] },
|
|
3770
|
+
"element:filter": { props: ["fields"] },
|
|
3771
|
+
"element:form": { props: ["fields"] },
|
|
3772
|
+
// The schema says `displayField`; real pages author `labelField`. Accept both.
|
|
3773
|
+
"element:record_picker": { props: ["displayField", "labelField", "searchFields"] }
|
|
3774
|
+
};
|
|
3775
|
+
var RELATED_LIST_TYPE = "record:related_list";
|
|
3776
|
+
function validatePageFieldBindings(stack) {
|
|
3777
|
+
const findings = [];
|
|
3778
|
+
if (!stack || typeof stack !== "object") return findings;
|
|
3779
|
+
const objectFields = /* @__PURE__ */ new Map();
|
|
3780
|
+
for (const obj of asArray24(stack.objects)) {
|
|
3781
|
+
const name = strName5(obj.name);
|
|
3782
|
+
if (!name) continue;
|
|
3783
|
+
const names = /* @__PURE__ */ new Set();
|
|
3784
|
+
for (const f of asArray24(obj.fields)) {
|
|
3785
|
+
const fn = strName5(f.name);
|
|
3786
|
+
if (fn) names.add(fn);
|
|
3787
|
+
}
|
|
3788
|
+
objectFields.set(name, names);
|
|
3789
|
+
}
|
|
3790
|
+
const pages = asArray24(stack.pages);
|
|
3791
|
+
for (let pi = 0; pi < pages.length; pi++) {
|
|
3792
|
+
const page = pages[pi];
|
|
3793
|
+
if (!page || typeof page !== "object") continue;
|
|
3794
|
+
const pageName = strName5(page.name) ?? `#${pi}`;
|
|
3795
|
+
const checkRefs = (refs, objectName, where) => {
|
|
3796
|
+
if (!objectName) return;
|
|
3797
|
+
const known = objectFields.get(objectName);
|
|
3798
|
+
if (!known) return;
|
|
3799
|
+
for (const ref of refs) {
|
|
3800
|
+
if (ref.name.includes(".")) continue;
|
|
3801
|
+
if (known.has(ref.name) || SYSTEM_FIELDS4.has(ref.name)) continue;
|
|
3802
|
+
findings.push({
|
|
3803
|
+
severity: "warning",
|
|
3804
|
+
rule: PAGE_FIELD_UNKNOWN,
|
|
3805
|
+
where,
|
|
3806
|
+
path: ref.path,
|
|
3807
|
+
message: `field "${ref.name}" is not a field on object "${objectName}" \u2014 the component silently skips it, so it never renders.`,
|
|
3808
|
+
hint: `Fix the field name, or add "${ref.name}" to ${objectName}. References must match the object's field names exactly.` + (known.size > 0 ? ` Object fields: ${[...known].sort().join(", ")}.` : "")
|
|
3809
|
+
});
|
|
3810
|
+
}
|
|
3811
|
+
};
|
|
3812
|
+
for (const { component, path, objectName } of walkPageComponents(page, `pages[${pi}]`)) {
|
|
3813
|
+
const type = strName5(component.type);
|
|
3814
|
+
const props = isRec3(component.properties) ? component.properties : void 0;
|
|
3815
|
+
if (!type || !props) continue;
|
|
3816
|
+
const where = `page "${pageName}" \xB7 ${type}`;
|
|
3817
|
+
if (type === RELATED_LIST_TYPE) {
|
|
3818
|
+
const relatedObject = strName5(props.objectName);
|
|
3819
|
+
const relatedRefs = [
|
|
3820
|
+
...fieldRefsFrom(props.columns, `${path}.properties.columns`),
|
|
3821
|
+
...fieldRefsFrom(props.sort, `${path}.properties.sort`),
|
|
3822
|
+
...fieldRefsFrom(props.filter, `${path}.properties.filter`),
|
|
3823
|
+
...fieldRefsFrom(props.relationshipField, `${path}.properties.relationshipField`)
|
|
3824
|
+
];
|
|
3825
|
+
checkRefs(relatedRefs, relatedObject, where);
|
|
3826
|
+
checkRefs(
|
|
3827
|
+
fieldRefsFrom(props.relationshipValueField, `${path}.properties.relationshipValueField`),
|
|
3828
|
+
objectName,
|
|
3829
|
+
where
|
|
3830
|
+
);
|
|
3831
|
+
const add = isRec3(props.add) ? props.add : void 0;
|
|
3832
|
+
const picker = add && isRec3(add.picker) ? add.picker : void 0;
|
|
3833
|
+
if (picker) {
|
|
3834
|
+
checkRefs(
|
|
3835
|
+
[
|
|
3836
|
+
...fieldRefsFrom(picker.valueField, `${path}.properties.add.picker.valueField`),
|
|
3837
|
+
...fieldRefsFrom(picker.labelField, `${path}.properties.add.picker.labelField`)
|
|
3838
|
+
],
|
|
3839
|
+
strName5(picker.object),
|
|
3840
|
+
where
|
|
3841
|
+
);
|
|
3842
|
+
}
|
|
3843
|
+
if (add) {
|
|
3844
|
+
checkRefs(
|
|
3845
|
+
fieldRefsFrom(add.linkField, `${path}.properties.add.linkField`),
|
|
3846
|
+
relatedObject,
|
|
3847
|
+
where
|
|
3848
|
+
);
|
|
3849
|
+
}
|
|
3850
|
+
continue;
|
|
3851
|
+
}
|
|
3852
|
+
const spec = COMPONENT_FIELD_SPECS[type];
|
|
3853
|
+
if (!spec) continue;
|
|
3854
|
+
const refs = [];
|
|
3855
|
+
for (const key of spec.props ?? []) {
|
|
3856
|
+
refs.push(...fieldRefsFrom(props[key], `${path}.properties.${key}`));
|
|
3857
|
+
}
|
|
3858
|
+
for (const key of spec.nestedSections ?? []) {
|
|
3859
|
+
const sections = Array.isArray(props[key]) ? props[key] : [];
|
|
3860
|
+
for (let si = 0; si < sections.length; si++) {
|
|
3861
|
+
const section = sections[si];
|
|
3862
|
+
if (!isRec3(section)) continue;
|
|
3863
|
+
refs.push(
|
|
3864
|
+
...fieldRefsFrom(section.fields, `${path}.properties.${key}[${si}].fields`)
|
|
3865
|
+
);
|
|
3866
|
+
}
|
|
3867
|
+
}
|
|
3868
|
+
checkRefs(refs, objectName, where);
|
|
3869
|
+
}
|
|
3870
|
+
const cfg = isRec3(page.interfaceConfig) ? page.interfaceConfig : void 0;
|
|
3871
|
+
if (cfg) {
|
|
3872
|
+
const cfgObject = strName5(cfg.source) ?? strName5(page.object);
|
|
3873
|
+
const base = `pages[${pi}].interfaceConfig`;
|
|
3874
|
+
const refs = [
|
|
3875
|
+
...fieldRefsFrom(cfg.columns, `${base}.columns`),
|
|
3876
|
+
...fieldRefsFrom(cfg.sort, `${base}.sort`),
|
|
3877
|
+
...fieldRefsFrom(cfg.filterBy, `${base}.filterBy`)
|
|
3878
|
+
];
|
|
3879
|
+
const userFilters = isRec3(cfg.userFilters) ? cfg.userFilters : void 0;
|
|
3880
|
+
if (userFilters) {
|
|
3881
|
+
refs.push(...fieldRefsFrom(userFilters.fields, `${base}.userFilters.fields`));
|
|
3882
|
+
}
|
|
3883
|
+
checkRefs(refs, cfgObject, `page "${pageName}" \xB7 interfaceConfig`);
|
|
3884
|
+
}
|
|
3885
|
+
}
|
|
3886
|
+
return findings;
|
|
3887
|
+
}
|
|
3888
|
+
|
|
3889
|
+
// src/validate-chart-bindings.ts
|
|
3890
|
+
var CHART_DIMENSION_UNKNOWN = "chart-dimension-unknown";
|
|
3891
|
+
var CHART_MEASURE_UNKNOWN = "chart-measure-unknown";
|
|
3892
|
+
var CHART_DATASET_UNKNOWN = "chart-dataset-unknown";
|
|
3893
|
+
var CHART_AXIS_NOT_SELECTED = "chart-axis-not-selected";
|
|
3894
|
+
function asArray25(v) {
|
|
3895
|
+
if (Array.isArray(v)) return v;
|
|
3896
|
+
if (v && typeof v === "object") {
|
|
3897
|
+
return Object.entries(v).map(([name, def]) => ({ name, ...def }));
|
|
3898
|
+
}
|
|
3899
|
+
return [];
|
|
3900
|
+
}
|
|
3901
|
+
function strName6(v) {
|
|
3902
|
+
return typeof v === "string" && v.length > 0 ? v : void 0;
|
|
3903
|
+
}
|
|
3904
|
+
function strList2(v) {
|
|
3905
|
+
return Array.isArray(v) ? v.filter((x) => typeof x === "string" && x.length > 0) : [];
|
|
3906
|
+
}
|
|
3907
|
+
function isRec4(v) {
|
|
3908
|
+
return !!v && typeof v === "object" && !Array.isArray(v);
|
|
3909
|
+
}
|
|
3910
|
+
function distance3(a, b) {
|
|
3911
|
+
const m = a.length;
|
|
3912
|
+
const n = b.length;
|
|
3913
|
+
if (m === 0) return n;
|
|
3914
|
+
if (n === 0) return m;
|
|
3915
|
+
let prev = Array.from({ length: n + 1 }, (_, j) => j);
|
|
3916
|
+
for (let i = 1; i <= m; i++) {
|
|
3917
|
+
const curr = [i, ...new Array(n).fill(0)];
|
|
3918
|
+
for (let j = 1; j <= n; j++) {
|
|
3919
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
3920
|
+
curr[j] = Math.min(curr[j - 1] + 1, prev[j] + 1, prev[j - 1] + cost);
|
|
3921
|
+
}
|
|
3922
|
+
prev = curr;
|
|
3923
|
+
}
|
|
3924
|
+
return prev[n];
|
|
3925
|
+
}
|
|
3926
|
+
function suggest4(target, known) {
|
|
3927
|
+
let best;
|
|
3928
|
+
let bestScore = Infinity;
|
|
3929
|
+
for (const c of known) {
|
|
3930
|
+
const d = distance3(target, c);
|
|
3931
|
+
if (d < bestScore) {
|
|
3932
|
+
bestScore = d;
|
|
3933
|
+
best = c;
|
|
3934
|
+
}
|
|
3935
|
+
}
|
|
3936
|
+
const limit = Math.max(2, Math.floor(target.length / 3));
|
|
3937
|
+
return best && bestScore <= limit ? ` Did you mean "${best}"?` : "";
|
|
3938
|
+
}
|
|
3939
|
+
function list2(names) {
|
|
3940
|
+
const all = [...names].sort();
|
|
3941
|
+
return all.length ? all.join(", ") : "(none)";
|
|
3942
|
+
}
|
|
3943
|
+
function indexDatasets(stack) {
|
|
3944
|
+
const out = /* @__PURE__ */ new Map();
|
|
3945
|
+
for (const ds of asArray25(stack.datasets)) {
|
|
3946
|
+
const name = strName6(ds.name);
|
|
3947
|
+
if (!name) continue;
|
|
3948
|
+
const dimensions = /* @__PURE__ */ new Set();
|
|
3949
|
+
for (const d of asArray25(ds.dimensions)) {
|
|
3950
|
+
const n = strName6(d.name);
|
|
3951
|
+
if (n) dimensions.add(n);
|
|
3952
|
+
}
|
|
3953
|
+
const measures = /* @__PURE__ */ new Set();
|
|
3954
|
+
for (const m of asArray25(ds.measures)) {
|
|
3955
|
+
const n = strName6(m.name);
|
|
3956
|
+
if (n) measures.add(n);
|
|
3957
|
+
}
|
|
3958
|
+
out.set(name, { dimensions, measures });
|
|
3959
|
+
}
|
|
3960
|
+
return out;
|
|
3961
|
+
}
|
|
3962
|
+
function validateChartBindings(stack) {
|
|
3963
|
+
const findings = [];
|
|
3964
|
+
if (!stack || typeof stack !== "object") return findings;
|
|
3965
|
+
const datasets = indexDatasets(stack);
|
|
3966
|
+
if (datasets.size === 0 && !stack.reports && !stack.views && !stack.pages) return findings;
|
|
3967
|
+
const check = (binding) => {
|
|
3968
|
+
const dsName = binding.dataset;
|
|
3969
|
+
if (!dsName) return;
|
|
3970
|
+
const ds = datasets.get(dsName);
|
|
3971
|
+
if (!ds) {
|
|
3972
|
+
findings.push({
|
|
3973
|
+
severity: "error",
|
|
3974
|
+
rule: CHART_DATASET_UNKNOWN,
|
|
3975
|
+
where: binding.where,
|
|
3976
|
+
path: `${binding.path}.dataset`,
|
|
3977
|
+
message: `binds dataset "${dsName}", which resolves to no declared dataset \u2014 the chart has no data to render.`,
|
|
3978
|
+
hint: `Declared datasets: ${list2(datasets.keys())}.${suggest4(dsName, datasets.keys())} Define it with defineDataset() or fix the reference (ADR-0021).`
|
|
3979
|
+
});
|
|
3980
|
+
return;
|
|
3981
|
+
}
|
|
3982
|
+
const dimensionRef = (name, path) => {
|
|
3983
|
+
if (ds.dimensions.has(name)) return;
|
|
3984
|
+
findings.push({
|
|
3985
|
+
severity: "error",
|
|
3986
|
+
rule: CHART_DIMENSION_UNKNOWN,
|
|
3987
|
+
where: binding.where,
|
|
3988
|
+
path,
|
|
3989
|
+
message: `"${name}" is not a dimension declared by dataset "${dsName}". Post-ADR-0021 result rows are keyed by DIMENSION NAME, not the base field, so this axis renders with no categories.`,
|
|
3990
|
+
hint: `Dataset dimensions: ${list2(ds.dimensions)}.${suggest4(name, ds.dimensions)} Declare the dimension on the dataset, or bind an existing one.`
|
|
3991
|
+
});
|
|
3992
|
+
};
|
|
3993
|
+
const measureRef = (name, path, selected2) => {
|
|
3994
|
+
if (!ds.measures.has(name)) {
|
|
3995
|
+
findings.push({
|
|
3996
|
+
severity: "error",
|
|
3997
|
+
rule: CHART_MEASURE_UNKNOWN,
|
|
3998
|
+
where: binding.where,
|
|
3999
|
+
path,
|
|
4000
|
+
message: `"${name}" is not a measure declared by dataset "${dsName}". Post-ADR-0021 result rows are keyed by MEASURE NAME (e.g. "sum_amount"), not the base field (e.g. "amount"), so this series comes back empty.`,
|
|
4001
|
+
hint: `Dataset measures: ${list2(ds.measures)}.${suggest4(name, ds.measures)} Declare the measure on the dataset, or bind an existing one.`
|
|
4002
|
+
});
|
|
4003
|
+
return;
|
|
4004
|
+
}
|
|
4005
|
+
if (selected2 && selected2.size > 0 && !selected2.has(name)) {
|
|
4006
|
+
findings.push({
|
|
4007
|
+
severity: "warning",
|
|
4008
|
+
rule: CHART_AXIS_NOT_SELECTED,
|
|
4009
|
+
where: binding.where,
|
|
4010
|
+
path,
|
|
4011
|
+
message: `"${name}" is a declared measure of "${dsName}" but is not in this chart's selected values (${list2(selected2)}) \u2014 the query does not return it, so the series plots nothing.`,
|
|
4012
|
+
hint: `Add "${name}" to \`values\`, or point the axis at a selected measure.`
|
|
4013
|
+
});
|
|
4014
|
+
}
|
|
4015
|
+
};
|
|
4016
|
+
const dimSel = binding.dimensions;
|
|
4017
|
+
if (dimSel) {
|
|
4018
|
+
for (let i = 0; i < dimSel.names.length; i++) {
|
|
4019
|
+
dimensionRef(dimSel.names[i], `${dimSel.path}[${i}]`);
|
|
4020
|
+
}
|
|
4021
|
+
}
|
|
4022
|
+
const valSel = binding.values;
|
|
4023
|
+
const selected = new Set(valSel?.names ?? []);
|
|
4024
|
+
if (valSel) {
|
|
4025
|
+
for (let i = 0; i < valSel.names.length; i++) {
|
|
4026
|
+
measureRef(valSel.names[i], `${valSel.path}[${i}]`);
|
|
4027
|
+
}
|
|
4028
|
+
}
|
|
4029
|
+
if (binding.xAxis) dimensionRef(binding.xAxis.name, binding.xAxis.path);
|
|
4030
|
+
if (binding.yAxis) measureRef(binding.yAxis.name, binding.yAxis.path, selected);
|
|
4031
|
+
for (const s of binding.series ?? []) measureRef(s.name, s.path, selected);
|
|
4032
|
+
};
|
|
4033
|
+
const reports = asArray25(stack.reports);
|
|
4034
|
+
for (let ri = 0; ri < reports.length; ri++) {
|
|
4035
|
+
const report = reports[ri];
|
|
4036
|
+
if (!isRec4(report)) continue;
|
|
4037
|
+
const reportName = strName6(report.name) ?? `#${ri}`;
|
|
4038
|
+
const checkReportChart = (chart, dataset, values, where, path) => {
|
|
4039
|
+
if (!isRec4(chart)) return;
|
|
4040
|
+
check({
|
|
4041
|
+
dataset,
|
|
4042
|
+
// `values` is the report's measure SELECTION, not a chart ref; feeding
|
|
4043
|
+
// it in lets the yAxis "declared but not selected" check work without
|
|
4044
|
+
// reporting the selection itself twice.
|
|
4045
|
+
values: { names: values, path: `${path}.values` },
|
|
4046
|
+
xAxis: strName6(chart.xAxis) ? { name: strName6(chart.xAxis), path: `${path}.chart.xAxis` } : void 0,
|
|
4047
|
+
yAxis: strName6(chart.yAxis) ? { name: strName6(chart.yAxis), path: `${path}.chart.yAxis` } : void 0,
|
|
4048
|
+
series: asArray25(chart.series).map((s, si) => ({ name: strName6(s.name), path: `${path}.chart.series[${si}].name` })).filter((s) => !!s.name),
|
|
4049
|
+
where,
|
|
4050
|
+
path: `${path}.chart`
|
|
4051
|
+
});
|
|
4052
|
+
};
|
|
4053
|
+
checkReportChart(
|
|
4054
|
+
report.chart,
|
|
4055
|
+
strName6(report.dataset),
|
|
4056
|
+
strList2(report.values),
|
|
4057
|
+
`report "${reportName}" \xB7 chart`,
|
|
4058
|
+
`reports[${ri}]`
|
|
4059
|
+
);
|
|
4060
|
+
const blocks = Array.isArray(report.blocks) ? report.blocks : [];
|
|
4061
|
+
for (let bi = 0; bi < blocks.length; bi++) {
|
|
4062
|
+
const block = blocks[bi];
|
|
4063
|
+
if (!isRec4(block)) continue;
|
|
4064
|
+
checkReportChart(
|
|
4065
|
+
block.chart,
|
|
4066
|
+
strName6(block.dataset),
|
|
4067
|
+
strList2(block.values),
|
|
4068
|
+
`report "${reportName}" \xB7 block "${strName6(block.name) ?? `#${bi}`}" chart`,
|
|
4069
|
+
`reports[${ri}].blocks[${bi}]`
|
|
4070
|
+
);
|
|
4071
|
+
}
|
|
4072
|
+
}
|
|
4073
|
+
const checkListChart = (container, where, path) => {
|
|
4074
|
+
if (!isRec4(container)) return;
|
|
4075
|
+
const chart = container.chart;
|
|
4076
|
+
if (!isRec4(chart)) return;
|
|
4077
|
+
check({
|
|
4078
|
+
dataset: strName6(chart.dataset),
|
|
4079
|
+
dimensions: { names: strList2(chart.dimensions), path: `${path}.chart.dimensions` },
|
|
4080
|
+
values: { names: strList2(chart.values), path: `${path}.chart.values` },
|
|
4081
|
+
where,
|
|
4082
|
+
path: `${path}.chart`
|
|
4083
|
+
});
|
|
4084
|
+
};
|
|
4085
|
+
const views = asArray25(stack.views);
|
|
4086
|
+
for (let vi = 0; vi < views.length; vi++) {
|
|
4087
|
+
const view = views[vi];
|
|
4088
|
+
if (!isRec4(view)) continue;
|
|
4089
|
+
const viewName = strName6(view.name) ?? strName6(view.objectName) ?? `#${vi}`;
|
|
4090
|
+
checkListChart(view.list, `view "${viewName}" \xB7 list chart`, `views[${vi}].list`);
|
|
4091
|
+
if (isRec4(view.listViews)) {
|
|
4092
|
+
for (const [key, lv] of Object.entries(view.listViews)) {
|
|
4093
|
+
checkListChart(lv, `view "${viewName}" \xB7 listViews.${key} chart`, `views[${vi}].listViews.${key}`);
|
|
4094
|
+
}
|
|
4095
|
+
}
|
|
4096
|
+
}
|
|
4097
|
+
const objects = asArray25(stack.objects);
|
|
4098
|
+
for (let oi = 0; oi < objects.length; oi++) {
|
|
4099
|
+
const obj = objects[oi];
|
|
4100
|
+
if (!isRec4(obj) || !isRec4(obj.listViews)) continue;
|
|
4101
|
+
const objName = strName6(obj.name) ?? `#${oi}`;
|
|
4102
|
+
for (const [key, lv] of Object.entries(obj.listViews)) {
|
|
4103
|
+
checkListChart(
|
|
4104
|
+
lv,
|
|
4105
|
+
`object "${objName}" \xB7 listViews.${key} chart`,
|
|
4106
|
+
`objects[${oi}].listViews.${key}`
|
|
4107
|
+
);
|
|
4108
|
+
}
|
|
4109
|
+
}
|
|
4110
|
+
const pages = asArray25(stack.pages);
|
|
4111
|
+
for (let pi = 0; pi < pages.length; pi++) {
|
|
4112
|
+
const page = pages[pi];
|
|
4113
|
+
if (!isRec4(page)) continue;
|
|
4114
|
+
const pageName = strName6(page.name) ?? `#${pi}`;
|
|
4115
|
+
for (const { component, path } of walkPageComponents(page, `pages[${pi}]`)) {
|
|
4116
|
+
const props = isRec4(component.properties) ? component.properties : void 0;
|
|
4117
|
+
if (!props || !strName6(props.dataset)) continue;
|
|
4118
|
+
const axisRefs = asArray25(props.yAxis).map((a, ai) => ({ name: strName6(a.field), path: `${path}.properties.yAxis[${ai}].field` })).filter((a) => !!a.name);
|
|
4119
|
+
const seriesRefs = asArray25(props.series).map((s, si) => ({ name: strName6(s.name), path: `${path}.properties.series[${si}].name` })).filter((s) => !!s.name);
|
|
4120
|
+
check({
|
|
4121
|
+
dataset: strName6(props.dataset),
|
|
4122
|
+
dimensions: { names: strList2(props.dimensions), path: `${path}.properties.dimensions` },
|
|
4123
|
+
values: { names: strList2(props.values), path: `${path}.properties.values` },
|
|
4124
|
+
series: [...axisRefs, ...seriesRefs],
|
|
4125
|
+
where: `page "${pageName}" \xB7 ${strName6(component.type) ?? "chart"}`,
|
|
4126
|
+
path: `${path}.properties`
|
|
4127
|
+
});
|
|
4128
|
+
}
|
|
4129
|
+
}
|
|
4130
|
+
return findings;
|
|
4131
|
+
}
|
|
4132
|
+
|
|
4133
|
+
// src/validate-nav-access.ts
|
|
4134
|
+
var import_system2 = require("@objectstack/spec/system");
|
|
4135
|
+
|
|
4136
|
+
// src/build-access-matrix.ts
|
|
4137
|
+
function asArray26(v) {
|
|
2120
4138
|
if (Array.isArray(v)) return v;
|
|
2121
4139
|
if (v && typeof v === "object") {
|
|
2122
4140
|
return Object.entries(v).map(([name, def]) => ({ name, ...def }));
|
|
@@ -2127,13 +4145,13 @@ function buildAccessMatrix(stack) {
|
|
|
2127
4145
|
const entries = [];
|
|
2128
4146
|
if (!stack || typeof stack !== "object") return { version: 1, entries };
|
|
2129
4147
|
const owdByObject = /* @__PURE__ */ new Map();
|
|
2130
|
-
for (const obj of
|
|
4148
|
+
for (const obj of asArray26(stack.objects)) {
|
|
2131
4149
|
const name = typeof obj.name === "string" ? obj.name : "";
|
|
2132
4150
|
if (!name) continue;
|
|
2133
4151
|
const owd = obj.sharingModel ?? obj.security?.sharingModel;
|
|
2134
4152
|
if (typeof owd === "string") owdByObject.set(name, owd);
|
|
2135
4153
|
}
|
|
2136
|
-
for (const ps of
|
|
4154
|
+
for (const ps of asArray26(stack.permissions)) {
|
|
2137
4155
|
const psName = typeof ps.name === "string" ? ps.name : "";
|
|
2138
4156
|
if (!psName) continue;
|
|
2139
4157
|
const objects = ps.objects && typeof ps.objects === "object" ? ps.objects : {};
|
|
@@ -2182,13 +4200,13 @@ function diffAccessMatrix(before, after) {
|
|
|
2182
4200
|
for (const [k, a] of afterMap) {
|
|
2183
4201
|
const b = beforeMap.get(k);
|
|
2184
4202
|
if (!b) {
|
|
2185
|
-
const grants = BIT_LABELS.filter(([bit]) => a[bit] === true).map(([,
|
|
4203
|
+
const grants = BIT_LABELS.filter(([bit]) => a[bit] === true).map(([, label2]) => label2);
|
|
2186
4204
|
lines.push(`'${a.permissionSet}' gains access to '${a.object}' (${grants.join(", ") || "no bits set"})`);
|
|
2187
4205
|
continue;
|
|
2188
4206
|
}
|
|
2189
|
-
for (const [bit,
|
|
4207
|
+
for (const [bit, label2] of BIT_LABELS) {
|
|
2190
4208
|
if (b[bit] !== a[bit]) {
|
|
2191
|
-
lines.push(`'${a.permissionSet}' ${a[bit] ? "gains" : "loses"} ${
|
|
4209
|
+
lines.push(`'${a.permissionSet}' ${a[bit] ? "gains" : "loses"} ${label2} on '${a.object}'`);
|
|
2192
4210
|
}
|
|
2193
4211
|
}
|
|
2194
4212
|
if ((b.readScope ?? "own") !== (a.readScope ?? "own")) {
|
|
@@ -2203,24 +4221,835 @@ function diffAccessMatrix(before, after) {
|
|
|
2203
4221
|
}
|
|
2204
4222
|
return lines;
|
|
2205
4223
|
}
|
|
4224
|
+
|
|
4225
|
+
// src/validate-nav-access.ts
|
|
4226
|
+
var NAV_OBJECT_UNGRANTED = "nav-object-ungranted";
|
|
4227
|
+
function asArray27(v) {
|
|
4228
|
+
if (Array.isArray(v)) return v;
|
|
4229
|
+
if (v && typeof v === "object") {
|
|
4230
|
+
return Object.entries(v).map(([name, def]) => ({ name, ...def }));
|
|
4231
|
+
}
|
|
4232
|
+
return [];
|
|
4233
|
+
}
|
|
4234
|
+
function strName7(v) {
|
|
4235
|
+
return typeof v === "string" && v.length > 0 ? v : void 0;
|
|
4236
|
+
}
|
|
4237
|
+
function collectNavExposures(stack) {
|
|
4238
|
+
const out = [];
|
|
4239
|
+
const apps = asArray27(stack.apps);
|
|
4240
|
+
for (let ai = 0; ai < apps.length; ai++) {
|
|
4241
|
+
const app = apps[ai];
|
|
4242
|
+
if (!app || typeof app !== "object") continue;
|
|
4243
|
+
const appName = strName7(app.name) ?? `#${ai}`;
|
|
4244
|
+
const walk = (items, basePath) => {
|
|
4245
|
+
const navItems = asArray27(items);
|
|
4246
|
+
for (let ni = 0; ni < navItems.length; ni++) {
|
|
4247
|
+
const nav = navItems[ni];
|
|
4248
|
+
if (!nav || typeof nav !== "object") continue;
|
|
4249
|
+
const navPath = `${basePath}[${ni}]`;
|
|
4250
|
+
const objectName = strName7(nav.objectName);
|
|
4251
|
+
if (nav.type === "object" && objectName) {
|
|
4252
|
+
out.push({
|
|
4253
|
+
objectName,
|
|
4254
|
+
where: `app "${appName}" \xB7 nav "${strName7(nav.id) ?? `#${ni}`}"`,
|
|
4255
|
+
path: `${navPath}.objectName`
|
|
4256
|
+
});
|
|
4257
|
+
}
|
|
4258
|
+
if (Array.isArray(nav.children)) walk(nav.children, `${navPath}.children`);
|
|
4259
|
+
}
|
|
4260
|
+
};
|
|
4261
|
+
walk(app.navigation, `apps[${ai}].navigation`);
|
|
4262
|
+
const areas = asArray27(app.areas);
|
|
4263
|
+
for (let ri = 0; ri < areas.length; ri++) {
|
|
4264
|
+
walk(areas[ri]?.navigation, `apps[${ai}].areas[${ri}].navigation`);
|
|
4265
|
+
}
|
|
4266
|
+
}
|
|
4267
|
+
return out;
|
|
4268
|
+
}
|
|
4269
|
+
function validateNavAccess(stack) {
|
|
4270
|
+
const findings = [];
|
|
4271
|
+
if (!stack || typeof stack !== "object") return findings;
|
|
4272
|
+
const permissionSets = asArray27(stack.permissions);
|
|
4273
|
+
if (permissionSets.length === 0) return findings;
|
|
4274
|
+
const exposures = collectNavExposures(stack);
|
|
4275
|
+
if (exposures.length === 0) return findings;
|
|
4276
|
+
const ownObjects = /* @__PURE__ */ new Set();
|
|
4277
|
+
for (const obj of asArray27(stack.objects)) {
|
|
4278
|
+
const n = strName7(obj.name);
|
|
4279
|
+
if (n) ownObjects.add(n);
|
|
4280
|
+
}
|
|
4281
|
+
const readable = /* @__PURE__ */ new Set();
|
|
4282
|
+
for (const entry of buildAccessMatrix(stack).entries) {
|
|
4283
|
+
if (entry.read) readable.add(entry.object);
|
|
4284
|
+
}
|
|
4285
|
+
if (readable.has("*")) return findings;
|
|
4286
|
+
const reported = /* @__PURE__ */ new Set();
|
|
4287
|
+
for (const exposure of exposures) {
|
|
4288
|
+
const { objectName } = exposure;
|
|
4289
|
+
if (reported.has(objectName)) continue;
|
|
4290
|
+
if ((0, import_system2.isPlatformProvidedObjectName)(objectName)) continue;
|
|
4291
|
+
if (!ownObjects.has(objectName)) continue;
|
|
4292
|
+
if (readable.has(objectName)) continue;
|
|
4293
|
+
reported.add(objectName);
|
|
4294
|
+
findings.push({
|
|
4295
|
+
severity: "warning",
|
|
4296
|
+
rule: NAV_OBJECT_UNGRANTED,
|
|
4297
|
+
where: exposure.where,
|
|
4298
|
+
path: exposure.path,
|
|
4299
|
+
message: `navigation exposes object "${objectName}", but no permission set this stack declares grants read on it \u2014 the entry renders, and opening it fails permission-denied for every principal except one holding the platform's built-in wildcard admin set. It works when you browse as an administrator and breaks for the users the app ships permission sets for.`,
|
|
4300
|
+
hint: `Add "${objectName}" to a permission set's \`objects\` with \`allowRead: true\` (or \`viewAllRecords\`), gate the entry with \`requiredPermissions\`/\`visible\` if it is meant for admins only, or drop it. Ignore this if a permission set from another installed package grants it.`
|
|
4301
|
+
});
|
|
4302
|
+
}
|
|
4303
|
+
return findings;
|
|
4304
|
+
}
|
|
4305
|
+
|
|
4306
|
+
// src/validate-translation-references.ts
|
|
4307
|
+
var import_system3 = require("@objectstack/spec/system");
|
|
4308
|
+
var TRANSLATION_TARGET_UNKNOWN = "translation-target-unknown";
|
|
4309
|
+
var TRANSLATION_OPTION_KEY_UNKNOWN = "translation-option-key-unknown";
|
|
4310
|
+
function isRec5(v) {
|
|
4311
|
+
return !!v && typeof v === "object" && !Array.isArray(v);
|
|
4312
|
+
}
|
|
4313
|
+
function asArray28(v) {
|
|
4314
|
+
if (Array.isArray(v)) return v;
|
|
4315
|
+
if (isRec5(v)) return Object.entries(v).map(([name, def]) => ({ name, ...isRec5(def) ? def : {} }));
|
|
4316
|
+
return [];
|
|
4317
|
+
}
|
|
4318
|
+
function strName8(v) {
|
|
4319
|
+
return typeof v === "string" && v.length > 0 ? v : void 0;
|
|
4320
|
+
}
|
|
4321
|
+
function distance4(a, b) {
|
|
4322
|
+
const m = a.length;
|
|
4323
|
+
const n = b.length;
|
|
4324
|
+
if (m === 0) return n;
|
|
4325
|
+
if (n === 0) return m;
|
|
4326
|
+
let prev = Array.from({ length: n + 1 }, (_, j) => j);
|
|
4327
|
+
for (let i = 1; i <= m; i++) {
|
|
4328
|
+
const curr = [i, ...new Array(n).fill(0)];
|
|
4329
|
+
for (let j = 1; j <= n; j++) {
|
|
4330
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
4331
|
+
curr[j] = Math.min(curr[j - 1] + 1, prev[j] + 1, prev[j - 1] + cost);
|
|
4332
|
+
}
|
|
4333
|
+
prev = curr;
|
|
4334
|
+
}
|
|
4335
|
+
return prev[n];
|
|
4336
|
+
}
|
|
4337
|
+
function suggest5(target, known) {
|
|
4338
|
+
const names = [...known];
|
|
4339
|
+
const segmentMatch = names.find(
|
|
4340
|
+
(candidate) => candidate.endsWith(`_${target}`) || candidate.startsWith(`${target}_`)
|
|
4341
|
+
);
|
|
4342
|
+
if (segmentMatch) return ` Did you mean "${segmentMatch}"?`;
|
|
4343
|
+
let best;
|
|
4344
|
+
let bestScore = Infinity;
|
|
4345
|
+
for (const candidate of names) {
|
|
4346
|
+
const d = distance4(target, candidate);
|
|
4347
|
+
if (d < bestScore) {
|
|
4348
|
+
bestScore = d;
|
|
4349
|
+
best = candidate;
|
|
4350
|
+
}
|
|
4351
|
+
}
|
|
4352
|
+
const limit = Math.max(2, Math.floor(target.length / 3));
|
|
4353
|
+
return best && bestScore <= limit ? ` Did you mean "${best}"?` : "";
|
|
4354
|
+
}
|
|
4355
|
+
function listNames(names, max = 12) {
|
|
4356
|
+
const all = [...names].sort();
|
|
4357
|
+
if (all.length === 0) return "";
|
|
4358
|
+
const shown = all.slice(0, max).join(", ");
|
|
4359
|
+
return all.length > max ? `${shown}, \u2026 (${all.length} total)` : shown;
|
|
4360
|
+
}
|
|
4361
|
+
var SYSTEM_FIELDS5 = /* @__PURE__ */ new Set([
|
|
4362
|
+
"id",
|
|
4363
|
+
"_id",
|
|
4364
|
+
"name",
|
|
4365
|
+
"created_at",
|
|
4366
|
+
"created_by",
|
|
4367
|
+
"updated_at",
|
|
4368
|
+
"updated_by",
|
|
4369
|
+
"owner_id",
|
|
4370
|
+
"organization_id",
|
|
4371
|
+
"tenant_id",
|
|
4372
|
+
"user_id",
|
|
4373
|
+
"is_deleted",
|
|
4374
|
+
"deleted_at",
|
|
4375
|
+
"space"
|
|
4376
|
+
]);
|
|
4377
|
+
function emptyFacts() {
|
|
4378
|
+
return { fields: /* @__PURE__ */ new Map(), views: /* @__PURE__ */ new Set(), actions: /* @__PURE__ */ new Map(), sections: /* @__PURE__ */ new Set() };
|
|
4379
|
+
}
|
|
4380
|
+
function collectViewRecord(view, factsFor) {
|
|
4381
|
+
const recordObject = viewObjectName(view);
|
|
4382
|
+
const bindingOf = (container) => viewObjectName(container) ?? recordObject;
|
|
4383
|
+
const addView = (objectName, name) => {
|
|
4384
|
+
if (objectName && name) factsFor(objectName).views.add(name);
|
|
4385
|
+
};
|
|
4386
|
+
const listBinding = isRec5(view.list) ? bindingOf(view.list) : void 0;
|
|
4387
|
+
if (isRec5(view.list)) addView(listBinding, strName8(view.list.name));
|
|
4388
|
+
addView(recordObject ?? listBinding, strName8(view.name));
|
|
4389
|
+
for (const key of ["listViews", "formViews"]) {
|
|
4390
|
+
const container = view[key];
|
|
4391
|
+
if (!isRec5(container)) continue;
|
|
4392
|
+
for (const [subKey, sub] of Object.entries(container)) {
|
|
4393
|
+
if (!isRec5(sub)) continue;
|
|
4394
|
+
const binding = bindingOf(sub) ?? listBinding;
|
|
4395
|
+
addView(binding, subKey);
|
|
4396
|
+
addView(binding, strName8(sub.name));
|
|
4397
|
+
if (binding) {
|
|
4398
|
+
for (const section of asArray28(sub.sections)) {
|
|
4399
|
+
const sectionName = strName8(section.name);
|
|
4400
|
+
if (sectionName) factsFor(binding).sections.add(sectionName);
|
|
4401
|
+
}
|
|
4402
|
+
}
|
|
4403
|
+
}
|
|
4404
|
+
}
|
|
4405
|
+
const sectionBinding = recordObject ?? listBinding;
|
|
4406
|
+
if (sectionBinding) {
|
|
4407
|
+
for (const section of asArray28(view.sections)) {
|
|
4408
|
+
const sectionName = strName8(section.name);
|
|
4409
|
+
if (sectionName) factsFor(sectionBinding).sections.add(sectionName);
|
|
4410
|
+
}
|
|
4411
|
+
}
|
|
4412
|
+
}
|
|
4413
|
+
function viewObjectName(view) {
|
|
4414
|
+
return strName8(view.objectName) ?? strName8(view.object) ?? (isRec5(view.data) ? strName8(view.data.object) : void 0);
|
|
4415
|
+
}
|
|
4416
|
+
function readOptions(field) {
|
|
4417
|
+
const raw = field.options;
|
|
4418
|
+
const values = /* @__PURE__ */ new Set();
|
|
4419
|
+
const byLabel = /* @__PURE__ */ new Map();
|
|
4420
|
+
if (Array.isArray(raw)) {
|
|
4421
|
+
for (const opt of raw) {
|
|
4422
|
+
if (typeof opt === "string") {
|
|
4423
|
+
values.add(opt);
|
|
4424
|
+
continue;
|
|
4425
|
+
}
|
|
4426
|
+
if (!isRec5(opt)) continue;
|
|
4427
|
+
const value = strName8(opt.value);
|
|
4428
|
+
if (!value) continue;
|
|
4429
|
+
values.add(value);
|
|
4430
|
+
const label2 = strName8(opt.label);
|
|
4431
|
+
if (label2) byLabel.set(label2.toLowerCase(), value);
|
|
4432
|
+
}
|
|
4433
|
+
} else if (isRec5(raw)) {
|
|
4434
|
+
for (const [value, label2] of Object.entries(raw)) {
|
|
4435
|
+
values.add(value);
|
|
4436
|
+
if (typeof label2 === "string" && label2.length > 0) byLabel.set(label2.toLowerCase(), value);
|
|
4437
|
+
}
|
|
4438
|
+
} else {
|
|
4439
|
+
return void 0;
|
|
4440
|
+
}
|
|
4441
|
+
return values.size > 0 ? { values, byLabel } : void 0;
|
|
4442
|
+
}
|
|
4443
|
+
function buildUniverse(stack) {
|
|
4444
|
+
const objects = /* @__PURE__ */ new Map();
|
|
4445
|
+
const factsFor = (name) => {
|
|
4446
|
+
let facts = objects.get(name);
|
|
4447
|
+
if (!facts) {
|
|
4448
|
+
facts = emptyFacts();
|
|
4449
|
+
objects.set(name, facts);
|
|
4450
|
+
}
|
|
4451
|
+
return facts;
|
|
4452
|
+
};
|
|
4453
|
+
for (const obj of asArray28(stack.objects)) {
|
|
4454
|
+
const objectName = strName8(obj.name);
|
|
4455
|
+
if (!objectName) continue;
|
|
4456
|
+
const facts = factsFor(objectName);
|
|
4457
|
+
for (const field of asArray28(obj.fields)) {
|
|
4458
|
+
const fieldName = strName8(field.name);
|
|
4459
|
+
if (fieldName) facts.fields.set(fieldName, field);
|
|
4460
|
+
}
|
|
4461
|
+
for (const action of asArray28(obj.actions)) {
|
|
4462
|
+
const actionName = strName8(action.name);
|
|
4463
|
+
if (actionName) facts.actions.set(actionName, action);
|
|
4464
|
+
}
|
|
4465
|
+
for (const view of asArray28(obj.views)) {
|
|
4466
|
+
collectViewRecord({ ...view, object: strName8(view.object) ?? objectName }, factsFor);
|
|
4467
|
+
}
|
|
4468
|
+
collectViewRecord({ object: objectName, listViews: obj.listViews }, factsFor);
|
|
4469
|
+
for (const group of asArray28(obj.fieldGroups)) {
|
|
4470
|
+
const key = strName8(group.key) ?? strName8(group.name);
|
|
4471
|
+
if (key) facts.sections.add(key);
|
|
4472
|
+
}
|
|
4473
|
+
}
|
|
4474
|
+
for (const view of asArray28(stack.views)) {
|
|
4475
|
+
collectViewRecord(view, factsFor);
|
|
4476
|
+
}
|
|
4477
|
+
const pages = asArray28(stack.pages);
|
|
4478
|
+
for (let pi = 0; pi < pages.length; pi++) {
|
|
4479
|
+
for (const walked of walkPageComponents(pages[pi], `pages[${pi}]`)) {
|
|
4480
|
+
if (!walked.objectName) continue;
|
|
4481
|
+
const props = isRec5(walked.component.properties) ? walked.component.properties : void 0;
|
|
4482
|
+
if (!props) continue;
|
|
4483
|
+
for (const section of asArray28(props.sections)) {
|
|
4484
|
+
const sectionName = strName8(section.name);
|
|
4485
|
+
if (sectionName) factsFor(walked.objectName).sections.add(sectionName);
|
|
4486
|
+
}
|
|
4487
|
+
}
|
|
4488
|
+
}
|
|
4489
|
+
const globalActions = /* @__PURE__ */ new Map();
|
|
4490
|
+
const actionOwners = /* @__PURE__ */ new Map();
|
|
4491
|
+
for (const action of asArray28(stack.actions)) {
|
|
4492
|
+
const actionName = strName8(action.name);
|
|
4493
|
+
if (!actionName) continue;
|
|
4494
|
+
const owner = strName8(action.objectName) ?? strName8(action.object);
|
|
4495
|
+
if (owner) {
|
|
4496
|
+
factsFor(owner).actions.set(actionName, action);
|
|
4497
|
+
actionOwners.set(actionName, owner);
|
|
4498
|
+
} else {
|
|
4499
|
+
globalActions.set(actionName, action);
|
|
4500
|
+
}
|
|
4501
|
+
}
|
|
4502
|
+
for (const [objectName, facts] of objects) {
|
|
4503
|
+
for (const actionName of facts.actions.keys()) {
|
|
4504
|
+
if (!actionOwners.has(actionName)) actionOwners.set(actionName, objectName);
|
|
4505
|
+
}
|
|
4506
|
+
}
|
|
4507
|
+
const apps = /* @__PURE__ */ new Map();
|
|
4508
|
+
for (const app of asArray28(stack.apps)) {
|
|
4509
|
+
const appName = strName8(app.name);
|
|
4510
|
+
if (!appName) continue;
|
|
4511
|
+
const navIds = apps.get(appName) ?? /* @__PURE__ */ new Set();
|
|
4512
|
+
const walkNav = (items) => {
|
|
4513
|
+
for (const item of asArray28(items)) {
|
|
4514
|
+
const id = strName8(item.id);
|
|
4515
|
+
if (id) navIds.add(id);
|
|
4516
|
+
if (item.children) walkNav(item.children);
|
|
4517
|
+
}
|
|
4518
|
+
};
|
|
4519
|
+
walkNav(app.navigation);
|
|
4520
|
+
for (const area of asArray28(app.areas)) {
|
|
4521
|
+
const areaId = strName8(area.id);
|
|
4522
|
+
if (areaId) navIds.add(areaId);
|
|
4523
|
+
walkNav(area.navigation);
|
|
4524
|
+
}
|
|
4525
|
+
apps.set(appName, navIds);
|
|
4526
|
+
}
|
|
4527
|
+
const dashboards = /* @__PURE__ */ new Map();
|
|
4528
|
+
for (const dash of asArray28(stack.dashboards)) {
|
|
4529
|
+
const dashName = strName8(dash.name);
|
|
4530
|
+
if (!dashName) continue;
|
|
4531
|
+
const widgets = /* @__PURE__ */ new Set();
|
|
4532
|
+
for (const widget of asArray28(dash.widgets)) {
|
|
4533
|
+
const id = strName8(widget.id) ?? strName8(widget.name);
|
|
4534
|
+
if (id) widgets.add(id);
|
|
4535
|
+
}
|
|
4536
|
+
const actions = /* @__PURE__ */ new Set();
|
|
4537
|
+
const headerActions = [
|
|
4538
|
+
...asArray28(isRec5(dash.header) ? dash.header.actions : void 0),
|
|
4539
|
+
...asArray28(dash.actions)
|
|
4540
|
+
];
|
|
4541
|
+
for (const action of headerActions) {
|
|
4542
|
+
const key = strName8(action.actionUrl) ?? strName8(action.url) ?? strName8(action.name);
|
|
4543
|
+
if (key) actions.add(key);
|
|
4544
|
+
}
|
|
4545
|
+
dashboards.set(dashName, { widgets, actions });
|
|
4546
|
+
}
|
|
4547
|
+
return { objects, apps, dashboards, globalActions, actionOwners };
|
|
4548
|
+
}
|
|
4549
|
+
function localePath(bundleIndex, locale) {
|
|
4550
|
+
return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(locale) ? `translations[${bundleIndex}].${locale}` : `translations[${bundleIndex}]["${locale}"]`;
|
|
4551
|
+
}
|
|
4552
|
+
function validateTranslationReferences(stack) {
|
|
4553
|
+
const findings = [];
|
|
4554
|
+
if (!isRec5(stack)) return findings;
|
|
4555
|
+
const bundles = Array.isArray(stack.translations) ? stack.translations : [];
|
|
4556
|
+
if (bundles.length === 0) return findings;
|
|
4557
|
+
const universe = buildUniverse(stack);
|
|
4558
|
+
const orphan = (where, path, message, hint) => {
|
|
4559
|
+
findings.push({ severity: "warning", rule: TRANSLATION_TARGET_UNKNOWN, where, path, message, hint });
|
|
4560
|
+
};
|
|
4561
|
+
for (let bi = 0; bi < bundles.length; bi++) {
|
|
4562
|
+
const bundle = bundles[bi];
|
|
4563
|
+
if (!isRec5(bundle)) continue;
|
|
4564
|
+
for (const [locale, rawData] of Object.entries(bundle)) {
|
|
4565
|
+
if (!isRec5(rawData)) continue;
|
|
4566
|
+
const base = localePath(bi, locale);
|
|
4567
|
+
const inLocale = `locale "${locale}"`;
|
|
4568
|
+
for (const [objectName, rawNode] of Object.entries(asRecord(rawData.objects))) {
|
|
4569
|
+
if (!isRec5(rawNode)) continue;
|
|
4570
|
+
const objPath = `${base}.objects.${objectName}`;
|
|
4571
|
+
const facts = universe.objects.get(objectName);
|
|
4572
|
+
if (!facts) {
|
|
4573
|
+
if ((0, import_system3.isPlatformProvidedObjectName)(objectName)) continue;
|
|
4574
|
+
orphan(
|
|
4575
|
+
`${inLocale} \xB7 object "${objectName}"`,
|
|
4576
|
+
objPath,
|
|
4577
|
+
(0, import_system3.hasPlatformObjectPrefix)(objectName) ? `Translations are keyed to "${objectName}", which carries a platform namespace prefix but is registered by no platform package, official plugin, or cloud runtime object \u2014 and this stack does not define it either. Nothing resolves these keys.` + suggest5(objectName, universe.objects.keys()) : `Translations are keyed to "${objectName}", which no object in this stack defines. The resolver looks up keys derived from the metadata, so this whole subtree is dead weight \u2014 every label it carries renders untranslated.` + suggest5(objectName, universe.objects.keys()),
|
|
4578
|
+
`Rename the key to the object it was written for, drop it, or ignore this if the object is contributed by another installed package.` + (universe.objects.size > 0 ? ` Defined objects: ${listNames(universe.objects.keys())}.` : "")
|
|
4579
|
+
);
|
|
4580
|
+
continue;
|
|
4581
|
+
}
|
|
4582
|
+
for (const [fieldName, rawField] of Object.entries(asRecord(rawNode.fields))) {
|
|
4583
|
+
const fieldPath = `${objPath}.fields.${fieldName}`;
|
|
4584
|
+
const field = facts.fields.get(fieldName);
|
|
4585
|
+
if (!field) {
|
|
4586
|
+
if (SYSTEM_FIELDS5.has(fieldName)) continue;
|
|
4587
|
+
orphan(
|
|
4588
|
+
`${inLocale} \xB7 object "${objectName}" \xB7 field "${fieldName}"`,
|
|
4589
|
+
fieldPath,
|
|
4590
|
+
`Translations are keyed to field "${fieldName}", which object "${objectName}" does not declare. The label renders untranslated in this locale \u2014 and because every neighbouring field DOES resolve, the hole reads as a styling quirk rather than a missing translation.` + suggest5(fieldName, facts.fields.keys()),
|
|
4591
|
+
`Point the key at a declared field, or drop it if the field was removed or renamed.` + (facts.fields.size > 0 ? ` Declared fields: ${listNames(facts.fields.keys())}.` : "")
|
|
4592
|
+
);
|
|
4593
|
+
continue;
|
|
4594
|
+
}
|
|
4595
|
+
if (!isRec5(rawField)) continue;
|
|
4596
|
+
checkOptionKeys(findings, {
|
|
4597
|
+
optionMap: rawField.options,
|
|
4598
|
+
field,
|
|
4599
|
+
fieldName,
|
|
4600
|
+
objectName,
|
|
4601
|
+
path: `${fieldPath}.options`,
|
|
4602
|
+
where: `${inLocale} \xB7 object "${objectName}" \xB7 field "${fieldName}"`
|
|
4603
|
+
});
|
|
4604
|
+
}
|
|
4605
|
+
for (const viewName of Object.keys(asRecord(rawNode._views))) {
|
|
4606
|
+
if (facts.views.has(viewName)) continue;
|
|
4607
|
+
orphan(
|
|
4608
|
+
`${inLocale} \xB7 object "${objectName}" \xB7 view "${viewName}"`,
|
|
4609
|
+
`${objPath}._views.${viewName}`,
|
|
4610
|
+
`Translations are keyed to view "${viewName}", which no view of object "${objectName}" declares. The view tab keeps its source-locale label.` + suggest5(viewName, facts.views),
|
|
4611
|
+
`Match the key to the view's \`name\` (not its label), or drop it.` + (facts.views.size > 0 ? ` Declared views: ${listNames(facts.views)}.` : "")
|
|
4612
|
+
);
|
|
4613
|
+
}
|
|
4614
|
+
for (const sectionName of Object.keys(asRecord(rawNode._sections))) {
|
|
4615
|
+
if (facts.sections.has(sectionName)) continue;
|
|
4616
|
+
orphan(
|
|
4617
|
+
`${inLocale} \xB7 object "${objectName}" \xB7 section "${sectionName}"`,
|
|
4618
|
+
`${objPath}._sections.${sectionName}`,
|
|
4619
|
+
`Translations are keyed to section "${sectionName}", which nothing on object "${objectName}" declares \u2014 no \`fieldGroups[].key\`, no named form-view section, no named \`record:details\` section. The section heading stays in the source locale.` + suggest5(sectionName, facts.sections),
|
|
4620
|
+
`Sections are translatable only through a STABLE NAME: give the group/section a \`key\`/\`name\` and use it here, or drop the translation.` + (facts.sections.size > 0 ? ` Declared sections: ${listNames(facts.sections)}.` : ` Object "${objectName}" declares no named section at all.`)
|
|
4621
|
+
);
|
|
4622
|
+
}
|
|
4623
|
+
for (const [actionName, rawAction] of Object.entries(asRecord(rawNode._actions))) {
|
|
4624
|
+
const actionPath = `${objPath}._actions.${actionName}`;
|
|
4625
|
+
const action = facts.actions.get(actionName);
|
|
4626
|
+
if (!action) {
|
|
4627
|
+
orphan(
|
|
4628
|
+
`${inLocale} \xB7 object "${objectName}" \xB7 action "${actionName}"`,
|
|
4629
|
+
actionPath,
|
|
4630
|
+
`Translations are keyed to action "${actionName}", which is defined by neither object "${objectName}"'s \`actions\` nor a \`stack.actions\` entry bound to it. The button keeps its source-locale label.` + suggest5(actionName, facts.actions.keys()),
|
|
4631
|
+
`Match the key to a defined action name, move it under the object that owns the action, or drop it.` + (facts.actions.size > 0 ? ` Actions on this object: ${listNames(facts.actions.keys())}.` : "")
|
|
4632
|
+
);
|
|
4633
|
+
continue;
|
|
4634
|
+
}
|
|
4635
|
+
checkActionParams(findings, {
|
|
4636
|
+
rawAction,
|
|
4637
|
+
action,
|
|
4638
|
+
path: actionPath,
|
|
4639
|
+
where: `${inLocale} \xB7 object "${objectName}" \xB7 action "${actionName}"`,
|
|
4640
|
+
subject: `action "${actionName}"`
|
|
4641
|
+
});
|
|
4642
|
+
}
|
|
4643
|
+
}
|
|
4644
|
+
for (const [actionName, rawAction] of Object.entries(asRecord(rawData.globalActions))) {
|
|
4645
|
+
const actionPath = `${base}.globalActions.${actionName}`;
|
|
4646
|
+
const action = universe.globalActions.get(actionName);
|
|
4647
|
+
if (!action) {
|
|
4648
|
+
const owner = universe.actionOwners.get(actionName);
|
|
4649
|
+
orphan(
|
|
4650
|
+
`${inLocale} \xB7 global action "${actionName}"`,
|
|
4651
|
+
actionPath,
|
|
4652
|
+
owner ? `Action "${actionName}" is bound to object "${owner}", so the resolver looks it up under \`objects.${owner}._actions.${actionName}\` \u2014 never under \`globalActions\`, which is only consulted for object-less actions. This key is never read.` : `Translations are keyed to global action "${actionName}", which no object-less action in this stack defines. The button keeps its source-locale label.` + suggest5(actionName, universe.globalActions.keys()),
|
|
4653
|
+
owner ? `Move these keys under \`objects.${owner}._actions.${actionName}\`.` : `Match the key to an object-less action's name, or drop it.` + (universe.globalActions.size > 0 ? ` Object-less actions: ${listNames(universe.globalActions.keys())}.` : "")
|
|
4654
|
+
);
|
|
4655
|
+
continue;
|
|
4656
|
+
}
|
|
4657
|
+
checkActionParams(findings, {
|
|
4658
|
+
rawAction,
|
|
4659
|
+
action,
|
|
4660
|
+
path: actionPath,
|
|
4661
|
+
where: `${inLocale} \xB7 global action "${actionName}"`,
|
|
4662
|
+
subject: `action "${actionName}"`
|
|
4663
|
+
});
|
|
4664
|
+
}
|
|
4665
|
+
for (const [appName, rawApp] of Object.entries(asRecord(rawData.apps))) {
|
|
4666
|
+
const appPath = `${base}.apps.${appName}`;
|
|
4667
|
+
const navIds = universe.apps.get(appName);
|
|
4668
|
+
if (!navIds) {
|
|
4669
|
+
orphan(
|
|
4670
|
+
`${inLocale} \xB7 app "${appName}"`,
|
|
4671
|
+
appPath,
|
|
4672
|
+
`Translations are keyed to app "${appName}", which this stack does not define. The app launcher shows the source-locale label.` + suggest5(appName, universe.apps.keys()),
|
|
4673
|
+
`Match the key to an app's \`name\`, or drop it.` + (universe.apps.size > 0 ? ` Defined apps: ${listNames(universe.apps.keys())}.` : "")
|
|
4674
|
+
);
|
|
4675
|
+
continue;
|
|
4676
|
+
}
|
|
4677
|
+
if (!isRec5(rawApp)) continue;
|
|
4678
|
+
for (const navId of Object.keys(asRecord(rawApp.navigation))) {
|
|
4679
|
+
if (navIds.has(navId)) continue;
|
|
4680
|
+
orphan(
|
|
4681
|
+
`${inLocale} \xB7 app "${appName}" \xB7 navigation "${navId}"`,
|
|
4682
|
+
`${appPath}.navigation.${navId}`,
|
|
4683
|
+
`Translations are keyed to navigation item "${navId}", which app "${appName}" does not declare. The menu entry keeps its source-locale label.` + suggest5(navId, navIds),
|
|
4684
|
+
`Match the key to the navigation item's \`id\`, or drop it.` + (navIds.size > 0 ? ` Declared navigation ids: ${listNames(navIds)}.` : "")
|
|
4685
|
+
);
|
|
4686
|
+
}
|
|
4687
|
+
}
|
|
4688
|
+
for (const [dashName, rawDash] of Object.entries(asRecord(rawData.dashboards))) {
|
|
4689
|
+
const dashPath = `${base}.dashboards.${dashName}`;
|
|
4690
|
+
const dash = universe.dashboards.get(dashName);
|
|
4691
|
+
if (!dash) {
|
|
4692
|
+
orphan(
|
|
4693
|
+
`${inLocale} \xB7 dashboard "${dashName}"`,
|
|
4694
|
+
dashPath,
|
|
4695
|
+
`Translations are keyed to dashboard "${dashName}", which this stack does not define. The dashboard title stays in the source locale.` + suggest5(dashName, universe.dashboards.keys()),
|
|
4696
|
+
`Match the key to a dashboard's \`name\`, or drop it.` + (universe.dashboards.size > 0 ? ` Defined dashboards: ${listNames(universe.dashboards.keys())}.` : "")
|
|
4697
|
+
);
|
|
4698
|
+
continue;
|
|
4699
|
+
}
|
|
4700
|
+
if (!isRec5(rawDash)) continue;
|
|
4701
|
+
for (const widgetId of Object.keys(asRecord(rawDash.widgets))) {
|
|
4702
|
+
if (dash.widgets.has(widgetId)) continue;
|
|
4703
|
+
orphan(
|
|
4704
|
+
`${inLocale} \xB7 dashboard "${dashName}" \xB7 widget "${widgetId}"`,
|
|
4705
|
+
`${dashPath}.widgets.${widgetId}`,
|
|
4706
|
+
`Translations are keyed to widget "${widgetId}", which dashboard "${dashName}" does not declare. The widget title stays in the source locale.` + suggest5(widgetId, dash.widgets),
|
|
4707
|
+
`Match the key to the widget's \`id\`, or drop it.` + (dash.widgets.size > 0 ? ` Declared widget ids: ${listNames(dash.widgets)}.` : "")
|
|
4708
|
+
);
|
|
4709
|
+
}
|
|
4710
|
+
for (const actionKey of Object.keys(asRecord(rawDash.actions))) {
|
|
4711
|
+
if (dash.actions.has(actionKey)) continue;
|
|
4712
|
+
orphan(
|
|
4713
|
+
`${inLocale} \xB7 dashboard "${dashName}" \xB7 action "${actionKey}"`,
|
|
4714
|
+
`${dashPath}.actions.${actionKey}`,
|
|
4715
|
+
`Translations are keyed to header action "${actionKey}", which dashboard "${dashName}" does not declare. The button keeps its source-locale label.` + suggest5(actionKey, dash.actions),
|
|
4716
|
+
`Header-action translations are keyed by the action's \`actionUrl\`, not its label.` + (dash.actions.size > 0 ? ` Declared header actions: ${listNames(dash.actions)}.` : "")
|
|
4717
|
+
);
|
|
4718
|
+
}
|
|
4719
|
+
}
|
|
4720
|
+
}
|
|
4721
|
+
}
|
|
4722
|
+
return findings;
|
|
4723
|
+
}
|
|
4724
|
+
function asRecord(v) {
|
|
4725
|
+
return isRec5(v) ? v : {};
|
|
4726
|
+
}
|
|
4727
|
+
function checkOptionKeys(findings, ctx) {
|
|
4728
|
+
const optionKeys = Object.keys(asRecord(ctx.optionMap));
|
|
4729
|
+
if (optionKeys.length === 0) return;
|
|
4730
|
+
const declared = readOptions(ctx.field);
|
|
4731
|
+
if (!declared) {
|
|
4732
|
+
findings.push({
|
|
4733
|
+
severity: "warning",
|
|
4734
|
+
rule: TRANSLATION_OPTION_KEY_UNKNOWN,
|
|
4735
|
+
where: ctx.where,
|
|
4736
|
+
path: ctx.path,
|
|
4737
|
+
message: `Option translations are keyed under field "${ctx.fieldName}" of object "${ctx.objectName}", which declares no \`options\` at all (field type "${strName8(ctx.field.type) ?? "unknown"}"). Nothing reads this map.`,
|
|
4738
|
+
hint: `Declare the options on the field, move the translations to the field that owns them, or drop them.`
|
|
4739
|
+
});
|
|
4740
|
+
return;
|
|
4741
|
+
}
|
|
4742
|
+
for (const key of optionKeys) {
|
|
4743
|
+
if (declared.values.has(key)) continue;
|
|
4744
|
+
const byLabel = declared.byLabel.get(key.toLowerCase());
|
|
4745
|
+
findings.push({
|
|
4746
|
+
severity: "warning",
|
|
4747
|
+
rule: TRANSLATION_OPTION_KEY_UNKNOWN,
|
|
4748
|
+
where: ctx.where,
|
|
4749
|
+
path: `${ctx.path}.${key}`,
|
|
4750
|
+
message: byLabel ? `Option translation is keyed by the DISPLAY LABEL "${key}" instead of the stored value "${byLabel}". The resolver looks the option up by value, so this entry is never found and the option renders with its source-locale label.` : `Option translation is keyed by "${key}", which is not one of the values declared by field "${ctx.objectName}.${ctx.fieldName}". The option renders untranslated.` + suggest5(key, declared.values),
|
|
4751
|
+
hint: byLabel ? `Rename the key to "${byLabel}".` : `Option keys are the stored \`value\`, not the label and not a variant spelling (\`direct_mail\`, not \`direct-mail\`). Declared values: ${listNames(declared.values)}.`
|
|
4752
|
+
});
|
|
4753
|
+
}
|
|
4754
|
+
}
|
|
4755
|
+
function checkActionParams(findings, ctx) {
|
|
4756
|
+
const rawParams = Object.keys(asRecord(isRec5(ctx.rawAction) ? ctx.rawAction.params : void 0));
|
|
4757
|
+
if (rawParams.length === 0) return;
|
|
4758
|
+
const declared = /* @__PURE__ */ new Set();
|
|
4759
|
+
for (const param of asArray28(ctx.action.params)) {
|
|
4760
|
+
const name = strName8(param.name) ?? strName8(param.field);
|
|
4761
|
+
if (name) declared.add(name);
|
|
4762
|
+
}
|
|
4763
|
+
for (const paramName of rawParams) {
|
|
4764
|
+
if (declared.has(paramName)) continue;
|
|
4765
|
+
findings.push({
|
|
4766
|
+
severity: "warning",
|
|
4767
|
+
rule: TRANSLATION_TARGET_UNKNOWN,
|
|
4768
|
+
where: `${ctx.where} \xB7 param "${paramName}"`,
|
|
4769
|
+
path: `${ctx.path}.params.${paramName}`,
|
|
4770
|
+
message: `Translations are keyed to parameter "${paramName}", which ${ctx.subject} does not declare. The parameter's label and help text render untranslated in the action dialog.` + suggest5(paramName, declared),
|
|
4771
|
+
hint: `Match the key to a declared param \`name\`, or drop it.` + (declared.size > 0 ? ` Declared params: ${listNames(declared)}.` : "")
|
|
4772
|
+
});
|
|
4773
|
+
}
|
|
4774
|
+
}
|
|
4775
|
+
|
|
4776
|
+
// src/validate-ai-surface-affinity.ts
|
|
4777
|
+
var AI_SKILL_SURFACE_MISMATCH = "ai-skill-surface-mismatch";
|
|
4778
|
+
function asArray29(v) {
|
|
4779
|
+
if (Array.isArray(v)) return v.filter((x) => !!x && typeof x === "object");
|
|
4780
|
+
if (v && typeof v === "object") {
|
|
4781
|
+
return Object.entries(v).map(([name, def]) => ({ name, ...def }));
|
|
4782
|
+
}
|
|
4783
|
+
return [];
|
|
4784
|
+
}
|
|
4785
|
+
function strName9(v) {
|
|
4786
|
+
return typeof v === "string" && v.length > 0 ? v : void 0;
|
|
4787
|
+
}
|
|
4788
|
+
function surfaceOf(v) {
|
|
4789
|
+
return typeof v === "string" && v.length > 0 ? v : "ask";
|
|
4790
|
+
}
|
|
4791
|
+
function validateAiSurfaceAffinity(stack) {
|
|
4792
|
+
const findings = [];
|
|
4793
|
+
if (!stack || typeof stack !== "object") return findings;
|
|
4794
|
+
const skillsByName = /* @__PURE__ */ new Map();
|
|
4795
|
+
for (const skill of asArray29(stack.skills)) {
|
|
4796
|
+
const n = strName9(skill.name);
|
|
4797
|
+
if (n) skillsByName.set(n, skill);
|
|
4798
|
+
}
|
|
4799
|
+
const agents = asArray29(stack.agents);
|
|
4800
|
+
for (let ai = 0; ai < agents.length; ai++) {
|
|
4801
|
+
const agent = agents[ai];
|
|
4802
|
+
const agentName = strName9(agent.name) ?? `#${ai}`;
|
|
4803
|
+
const agentSurface = surfaceOf(agent.surface);
|
|
4804
|
+
const skillRefs = Array.isArray(agent.skills) ? agent.skills : [];
|
|
4805
|
+
for (let si = 0; si < skillRefs.length; si++) {
|
|
4806
|
+
const ref = strName9(skillRefs[si]);
|
|
4807
|
+
if (!ref) continue;
|
|
4808
|
+
const skill = skillsByName.get(ref);
|
|
4809
|
+
if (!skill) continue;
|
|
4810
|
+
const skillSurface = surfaceOf(skill.surface);
|
|
4811
|
+
if (skillSurface === "both" || skillSurface === agentSurface) continue;
|
|
4812
|
+
findings.push({
|
|
4813
|
+
severity: "error",
|
|
4814
|
+
rule: AI_SKILL_SURFACE_MISMATCH,
|
|
4815
|
+
where: `agent "${agentName}" \xB7 skills`,
|
|
4816
|
+
path: `agents[${ai}].skills[${si}]`,
|
|
4817
|
+
message: `Agent "${agentName}" (surface: '${agentSurface}') references skill "${ref}" (surface: '${skillSurface}') \u2014 incompatible affinity (ADR-0064 \xA73). The runtime refuses this binding with a load error, so chatting with this agent fails at request time even though the stack parses and validates cleanly.`,
|
|
4818
|
+
hint: `A skill may only attach to an agent whose surface it matches. Move "${ref}" to a '${skillSurface}'-surface agent, change its \`surface\` to '${agentSurface}', or \u2014 only if it is a genuinely shared, read-only capability \u2014 declare \`surface: 'both'\`.`
|
|
4819
|
+
});
|
|
4820
|
+
}
|
|
4821
|
+
}
|
|
4822
|
+
return findings;
|
|
4823
|
+
}
|
|
4824
|
+
|
|
4825
|
+
// src/validate-ai-tool-references.ts
|
|
4826
|
+
var import_system4 = require("@objectstack/spec/system");
|
|
4827
|
+
var AI_SKILL_TOOL_UNRESOLVED = "ai-skill-tool-unresolved";
|
|
4828
|
+
function asArray30(v) {
|
|
4829
|
+
if (Array.isArray(v)) return v.filter((x) => !!x && typeof x === "object");
|
|
4830
|
+
if (v && typeof v === "object") {
|
|
4831
|
+
return Object.entries(v).map(([name, def]) => ({ name, ...def }));
|
|
4832
|
+
}
|
|
4833
|
+
return [];
|
|
4834
|
+
}
|
|
4835
|
+
function strName10(v) {
|
|
4836
|
+
return typeof v === "string" && v.length > 0 ? v : void 0;
|
|
4837
|
+
}
|
|
4838
|
+
function distance5(a, b) {
|
|
4839
|
+
const m = a.length;
|
|
4840
|
+
const n = b.length;
|
|
4841
|
+
if (m === 0) return n;
|
|
4842
|
+
if (n === 0) return m;
|
|
4843
|
+
let prev = Array.from({ length: n + 1 }, (_, j) => j);
|
|
4844
|
+
for (let i = 1; i <= m; i++) {
|
|
4845
|
+
const curr = [i, ...new Array(n).fill(0)];
|
|
4846
|
+
for (let j = 1; j <= n; j++) {
|
|
4847
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
4848
|
+
curr[j] = Math.min(curr[j - 1] + 1, prev[j] + 1, prev[j - 1] + cost);
|
|
4849
|
+
}
|
|
4850
|
+
prev = curr;
|
|
4851
|
+
}
|
|
4852
|
+
return prev[n];
|
|
4853
|
+
}
|
|
4854
|
+
function suggest6(target, known) {
|
|
4855
|
+
for (const prefix of import_system4.PLATFORM_TOOL_FAMILY_PREFIXES) {
|
|
4856
|
+
if (known.has(`${prefix}${target}`)) return ` Did you mean "${prefix}${target}"?`;
|
|
4857
|
+
}
|
|
4858
|
+
let best;
|
|
4859
|
+
let bestScore = Infinity;
|
|
4860
|
+
for (const candidate of known) {
|
|
4861
|
+
const d = distance5(target, candidate);
|
|
4862
|
+
if (d < bestScore) {
|
|
4863
|
+
bestScore = d;
|
|
4864
|
+
best = candidate;
|
|
4865
|
+
}
|
|
4866
|
+
}
|
|
4867
|
+
const limit = Math.max(2, Math.floor(target.length / 3));
|
|
4868
|
+
return best && bestScore <= limit ? ` Did you mean "${best}"?` : "";
|
|
4869
|
+
}
|
|
4870
|
+
var HEADLESS_ACTION_TYPES = /* @__PURE__ */ new Set(["script", "api", "flow"]);
|
|
4871
|
+
function materialisesAsTool(action) {
|
|
4872
|
+
const ai = action.ai;
|
|
4873
|
+
if (!ai || typeof ai !== "object") return false;
|
|
4874
|
+
const aiRec = ai;
|
|
4875
|
+
if (aiRec.exposed !== true) return false;
|
|
4876
|
+
if (!strName10(aiRec.description)) return false;
|
|
4877
|
+
const type = strName10(action.type);
|
|
4878
|
+
if (!type || !HEADLESS_ACTION_TYPES.has(type)) return false;
|
|
4879
|
+
if (type === "script") return Boolean(action.target || action.body);
|
|
4880
|
+
return Boolean(action.target);
|
|
4881
|
+
}
|
|
4882
|
+
function collectToolUniverse(stack) {
|
|
4883
|
+
const universe = new Set(import_system4.PLATFORM_PROVIDED_TOOL_NAMES);
|
|
4884
|
+
for (const tool of asArray30(stack.tools)) {
|
|
4885
|
+
const n = strName10(tool.name);
|
|
4886
|
+
if (n) universe.add(n);
|
|
4887
|
+
}
|
|
4888
|
+
const addActionFamily = (actions) => {
|
|
4889
|
+
for (const action of asArray30(actions)) {
|
|
4890
|
+
const n = strName10(action.name);
|
|
4891
|
+
if (n && materialisesAsTool(action)) universe.add(`action_${n}`);
|
|
4892
|
+
}
|
|
4893
|
+
};
|
|
4894
|
+
addActionFamily(stack.actions);
|
|
4895
|
+
for (const obj of asArray30(stack.objects)) {
|
|
4896
|
+
addActionFamily(obj.actions);
|
|
4897
|
+
}
|
|
4898
|
+
return universe;
|
|
4899
|
+
}
|
|
4900
|
+
function collectUnexposedActionNames(stack) {
|
|
4901
|
+
const names = /* @__PURE__ */ new Set();
|
|
4902
|
+
const scan = (actions) => {
|
|
4903
|
+
for (const action of asArray30(actions)) {
|
|
4904
|
+
const n = strName10(action.name);
|
|
4905
|
+
if (n && !materialisesAsTool(action)) names.add(n);
|
|
4906
|
+
}
|
|
4907
|
+
};
|
|
4908
|
+
scan(stack.actions);
|
|
4909
|
+
for (const obj of asArray30(stack.objects)) scan(obj.actions);
|
|
4910
|
+
return names;
|
|
4911
|
+
}
|
|
4912
|
+
function validateAiToolReferences(stack) {
|
|
4913
|
+
const findings = [];
|
|
4914
|
+
if (!stack || typeof stack !== "object") return findings;
|
|
4915
|
+
const universe = collectToolUniverse(stack);
|
|
4916
|
+
const unexposedActions = collectUnexposedActionNames(stack);
|
|
4917
|
+
const resolves = (ref) => {
|
|
4918
|
+
if (ref.endsWith("*")) {
|
|
4919
|
+
const prefix = ref.slice(0, -1);
|
|
4920
|
+
for (const name of universe) {
|
|
4921
|
+
if (name.startsWith(prefix)) return true;
|
|
4922
|
+
}
|
|
4923
|
+
return false;
|
|
4924
|
+
}
|
|
4925
|
+
return universe.has(ref);
|
|
4926
|
+
};
|
|
4927
|
+
const skills = asArray30(stack.skills);
|
|
4928
|
+
for (let si = 0; si < skills.length; si++) {
|
|
4929
|
+
const skill = skills[si];
|
|
4930
|
+
const skillName = strName10(skill.name) ?? `#${si}`;
|
|
4931
|
+
const refs = Array.isArray(skill.tools) ? skill.tools : [];
|
|
4932
|
+
for (let ti = 0; ti < refs.length; ti++) {
|
|
4933
|
+
const ref = strName10(refs[ti]);
|
|
4934
|
+
if (!ref || resolves(ref)) continue;
|
|
4935
|
+
const isPattern = ref.endsWith("*");
|
|
4936
|
+
const unexposed = !isPattern && ref.startsWith("action_") && unexposedActions.has(ref.slice("action_".length)) ? ref.slice("action_".length) : void 0;
|
|
4937
|
+
findings.push({
|
|
4938
|
+
severity: "warning",
|
|
4939
|
+
rule: AI_SKILL_TOOL_UNRESOLVED,
|
|
4940
|
+
where: `skill "${skillName}" \xB7 tools`,
|
|
4941
|
+
path: `skills[${si}].tools[${ti}]`,
|
|
4942
|
+
message: isPattern ? `Skill "${skillName}" subscribes to tool family "${ref}", which matches nothing this stack can resolve (no declared tool, no platform tool, and no AI-exposed declarative action materialises into it). The subscription contributes zero tools at runtime.` : unexposed ? `Skill "${skillName}" references tool "${ref}", but the action "${unexposed}" does not become an AI tool: the runtime materialises \`action_<name>\` only for an action that opts in with \`ai.exposed: true\` + \`ai.description\` (ADR-0011) AND has a headless path (type \`script\`/\`api\`/\`flow\` with a target or body \u2014 \`url\`/\`modal\`/\`form\` are UI-only). The reference is dropped at runtime, so the skill promises a capability the agent cannot call.` : `Skill "${skillName}" references tool "${ref}", which resolves to nothing this stack can see: not a \`stack.tools\` record, not a platform-registered tool, and not a materialised action tool (\`action_<name>\`). The runtime silently drops the reference, so the skill's instructions claim a capability the agent does not have \u2014 the assistant will improvise or fail when asked to use it.` + suggest6(ref, universe),
|
|
4943
|
+
hint: unexposed ? `Either opt "${unexposed}" in \u2014 set \`ai: { exposed: true, description: '\u2026' }\` (\u226540 chars, LLM-facing) and give it a headless type \u2014 or drop the reference and have the skill's instructions recommend the UI action instead. A \`modal\`/\`form\`/\`url\` action stays human-driven by design; that is a legitimate answer, not a gap.` : `Back "${ref}" with a real executable: declare a declarative action (or flow), opt it in with \`ai.exposed: true\` + \`ai.description\`, and reference its materialised tool (\`action_<name>\` \u2014 the ADR-0109 default path, no tool record needed); or reference a platform tool by its registered name; or remove the reference and the instructions that mention it. Ignore this only if a runtime plugin outside the platform registry provides "${ref}". Family prefixes materialised by the runtime: ${import_system4.PLATFORM_TOOL_FAMILY_PREFIXES.join(", ")}.`
|
|
4944
|
+
});
|
|
4945
|
+
}
|
|
4946
|
+
}
|
|
4947
|
+
return findings;
|
|
4948
|
+
}
|
|
4949
|
+
|
|
4950
|
+
// src/validate-ai-agent-authoring.ts
|
|
4951
|
+
var AGENT_AUTHORING_WITHDRAWN = "agent-authoring-withdrawn";
|
|
4952
|
+
function asArray31(v) {
|
|
4953
|
+
if (Array.isArray(v)) return v.filter((x) => !!x && typeof x === "object");
|
|
4954
|
+
if (v && typeof v === "object") {
|
|
4955
|
+
return Object.entries(v).map(([name, def]) => ({ name, ...def }));
|
|
4956
|
+
}
|
|
4957
|
+
return [];
|
|
4958
|
+
}
|
|
4959
|
+
function strName11(v) {
|
|
4960
|
+
return typeof v === "string" && v.length > 0 ? v : void 0;
|
|
4961
|
+
}
|
|
4962
|
+
var PLATFORM_AGENT_NAMES = /* @__PURE__ */ new Set(["ask", "build", "data_chat", "metadata_assistant"]);
|
|
4963
|
+
function validateAiAgentAuthoring(stack) {
|
|
4964
|
+
const findings = [];
|
|
4965
|
+
if (!stack || typeof stack !== "object") return findings;
|
|
4966
|
+
const agents = asArray31(stack.agents);
|
|
4967
|
+
for (let ai = 0; ai < agents.length; ai++) {
|
|
4968
|
+
const agent = agents[ai];
|
|
4969
|
+
const name = strName11(agent.name) ?? `#${ai}`;
|
|
4970
|
+
const isPlatformName = PLATFORM_AGENT_NAMES.has(name);
|
|
4971
|
+
const skillCount = Array.isArray(agent.skills) ? agent.skills.length : 0;
|
|
4972
|
+
findings.push({
|
|
4973
|
+
severity: "warning",
|
|
4974
|
+
rule: AGENT_AUTHORING_WITHDRAWN,
|
|
4975
|
+
where: `agent "${name}"`,
|
|
4976
|
+
path: `agents[${ai}]`,
|
|
4977
|
+
message: isPlatformName ? `This stack declares an agent named "${name}", which is a PLATFORM agent id. The runtime serves its own record for that name and ignores this one \u2014 the declaration has no effect and will drift from the platform's definition.` : `This stack declares the agent "${name}", but tenant/app-package agents were withdrawn (ADR-0063 \xA72): the kernel ships exactly two agents (\`ask\`, \`build\`) and the surface the user is in binds one. The runtime filters this record out of the agent catalog and refuses to load it, so it never runs \u2014 it parses, validates, and ships as inert metadata.`,
|
|
4978
|
+
hint: isPlatformName ? `Remove the declaration; the platform owns "${name}". Extend it with skills instead.` : `Delete the agent and express its capability as skills. Everything an agent carried that a skill does not is persona text: move the useful parts of \`instructions\` into the skills' own instructions.` + (skillCount > 0 ? ` The ${skillCount} skill${skillCount === 1 ? "" : "s"} this agent references already carry the capability \u2014 they attach to the platform agent by \`surface\` affinity, so nothing is lost by dropping the persona.` : ``)
|
|
4979
|
+
});
|
|
4980
|
+
}
|
|
4981
|
+
return findings;
|
|
4982
|
+
}
|
|
4983
|
+
|
|
4984
|
+
// src/reference-integrity-suite.ts
|
|
4985
|
+
var REFERENCE_INTEGRITY_RULES = [
|
|
4986
|
+
{ name: "validateObjectReferences", run: validateObjectReferences },
|
|
4987
|
+
{ name: "validateActionNameRefs", run: validateActionNameRefs },
|
|
4988
|
+
{ name: "validatePageFieldBindings", run: validatePageFieldBindings },
|
|
4989
|
+
{ name: "validateChartBindings", run: validateChartBindings },
|
|
4990
|
+
{ name: "validateNavAccess", run: validateNavAccess },
|
|
4991
|
+
{ name: "validateTranslationReferences", run: validateTranslationReferences },
|
|
4992
|
+
{ name: "validateFlowTemplatePaths", run: validateFlowTemplatePaths },
|
|
4993
|
+
{ name: "validateAiSurfaceAffinity", run: validateAiSurfaceAffinity },
|
|
4994
|
+
{ name: "validateAiToolReferences", run: validateAiToolReferences },
|
|
4995
|
+
{ name: "validateAiAgentAuthoring", run: validateAiAgentAuthoring }
|
|
4996
|
+
];
|
|
4997
|
+
function validateReferenceIntegrity(stack) {
|
|
4998
|
+
const findings = [];
|
|
4999
|
+
for (const rule of REFERENCE_INTEGRITY_RULES) {
|
|
5000
|
+
findings.push(...rule.run(stack));
|
|
5001
|
+
}
|
|
5002
|
+
return findings;
|
|
5003
|
+
}
|
|
2206
5004
|
// Annotate the CommonJS export names for ESM import in node:
|
|
2207
5005
|
0 && (module.exports = {
|
|
5006
|
+
ACTION_NAME_UNDEFINED,
|
|
5007
|
+
AGENT_AUTHORING_WITHDRAWN,
|
|
5008
|
+
AI_SKILL_SURFACE_MISMATCH,
|
|
5009
|
+
AI_SKILL_TOOL_UNRESOLVED,
|
|
5010
|
+
APPROVAL_APPROVERS_MAY_RESOLVE_EMPTY,
|
|
5011
|
+
APPROVAL_APPROVER_CROSS_ORG_UNSUPPORTED,
|
|
2208
5012
|
APPROVAL_APPROVER_NOT_MEMBERSHIP_TIER,
|
|
2209
5013
|
APPROVAL_APPROVER_TYPE_DEPRECATED,
|
|
2210
5014
|
APPROVAL_APPROVER_TYPE_UNKNOWN,
|
|
5015
|
+
APPROVAL_DECISION_OUTPUTS_RESERVED,
|
|
2211
5016
|
APPROVAL_ESCALATION_REASSIGN_NO_TARGET,
|
|
5017
|
+
APPROVAL_EXPRESSION_INVALID,
|
|
5018
|
+
APPROVAL_EXPRESSION_NO_EMPTY_POLICY,
|
|
2212
5019
|
CAPABILITY_REFERENCE_UNKNOWN,
|
|
5020
|
+
CHART_AXIS_NOT_SELECTED,
|
|
2213
5021
|
CHART_CONFIG_MISSING,
|
|
5022
|
+
CHART_DATASET_UNKNOWN,
|
|
5023
|
+
CHART_DIMENSION_UNKNOWN,
|
|
2214
5024
|
CHART_FIELD_UNKNOWN,
|
|
5025
|
+
CHART_MEASURE_UNKNOWN,
|
|
5026
|
+
DASHBOARD_ACTION_ROUTE_UNRESOLVED,
|
|
5027
|
+
DASHBOARD_ACTION_TARGET_UNDEFINED,
|
|
5028
|
+
DASHBOARD_FILTER_FIELD_UNKNOWN,
|
|
2215
5029
|
FIELD_GROUP_EMPTY,
|
|
2216
5030
|
FIELD_GROUP_UNDECLARED,
|
|
5031
|
+
FILTER_TOKEN_UNKNOWN,
|
|
2217
5032
|
FLOW_DRAFT_STATUS_AMBIGUOUS,
|
|
5033
|
+
FLOW_TEMPLATE_LOOKUP_TRAVERSAL,
|
|
5034
|
+
FLOW_TEMPLATE_UNKNOWN_FIELD,
|
|
2218
5035
|
FLOW_TRIGGER_UNKNOWN_OBJECT,
|
|
5036
|
+
FLOW_UPDATE_READONLY_FIELD,
|
|
5037
|
+
FLOW_UPDATE_READONLY_WHEN_FIELD,
|
|
2219
5038
|
FORM_COLSPAN_ABSOLUTE,
|
|
2220
5039
|
FORM_FIELD_UNKNOWN,
|
|
2221
5040
|
LIST_VIEW_FILTERS_IN_VIEWS_MODE,
|
|
2222
5041
|
MEASURE_AGGREGATE_INCOHERENT,
|
|
5042
|
+
NAV_OBJECT_UNGRANTED,
|
|
5043
|
+
OBJECT_REFERENCE_UNKNOWN,
|
|
5044
|
+
OBJECT_REFERENCE_UNREGISTERED_PLATFORM,
|
|
5045
|
+
ORG_AXIS_CROSS_ORG_BU_GRANT,
|
|
5046
|
+
ORG_AXIS_PERMISSION_INHERITANCE,
|
|
5047
|
+
PAGE_FIELD_UNKNOWN,
|
|
2223
5048
|
PAGE_SOURCE_CLASSNAME,
|
|
5049
|
+
REACT_CHART_AGGREGATE_INVALID,
|
|
5050
|
+
REACT_CHART_AXIS_UNKNOWN,
|
|
5051
|
+
REACT_CHART_FIELD_UNKNOWN,
|
|
5052
|
+
REFERENCE_INTEGRITY_RULES,
|
|
2224
5053
|
SECURITY_ANCHOR_HIGH_PRIVILEGE,
|
|
2225
5054
|
SECURITY_BOOK_AUDIENCE_UNKNOWN_SET,
|
|
2226
5055
|
SECURITY_DELEGATION_MISSING_REASON,
|
|
@@ -2232,6 +5061,8 @@ function diffAccessMatrix(before, after) {
|
|
|
2232
5061
|
SECURITY_PRIVATE_NO_READSCOPE,
|
|
2233
5062
|
SECURITY_ROLE_WORD,
|
|
2234
5063
|
SECURITY_WILDCARD_VAMA,
|
|
5064
|
+
SEED_INSERT_MODE_DUPLICATES_ON_REPLAY,
|
|
5065
|
+
SEED_VALUE_OUTSIDE_STATE_MACHINE,
|
|
2235
5066
|
SEMANTIC_ROLE_FIELD_UNKNOWN,
|
|
2236
5067
|
STYLE_CLASSNAME_TAILWIND,
|
|
2237
5068
|
STYLE_NODE_MISSING_ID,
|
|
@@ -2241,6 +5072,8 @@ function diffAccessMatrix(before, after) {
|
|
|
2241
5072
|
TABLE_COUNT_ONLY,
|
|
2242
5073
|
TITLE_FORMAT_RETIRED,
|
|
2243
5074
|
TITLE_UNRESOLVABLE,
|
|
5075
|
+
TRANSLATION_OPTION_KEY_UNKNOWN,
|
|
5076
|
+
TRANSLATION_TARGET_UNKNOWN,
|
|
2244
5077
|
VIEW_CONTAINER_SHAPE,
|
|
2245
5078
|
VISIBILITY_ALIAS_DEPRECATED,
|
|
2246
5079
|
VISIBILITY_ROOT_MISLAYERED,
|
|
@@ -2249,20 +5082,37 @@ function diffAccessMatrix(before, after) {
|
|
|
2249
5082
|
WIDGET_MEASURE_UNKNOWN,
|
|
2250
5083
|
buildAccessMatrix,
|
|
2251
5084
|
diffAccessMatrix,
|
|
5085
|
+
validateActionNameRefs,
|
|
5086
|
+
validateAiAgentAuthoring,
|
|
5087
|
+
validateAiSurfaceAffinity,
|
|
5088
|
+
validateAiToolReferences,
|
|
2252
5089
|
validateApprovalApprovers,
|
|
2253
5090
|
validateCapabilityReferences,
|
|
5091
|
+
validateChartBindings,
|
|
5092
|
+
validateDashboardActionRefs,
|
|
5093
|
+
validateFilterTokens,
|
|
5094
|
+
validateFlowTemplatePaths,
|
|
2254
5095
|
validateFlowTriggerReadiness,
|
|
2255
5096
|
validateFormLayout,
|
|
2256
5097
|
validateJsxPages,
|
|
2257
5098
|
validateListViewMode,
|
|
5099
|
+
validateNavAccess,
|
|
5100
|
+
validateObjectReferences,
|
|
5101
|
+
validateOrgAxisRedLines,
|
|
5102
|
+
validatePageFieldBindings,
|
|
2258
5103
|
validatePageSourceStyling,
|
|
2259
5104
|
validateReactPageProps,
|
|
2260
5105
|
validateReactPages,
|
|
5106
|
+
validateReadonlyFlowWrites,
|
|
2261
5107
|
validateRecordTitle,
|
|
5108
|
+
validateReferenceIntegrity,
|
|
2262
5109
|
validateResponsiveStyles,
|
|
2263
5110
|
validateSecurityPosture,
|
|
5111
|
+
validateSeedReplaySafety,
|
|
5112
|
+
validateSeedStateMachine,
|
|
2264
5113
|
validateSemanticRoles,
|
|
2265
5114
|
validateStackExpressions,
|
|
5115
|
+
validateTranslationReferences,
|
|
2266
5116
|
validateViewContainers,
|
|
2267
5117
|
validateVisibilityPredicates,
|
|
2268
5118
|
validateWidgetBindings
|