@objectstack/lint 16.1.0 → 17.0.0-rc.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +3627 -0
- package/dist/index.cjs +3889 -287
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +902 -35
- package/dist/index.d.ts +902 -35
- package/dist/index.js +3820 -281
- package/dist/index.js.map +1 -1
- package/package.json +9 -8
package/dist/index.cjs
CHANGED
|
@@ -20,25 +20,68 @@ 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_BODY_WRITE_EXCLUSIONS: () => ACTION_BODY_WRITE_EXCLUSIONS,
|
|
24
|
+
ACTION_BODY_WRITE_PATTERNS: () => ACTION_BODY_WRITE_PATTERNS,
|
|
25
|
+
ACTION_BODY_WRITE_PATTERN_IDS: () => ACTION_BODY_WRITE_PATTERN_IDS,
|
|
26
|
+
ACTION_BODY_WRITE_UNKNOWN_FIELD: () => ACTION_BODY_WRITE_UNKNOWN_FIELD,
|
|
27
|
+
ACTION_NAME_UNDEFINED: () => ACTION_NAME_UNDEFINED,
|
|
28
|
+
ACTION_RECORD_WRITE_DISCARDED: () => ACTION_RECORD_WRITE_DISCARDED,
|
|
29
|
+
ACTION_RECORD_WRITE_PATTERNS: () => ACTION_RECORD_WRITE_PATTERNS,
|
|
30
|
+
ACTION_RECORD_WRITE_PATTERN_IDS: () => ACTION_RECORD_WRITE_PATTERN_IDS,
|
|
31
|
+
AGENT_AUTHORING_WITHDRAWN: () => AGENT_AUTHORING_WITHDRAWN,
|
|
32
|
+
AI_SKILL_SURFACE_MISMATCH: () => AI_SKILL_SURFACE_MISMATCH,
|
|
33
|
+
AI_SKILL_TOOL_UNRESOLVED: () => AI_SKILL_TOOL_UNRESOLVED,
|
|
34
|
+
APPROVAL_APPROVERS_MAY_RESOLVE_EMPTY: () => APPROVAL_APPROVERS_MAY_RESOLVE_EMPTY,
|
|
35
|
+
APPROVAL_APPROVER_CROSS_ORG_UNSUPPORTED: () => APPROVAL_APPROVER_CROSS_ORG_UNSUPPORTED,
|
|
23
36
|
APPROVAL_APPROVER_NOT_MEMBERSHIP_TIER: () => APPROVAL_APPROVER_NOT_MEMBERSHIP_TIER,
|
|
24
37
|
APPROVAL_APPROVER_TYPE_DEPRECATED: () => APPROVAL_APPROVER_TYPE_DEPRECATED,
|
|
25
38
|
APPROVAL_APPROVER_TYPE_UNKNOWN: () => APPROVAL_APPROVER_TYPE_UNKNOWN,
|
|
39
|
+
APPROVAL_DECISION_OUTPUTS_RESERVED: () => APPROVAL_DECISION_OUTPUTS_RESERVED,
|
|
26
40
|
APPROVAL_ESCALATION_REASSIGN_NO_TARGET: () => APPROVAL_ESCALATION_REASSIGN_NO_TARGET,
|
|
41
|
+
APPROVAL_EXPRESSION_INVALID: () => APPROVAL_EXPRESSION_INVALID,
|
|
42
|
+
APPROVAL_EXPRESSION_NO_EMPTY_POLICY: () => APPROVAL_EXPRESSION_NO_EMPTY_POLICY,
|
|
27
43
|
CAPABILITY_REFERENCE_UNKNOWN: () => CAPABILITY_REFERENCE_UNKNOWN,
|
|
44
|
+
CHART_AXIS_NOT_SELECTED: () => CHART_AXIS_NOT_SELECTED,
|
|
28
45
|
CHART_CONFIG_MISSING: () => CHART_CONFIG_MISSING,
|
|
46
|
+
CHART_DATASET_UNKNOWN: () => CHART_DATASET_UNKNOWN,
|
|
47
|
+
CHART_DIMENSION_UNKNOWN: () => CHART_DIMENSION_UNKNOWN,
|
|
29
48
|
CHART_FIELD_UNKNOWN: () => CHART_FIELD_UNKNOWN,
|
|
49
|
+
CHART_MEASURE_UNKNOWN: () => CHART_MEASURE_UNKNOWN,
|
|
30
50
|
DASHBOARD_ACTION_ROUTE_UNRESOLVED: () => DASHBOARD_ACTION_ROUTE_UNRESOLVED,
|
|
31
51
|
DASHBOARD_ACTION_TARGET_UNDEFINED: () => DASHBOARD_ACTION_TARGET_UNDEFINED,
|
|
32
52
|
DASHBOARD_FILTER_FIELD_UNKNOWN: () => DASHBOARD_FILTER_FIELD_UNKNOWN,
|
|
33
53
|
FIELD_GROUP_EMPTY: () => FIELD_GROUP_EMPTY,
|
|
34
54
|
FIELD_GROUP_UNDECLARED: () => FIELD_GROUP_UNDECLARED,
|
|
55
|
+
FILTER_TOKEN_UNKNOWN: () => FILTER_TOKEN_UNKNOWN,
|
|
35
56
|
FLOW_DRAFT_STATUS_AMBIGUOUS: () => FLOW_DRAFT_STATUS_AMBIGUOUS,
|
|
57
|
+
FLOW_NODE_WRITE_UNKNOWN_FIELD: () => FLOW_NODE_WRITE_UNKNOWN_FIELD,
|
|
58
|
+
FLOW_TEMPLATE_LOOKUP_TRAVERSAL: () => FLOW_TEMPLATE_LOOKUP_TRAVERSAL,
|
|
59
|
+
FLOW_TEMPLATE_UNKNOWN_FIELD: () => FLOW_TEMPLATE_UNKNOWN_FIELD,
|
|
36
60
|
FLOW_TRIGGER_UNKNOWN_OBJECT: () => FLOW_TRIGGER_UNKNOWN_OBJECT,
|
|
61
|
+
FLOW_UPDATE_READONLY_FIELD: () => FLOW_UPDATE_READONLY_FIELD,
|
|
62
|
+
FLOW_UPDATE_READONLY_WHEN_FIELD: () => FLOW_UPDATE_READONLY_WHEN_FIELD,
|
|
63
|
+
FLOW_WRITE_NODE_TYPES: () => FLOW_WRITE_NODE_TYPES,
|
|
64
|
+
FLOW_WRITE_NODE_TYPES_DEFERRED: () => FLOW_WRITE_NODE_TYPES_DEFERRED,
|
|
37
65
|
FORM_COLSPAN_ABSOLUTE: () => FORM_COLSPAN_ABSOLUTE,
|
|
38
66
|
FORM_FIELD_UNKNOWN: () => FORM_FIELD_UNKNOWN,
|
|
67
|
+
HOOK_BODY_WRITE_EXCLUSIONS: () => HOOK_BODY_WRITE_EXCLUSIONS,
|
|
68
|
+
HOOK_BODY_WRITE_PATTERNS: () => HOOK_BODY_WRITE_PATTERNS,
|
|
69
|
+
HOOK_BODY_WRITE_PATTERN_IDS: () => HOOK_BODY_WRITE_PATTERN_IDS,
|
|
70
|
+
HOOK_BODY_WRITE_UNKNOWN_FIELD: () => HOOK_BODY_WRITE_UNKNOWN_FIELD,
|
|
39
71
|
LIST_VIEW_FILTERS_IN_VIEWS_MODE: () => LIST_VIEW_FILTERS_IN_VIEWS_MODE,
|
|
40
72
|
MEASURE_AGGREGATE_INCOHERENT: () => MEASURE_AGGREGATE_INCOHERENT,
|
|
73
|
+
NAV_OBJECT_UNGRANTED: () => NAV_OBJECT_UNGRANTED,
|
|
74
|
+
OBJECT_REFERENCE_UNKNOWN: () => OBJECT_REFERENCE_UNKNOWN,
|
|
75
|
+
OBJECT_REFERENCE_UNREGISTERED_PLATFORM: () => OBJECT_REFERENCE_UNREGISTERED_PLATFORM,
|
|
76
|
+
ORG_AXIS_CROSS_ORG_BU_GRANT: () => ORG_AXIS_CROSS_ORG_BU_GRANT,
|
|
77
|
+
ORG_AXIS_PERMISSION_INHERITANCE: () => ORG_AXIS_PERMISSION_INHERITANCE,
|
|
78
|
+
PAGE_FIELD_UNKNOWN: () => PAGE_FIELD_UNKNOWN,
|
|
41
79
|
PAGE_SOURCE_CLASSNAME: () => PAGE_SOURCE_CLASSNAME,
|
|
80
|
+
REACT_CHART_AGGREGATE_INVALID: () => REACT_CHART_AGGREGATE_INVALID,
|
|
81
|
+
REACT_CHART_AXIS_UNKNOWN: () => REACT_CHART_AXIS_UNKNOWN,
|
|
82
|
+
REACT_CHART_FIELD_UNKNOWN: () => REACT_CHART_FIELD_UNKNOWN,
|
|
83
|
+
REFERENCE_INTEGRITY_RULES: () => REFERENCE_INTEGRITY_RULES,
|
|
84
|
+
SEARCHABLE_FIELD_UNKNOWN: () => SEARCHABLE_FIELD_UNKNOWN,
|
|
42
85
|
SECURITY_ANCHOR_HIGH_PRIVILEGE: () => SECURITY_ANCHOR_HIGH_PRIVILEGE,
|
|
43
86
|
SECURITY_BOOK_AUDIENCE_UNKNOWN_SET: () => SECURITY_BOOK_AUDIENCE_UNKNOWN_SET,
|
|
44
87
|
SECURITY_DELEGATION_MISSING_REASON: () => SECURITY_DELEGATION_MISSING_REASON,
|
|
@@ -50,6 +93,8 @@ __export(index_exports, {
|
|
|
50
93
|
SECURITY_PRIVATE_NO_READSCOPE: () => SECURITY_PRIVATE_NO_READSCOPE,
|
|
51
94
|
SECURITY_ROLE_WORD: () => SECURITY_ROLE_WORD,
|
|
52
95
|
SECURITY_WILDCARD_VAMA: () => SECURITY_WILDCARD_VAMA,
|
|
96
|
+
SEED_INSERT_MODE_DUPLICATES_ON_REPLAY: () => SEED_INSERT_MODE_DUPLICATES_ON_REPLAY,
|
|
97
|
+
SEED_VALUE_OUTSIDE_STATE_MACHINE: () => SEED_VALUE_OUTSIDE_STATE_MACHINE,
|
|
53
98
|
SEMANTIC_ROLE_FIELD_UNKNOWN: () => SEMANTIC_ROLE_FIELD_UNKNOWN,
|
|
54
99
|
STYLE_CLASSNAME_TAILWIND: () => STYLE_CLASSNAME_TAILWIND,
|
|
55
100
|
STYLE_NODE_MISSING_ID: () => STYLE_NODE_MISSING_ID,
|
|
@@ -59,6 +104,8 @@ __export(index_exports, {
|
|
|
59
104
|
TABLE_COUNT_ONLY: () => TABLE_COUNT_ONLY,
|
|
60
105
|
TITLE_FORMAT_RETIRED: () => TITLE_FORMAT_RETIRED,
|
|
61
106
|
TITLE_UNRESOLVABLE: () => TITLE_UNRESOLVABLE,
|
|
107
|
+
TRANSLATION_OPTION_KEY_UNKNOWN: () => TRANSLATION_OPTION_KEY_UNKNOWN,
|
|
108
|
+
TRANSLATION_TARGET_UNKNOWN: () => TRANSLATION_TARGET_UNKNOWN,
|
|
62
109
|
VIEW_CONTAINER_SHAPE: () => VIEW_CONTAINER_SHAPE,
|
|
63
110
|
VISIBILITY_ALIAS_DEPRECATED: () => VISIBILITY_ALIAS_DEPRECATED,
|
|
64
111
|
VISIBILITY_ROOT_MISLAYERED: () => VISIBILITY_ROOT_MISLAYERED,
|
|
@@ -67,21 +114,43 @@ __export(index_exports, {
|
|
|
67
114
|
WIDGET_MEASURE_UNKNOWN: () => WIDGET_MEASURE_UNKNOWN,
|
|
68
115
|
buildAccessMatrix: () => buildAccessMatrix,
|
|
69
116
|
diffAccessMatrix: () => diffAccessMatrix,
|
|
117
|
+
extractHookBodyWriteSet: () => extractHookBodyWriteSet,
|
|
118
|
+
extractHookBodyWrites: () => extractHookBodyWrites,
|
|
119
|
+
validateActionBodyWrites: () => validateActionBodyWrites,
|
|
120
|
+
validateActionNameRefs: () => validateActionNameRefs,
|
|
121
|
+
validateAiAgentAuthoring: () => validateAiAgentAuthoring,
|
|
122
|
+
validateAiSurfaceAffinity: () => validateAiSurfaceAffinity,
|
|
123
|
+
validateAiToolReferences: () => validateAiToolReferences,
|
|
70
124
|
validateApprovalApprovers: () => validateApprovalApprovers,
|
|
71
125
|
validateCapabilityReferences: () => validateCapabilityReferences,
|
|
126
|
+
validateChartBindings: () => validateChartBindings,
|
|
72
127
|
validateDashboardActionRefs: () => validateDashboardActionRefs,
|
|
128
|
+
validateFilterTokens: () => validateFilterTokens,
|
|
129
|
+
validateFlowNodeWrites: () => validateFlowNodeWrites,
|
|
130
|
+
validateFlowTemplatePaths: () => validateFlowTemplatePaths,
|
|
73
131
|
validateFlowTriggerReadiness: () => validateFlowTriggerReadiness,
|
|
74
132
|
validateFormLayout: () => validateFormLayout,
|
|
133
|
+
validateHookBodyWrites: () => validateHookBodyWrites,
|
|
75
134
|
validateJsxPages: () => validateJsxPages,
|
|
76
135
|
validateListViewMode: () => validateListViewMode,
|
|
136
|
+
validateNavAccess: () => validateNavAccess,
|
|
137
|
+
validateObjectReferences: () => validateObjectReferences,
|
|
138
|
+
validateOrgAxisRedLines: () => validateOrgAxisRedLines,
|
|
139
|
+
validatePageFieldBindings: () => validatePageFieldBindings,
|
|
77
140
|
validatePageSourceStyling: () => validatePageSourceStyling,
|
|
78
141
|
validateReactPageProps: () => validateReactPageProps,
|
|
79
142
|
validateReactPages: () => validateReactPages,
|
|
143
|
+
validateReadonlyFlowWrites: () => validateReadonlyFlowWrites,
|
|
80
144
|
validateRecordTitle: () => validateRecordTitle,
|
|
145
|
+
validateReferenceIntegrity: () => validateReferenceIntegrity,
|
|
81
146
|
validateResponsiveStyles: () => validateResponsiveStyles,
|
|
147
|
+
validateSearchableFields: () => validateSearchableFields,
|
|
82
148
|
validateSecurityPosture: () => validateSecurityPosture,
|
|
149
|
+
validateSeedReplaySafety: () => validateSeedReplaySafety,
|
|
150
|
+
validateSeedStateMachine: () => validateSeedStateMachine,
|
|
83
151
|
validateSemanticRoles: () => validateSemanticRoles,
|
|
84
152
|
validateStackExpressions: () => validateStackExpressions,
|
|
153
|
+
validateTranslationReferences: () => validateTranslationReferences,
|
|
85
154
|
validateViewContainers: () => validateViewContainers,
|
|
86
155
|
validateVisibilityPredicates: () => validateVisibilityPredicates,
|
|
87
156
|
validateWidgetBindings: () => validateWidgetBindings
|
|
@@ -89,7 +158,18 @@ __export(index_exports, {
|
|
|
89
158
|
module.exports = __toCommonJS(index_exports);
|
|
90
159
|
|
|
91
160
|
// src/validate-widget-bindings.ts
|
|
161
|
+
var import_data2 = require("@objectstack/spec/data");
|
|
162
|
+
var import_ui = require("@objectstack/spec/ui");
|
|
163
|
+
|
|
164
|
+
// src/system-fields.ts
|
|
92
165
|
var import_data = require("@objectstack/spec/data");
|
|
166
|
+
var import_system = require("@objectstack/spec/system");
|
|
167
|
+
var SYSTEM_FIELDS = /* @__PURE__ */ new Set([
|
|
168
|
+
...import_data.FIELD_GROUP_SYSTEM_FIELDS,
|
|
169
|
+
...Object.values(import_system.SystemFieldName)
|
|
170
|
+
]);
|
|
171
|
+
|
|
172
|
+
// src/validate-widget-bindings.ts
|
|
93
173
|
var WIDGET_DATASET_UNKNOWN = "widget-dataset-unknown";
|
|
94
174
|
var WIDGET_DIMENSION_UNKNOWN = "widget-dimension-unknown";
|
|
95
175
|
var WIDGET_MEASURE_UNKNOWN = "widget-measure-unknown";
|
|
@@ -120,20 +200,18 @@ function asArray(v) {
|
|
|
120
200
|
function asStrings(v) {
|
|
121
201
|
return Array.isArray(v) ? v.filter((s) => typeof s === "string") : [];
|
|
122
202
|
}
|
|
123
|
-
var
|
|
124
|
-
"
|
|
125
|
-
"
|
|
126
|
-
"
|
|
127
|
-
"
|
|
128
|
-
"
|
|
129
|
-
"
|
|
130
|
-
"
|
|
131
|
-
"funnel",
|
|
132
|
-
"scatter",
|
|
133
|
-
"treemap",
|
|
134
|
-
"sankey",
|
|
135
|
-
"radar"
|
|
203
|
+
var MEASURE_EXEMPT_CHART_TYPES = /* @__PURE__ */ new Set([
|
|
204
|
+
"gauge",
|
|
205
|
+
"solid-gauge",
|
|
206
|
+
"metric",
|
|
207
|
+
"kpi",
|
|
208
|
+
"bullet",
|
|
209
|
+
"table",
|
|
210
|
+
"pivot"
|
|
136
211
|
]);
|
|
212
|
+
var CHART_TYPES = new Set(
|
|
213
|
+
import_ui.ChartTypeSchema.options.filter((t) => !MEASURE_EXEMPT_CHART_TYPES.has(t))
|
|
214
|
+
);
|
|
137
215
|
function levenshtein(a, b) {
|
|
138
216
|
const m = a.length, n = b.length;
|
|
139
217
|
let prev = Array.from({ length: n + 1 }, (_, j) => j);
|
|
@@ -179,18 +257,6 @@ function list(names) {
|
|
|
179
257
|
}
|
|
180
258
|
var DATE_RANGE_FILTER_NAME = "dateRange";
|
|
181
259
|
var DATE_RANGE_DEFAULT_FIELD = "created_at";
|
|
182
|
-
var SYSTEM_FIELDS = /* @__PURE__ */ new Set([
|
|
183
|
-
"id",
|
|
184
|
-
"created_at",
|
|
185
|
-
"created_by",
|
|
186
|
-
"updated_at",
|
|
187
|
-
"updated_by",
|
|
188
|
-
"owner_id",
|
|
189
|
-
"organization_id",
|
|
190
|
-
"tenant_id",
|
|
191
|
-
"user_id",
|
|
192
|
-
"deleted_at"
|
|
193
|
-
]);
|
|
194
260
|
function dashboardFilterDefs(dash) {
|
|
195
261
|
const byName = /* @__PURE__ */ new Map();
|
|
196
262
|
const dateRange = dash.dateRange;
|
|
@@ -245,7 +311,7 @@ function validateWidgetBindings(stack) {
|
|
|
245
311
|
const aggregate = typeof m.aggregate === "string" ? m.aggregate : void 0;
|
|
246
312
|
if (!field || !aggregate) continue;
|
|
247
313
|
const ftype = fieldTypes.get(field);
|
|
248
|
-
if (ftype && (0,
|
|
314
|
+
if (ftype && (0, import_data2.isIncoherentAggregate)(aggregate, ftype)) {
|
|
249
315
|
findings.push({
|
|
250
316
|
severity: "warning",
|
|
251
317
|
rule: MEASURE_AGGREGATE_INCOHERENT,
|
|
@@ -306,6 +372,15 @@ function validateWidgetBindings(stack) {
|
|
|
306
372
|
hint: `Declared datasets: ${list(datasets.keys())}.${suggest(dsName, datasets.keys())} Define the dataset with defineDataset() or fix the reference (ADR-0021).`
|
|
307
373
|
});
|
|
308
374
|
}
|
|
375
|
+
if (!dsName) {
|
|
376
|
+
push({
|
|
377
|
+
severity: "error",
|
|
378
|
+
rule: WIDGET_DATASET_UNKNOWN,
|
|
379
|
+
message: `binds no \`dataset\` \u2014 the ADR-0021 widget shape requires one, so this widget resolves no data and renders empty.`,
|
|
380
|
+
hint: `Set \`dataset: '<name>'\` (plus \`values\`, and \`dimensions\` where the chart family needs them). Declared datasets: ${list(datasets.keys())}.`
|
|
381
|
+
});
|
|
382
|
+
continue;
|
|
383
|
+
}
|
|
309
384
|
if (!dataset) continue;
|
|
310
385
|
if (dashFilterDefs.length > 0) {
|
|
311
386
|
const datasetObject = typeof dataset.object === "string" ? dataset.object : void 0;
|
|
@@ -367,13 +442,13 @@ function validateWidgetBindings(stack) {
|
|
|
367
442
|
hint: `Point xAxis.field at a dataset dimension name.${suggest(xAxis.field, dimensionNames)}`
|
|
368
443
|
});
|
|
369
444
|
}
|
|
370
|
-
const measureField = (
|
|
445
|
+
const measureField = (label2, field) => {
|
|
371
446
|
if (values.includes(field)) return;
|
|
372
447
|
const declaredButUnselected = measures.has(field);
|
|
373
448
|
push({
|
|
374
449
|
severity: "error",
|
|
375
450
|
rule: CHART_FIELD_UNKNOWN,
|
|
376
|
-
message: declaredButUnselected ? `chartConfig.${
|
|
451
|
+
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())}).`,
|
|
377
452
|
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())}`
|
|
378
453
|
});
|
|
379
454
|
};
|
|
@@ -415,6 +490,7 @@ function validateWidgetBindings(stack) {
|
|
|
415
490
|
|
|
416
491
|
// src/validate-expressions.ts
|
|
417
492
|
var import_formula = require("@objectstack/formula");
|
|
493
|
+
var import_automation = require("@objectstack/spec/automation");
|
|
418
494
|
function asArray2(v) {
|
|
419
495
|
if (Array.isArray(v)) return v;
|
|
420
496
|
if (v && typeof v === "object") {
|
|
@@ -475,37 +551,53 @@ function validateStackExpressions(stack) {
|
|
|
475
551
|
for (const e of res.errors) issues.push({ where, message: e.message, source: e.source, severity: "error" });
|
|
476
552
|
for (const w of res.warnings) issues.push({ where, message: w.message, source: w.source, severity: "warning" });
|
|
477
553
|
};
|
|
554
|
+
const checkDeclaredPredicate = (where, raw) => {
|
|
555
|
+
if (raw == null) return;
|
|
556
|
+
const res = (0, import_formula.validateExpression)("predicate", raw);
|
|
557
|
+
for (const e of res.errors) issues.push({ where, message: e.message, source: e.source, severity: "error" });
|
|
558
|
+
for (const w of res.warnings) issues.push({ where, message: w.message, source: w.source, severity: "warning" });
|
|
559
|
+
};
|
|
478
560
|
for (const flow of asArray2(stack.flows)) {
|
|
479
561
|
const flowName = typeof flow.name === "string" ? flow.name : "(unnamed flow)";
|
|
480
562
|
const nodes = Array.isArray(flow.nodes) ? flow.nodes : [];
|
|
481
|
-
const edges = Array.isArray(flow.edges) ? flow.edges : [];
|
|
482
563
|
const startNode = nodes.find((n) => n.type === "start");
|
|
483
564
|
const startCfg = startNode?.config ?? {};
|
|
484
565
|
const objectName = typeof startCfg.objectName === "string" ? startCfg.objectName : void 0;
|
|
485
|
-
for (const
|
|
486
|
-
const
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
const
|
|
491
|
-
const
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
566
|
+
for (const graph of (0, import_automation.collectFlowGraphs)(flow)) {
|
|
567
|
+
const at = graph.scope ? `flow '${flowName}' \xB7 ${graph.scope}` : `flow '${flowName}'`;
|
|
568
|
+
for (const node of graph.nodes) {
|
|
569
|
+
const cfg = node.config ?? {};
|
|
570
|
+
check(`${at} \xB7 node '${node.id}' (${node.type}) condition`, cfg.condition, objectName);
|
|
571
|
+
const nodeType = typeof node.type === "string" ? node.type : "";
|
|
572
|
+
for (const found of (0, import_automation.resolveFlowNodeExpressions)(nodeType, cfg)) {
|
|
573
|
+
if (found.entry.role !== "predicate") continue;
|
|
574
|
+
checkDeclaredPredicate(
|
|
575
|
+
`${at} \xB7 node '${node.id}' (${nodeType}) ${found.entry.label} at config.${found.path}`,
|
|
576
|
+
found.value
|
|
577
|
+
);
|
|
578
|
+
}
|
|
579
|
+
if (node.type === "script") {
|
|
580
|
+
const fn = (typeof cfg.function === "string" ? cfg.function.trim() : "") || (typeof cfg.functionName === "string" ? cfg.functionName.trim() : "");
|
|
581
|
+
const action = typeof cfg.actionType === "string" ? cfg.actionType.trim() : "";
|
|
582
|
+
const inline = typeof cfg.script === "string" ? cfg.script.trim() : "";
|
|
583
|
+
if (!fn && !action && !inline) {
|
|
584
|
+
issues.push({
|
|
585
|
+
where: `${at} \xB7 node '${node.id}' (script) callable`,
|
|
586
|
+
message: `script node declares neither \`actionType\` nor \`function\` \u2014 it would do nothing at runtime. Name a built-in action (e.g. \`actionType: 'email'\`) or a registered function (\`function: 'my_fn'\`, registered via \`defineStack({ functions })\`).`,
|
|
587
|
+
source: JSON.stringify({ id: node.id, type: node.type, config: cfg })
|
|
588
|
+
});
|
|
589
|
+
} else if (action === "invoke_function" && !fn) {
|
|
590
|
+
issues.push({
|
|
591
|
+
where: `${at} \xB7 node '${node.id}' (script) callable`,
|
|
592
|
+
message: `script node uses \`actionType: 'invoke_function'\` but no \`function\` (or \`functionName\`) \u2014 it names no callable. Set \`function: 'my_fn'\` and register it via \`defineStack({ functions })\`.`,
|
|
593
|
+
source: JSON.stringify({ id: node.id, type: node.type, config: cfg })
|
|
594
|
+
});
|
|
595
|
+
}
|
|
504
596
|
}
|
|
505
597
|
}
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
598
|
+
for (const edge of graph.edges) {
|
|
599
|
+
check(`${at} \xB7 edge '${edge.id}' (${edge.source}\u2192${edge.target}) condition`, edge.condition, objectName);
|
|
600
|
+
}
|
|
509
601
|
}
|
|
510
602
|
}
|
|
511
603
|
for (const obj of objects) {
|
|
@@ -564,8 +656,33 @@ function validateStackExpressions(stack) {
|
|
|
564
656
|
check(where, rule.condition ?? rule.criteria ?? rule.predicate, ruleObj, "record");
|
|
565
657
|
}
|
|
566
658
|
for (const hook of asArray2(stack.hooks)) {
|
|
567
|
-
const
|
|
568
|
-
|
|
659
|
+
const hookName = hook.name ?? "?";
|
|
660
|
+
if (typeof hook.object === "string") {
|
|
661
|
+
check(`hook '${hookName}' (${hook.object}) condition`, hook.condition, hook.object, "record");
|
|
662
|
+
continue;
|
|
663
|
+
}
|
|
664
|
+
const targets = Array.isArray(hook.object) ? hook.object.filter((o) => typeof o === "string" && o !== "*") : [];
|
|
665
|
+
if (targets.length === 0) {
|
|
666
|
+
check(`hook '${hookName}' condition`, hook.condition, void 0, "record");
|
|
667
|
+
continue;
|
|
668
|
+
}
|
|
669
|
+
const before = issues.length;
|
|
670
|
+
const seen = /* @__PURE__ */ new Set();
|
|
671
|
+
const kept = [];
|
|
672
|
+
for (const target of targets) {
|
|
673
|
+
const mark = issues.length;
|
|
674
|
+
check(`hook '${hookName}' (${target}) condition`, hook.condition, target, "record");
|
|
675
|
+
for (let i = mark; i < issues.length; i++) {
|
|
676
|
+
const issue = issues[i];
|
|
677
|
+
const key = `${issue.message}\0${issue.source ?? ""}`;
|
|
678
|
+
if (!seen.has(key)) {
|
|
679
|
+
seen.add(key);
|
|
680
|
+
kept.push(issue);
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
issues.length = before;
|
|
685
|
+
issues.push(...kept);
|
|
569
686
|
}
|
|
570
687
|
return issues;
|
|
571
688
|
}
|
|
@@ -624,14 +741,14 @@ function scanListViews(listViews, wherePrefix, pathPrefix, out) {
|
|
|
624
741
|
function validateListViewMode(stack) {
|
|
625
742
|
const out = [];
|
|
626
743
|
asArray3(stack.objects).forEach((obj, i) => {
|
|
627
|
-
const
|
|
628
|
-
scanListViews(obj.listViews,
|
|
744
|
+
const label2 = typeof obj.name === "string" ? `object "${obj.name}"` : `objects[${i}]`;
|
|
745
|
+
scanListViews(obj.listViews, label2, `objects[${i}]`, out);
|
|
629
746
|
});
|
|
630
747
|
asArray3(stack.views).forEach((view, i) => {
|
|
631
748
|
const named = typeof view.objectName === "string" ? view.objectName : typeof view.name === "string" ? view.name : void 0;
|
|
632
|
-
const
|
|
633
|
-
scanView(view.list, `${
|
|
634
|
-
scanListViews(view.listViews,
|
|
749
|
+
const label2 = named ? `view "${named}"` : `views[${i}]`;
|
|
750
|
+
scanView(view.list, `${label2} \u203A list`, `views[${i}].list`, out);
|
|
751
|
+
scanListViews(view.listViews, label2, `views[${i}]`, out);
|
|
635
752
|
});
|
|
636
753
|
return out;
|
|
637
754
|
}
|
|
@@ -639,6 +756,8 @@ function validateListViewMode(stack) {
|
|
|
639
756
|
// src/validate-flow-trigger-readiness.ts
|
|
640
757
|
var FLOW_TRIGGER_UNKNOWN_OBJECT = "flow-trigger-unknown-object";
|
|
641
758
|
var FLOW_DRAFT_STATUS_AMBIGUOUS = "flow-draft-status-ambiguous";
|
|
759
|
+
var FLOW_TRIGGER_UNKNOWN_EVENT = "flow-trigger-unknown-event";
|
|
760
|
+
var VALID_RECORD_TRIGGER = /^record-(?:before|after)-(?:create|insert|update|delete|write)$/;
|
|
642
761
|
function asArray4(v) {
|
|
643
762
|
if (Array.isArray(v)) return v;
|
|
644
763
|
if (v && typeof v === "object") {
|
|
@@ -666,10 +785,11 @@ function validateFlowTriggerReadiness(stack) {
|
|
|
666
785
|
const start = startNodeOf(flow);
|
|
667
786
|
const config = start?.node.config ?? {};
|
|
668
787
|
const triggerType = typeof config.triggerType === "string" ? config.triggerType : void 0;
|
|
669
|
-
const
|
|
788
|
+
const isRecordTriggered2 = !!triggerType && triggerType.startsWith("record-");
|
|
789
|
+
const isArrayRecordTriggered = Array.isArray(config.triggerType) && config.triggerType.some((t) => typeof t === "string" && t.startsWith("record-"));
|
|
670
790
|
const isTimeRelative = config.timeRelative != null && typeof config.timeRelative === "object";
|
|
671
|
-
const isAutoTriggered =
|
|
672
|
-
if (
|
|
791
|
+
const isAutoTriggered = isRecordTriggered2 || triggerType === "api" || config.schedule != null || isTimeRelative || flow.type === "schedule" || flow.type === "api";
|
|
792
|
+
if (isRecordTriggered2 && start) {
|
|
673
793
|
const objectName = typeof config.objectName === "string" ? config.objectName : void 0;
|
|
674
794
|
if (objectName && !objectNames.has(objectName) && !objectName.startsWith("sys_")) {
|
|
675
795
|
findings.push({
|
|
@@ -696,6 +816,26 @@ function validateFlowTriggerReadiness(stack) {
|
|
|
696
816
|
});
|
|
697
817
|
}
|
|
698
818
|
}
|
|
819
|
+
if (start && isRecordTriggered2 && !VALID_RECORD_TRIGGER.test((triggerType ?? "").trim())) {
|
|
820
|
+
findings.push({
|
|
821
|
+
severity: "warning",
|
|
822
|
+
rule: FLOW_TRIGGER_UNKNOWN_EVENT,
|
|
823
|
+
where: `flow "${flowName}" \u203A start node`,
|
|
824
|
+
path: `flows[${flowIndex}].nodes[${start.index}].config.triggerType`,
|
|
825
|
+
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).`,
|
|
826
|
+
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).`
|
|
827
|
+
});
|
|
828
|
+
}
|
|
829
|
+
if (start && isArrayRecordTriggered) {
|
|
830
|
+
findings.push({
|
|
831
|
+
severity: "warning",
|
|
832
|
+
rule: FLOW_TRIGGER_UNKNOWN_EVENT,
|
|
833
|
+
where: `flow "${flowName}" \u203A start node`,
|
|
834
|
+
path: `flows[${flowIndex}].nodes[${start.index}].config.triggerType`,
|
|
835
|
+
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).`,
|
|
836
|
+
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).`
|
|
837
|
+
});
|
|
838
|
+
}
|
|
699
839
|
if (isAutoTriggered && (flow.status == null || flow.status === "draft")) {
|
|
700
840
|
findings.push({
|
|
701
841
|
severity: "warning",
|
|
@@ -710,6 +850,375 @@ function validateFlowTriggerReadiness(stack) {
|
|
|
710
850
|
return findings;
|
|
711
851
|
}
|
|
712
852
|
|
|
853
|
+
// src/flow-walk.ts
|
|
854
|
+
var import_automation2 = require("@objectstack/spec/automation");
|
|
855
|
+
function isRec(v) {
|
|
856
|
+
return !!v && typeof v === "object" && !Array.isArray(v);
|
|
857
|
+
}
|
|
858
|
+
function strName(v) {
|
|
859
|
+
return typeof v === "string" && v.length > 0 ? v : void 0;
|
|
860
|
+
}
|
|
861
|
+
var REGION_SLOTS = new Map(
|
|
862
|
+
[...import_automation2.FLOW_REGION_SLOTS_BY_TYPE].map(([type, slots]) => [type, slots.map((s) => s.key)])
|
|
863
|
+
);
|
|
864
|
+
var REGION_CONFIG_KEYS = import_automation2.FLOW_REGION_CONFIG_KEYS;
|
|
865
|
+
var MAX_REGION_DEPTH = 16;
|
|
866
|
+
function flowNodeLabel(node, index) {
|
|
867
|
+
return strName(node.label) ?? strName(node.id) ?? `#${index}`;
|
|
868
|
+
}
|
|
869
|
+
function stripRegions(config) {
|
|
870
|
+
if (!isRec(config)) return void 0;
|
|
871
|
+
let out;
|
|
872
|
+
for (const key of Object.keys(config)) {
|
|
873
|
+
if (!REGION_CONFIG_KEYS.has(key)) continue;
|
|
874
|
+
out ?? (out = { ...config });
|
|
875
|
+
delete out[key];
|
|
876
|
+
}
|
|
877
|
+
return out ?? config;
|
|
878
|
+
}
|
|
879
|
+
function walkFlowNodes(flow, flowPath) {
|
|
880
|
+
const out = [];
|
|
881
|
+
if (!isRec(flow)) return out;
|
|
882
|
+
const visitList = (nodes, basePath, trail, depth) => {
|
|
883
|
+
if (!Array.isArray(nodes) || depth > MAX_REGION_DEPTH) return;
|
|
884
|
+
nodes.forEach((raw, index) => {
|
|
885
|
+
if (!isRec(raw)) return;
|
|
886
|
+
const path = `${basePath}[${index}]`;
|
|
887
|
+
out.push({
|
|
888
|
+
node: raw,
|
|
889
|
+
path,
|
|
890
|
+
localConfig: stripRegions(raw.config),
|
|
891
|
+
regionTrail: trail,
|
|
892
|
+
depth
|
|
893
|
+
});
|
|
894
|
+
const type = strName(raw.type);
|
|
895
|
+
const slots = type ? REGION_SLOTS.get(type) : void 0;
|
|
896
|
+
if (!slots || !isRec(raw.config)) return;
|
|
897
|
+
const config = raw.config;
|
|
898
|
+
const here = `${type} "${flowNodeLabel(raw, index)}"`;
|
|
899
|
+
for (const slot of slots) {
|
|
900
|
+
const value = config[slot];
|
|
901
|
+
if (slot === "branches") {
|
|
902
|
+
if (!Array.isArray(value)) continue;
|
|
903
|
+
value.forEach((branch, b) => {
|
|
904
|
+
if (!isRec(branch)) return;
|
|
905
|
+
const branchName = strName(branch.name) ?? `#${b}`;
|
|
906
|
+
visitList(
|
|
907
|
+
branch.nodes,
|
|
908
|
+
`${path}.config.branches[${b}].nodes`,
|
|
909
|
+
joinTrail(trail, `${here} \u203A branch ${branchName}`),
|
|
910
|
+
depth + 1
|
|
911
|
+
);
|
|
912
|
+
});
|
|
913
|
+
continue;
|
|
914
|
+
}
|
|
915
|
+
if (!isRec(value)) continue;
|
|
916
|
+
visitList(
|
|
917
|
+
value.nodes,
|
|
918
|
+
`${path}.config.${slot}.nodes`,
|
|
919
|
+
joinTrail(trail, `${here} \u203A ${slot}`),
|
|
920
|
+
depth + 1
|
|
921
|
+
);
|
|
922
|
+
}
|
|
923
|
+
});
|
|
924
|
+
};
|
|
925
|
+
visitList(flow.nodes, `${flowPath}.nodes`, "", 0);
|
|
926
|
+
return out;
|
|
927
|
+
}
|
|
928
|
+
function joinTrail(trail, segment) {
|
|
929
|
+
return trail ? `${trail} \u203A ${segment}` : segment;
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
// src/validate-flow-template-paths.ts
|
|
933
|
+
var FLOW_TEMPLATE_UNKNOWN_FIELD = "flow-template-unknown-field";
|
|
934
|
+
var FLOW_TEMPLATE_LOOKUP_TRAVERSAL = "flow-template-lookup-traversal";
|
|
935
|
+
function asArray5(v) {
|
|
936
|
+
if (Array.isArray(v)) return v;
|
|
937
|
+
if (v && typeof v === "object") {
|
|
938
|
+
return Object.entries(v).map(([name, def]) => ({
|
|
939
|
+
name,
|
|
940
|
+
...def
|
|
941
|
+
}));
|
|
942
|
+
}
|
|
943
|
+
return [];
|
|
944
|
+
}
|
|
945
|
+
var IMPLICIT_HEADS = /* @__PURE__ */ new Set([
|
|
946
|
+
...SYSTEM_FIELDS,
|
|
947
|
+
"name",
|
|
948
|
+
"owner",
|
|
949
|
+
"record_type"
|
|
950
|
+
]);
|
|
951
|
+
var RELATION_TYPES = /* @__PURE__ */ new Set([
|
|
952
|
+
"lookup",
|
|
953
|
+
"master_detail",
|
|
954
|
+
"user",
|
|
955
|
+
"tree"
|
|
956
|
+
]);
|
|
957
|
+
var FILTER_GUARDED_NODE_TYPES = /* @__PURE__ */ new Set([
|
|
958
|
+
"get_record",
|
|
959
|
+
"update_record",
|
|
960
|
+
"delete_record"
|
|
961
|
+
]);
|
|
962
|
+
function fieldTypesOf(obj) {
|
|
963
|
+
const types = /* @__PURE__ */ new Map();
|
|
964
|
+
for (const f of asArray5(obj.fields)) {
|
|
965
|
+
if (typeof f.name === "string") {
|
|
966
|
+
types.set(f.name, typeof f.type === "string" ? f.type : "");
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
return types;
|
|
970
|
+
}
|
|
971
|
+
function recordRefsIn(text) {
|
|
972
|
+
const refs = [];
|
|
973
|
+
const tokenRe = /\{([^{}]+)\}/g;
|
|
974
|
+
let m;
|
|
975
|
+
while ((m = tokenRe.exec(text)) !== null) {
|
|
976
|
+
const body = m[1].trim();
|
|
977
|
+
if (!/^[A-Za-z_$][\w$]*(?:\.(?:[A-Za-z_$][\w$]*|\d+))*$/.test(body)) continue;
|
|
978
|
+
const segments = body.split(".");
|
|
979
|
+
if (segments[0] !== "record") continue;
|
|
980
|
+
const rest = segments.slice(1);
|
|
981
|
+
if (rest.length > 0) refs.push(rest);
|
|
982
|
+
}
|
|
983
|
+
return refs;
|
|
984
|
+
}
|
|
985
|
+
function stringLeaves(value, out) {
|
|
986
|
+
if (typeof value === "string") {
|
|
987
|
+
if (value.includes("{")) out.push(value);
|
|
988
|
+
return;
|
|
989
|
+
}
|
|
990
|
+
if (Array.isArray(value)) {
|
|
991
|
+
for (const v of value) stringLeaves(v, out);
|
|
992
|
+
return;
|
|
993
|
+
}
|
|
994
|
+
if (value && typeof value === "object") {
|
|
995
|
+
for (const v of Object.values(value)) stringLeaves(v, out);
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
var NODE_CONFIG_KEYS = [
|
|
999
|
+
"config",
|
|
1000
|
+
"notify",
|
|
1001
|
+
"update_record",
|
|
1002
|
+
"create_record",
|
|
1003
|
+
"http",
|
|
1004
|
+
"script",
|
|
1005
|
+
"screen",
|
|
1006
|
+
"wait",
|
|
1007
|
+
"approval",
|
|
1008
|
+
"connector_action",
|
|
1009
|
+
"subflow",
|
|
1010
|
+
"decision",
|
|
1011
|
+
"start"
|
|
1012
|
+
];
|
|
1013
|
+
function collectNodeLeaves(node, guarded) {
|
|
1014
|
+
const filterLeaves = [];
|
|
1015
|
+
const otherLeaves = [];
|
|
1016
|
+
for (const key of NODE_CONFIG_KEYS) {
|
|
1017
|
+
if (!(key in node)) continue;
|
|
1018
|
+
const block = node[key];
|
|
1019
|
+
const splitFilter = guarded && !!block && typeof block === "object" && !Array.isArray(block);
|
|
1020
|
+
if (splitFilter) {
|
|
1021
|
+
const { filter, ...rest } = block;
|
|
1022
|
+
const inFilter = [];
|
|
1023
|
+
stringLeaves(filter, inFilter);
|
|
1024
|
+
for (const text of inFilter) filterLeaves.push({ text, inFilter: true });
|
|
1025
|
+
const outside = [];
|
|
1026
|
+
stringLeaves(rest, outside);
|
|
1027
|
+
for (const text of outside) otherLeaves.push({ text, inFilter: false });
|
|
1028
|
+
continue;
|
|
1029
|
+
}
|
|
1030
|
+
const plain = [];
|
|
1031
|
+
stringLeaves(block, plain);
|
|
1032
|
+
for (const text of plain) otherLeaves.push({ text, inFilter: false });
|
|
1033
|
+
}
|
|
1034
|
+
return [...filterLeaves, ...otherLeaves];
|
|
1035
|
+
}
|
|
1036
|
+
function isRecordTriggered(flow, startConfig) {
|
|
1037
|
+
if (flow.type === "record_change") return true;
|
|
1038
|
+
const triggerType = typeof startConfig.triggerType === "string" ? startConfig.triggerType : void 0;
|
|
1039
|
+
return !!triggerType && triggerType.startsWith("record-");
|
|
1040
|
+
}
|
|
1041
|
+
function boundObjectOf(flow) {
|
|
1042
|
+
const nodes = Array.isArray(flow.nodes) ? flow.nodes : [];
|
|
1043
|
+
const start = nodes.find((n) => n?.type === "start");
|
|
1044
|
+
if (!start) return void 0;
|
|
1045
|
+
const config = start.config ?? {};
|
|
1046
|
+
const typed = start.start ?? {};
|
|
1047
|
+
const fromConfig = typeof config.objectName === "string" ? config.objectName : void 0;
|
|
1048
|
+
const fromTyped = typeof typed.objectName === "string" ? typed.objectName : void 0;
|
|
1049
|
+
return fromConfig ?? fromTyped;
|
|
1050
|
+
}
|
|
1051
|
+
function declaredExpandOf(flow) {
|
|
1052
|
+
const nodes = Array.isArray(flow.nodes) ? flow.nodes : [];
|
|
1053
|
+
const start = nodes.find((n) => n?.type === "start");
|
|
1054
|
+
const raw = (start?.config ?? {}).expand;
|
|
1055
|
+
if (typeof raw === "string") return new Set(raw ? [raw] : []);
|
|
1056
|
+
if (Array.isArray(raw)) return new Set(raw.filter((r) => typeof r === "string" && r.length > 0));
|
|
1057
|
+
return /* @__PURE__ */ new Set();
|
|
1058
|
+
}
|
|
1059
|
+
function validateFlowTemplatePaths(stack) {
|
|
1060
|
+
const findings = [];
|
|
1061
|
+
const flows = asArray5(stack.flows);
|
|
1062
|
+
if (flows.length === 0) return findings;
|
|
1063
|
+
const objectsByName = /* @__PURE__ */ new Map();
|
|
1064
|
+
for (const obj of asArray5(stack.objects)) {
|
|
1065
|
+
if (typeof obj.name === "string") objectsByName.set(obj.name, obj);
|
|
1066
|
+
}
|
|
1067
|
+
flows.forEach((flow, flowIndex) => {
|
|
1068
|
+
const flowName = typeof flow.name === "string" ? flow.name : `#${flowIndex}`;
|
|
1069
|
+
const nodes = Array.isArray(flow.nodes) ? flow.nodes : [];
|
|
1070
|
+
const start = nodes.find((n) => n?.type === "start")?.config ?? {};
|
|
1071
|
+
if (!isRecordTriggered(flow, start)) return;
|
|
1072
|
+
const objectName = boundObjectOf(flow);
|
|
1073
|
+
if (!objectName) return;
|
|
1074
|
+
const obj = objectsByName.get(objectName);
|
|
1075
|
+
if (!obj) return;
|
|
1076
|
+
const fieldTypes = fieldTypesOf(obj);
|
|
1077
|
+
const expandSet = declaredExpandOf(flow);
|
|
1078
|
+
walkFlowNodes(flow, `flows[${flowIndex}]`).forEach(({ node, path: nodePath, regionTrail, localConfig }, walkIndex) => {
|
|
1079
|
+
const nodeLabel = typeof node.type === "string" ? node.type : typeof node.id === "string" ? node.id : `#${walkIndex}`;
|
|
1080
|
+
const where = regionTrail ? `flow "${flowName}" ${regionTrail} node "${nodeLabel}"` : `flow "${flowName}" node "${nodeLabel}"`;
|
|
1081
|
+
const nodeType = typeof node.type === "string" ? node.type : "";
|
|
1082
|
+
const guarded = FILTER_GUARDED_NODE_TYPES.has(nodeType);
|
|
1083
|
+
const scanNode = localConfig !== void 0 && localConfig !== node.config ? { ...node, config: localConfig } : node;
|
|
1084
|
+
const leaves = collectNodeLeaves(scanNode, guarded);
|
|
1085
|
+
if (leaves.length === 0) return;
|
|
1086
|
+
const seenUnknown = /* @__PURE__ */ new Set();
|
|
1087
|
+
const seenTraversal = /* @__PURE__ */ new Set();
|
|
1088
|
+
for (const leaf of leaves) {
|
|
1089
|
+
const inFilter = leaf.inFilter;
|
|
1090
|
+
for (const rest of recordRefsIn(leaf.text)) {
|
|
1091
|
+
const head = rest[0];
|
|
1092
|
+
const hasSubPath = rest.length > 1;
|
|
1093
|
+
const nextIsIdentifier = hasSubPath && !/^\d+$/.test(rest[1]);
|
|
1094
|
+
const isKnown = fieldTypes.has(head) || IMPLICIT_HEADS.has(head);
|
|
1095
|
+
if (!isKnown) {
|
|
1096
|
+
if (seenUnknown.has(head)) continue;
|
|
1097
|
+
seenUnknown.add(head);
|
|
1098
|
+
findings.push({
|
|
1099
|
+
severity: inFilter ? "error" : "warning",
|
|
1100
|
+
rule: FLOW_TEMPLATE_UNKNOWN_FIELD,
|
|
1101
|
+
where,
|
|
1102
|
+
path: nodePath,
|
|
1103
|
+
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).`,
|
|
1104
|
+
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.`
|
|
1105
|
+
});
|
|
1106
|
+
continue;
|
|
1107
|
+
}
|
|
1108
|
+
if (nextIsIdentifier) {
|
|
1109
|
+
const headType = fieldTypes.get(head) ?? "";
|
|
1110
|
+
if (RELATION_TYPES.has(headType) && !expandSet.has(head)) {
|
|
1111
|
+
const key = rest.join(".");
|
|
1112
|
+
if (seenTraversal.has(key)) continue;
|
|
1113
|
+
seenTraversal.add(key);
|
|
1114
|
+
findings.push({
|
|
1115
|
+
severity: inFilter ? "error" : "warning",
|
|
1116
|
+
rule: FLOW_TEMPLATE_LOOKUP_TRAVERSAL,
|
|
1117
|
+
where,
|
|
1118
|
+
path: nodePath,
|
|
1119
|
+
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).`,
|
|
1120
|
+
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}'.`
|
|
1121
|
+
});
|
|
1122
|
+
}
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1126
|
+
});
|
|
1127
|
+
});
|
|
1128
|
+
return findings;
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
// src/validate-readonly-flow-writes.ts
|
|
1132
|
+
var FLOW_UPDATE_READONLY_FIELD = "flow-update-readonly-field";
|
|
1133
|
+
var FLOW_UPDATE_READONLY_WHEN_FIELD = "flow-update-readonly-when-field";
|
|
1134
|
+
function asArray6(v) {
|
|
1135
|
+
if (Array.isArray(v)) return v;
|
|
1136
|
+
if (v && typeof v === "object") {
|
|
1137
|
+
return Object.entries(v).map(([name, def]) => ({
|
|
1138
|
+
name,
|
|
1139
|
+
...def
|
|
1140
|
+
}));
|
|
1141
|
+
}
|
|
1142
|
+
return [];
|
|
1143
|
+
}
|
|
1144
|
+
function buildReadonlyIndex(objects) {
|
|
1145
|
+
const idx = /* @__PURE__ */ new Map();
|
|
1146
|
+
for (const obj of objects) {
|
|
1147
|
+
const name = typeof obj.name === "string" ? obj.name : void 0;
|
|
1148
|
+
if (!name) continue;
|
|
1149
|
+
const fieldMap = /* @__PURE__ */ new Map();
|
|
1150
|
+
const collect = (fieldName, def) => {
|
|
1151
|
+
const rw = def?.readonlyWhen;
|
|
1152
|
+
const readonlyWhen = rw != null && !(typeof rw === "string" && rw.trim() === "");
|
|
1153
|
+
fieldMap.set(fieldName, { readonly: def?.readonly === true, readonlyWhen });
|
|
1154
|
+
};
|
|
1155
|
+
const fields = obj.fields;
|
|
1156
|
+
if (Array.isArray(fields)) {
|
|
1157
|
+
for (const f of fields) {
|
|
1158
|
+
const fn = f?.name;
|
|
1159
|
+
if (typeof fn === "string") collect(fn, f);
|
|
1160
|
+
}
|
|
1161
|
+
} else if (fields && typeof fields === "object") {
|
|
1162
|
+
for (const [fn, def] of Object.entries(fields)) collect(fn, def);
|
|
1163
|
+
}
|
|
1164
|
+
idx.set(name, fieldMap);
|
|
1165
|
+
}
|
|
1166
|
+
return idx;
|
|
1167
|
+
}
|
|
1168
|
+
function readLiteralObjectName(config) {
|
|
1169
|
+
const raw = config.objectName ?? config.object;
|
|
1170
|
+
if (typeof raw !== "string" || raw.includes("{")) return void 0;
|
|
1171
|
+
return raw || void 0;
|
|
1172
|
+
}
|
|
1173
|
+
function validateReadonlyFlowWrites(stack) {
|
|
1174
|
+
const findings = [];
|
|
1175
|
+
const flows = asArray6(stack.flows);
|
|
1176
|
+
if (flows.length === 0) return findings;
|
|
1177
|
+
const roIndex = buildReadonlyIndex(asArray6(stack.objects));
|
|
1178
|
+
flows.forEach((flow, flowIndex) => {
|
|
1179
|
+
if (flow.runAs === "system") return;
|
|
1180
|
+
const runAs = flow.runAs === "user" || flow.runAs === "system" ? flow.runAs : "user";
|
|
1181
|
+
const flowName = typeof flow.name === "string" ? flow.name : `#${flowIndex}`;
|
|
1182
|
+
const walked = walkFlowNodes(flow, `flows[${flowIndex}]`);
|
|
1183
|
+
walked.forEach(({ node, path: nodePath, regionTrail }, walkIndex) => {
|
|
1184
|
+
if (node?.type !== "update_record") return;
|
|
1185
|
+
const config = node.config ?? {};
|
|
1186
|
+
const objectName = readLiteralObjectName(config);
|
|
1187
|
+
if (!objectName) return;
|
|
1188
|
+
const fieldMap = roIndex.get(objectName);
|
|
1189
|
+
if (!fieldMap) return;
|
|
1190
|
+
const fields = config.fields;
|
|
1191
|
+
if (!fields || typeof fields !== "object" || Array.isArray(fields)) return;
|
|
1192
|
+
const nodeName = flowNodeLabel(node, walkIndex);
|
|
1193
|
+
const where = regionTrail ? `flow "${flowName}" \u203A ${regionTrail} \u203A node "${nodeName}"` : `flow "${flowName}" \u203A node "${nodeName}"`;
|
|
1194
|
+
for (const fieldName of Object.keys(fields)) {
|
|
1195
|
+
const meta = fieldMap.get(fieldName);
|
|
1196
|
+
if (!meta) continue;
|
|
1197
|
+
if (meta.readonly) {
|
|
1198
|
+
findings.push({
|
|
1199
|
+
severity: "error",
|
|
1200
|
+
rule: FLOW_UPDATE_READONLY_FIELD,
|
|
1201
|
+
where,
|
|
1202
|
+
path: `${nodePath}.config.fields.${fieldName}`,
|
|
1203
|
+
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.`,
|
|
1204
|
+
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.`
|
|
1205
|
+
});
|
|
1206
|
+
} else if (meta.readonlyWhen) {
|
|
1207
|
+
findings.push({
|
|
1208
|
+
severity: "warning",
|
|
1209
|
+
rule: FLOW_UPDATE_READONLY_WHEN_FIELD,
|
|
1210
|
+
where,
|
|
1211
|
+
path: `${nodePath}.config.fields.${fieldName}`,
|
|
1212
|
+
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.`,
|
|
1213
|
+
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.`
|
|
1214
|
+
});
|
|
1215
|
+
}
|
|
1216
|
+
}
|
|
1217
|
+
});
|
|
1218
|
+
});
|
|
1219
|
+
return findings;
|
|
1220
|
+
}
|
|
1221
|
+
|
|
713
1222
|
// src/validate-view-containers.ts
|
|
714
1223
|
var VIEW_CONTAINER_SHAPE = "view-container-shape";
|
|
715
1224
|
var CONTAINER_SLOT_KEYS = ["list", "form", "listViews", "formViews"];
|
|
@@ -732,13 +1241,13 @@ function validateViewContainers(stack) {
|
|
|
732
1241
|
const rec = value;
|
|
733
1242
|
if (rec.viewKind != null) continue;
|
|
734
1243
|
if (containerViewCount(rec) > 0) continue;
|
|
735
|
-
const
|
|
1244
|
+
const label2 = typeof rec.name === "string" ? ` ("${rec.name}")` : "";
|
|
736
1245
|
const hasContainerSlot = CONTAINER_SLOT_KEYS.some((k) => k in rec);
|
|
737
1246
|
const looksFlat = !hasContainerSlot && ["type", "columns", "data", "filter", "sort"].some((k) => k in rec);
|
|
738
1247
|
out.push({
|
|
739
1248
|
severity: "error",
|
|
740
1249
|
rule: VIEW_CONTAINER_SHAPE,
|
|
741
|
-
where: `views${key}${
|
|
1250
|
+
where: `views${key}${label2}`,
|
|
742
1251
|
path: `views${key}`,
|
|
743
1252
|
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.",
|
|
744
1253
|
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."
|
|
@@ -950,7 +1459,7 @@ function looksLikeTailwind(className) {
|
|
|
950
1459
|
return false;
|
|
951
1460
|
});
|
|
952
1461
|
}
|
|
953
|
-
function
|
|
1462
|
+
function asArray7(v) {
|
|
954
1463
|
if (Array.isArray(v)) return v;
|
|
955
1464
|
if (v && typeof v === "object") {
|
|
956
1465
|
return Object.entries(v).map(([name, def]) => ({ name, ...def }));
|
|
@@ -1043,13 +1552,13 @@ function checkNode(node, pageName, path, findings) {
|
|
|
1043
1552
|
}
|
|
1044
1553
|
function validateResponsiveStyles(stack) {
|
|
1045
1554
|
const findings = [];
|
|
1046
|
-
const pages =
|
|
1555
|
+
const pages = asArray7(stack.pages);
|
|
1047
1556
|
for (let p = 0; p < pages.length; p++) {
|
|
1048
1557
|
const page = pages[p];
|
|
1049
1558
|
const pageName = typeof page.name === "string" ? page.name : `pages[${p}]`;
|
|
1050
|
-
const regions =
|
|
1559
|
+
const regions = asArray7(page.regions);
|
|
1051
1560
|
for (let r = 0; r < regions.length; r++) {
|
|
1052
|
-
const components =
|
|
1561
|
+
const components = asArray7(regions[r].components);
|
|
1053
1562
|
for (let c = 0; c < components.length; c++) {
|
|
1054
1563
|
checkNode(components[c], pageName, `pages[${p}].regions[${r}].components[${c}]`, findings);
|
|
1055
1564
|
}
|
|
@@ -1060,10 +1569,10 @@ function validateResponsiveStyles(stack) {
|
|
|
1060
1569
|
|
|
1061
1570
|
// src/validate-jsx-pages.ts
|
|
1062
1571
|
var import_sdui_parser = require("@objectstack/sdui-parser");
|
|
1063
|
-
var
|
|
1572
|
+
var asArray8 = (v) => Array.isArray(v) ? v : [];
|
|
1064
1573
|
function validateJsxPages(stack, opts = {}) {
|
|
1065
1574
|
const findings = [];
|
|
1066
|
-
const pages =
|
|
1575
|
+
const pages = asArray8(stack.pages);
|
|
1067
1576
|
for (let p = 0; p < pages.length; p++) {
|
|
1068
1577
|
const page = pages[p];
|
|
1069
1578
|
if (!page || page.kind !== "html" && page.kind !== "jsx") continue;
|
|
@@ -1111,10 +1620,10 @@ function loadSucraseTransform() {
|
|
|
1111
1620
|
}
|
|
1112
1621
|
return cachedTransform;
|
|
1113
1622
|
}
|
|
1114
|
-
var
|
|
1623
|
+
var asArray9 = (v) => Array.isArray(v) ? v : [];
|
|
1115
1624
|
function validateReactPages(stack) {
|
|
1116
1625
|
const findings = [];
|
|
1117
|
-
const pages =
|
|
1626
|
+
const pages = asArray9(stack.pages);
|
|
1118
1627
|
for (let p = 0; p < pages.length; p++) {
|
|
1119
1628
|
const page = pages[p];
|
|
1120
1629
|
if (!page || page.kind !== "react") continue;
|
|
@@ -1151,87 +1660,783 @@ function validateReactPages(stack) {
|
|
|
1151
1660
|
|
|
1152
1661
|
// src/validate-react-page-props.ts
|
|
1153
1662
|
var import_node_module2 = require("module");
|
|
1154
|
-
var
|
|
1155
|
-
var
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
throw new Error(
|
|
1164
|
-
`@objectstack/lint: validating a kind:'react' page requires the "typescript" package, which could not be loaded (${err instanceof Error ? err.message : String(err)}). It is a declared dependency of @objectstack/lint \u2014 if this deployment prunes packages, keep "typescript" in the image; it is only loaded when a react-source page is validated.`
|
|
1165
|
-
);
|
|
1663
|
+
var import_ui2 = require("@objectstack/spec/ui");
|
|
1664
|
+
var import_data3 = require("@objectstack/spec/data");
|
|
1665
|
+
|
|
1666
|
+
// src/validate-searchable-fields.ts
|
|
1667
|
+
var SEARCHABLE_FIELD_UNKNOWN = "searchable-field-unknown";
|
|
1668
|
+
function asArray10(v) {
|
|
1669
|
+
if (Array.isArray(v)) return v;
|
|
1670
|
+
if (v && typeof v === "object") {
|
|
1671
|
+
return Object.entries(v).map(([name, def]) => ({ name, ...def }));
|
|
1166
1672
|
}
|
|
1167
|
-
return
|
|
1673
|
+
return [];
|
|
1168
1674
|
}
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
);
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1675
|
+
function isRec2(v) {
|
|
1676
|
+
return !!v && typeof v === "object" && !Array.isArray(v);
|
|
1677
|
+
}
|
|
1678
|
+
function strName2(v) {
|
|
1679
|
+
return typeof v === "string" && v.length > 0 ? v : void 0;
|
|
1680
|
+
}
|
|
1681
|
+
function declaredFieldNames(obj) {
|
|
1682
|
+
const fields = obj.fields;
|
|
1683
|
+
if (!fields || typeof fields !== "object") return null;
|
|
1684
|
+
const names = /* @__PURE__ */ new Set();
|
|
1685
|
+
for (const f of asArray10(fields)) {
|
|
1686
|
+
const n = strName2(f.name);
|
|
1687
|
+
if (n) names.add(n);
|
|
1688
|
+
}
|
|
1689
|
+
return names.size > 0 ? names : null;
|
|
1690
|
+
}
|
|
1691
|
+
function suggest2(target, known) {
|
|
1692
|
+
let best;
|
|
1693
|
+
let bestScore = Infinity;
|
|
1694
|
+
for (const candidate of known) {
|
|
1695
|
+
const d = distance(target, candidate);
|
|
1696
|
+
if (d < bestScore) {
|
|
1697
|
+
bestScore = d;
|
|
1698
|
+
best = candidate;
|
|
1189
1699
|
}
|
|
1190
1700
|
}
|
|
1191
|
-
|
|
1701
|
+
const limit = Math.max(2, Math.floor(target.length / 3));
|
|
1702
|
+
return best && bestScore <= limit ? ` Did you mean "${best}"?` : "";
|
|
1192
1703
|
}
|
|
1193
|
-
function
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1704
|
+
function distance(a, b) {
|
|
1705
|
+
const m = a.length;
|
|
1706
|
+
const n = b.length;
|
|
1707
|
+
if (m === 0) return n;
|
|
1708
|
+
if (n === 0) return m;
|
|
1709
|
+
let prev = Array.from({ length: n + 1 }, (_, j) => j);
|
|
1710
|
+
for (let i = 1; i <= m; i++) {
|
|
1711
|
+
const curr = [i, ...new Array(n).fill(0)];
|
|
1712
|
+
for (let j = 1; j <= n; j++) {
|
|
1713
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
1714
|
+
curr[j] = Math.min(curr[j - 1] + 1, prev[j] + 1, prev[j - 1] + cost);
|
|
1202
1715
|
}
|
|
1716
|
+
prev = curr;
|
|
1203
1717
|
}
|
|
1204
|
-
return
|
|
1718
|
+
return prev[n];
|
|
1205
1719
|
}
|
|
1206
|
-
function
|
|
1720
|
+
function indexObjectSearchTargets(stack) {
|
|
1721
|
+
const fieldsByObject = /* @__PURE__ */ new Map();
|
|
1722
|
+
if (!isRec2(stack)) return fieldsByObject;
|
|
1723
|
+
for (const obj of asArray10(stack.objects)) {
|
|
1724
|
+
const name = strName2(obj.name);
|
|
1725
|
+
if (name) fieldsByObject.set(name, declaredFieldNames(obj));
|
|
1726
|
+
}
|
|
1727
|
+
return fieldsByObject;
|
|
1728
|
+
}
|
|
1729
|
+
function checkSearchableFieldList(declared, objectName, fieldsByObject, where, path, subject) {
|
|
1207
1730
|
const findings = [];
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
const
|
|
1215
|
-
const
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1731
|
+
if (!Array.isArray(declared) || declared.length === 0) return findings;
|
|
1732
|
+
if (!objectName) return findings;
|
|
1733
|
+
if (!fieldsByObject.has(objectName)) return findings;
|
|
1734
|
+
const known = fieldsByObject.get(objectName);
|
|
1735
|
+
if (!known) return findings;
|
|
1736
|
+
for (let i = 0; i < declared.length; i++) {
|
|
1737
|
+
const entry = declared[i];
|
|
1738
|
+
const name = strName2(entry);
|
|
1739
|
+
if (!name) continue;
|
|
1740
|
+
if (known.has(name) || SYSTEM_FIELDS.has(name)) continue;
|
|
1741
|
+
const dotted = name.includes(".");
|
|
1742
|
+
findings.push({
|
|
1743
|
+
severity: "error",
|
|
1744
|
+
rule: SEARCHABLE_FIELD_UNKNOWN,
|
|
1745
|
+
where,
|
|
1746
|
+
path: `${path}[${i}]`,
|
|
1747
|
+
message: `${subject} entry "${name}" is not a field on object "${objectName}". The declaration is stale: searching it can never match, and the engine silently drops it \u2014 leaving a narrower search than declared, or the auto-default set once every entry is dropped.` + (dotted ? "" : suggest2(name, known)),
|
|
1748
|
+
hint: (dotted ? `'search' scans this object's own columns, so a related record's column cannot be a search target \u2014 expand the relation and search the related object, or copy the value onto a formula field here. ` : `Fix the name, or add "${name}" to ${objectName}.fields. `) + `Clients echo this declaration verbatim as the '$searchFields' override, so a stale entry becomes a 400 INVALID_FIELD on list search (#4254), not just a quietly narrowed one.` + (known.size > 0 ? ` Object fields: ${[...known].sort().join(", ")}.` : "")
|
|
1749
|
+
});
|
|
1750
|
+
}
|
|
1751
|
+
return findings;
|
|
1752
|
+
}
|
|
1753
|
+
function validateSearchableFields(stack) {
|
|
1754
|
+
const findings = [];
|
|
1755
|
+
if (!isRec2(stack)) return findings;
|
|
1756
|
+
const objects = asArray10(stack.objects);
|
|
1757
|
+
const fieldsByObject = indexObjectSearchTargets(stack);
|
|
1758
|
+
const check = (declared, objectName, where, path, subject) => {
|
|
1759
|
+
findings.push(
|
|
1760
|
+
...checkSearchableFieldList(declared, objectName, fieldsByObject, where, path, subject)
|
|
1761
|
+
);
|
|
1762
|
+
};
|
|
1763
|
+
for (let oi = 0; oi < objects.length; oi++) {
|
|
1764
|
+
const obj = objects[oi];
|
|
1765
|
+
if (!isRec2(obj)) continue;
|
|
1766
|
+
const objName = strName2(obj.name);
|
|
1767
|
+
const label2 = objName ? `object "${objName}"` : `objects[${oi}]`;
|
|
1768
|
+
check(
|
|
1769
|
+
obj.searchableFields,
|
|
1770
|
+
objName,
|
|
1771
|
+
label2,
|
|
1772
|
+
`objects[${oi}].searchableFields`,
|
|
1773
|
+
"searchableFields"
|
|
1774
|
+
);
|
|
1775
|
+
if (isRec2(obj.listViews)) {
|
|
1776
|
+
for (const [key, lv] of Object.entries(obj.listViews)) {
|
|
1777
|
+
if (!isRec2(lv)) continue;
|
|
1778
|
+
check(
|
|
1779
|
+
lv.searchableFields,
|
|
1780
|
+
// A built-in list view belongs to its object; an inline `data.object`
|
|
1781
|
+
// may still retarget it (ADR-0047 allows the explicit binding).
|
|
1782
|
+
listViewObject(lv) ?? objName,
|
|
1783
|
+
`${label2} \u203A listViews.${key}`,
|
|
1784
|
+
`objects[${oi}].listViews.${key}.searchableFields`,
|
|
1785
|
+
"list-view searchableFields"
|
|
1786
|
+
);
|
|
1787
|
+
}
|
|
1788
|
+
}
|
|
1789
|
+
}
|
|
1790
|
+
const views = asArray10(stack.views);
|
|
1791
|
+
for (let vi = 0; vi < views.length; vi++) {
|
|
1792
|
+
const view = views[vi];
|
|
1793
|
+
if (!isRec2(view)) continue;
|
|
1794
|
+
const viewLabel = strName2(view.name) ?? strName2(view.objectName) ?? `#${vi}`;
|
|
1795
|
+
const viewObject = strName2(view.objectName) ?? strName2(view.object);
|
|
1796
|
+
if (isRec2(view.list)) {
|
|
1797
|
+
check(
|
|
1798
|
+
view.list.searchableFields,
|
|
1799
|
+
listViewObject(view.list) ?? viewObject,
|
|
1800
|
+
`view "${viewLabel}" \u203A list`,
|
|
1801
|
+
`views[${vi}].list.searchableFields`,
|
|
1802
|
+
"list-view searchableFields"
|
|
1803
|
+
);
|
|
1804
|
+
}
|
|
1805
|
+
if (isRec2(view.listViews)) {
|
|
1806
|
+
for (const [key, lv] of Object.entries(view.listViews)) {
|
|
1807
|
+
if (!isRec2(lv)) continue;
|
|
1808
|
+
check(
|
|
1809
|
+
lv.searchableFields,
|
|
1810
|
+
listViewObject(lv) ?? viewObject,
|
|
1811
|
+
`view "${viewLabel}" \u203A listViews.${key}`,
|
|
1812
|
+
`views[${vi}].listViews.${key}.searchableFields`,
|
|
1813
|
+
"list-view searchableFields"
|
|
1814
|
+
);
|
|
1815
|
+
}
|
|
1816
|
+
}
|
|
1817
|
+
}
|
|
1818
|
+
return findings;
|
|
1819
|
+
}
|
|
1820
|
+
function listViewObject(listView) {
|
|
1821
|
+
const data = listView.data;
|
|
1822
|
+
return isRec2(data) ? strName2(data.object) : void 0;
|
|
1823
|
+
}
|
|
1824
|
+
|
|
1825
|
+
// src/page-walk.ts
|
|
1826
|
+
function isRec3(v) {
|
|
1827
|
+
return !!v && typeof v === "object" && !Array.isArray(v);
|
|
1828
|
+
}
|
|
1829
|
+
function strName3(v) {
|
|
1830
|
+
return typeof v === "string" && v.length > 0 ? v : void 0;
|
|
1831
|
+
}
|
|
1832
|
+
var SOURCE_AUTHORED_KINDS = /* @__PURE__ */ new Set(["html", "react", "jsx"]);
|
|
1833
|
+
function isSourceAuthoredPage(page) {
|
|
1834
|
+
const kind = strName3(page.kind);
|
|
1835
|
+
return kind !== void 0 && SOURCE_AUTHORED_KINDS.has(kind);
|
|
1836
|
+
}
|
|
1837
|
+
function walkPageComponents(page, pagePath) {
|
|
1838
|
+
const out = [];
|
|
1839
|
+
if (!isRec3(page) || isSourceAuthoredPage(page)) return out;
|
|
1840
|
+
const pageObject = strName3(page.object);
|
|
1841
|
+
const visit = (node, path, inheritedObject) => {
|
|
1842
|
+
if (!isRec3(node)) return;
|
|
1843
|
+
const props = isRec3(node.properties) ? node.properties : void 0;
|
|
1844
|
+
const dataSource = isRec3(node.dataSource) ? node.dataSource : void 0;
|
|
1845
|
+
const objectName = strName3(dataSource?.object) ?? strName3(props?.object) ?? inheritedObject;
|
|
1846
|
+
out.push({ component: node, path, objectName });
|
|
1847
|
+
if (!props) return;
|
|
1848
|
+
if (Array.isArray(props.items)) {
|
|
1849
|
+
for (let i = 0; i < props.items.length; i++) {
|
|
1850
|
+
const item = props.items[i];
|
|
1851
|
+
if (!isRec3(item) || !Array.isArray(item.children)) continue;
|
|
1852
|
+
for (let c = 0; c < item.children.length; c++) {
|
|
1853
|
+
visit(item.children[c], `${path}.properties.items[${i}].children[${c}]`, objectName);
|
|
1854
|
+
}
|
|
1855
|
+
}
|
|
1856
|
+
}
|
|
1857
|
+
if (Array.isArray(props.children)) {
|
|
1858
|
+
for (let i = 0; i < props.children.length; i++) {
|
|
1859
|
+
visit(props.children[i], `${path}.properties.children[${i}]`, objectName);
|
|
1860
|
+
}
|
|
1861
|
+
}
|
|
1862
|
+
for (const key of ["body", "footer"]) {
|
|
1863
|
+
const slotList = props[key];
|
|
1864
|
+
if (!Array.isArray(slotList)) continue;
|
|
1865
|
+
for (let i = 0; i < slotList.length; i++) {
|
|
1866
|
+
visit(slotList[i], `${path}.properties.${key}[${i}]`, objectName);
|
|
1867
|
+
}
|
|
1868
|
+
}
|
|
1869
|
+
};
|
|
1870
|
+
const regions = Array.isArray(page.regions) ? page.regions : [];
|
|
1871
|
+
for (let r = 0; r < regions.length; r++) {
|
|
1872
|
+
const region = regions[r];
|
|
1873
|
+
if (!isRec3(region) || !Array.isArray(region.components)) continue;
|
|
1874
|
+
for (let c = 0; c < region.components.length; c++) {
|
|
1875
|
+
visit(region.components[c], `${pagePath}.regions[${r}].components[${c}]`, pageObject);
|
|
1876
|
+
}
|
|
1877
|
+
}
|
|
1878
|
+
const slots = isRec3(page.slots) ? page.slots : void 0;
|
|
1879
|
+
if (slots) {
|
|
1880
|
+
for (const [slot, value] of Object.entries(slots)) {
|
|
1881
|
+
const list3 = Array.isArray(value) ? value : [value];
|
|
1882
|
+
const indexed = Array.isArray(value);
|
|
1883
|
+
for (let i = 0; i < list3.length; i++) {
|
|
1884
|
+
visit(list3[i], `${pagePath}.slots.${slot}${indexed ? `[${i}]` : ""}`, pageObject);
|
|
1885
|
+
}
|
|
1886
|
+
}
|
|
1887
|
+
}
|
|
1888
|
+
return out;
|
|
1889
|
+
}
|
|
1890
|
+
|
|
1891
|
+
// src/validate-page-field-bindings.ts
|
|
1892
|
+
var PAGE_FIELD_UNKNOWN = "page-field-unknown";
|
|
1893
|
+
function asArray11(v) {
|
|
1894
|
+
if (Array.isArray(v)) return v;
|
|
1895
|
+
if (v && typeof v === "object") {
|
|
1896
|
+
return Object.entries(v).map(([name, def]) => ({ name, ...def }));
|
|
1897
|
+
}
|
|
1898
|
+
return [];
|
|
1899
|
+
}
|
|
1900
|
+
function strName4(v) {
|
|
1901
|
+
return typeof v === "string" && v.length > 0 ? v : void 0;
|
|
1902
|
+
}
|
|
1903
|
+
function isRec4(v) {
|
|
1904
|
+
return !!v && typeof v === "object" && !Array.isArray(v);
|
|
1905
|
+
}
|
|
1906
|
+
function fieldRefsFrom(value, basePath) {
|
|
1907
|
+
const out = [];
|
|
1908
|
+
const one = (v, path) => {
|
|
1909
|
+
const bare = strName4(v);
|
|
1910
|
+
if (bare) {
|
|
1911
|
+
out.push({ name: bare, path });
|
|
1912
|
+
return;
|
|
1913
|
+
}
|
|
1914
|
+
if (!isRec4(v)) return;
|
|
1915
|
+
const named = strName4(v.field) ?? strName4(v.name);
|
|
1916
|
+
if (named) out.push({ name: named, path: `${path}.${strName4(v.field) ? "field" : "name"}` });
|
|
1917
|
+
};
|
|
1918
|
+
if (Array.isArray(value)) {
|
|
1919
|
+
for (let i = 0; i < value.length; i++) one(value[i], `${basePath}[${i}]`);
|
|
1920
|
+
} else {
|
|
1921
|
+
one(value, basePath);
|
|
1922
|
+
}
|
|
1923
|
+
return out;
|
|
1924
|
+
}
|
|
1925
|
+
function sortFieldRefs(value, basePath) {
|
|
1926
|
+
if (typeof value === "string") {
|
|
1927
|
+
const head = value.trim().split(/\s+/)[0];
|
|
1928
|
+
return head ? [{ name: head, path: basePath }] : [];
|
|
1929
|
+
}
|
|
1930
|
+
return fieldRefsFrom(value, basePath);
|
|
1931
|
+
}
|
|
1932
|
+
var COMPONENT_FIELD_SPECS = {
|
|
1933
|
+
"record:highlights": { props: ["fields"] },
|
|
1934
|
+
// `sections`/`hideFields` are not in RecordDetailsProps, but every real page
|
|
1935
|
+
// authors them (they survive because `properties` is unvalidated).
|
|
1936
|
+
"record:details": { props: ["fields", "hideFields"], nestedSections: ["sections"] },
|
|
1937
|
+
"record:path": { props: ["statusField"] },
|
|
1938
|
+
"element:number": { props: ["field"] },
|
|
1939
|
+
"element:filter": { props: ["fields"] },
|
|
1940
|
+
"element:form": { props: ["fields"] },
|
|
1941
|
+
// The schema says `displayField`; real pages author `labelField`. Accept both.
|
|
1942
|
+
"element:record_picker": { props: ["displayField", "labelField", "searchFields"] }
|
|
1943
|
+
};
|
|
1944
|
+
var RELATED_LIST_TYPE = "record:related_list";
|
|
1945
|
+
function componentFieldRefs(type, props, basePath, sep = ".") {
|
|
1946
|
+
const spec = COMPONENT_FIELD_SPECS[type];
|
|
1947
|
+
if (!spec) return null;
|
|
1948
|
+
const refs = [];
|
|
1949
|
+
for (const key of spec.props ?? []) {
|
|
1950
|
+
refs.push(...fieldRefsFrom(props[key], `${basePath}${sep}${key}`));
|
|
1951
|
+
}
|
|
1952
|
+
for (const key of spec.nestedSections ?? []) {
|
|
1953
|
+
const sections = Array.isArray(props[key]) ? props[key] : [];
|
|
1954
|
+
for (let si = 0; si < sections.length; si++) {
|
|
1955
|
+
const section = sections[si];
|
|
1956
|
+
if (!isRec4(section)) continue;
|
|
1957
|
+
refs.push(...fieldRefsFrom(section.fields, `${basePath}${sep}${key}[${si}].fields`));
|
|
1958
|
+
}
|
|
1959
|
+
}
|
|
1960
|
+
return refs;
|
|
1961
|
+
}
|
|
1962
|
+
function relatedListFieldRefs(props, basePath, sep = ".") {
|
|
1963
|
+
const add = isRec4(props.add) ? props.add : void 0;
|
|
1964
|
+
const picker = add && isRec4(add.picker) ? add.picker : void 0;
|
|
1965
|
+
const at = (key) => `${basePath}${sep}${key}`;
|
|
1966
|
+
return {
|
|
1967
|
+
relatedObject: strName4(props.objectName),
|
|
1968
|
+
related: [
|
|
1969
|
+
...fieldRefsFrom(props.columns, at("columns")),
|
|
1970
|
+
...sortFieldRefs(props.sort, at("sort")),
|
|
1971
|
+
...fieldRefsFrom(props.filter, at("filter")),
|
|
1972
|
+
...fieldRefsFrom(props.relationshipField, at("relationshipField")),
|
|
1973
|
+
...add ? fieldRefsFrom(add.linkField, at("add.linkField")) : []
|
|
1974
|
+
],
|
|
1975
|
+
parent: fieldRefsFrom(props.relationshipValueField, at("relationshipValueField")),
|
|
1976
|
+
pickerObject: picker ? strName4(picker.object) : void 0,
|
|
1977
|
+
picker: picker ? [
|
|
1978
|
+
...fieldRefsFrom(picker.valueField, at("add.picker.valueField")),
|
|
1979
|
+
...fieldRefsFrom(picker.labelField, at("add.picker.labelField"))
|
|
1980
|
+
] : []
|
|
1981
|
+
};
|
|
1982
|
+
}
|
|
1983
|
+
function indexObjectFields(stack) {
|
|
1984
|
+
const objectFields = /* @__PURE__ */ new Map();
|
|
1985
|
+
if (!isRec4(stack)) return objectFields;
|
|
1986
|
+
for (const obj of asArray11(stack.objects)) {
|
|
1987
|
+
const name = strName4(obj.name);
|
|
1988
|
+
if (!name) continue;
|
|
1989
|
+
const names = /* @__PURE__ */ new Set();
|
|
1990
|
+
for (const f of asArray11(obj.fields)) {
|
|
1991
|
+
const fn = strName4(f.name);
|
|
1992
|
+
if (fn) names.add(fn);
|
|
1993
|
+
}
|
|
1994
|
+
objectFields.set(name, names);
|
|
1995
|
+
}
|
|
1996
|
+
return objectFields;
|
|
1997
|
+
}
|
|
1998
|
+
function checkFieldRefs(refs, objectName, objectFields, where, consequence = "skipped") {
|
|
1999
|
+
const findings = [];
|
|
2000
|
+
if (!objectName) return findings;
|
|
2001
|
+
const known = objectFields.get(objectName);
|
|
2002
|
+
if (!known) return findings;
|
|
2003
|
+
for (const ref of refs) {
|
|
2004
|
+
if (ref.name.includes(".")) continue;
|
|
2005
|
+
if (known.has(ref.name) || SYSTEM_FIELDS.has(ref.name)) continue;
|
|
2006
|
+
findings.push({
|
|
2007
|
+
severity: consequence === "queried" ? "error" : "warning",
|
|
2008
|
+
rule: PAGE_FIELD_UNKNOWN,
|
|
2009
|
+
where,
|
|
2010
|
+
path: ref.path,
|
|
2011
|
+
message: `field "${ref.name}" is not a field on object "${objectName}" \u2014 ` + (consequence === "queried" ? 'it is used in a QUERY, so the predicate can never match: the surface renders an empty result that looks exactly like "there is no data".' : "the component silently skips it, so it never renders."),
|
|
2012
|
+
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(", ")}.` : "")
|
|
2013
|
+
});
|
|
2014
|
+
}
|
|
2015
|
+
return findings;
|
|
2016
|
+
}
|
|
2017
|
+
function validatePageFieldBindings(stack) {
|
|
2018
|
+
const findings = [];
|
|
2019
|
+
if (!stack || typeof stack !== "object") return findings;
|
|
2020
|
+
const objectFields = indexObjectFields(stack);
|
|
2021
|
+
const pages = asArray11(stack.pages);
|
|
2022
|
+
for (let pi = 0; pi < pages.length; pi++) {
|
|
2023
|
+
const page = pages[pi];
|
|
2024
|
+
if (!page || typeof page !== "object") continue;
|
|
2025
|
+
const pageName = strName4(page.name) ?? `#${pi}`;
|
|
2026
|
+
const checkRefs = (refs, objectName, where) => {
|
|
2027
|
+
findings.push(...checkFieldRefs(refs, objectName, objectFields, where));
|
|
2028
|
+
};
|
|
2029
|
+
for (const { component, path, objectName } of walkPageComponents(page, `pages[${pi}]`)) {
|
|
2030
|
+
const type = strName4(component.type);
|
|
2031
|
+
const props = isRec4(component.properties) ? component.properties : void 0;
|
|
2032
|
+
if (!type || !props) continue;
|
|
2033
|
+
const where = `page "${pageName}" \xB7 ${type}`;
|
|
2034
|
+
const base = `${path}.properties`;
|
|
2035
|
+
if (type === RELATED_LIST_TYPE) {
|
|
2036
|
+
const split = relatedListFieldRefs(props, base);
|
|
2037
|
+
checkRefs(split.related, split.relatedObject, where);
|
|
2038
|
+
checkRefs(split.parent, objectName, where);
|
|
2039
|
+
checkRefs(split.picker, split.pickerObject, where);
|
|
2040
|
+
continue;
|
|
2041
|
+
}
|
|
2042
|
+
const refs = componentFieldRefs(type, props, base);
|
|
2043
|
+
if (!refs) continue;
|
|
2044
|
+
checkRefs(refs, objectName, where);
|
|
2045
|
+
}
|
|
2046
|
+
const cfg = isRec4(page.interfaceConfig) ? page.interfaceConfig : void 0;
|
|
2047
|
+
if (cfg) {
|
|
2048
|
+
const cfgObject = strName4(cfg.source) ?? strName4(page.object);
|
|
2049
|
+
const base = `pages[${pi}].interfaceConfig`;
|
|
2050
|
+
const refs = [
|
|
2051
|
+
...fieldRefsFrom(cfg.columns, `${base}.columns`),
|
|
2052
|
+
...sortFieldRefs(cfg.sort, `${base}.sort`),
|
|
2053
|
+
...fieldRefsFrom(cfg.filterBy, `${base}.filterBy`)
|
|
2054
|
+
];
|
|
2055
|
+
const userFilters = isRec4(cfg.userFilters) ? cfg.userFilters : void 0;
|
|
2056
|
+
if (userFilters) {
|
|
2057
|
+
refs.push(...fieldRefsFrom(userFilters.fields, `${base}.userFilters.fields`));
|
|
2058
|
+
}
|
|
2059
|
+
checkRefs(refs, cfgObject, `page "${pageName}" \xB7 interfaceConfig`);
|
|
2060
|
+
}
|
|
2061
|
+
}
|
|
2062
|
+
return findings;
|
|
2063
|
+
}
|
|
2064
|
+
|
|
2065
|
+
// src/validate-react-page-props.ts
|
|
2066
|
+
var import_meta2 = {};
|
|
2067
|
+
var cachedTs = null;
|
|
2068
|
+
function loadTypeScript() {
|
|
2069
|
+
if (cachedTs) return cachedTs;
|
|
2070
|
+
const anchor = typeof import_meta2 !== "undefined" && import_meta2.url ? import_meta2.url : typeof __filename !== "undefined" ? __filename : process.cwd() + "/";
|
|
2071
|
+
try {
|
|
2072
|
+
cachedTs = (0, import_node_module2.createRequire)(anchor)("typescript");
|
|
2073
|
+
} catch (err) {
|
|
2074
|
+
throw new Error(
|
|
2075
|
+
`@objectstack/lint: validating a kind:'react' page requires the "typescript" package, which could not be loaded (${err instanceof Error ? err.message : String(err)}). It is a declared dependency of @objectstack/lint \u2014 if this deployment prunes packages, keep "typescript" in the image; it is only loaded when a react-source page is validated.`
|
|
2076
|
+
);
|
|
2077
|
+
}
|
|
2078
|
+
return cachedTs;
|
|
2079
|
+
}
|
|
2080
|
+
var asArray12 = (v) => Array.isArray(v) ? v : [];
|
|
2081
|
+
var BLOCKS = new Map(
|
|
2082
|
+
import_ui2.REACT_BLOCKS.map((b) => [
|
|
2083
|
+
b.tag,
|
|
2084
|
+
{
|
|
2085
|
+
requiredBindings: b.interactions.filter((i) => i.required).map((i) => i.name),
|
|
2086
|
+
knownProps: new Set(b.interactions.map((i) => i.name))
|
|
2087
|
+
}
|
|
2088
|
+
])
|
|
2089
|
+
);
|
|
2090
|
+
function editDistance(a, b, cap = 2) {
|
|
2091
|
+
if (Math.abs(a.length - b.length) > cap) return cap + 1;
|
|
2092
|
+
const dp = Array.from({ length: a.length + 1 }, (_, i) => i);
|
|
2093
|
+
for (let j = 1; j <= b.length; j++) {
|
|
2094
|
+
let prev = dp[0];
|
|
2095
|
+
dp[0] = j;
|
|
2096
|
+
for (let i = 1; i <= a.length; i++) {
|
|
2097
|
+
const tmp = dp[i];
|
|
2098
|
+
dp[i] = Math.min(dp[i] + 1, dp[i - 1] + 1, prev + (a[i - 1] === b[j - 1] ? 0 : 1));
|
|
2099
|
+
prev = tmp;
|
|
2100
|
+
}
|
|
2101
|
+
}
|
|
2102
|
+
return dp[a.length];
|
|
2103
|
+
}
|
|
2104
|
+
function nearestKnown(prop, known) {
|
|
2105
|
+
if (known.has(prop)) return null;
|
|
2106
|
+
let best = null;
|
|
2107
|
+
let bestD = 3;
|
|
2108
|
+
for (const k of known) {
|
|
2109
|
+
const d = editDistance(prop, k);
|
|
2110
|
+
if (d < bestD) {
|
|
2111
|
+
bestD = d;
|
|
2112
|
+
best = k;
|
|
2113
|
+
}
|
|
2114
|
+
}
|
|
2115
|
+
return bestD <= 2 ? best : null;
|
|
2116
|
+
}
|
|
2117
|
+
var NOT_STATIC = /* @__PURE__ */ Symbol("not-static");
|
|
2118
|
+
function staticValue(tsc, sf, node) {
|
|
2119
|
+
if (!node) return NOT_STATIC;
|
|
2120
|
+
if (tsc.isParenthesizedExpression(node)) return staticValue(tsc, sf, node.expression);
|
|
2121
|
+
if (tsc.isStringLiteral(node) || tsc.isNoSubstitutionTemplateLiteral(node)) return node.text;
|
|
2122
|
+
if (tsc.isNumericLiteral(node)) return Number(node.text);
|
|
2123
|
+
if (node.kind === tsc.SyntaxKind.TrueKeyword) return true;
|
|
2124
|
+
if (node.kind === tsc.SyntaxKind.FalseKeyword) return false;
|
|
2125
|
+
if (node.kind === tsc.SyntaxKind.NullKeyword) return null;
|
|
2126
|
+
if (tsc.isArrayLiteralExpression(node)) {
|
|
2127
|
+
const out = [];
|
|
2128
|
+
for (const el of node.elements) {
|
|
2129
|
+
const v = staticValue(tsc, sf, el);
|
|
2130
|
+
if (v === NOT_STATIC) return NOT_STATIC;
|
|
2131
|
+
out.push(v);
|
|
2132
|
+
}
|
|
2133
|
+
return out;
|
|
2134
|
+
}
|
|
2135
|
+
if (tsc.isObjectLiteralExpression(node)) {
|
|
2136
|
+
const out = {};
|
|
2137
|
+
for (const p of node.properties) {
|
|
2138
|
+
if (!tsc.isPropertyAssignment(p)) return NOT_STATIC;
|
|
2139
|
+
const key = tsc.isIdentifier(p.name) || tsc.isStringLiteral(p.name) ? p.name.text : null;
|
|
2140
|
+
if (key === null) return NOT_STATIC;
|
|
2141
|
+
const v = staticValue(tsc, sf, p.initializer);
|
|
2142
|
+
if (v === NOT_STATIC) return NOT_STATIC;
|
|
2143
|
+
out[key] = v;
|
|
2144
|
+
}
|
|
2145
|
+
return out;
|
|
2146
|
+
}
|
|
2147
|
+
return NOT_STATIC;
|
|
2148
|
+
}
|
|
2149
|
+
function attrValue(tsc, sf, attr) {
|
|
2150
|
+
const init = attr.initializer;
|
|
2151
|
+
if (!init) return true;
|
|
2152
|
+
if (tsc.isStringLiteral(init)) return init.text;
|
|
2153
|
+
if (tsc.isJsxExpression(init)) return staticValue(tsc, sf, init.expression);
|
|
2154
|
+
return NOT_STATIC;
|
|
2155
|
+
}
|
|
2156
|
+
function filterAttrValue(tsc, sf, attr) {
|
|
2157
|
+
const init = attr.initializer;
|
|
2158
|
+
if (!init || !tsc.isJsxExpression(init)) return NOT_STATIC;
|
|
2159
|
+
const perPosition = (node) => {
|
|
2160
|
+
if (!node) return NOT_STATIC;
|
|
2161
|
+
if (tsc.isParenthesizedExpression(node)) return perPosition(node.expression);
|
|
2162
|
+
if (tsc.isArrayLiteralExpression(node)) return node.elements.map((el) => perPosition(el));
|
|
2163
|
+
return staticValue(tsc, sf, node);
|
|
2164
|
+
};
|
|
2165
|
+
return perPosition(init.expression);
|
|
2166
|
+
}
|
|
2167
|
+
var REACT_CHART_FIELD_UNKNOWN = "react-chart-field-unknown";
|
|
2168
|
+
var REACT_CHART_AGGREGATE_INVALID = "react-chart-aggregate-invalid";
|
|
2169
|
+
var REACT_CHART_AXIS_UNKNOWN = "react-chart-axis-unknown";
|
|
2170
|
+
var CHART_FUNCTIONS = ["count", "sum", "avg", "min", "max"];
|
|
2171
|
+
var isRec5 = (v) => !!v && typeof v === "object" && !Array.isArray(v);
|
|
2172
|
+
var strOf = (v) => typeof v === "string" && v.length > 0 ? v : void 0;
|
|
2173
|
+
function checkObjectChart(attrs, objectFields, findings) {
|
|
2174
|
+
const { values, where, path } = attrs;
|
|
2175
|
+
const push = (severity, rule, message, hint) => findings.push({ severity, rule, where, path, message, hint });
|
|
2176
|
+
if (values.has("data")) return;
|
|
2177
|
+
const aggregate = values.get("aggregate");
|
|
2178
|
+
if (aggregate === void 0 || aggregate === NOT_STATIC) return;
|
|
2179
|
+
if (!isRec5(aggregate)) return;
|
|
2180
|
+
const fn = strOf(aggregate.function);
|
|
2181
|
+
const field = strOf(aggregate.field);
|
|
2182
|
+
const groupBy = aggregate.groupBy;
|
|
2183
|
+
const groupByField = strOf(groupBy) ?? (isRec5(groupBy) ? strOf(groupBy.field) : void 0);
|
|
2184
|
+
if (fn && !CHART_FUNCTIONS.includes(fn)) {
|
|
2185
|
+
push(
|
|
2186
|
+
"error",
|
|
2187
|
+
REACT_CHART_AGGREGATE_INVALID,
|
|
2188
|
+
`aggregate.function "${fn}" is not an aggregation this chart can run.`,
|
|
2189
|
+
`Use one of: ${CHART_FUNCTIONS.join(", ")}.`
|
|
2190
|
+
);
|
|
2191
|
+
} else if (fn && fn !== "count" && !field) {
|
|
2192
|
+
push(
|
|
2193
|
+
"error",
|
|
2194
|
+
REACT_CHART_AGGREGATE_INVALID,
|
|
2195
|
+
`aggregate.function "${fn}" has no "field" to aggregate.`,
|
|
2196
|
+
'Add aggregate.field, or use function "count" (the only one that may omit it).'
|
|
2197
|
+
);
|
|
2198
|
+
}
|
|
2199
|
+
const objectName = strOf(values.get("objectName"));
|
|
2200
|
+
const known = objectName ? objectFields.get(objectName) : void 0;
|
|
2201
|
+
if (objectName && known) {
|
|
2202
|
+
const fieldRef = (name, prop) => {
|
|
2203
|
+
if (!name) return;
|
|
2204
|
+
if (name.includes(".")) return;
|
|
2205
|
+
if (known.has(name) || SYSTEM_FIELDS.has(name)) return;
|
|
2206
|
+
push(
|
|
2207
|
+
"error",
|
|
2208
|
+
REACT_CHART_FIELD_UNKNOWN,
|
|
2209
|
+
`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.`,
|
|
2210
|
+
`Fix the field name, or add "${name}" to ${objectName}.` + (known.size > 0 ? ` Object fields: ${[...known].sort().join(", ")}.` : "")
|
|
2211
|
+
);
|
|
2212
|
+
};
|
|
2213
|
+
fieldRef(field, "field");
|
|
2214
|
+
fieldRef(groupByField, "groupBy");
|
|
2215
|
+
}
|
|
2216
|
+
const keys = (0, import_ui2.chartAggregateResultKeys)({ field, function: fn, groupBy });
|
|
2217
|
+
const columns = [keys.category, keys.value].filter((k) => !!k);
|
|
2218
|
+
if (columns.length === 0) return;
|
|
2219
|
+
const axisRef = (name, prop) => {
|
|
2220
|
+
if (!name) return;
|
|
2221
|
+
if (columns.includes(name)) return;
|
|
2222
|
+
if (keys.comparison && name === keys.comparison) return;
|
|
2223
|
+
push(
|
|
2224
|
+
"error",
|
|
2225
|
+
REACT_CHART_AXIS_UNKNOWN,
|
|
2226
|
+
`"${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).`,
|
|
2227
|
+
`Result columns: ${columns.join(", ")}` + (keys.comparison ? ` (plus "${keys.comparison}" with a comparison overlay)` : "") + `. Bind ${prop} to one of them.`
|
|
2228
|
+
);
|
|
2229
|
+
};
|
|
2230
|
+
const xAxisRaw = values.get("xAxis");
|
|
2231
|
+
const categoryAxis = strOf(values.get("xAxisKey")) ?? strOf(xAxisRaw) ?? (isRec5(xAxisRaw) ? strOf(xAxisRaw.field) : void 0);
|
|
2232
|
+
const categoryProp = values.has("xAxisKey") ? "xAxisKey" : "xAxis.field";
|
|
2233
|
+
axisRef(categoryAxis, categoryProp);
|
|
2234
|
+
const yAxisRaw = values.get("yAxis");
|
|
2235
|
+
const yAxisList = Array.isArray(yAxisRaw) ? yAxisRaw : yAxisRaw !== void 0 ? [yAxisRaw] : [];
|
|
2236
|
+
for (const a of yAxisList) {
|
|
2237
|
+
axisRef(strOf(a) ?? (isRec5(a) ? strOf(a.field) : void 0), "yAxis[].field");
|
|
2238
|
+
}
|
|
2239
|
+
const series = values.get("series");
|
|
2240
|
+
if (Array.isArray(series)) {
|
|
2241
|
+
for (const s of series) {
|
|
2242
|
+
if (!isRec5(s)) continue;
|
|
2243
|
+
const dataKey = strOf(s.dataKey);
|
|
2244
|
+
axisRef(dataKey ?? strOf(s.name), dataKey ? "series[].dataKey" : "series[].name");
|
|
2245
|
+
}
|
|
2246
|
+
}
|
|
2247
|
+
if (categoryAxis && keys.category && categoryAxis !== keys.category && categoryAxis === keys.value) {
|
|
2248
|
+
push(
|
|
2249
|
+
"error",
|
|
2250
|
+
REACT_CHART_AXIS_UNKNOWN,
|
|
2251
|
+
`${categoryProp} "${categoryAxis}" is the aggregate's VALUE column, not its category column.`,
|
|
2252
|
+
`The category axis is keyed by groupBy \u2014 bind it to "${keys.category}".`
|
|
2253
|
+
);
|
|
2254
|
+
}
|
|
2255
|
+
}
|
|
2256
|
+
var REACT_FIELD_SPECS = {
|
|
2257
|
+
ListView: {
|
|
2258
|
+
// `fields` is the React overlay's "limit/order the columns"; `columns` the
|
|
2259
|
+
// spec ListView prop. Both name columns on the bound object, and a page may
|
|
2260
|
+
// write either. `hiddenFields`/`fieldOrder`/`filterableFields` are schema
|
|
2261
|
+
// props outside the curated contract — unadvertised but honored by the
|
|
2262
|
+
// renderer, so a stale name there is drift just the same.
|
|
2263
|
+
fields: ["fields", "columns", "hiddenFields", "fieldOrder", "filterableFields"],
|
|
2264
|
+
sorts: ["sort"],
|
|
2265
|
+
nestedFields: ["userFilters", "grouping"],
|
|
2266
|
+
filterArrays: ["filters"]
|
|
2267
|
+
},
|
|
2268
|
+
ObjectForm: {
|
|
2269
|
+
fields: ["fields"],
|
|
2270
|
+
keyedByField: ["initialValues"],
|
|
2271
|
+
// `groups` is FormViewSchema's legacy alias for `sections`.
|
|
2272
|
+
sections: ["sections", "groups"]
|
|
2273
|
+
},
|
|
2274
|
+
ObjectChart: {
|
|
2275
|
+
// The axes are result columns (checkObjectChart owns them); `filter` is an
|
|
2276
|
+
// ordinary ObjectQL predicate over the bound object, like ListView's.
|
|
2277
|
+
filterArrays: ["filter"]
|
|
2278
|
+
}
|
|
2279
|
+
};
|
|
2280
|
+
var PATH_SEP = " \u203A ";
|
|
2281
|
+
var SCHEMA_TYPE_BY_TAG = new Map(
|
|
2282
|
+
import_ui2.REACT_BLOCKS.map((b) => [b.tag, b.schemaType])
|
|
2283
|
+
);
|
|
2284
|
+
var FILTER_PROPS = new Set(
|
|
2285
|
+
Object.values(REACT_FIELD_SPECS).flatMap((s) => s.filterArrays ?? [])
|
|
2286
|
+
);
|
|
2287
|
+
function readableProps(values) {
|
|
2288
|
+
const out = {};
|
|
2289
|
+
for (const [k, v] of values) if (v !== NOT_STATIC) out[k] = v;
|
|
2290
|
+
return out;
|
|
2291
|
+
}
|
|
2292
|
+
function subformFieldRefs(value, basePath) {
|
|
2293
|
+
const child = [];
|
|
2294
|
+
const parent = [];
|
|
2295
|
+
if (!Array.isArray(value)) return { child, parent };
|
|
2296
|
+
for (let i = 0; i < value.length; i++) {
|
|
2297
|
+
const sub = value[i];
|
|
2298
|
+
if (!isRec5(sub)) continue;
|
|
2299
|
+
const at = (key) => `${basePath}[${i}].${key}`;
|
|
2300
|
+
child.push({
|
|
2301
|
+
objectName: strOf(sub.childObject),
|
|
2302
|
+
refs: [
|
|
2303
|
+
...fieldRefsFrom(sub.columns, at("columns")),
|
|
2304
|
+
...fieldRefsFrom(sub.relationshipField, at("relationshipField")),
|
|
2305
|
+
...fieldRefsFrom(sub.amountField, at("amountField"))
|
|
2306
|
+
]
|
|
2307
|
+
});
|
|
2308
|
+
parent.push(...fieldRefsFrom(sub.totalField, at("totalField")));
|
|
2309
|
+
}
|
|
2310
|
+
return { child, parent };
|
|
2311
|
+
}
|
|
2312
|
+
function filterFieldRefs(node, basePath, out) {
|
|
2313
|
+
if (!Array.isArray(node) || node.length === 0) return;
|
|
2314
|
+
const head = node[0];
|
|
2315
|
+
if (typeof head === "string" && (head.toLowerCase() === "and" || head.toLowerCase() === "or")) {
|
|
2316
|
+
for (let i = 1; i < node.length; i++) filterFieldRefs(node[i], `${basePath}[${i}]`, out);
|
|
2317
|
+
return;
|
|
2318
|
+
}
|
|
2319
|
+
if (Array.isArray(head)) {
|
|
2320
|
+
for (let i = 0; i < node.length; i++) filterFieldRefs(node[i], `${basePath}[${i}]`, out);
|
|
2321
|
+
return;
|
|
2322
|
+
}
|
|
2323
|
+
if (typeof head === "string" && head.length > 0 && node.length >= 2 && typeof node[1] === "string" && import_data3.VALID_AST_OPERATORS.has(node[1].toLowerCase())) {
|
|
2324
|
+
out.push({ name: head, path: `${basePath}[0]` });
|
|
2325
|
+
}
|
|
2326
|
+
}
|
|
2327
|
+
function reactFieldRefs(spec, values, basePath) {
|
|
2328
|
+
const own = [];
|
|
2329
|
+
const queried = [];
|
|
2330
|
+
const readable = (key) => {
|
|
2331
|
+
const v = values.get(key);
|
|
2332
|
+
return v === NOT_STATIC ? void 0 : v;
|
|
2333
|
+
};
|
|
2334
|
+
const at = (key) => `${basePath}${PATH_SEP}${key}`;
|
|
2335
|
+
for (const key of spec.fields ?? []) {
|
|
2336
|
+
own.push(...fieldRefsFrom(readable(key), at(key)));
|
|
2337
|
+
}
|
|
2338
|
+
for (const key of spec.sorts ?? []) {
|
|
2339
|
+
own.push(...sortFieldRefs(readable(key), at(key)));
|
|
2340
|
+
}
|
|
2341
|
+
for (const key of spec.nestedFields ?? []) {
|
|
2342
|
+
const v = readable(key);
|
|
2343
|
+
if (isRec5(v)) own.push(...fieldRefsFrom(v.fields, at(`${key}.fields`)));
|
|
2344
|
+
}
|
|
2345
|
+
for (const key of spec.sections ?? []) {
|
|
2346
|
+
const v = readable(key);
|
|
2347
|
+
if (!Array.isArray(v)) continue;
|
|
2348
|
+
for (let i = 0; i < v.length; i++) {
|
|
2349
|
+
const section = v[i];
|
|
2350
|
+
if (!isRec5(section)) continue;
|
|
2351
|
+
own.push(...fieldRefsFrom(section.fields, at(`${key}[${i}].fields`)));
|
|
2352
|
+
}
|
|
2353
|
+
}
|
|
2354
|
+
for (const key of spec.keyedByField ?? []) {
|
|
2355
|
+
const v = readable(key);
|
|
2356
|
+
if (!isRec5(v)) continue;
|
|
2357
|
+
for (const k of Object.keys(v)) own.push({ name: k, path: at(`${key}.${k}`) });
|
|
2358
|
+
}
|
|
2359
|
+
for (const key of spec.filterArrays ?? []) {
|
|
2360
|
+
filterFieldRefs(values.get(key), at(key), queried);
|
|
2361
|
+
}
|
|
2362
|
+
return { own, queried };
|
|
2363
|
+
}
|
|
2364
|
+
function checkBlockFieldProps(tag, values, objectFields, where, path) {
|
|
2365
|
+
const objectName = strOf(values.get("objectName"));
|
|
2366
|
+
const out = [];
|
|
2367
|
+
const spec = REACT_FIELD_SPECS[tag];
|
|
2368
|
+
if (spec) {
|
|
2369
|
+
const { own, queried } = reactFieldRefs(spec, values, path);
|
|
2370
|
+
out.push(...checkFieldRefs(own, objectName, objectFields, where));
|
|
2371
|
+
out.push(...checkFieldRefs(queried, objectName, objectFields, where, "queried"));
|
|
2372
|
+
}
|
|
2373
|
+
if (tag === "ObjectForm") {
|
|
2374
|
+
const raw = values.get("subforms");
|
|
2375
|
+
const subs = subformFieldRefs(raw === NOT_STATIC ? void 0 : raw, `${path}${PATH_SEP}subforms`);
|
|
2376
|
+
for (const sub of subs.child) {
|
|
2377
|
+
out.push(...checkFieldRefs(sub.refs, sub.objectName, objectFields, where));
|
|
2378
|
+
}
|
|
2379
|
+
out.push(...checkFieldRefs(subs.parent, objectName, objectFields, where));
|
|
2380
|
+
}
|
|
2381
|
+
const schemaType = tag === "Block" ? strOf(values.get("type")) : SCHEMA_TYPE_BY_TAG.get(tag);
|
|
2382
|
+
if (schemaType) {
|
|
2383
|
+
const props = readableProps(values);
|
|
2384
|
+
if (schemaType === RELATED_LIST_TYPE) {
|
|
2385
|
+
const split = relatedListFieldRefs(props, path, PATH_SEP);
|
|
2386
|
+
out.push(...checkFieldRefs(split.related, split.relatedObject, objectFields, where));
|
|
2387
|
+
out.push(...checkFieldRefs(split.picker, split.pickerObject, objectFields, where));
|
|
2388
|
+
} else if (COMPONENT_FIELD_SPECS[schemaType]) {
|
|
2389
|
+
out.push(
|
|
2390
|
+
...checkFieldRefs(
|
|
2391
|
+
componentFieldRefs(schemaType, props, path, PATH_SEP) ?? [],
|
|
2392
|
+
objectName,
|
|
2393
|
+
objectFields,
|
|
2394
|
+
where
|
|
2395
|
+
)
|
|
2396
|
+
);
|
|
2397
|
+
}
|
|
2398
|
+
}
|
|
2399
|
+
return out;
|
|
2400
|
+
}
|
|
2401
|
+
function validateReactPageProps(stack) {
|
|
2402
|
+
const findings = [];
|
|
2403
|
+
const objectFields = indexObjectFields(stack);
|
|
2404
|
+
const searchTargets = indexObjectSearchTargets(stack);
|
|
2405
|
+
const pages = asArray12(stack.pages);
|
|
2406
|
+
for (let p = 0; p < pages.length; p++) {
|
|
2407
|
+
const page = pages[p];
|
|
2408
|
+
if (!page || page.kind !== "react") continue;
|
|
2409
|
+
const source = page.source;
|
|
2410
|
+
if (typeof source !== "string" || source.trim() === "") continue;
|
|
2411
|
+
const name = String(page.name ?? `#${p}`);
|
|
2412
|
+
const tsc = loadTypeScript();
|
|
2413
|
+
let sf;
|
|
2414
|
+
try {
|
|
2415
|
+
sf = tsc.createSourceFile("page.tsx", source, tsc.ScriptTarget.Latest, true, tsc.ScriptKind.TSX);
|
|
2416
|
+
} catch {
|
|
2417
|
+
continue;
|
|
2418
|
+
}
|
|
2419
|
+
const visit = (node) => {
|
|
2420
|
+
if (tsc.isJsxOpeningElement(node) || tsc.isJsxSelfClosingElement(node)) {
|
|
2421
|
+
const tag = node.tagName.getText(sf);
|
|
2422
|
+
const block = BLOCKS.get(tag);
|
|
2423
|
+
if (block) {
|
|
2424
|
+
let hasSpread = false;
|
|
2425
|
+
const used = /* @__PURE__ */ new Set();
|
|
2426
|
+
const values = /* @__PURE__ */ new Map();
|
|
2427
|
+
for (const a of node.attributes.properties) {
|
|
2428
|
+
if (tsc.isJsxSpreadAttribute(a)) {
|
|
1231
2429
|
hasSpread = true;
|
|
1232
2430
|
continue;
|
|
1233
2431
|
}
|
|
1234
|
-
if (tsc.isJsxAttribute(a))
|
|
2432
|
+
if (tsc.isJsxAttribute(a)) {
|
|
2433
|
+
const propName = a.name.getText(sf);
|
|
2434
|
+
used.add(propName);
|
|
2435
|
+
values.set(
|
|
2436
|
+
propName,
|
|
2437
|
+
FILTER_PROPS.has(propName) ? filterAttrValue(tsc, sf, a) : attrValue(tsc, sf, a)
|
|
2438
|
+
);
|
|
2439
|
+
}
|
|
1235
2440
|
}
|
|
1236
2441
|
const where = `page "${name}" \u203A <${tag}>`;
|
|
1237
2442
|
const path = `pages[${p}].source`;
|
|
@@ -1262,6 +2467,26 @@ function validateReactPageProps(stack) {
|
|
|
1262
2467
|
});
|
|
1263
2468
|
}
|
|
1264
2469
|
}
|
|
2470
|
+
if (tag === "ObjectChart" && !hasSpread) {
|
|
2471
|
+
checkObjectChart({ values, where, path }, objectFields, findings);
|
|
2472
|
+
}
|
|
2473
|
+
if (tag === "ListView" && !hasSpread) {
|
|
2474
|
+
findings.push(
|
|
2475
|
+
...checkSearchableFieldList(
|
|
2476
|
+
values.get("searchableFields"),
|
|
2477
|
+
strOf(values.get("objectName")),
|
|
2478
|
+
searchTargets,
|
|
2479
|
+
where,
|
|
2480
|
+
`${path} \u203A searchableFields`,
|
|
2481
|
+
"searchableFields"
|
|
2482
|
+
)
|
|
2483
|
+
);
|
|
2484
|
+
}
|
|
2485
|
+
if (!hasSpread) {
|
|
2486
|
+
findings.push(
|
|
2487
|
+
...checkBlockFieldProps(tag, values, objectFields, where, path)
|
|
2488
|
+
);
|
|
2489
|
+
}
|
|
1265
2490
|
}
|
|
1266
2491
|
}
|
|
1267
2492
|
tsc.forEachChild(node, visit);
|
|
@@ -1273,11 +2498,11 @@ function validateReactPageProps(stack) {
|
|
|
1273
2498
|
|
|
1274
2499
|
// src/validate-page-source-styling.ts
|
|
1275
2500
|
var PAGE_SOURCE_CLASSNAME = "page-source-className-tailwind";
|
|
1276
|
-
var
|
|
2501
|
+
var asArray13 = (v) => Array.isArray(v) ? v : [];
|
|
1277
2502
|
var CLASSNAME_ATTR = /\bclassName\s*=\s*["'{]/g;
|
|
1278
2503
|
function validatePageSourceStyling(stack) {
|
|
1279
2504
|
const findings = [];
|
|
1280
|
-
const pages =
|
|
2505
|
+
const pages = asArray13(stack.pages);
|
|
1281
2506
|
for (let p = 0; p < pages.length; p++) {
|
|
1282
2507
|
const page = pages[p];
|
|
1283
2508
|
if (!page) continue;
|
|
@@ -1303,10 +2528,10 @@ function validatePageSourceStyling(stack) {
|
|
|
1303
2528
|
}
|
|
1304
2529
|
|
|
1305
2530
|
// src/validate-record-title.ts
|
|
1306
|
-
var
|
|
2531
|
+
var import_data4 = require("@objectstack/spec/data");
|
|
1307
2532
|
var TITLE_FORMAT_RETIRED = "title-format-retired";
|
|
1308
2533
|
var TITLE_UNRESOLVABLE = "title-unresolvable";
|
|
1309
|
-
function
|
|
2534
|
+
function asArray14(v) {
|
|
1310
2535
|
if (Array.isArray(v)) return v;
|
|
1311
2536
|
if (v && typeof v === "object") {
|
|
1312
2537
|
return Object.entries(v).map(([name, def]) => ({ name, ...def }));
|
|
@@ -1315,7 +2540,7 @@ function asArray10(v) {
|
|
|
1315
2540
|
}
|
|
1316
2541
|
function validateRecordTitle(stack) {
|
|
1317
2542
|
const findings = [];
|
|
1318
|
-
const objects =
|
|
2543
|
+
const objects = asArray14(stack.objects);
|
|
1319
2544
|
for (let i = 0; i < objects.length; i++) {
|
|
1320
2545
|
const obj = objects[i];
|
|
1321
2546
|
const objName = typeof obj.name === "string" ? obj.name : `(object ${i})`;
|
|
@@ -1331,7 +2556,7 @@ function validateRecordTitle(stack) {
|
|
|
1331
2556
|
hint: `titleFormat is a render-only template the server cannot return or query, and an explicit nameField now takes precedence. For a single-field title set nameField: '<field>'. For a composite title, add a formula field (returnType: 'text') and designate it via nameField.`
|
|
1332
2557
|
});
|
|
1333
2558
|
}
|
|
1334
|
-
const completeness = (0,
|
|
2559
|
+
const completeness = (0, import_data4.objectTitleCompleteness)(obj);
|
|
1335
2560
|
if (completeness.status === "none") {
|
|
1336
2561
|
findings.push({
|
|
1337
2562
|
severity: "warning",
|
|
@@ -1351,7 +2576,7 @@ var FIELD_GROUP_UNDECLARED = "field-group-undeclared";
|
|
|
1351
2576
|
var FIELD_GROUP_EMPTY = "field-group-empty";
|
|
1352
2577
|
var FIELD_GROUP_SHADOWED = "field-group-shadowed";
|
|
1353
2578
|
var SEMANTIC_ROLE_FIELD_UNKNOWN = "semantic-role-field-unknown";
|
|
1354
|
-
function
|
|
2579
|
+
function asArray15(v) {
|
|
1355
2580
|
if (Array.isArray(v)) return v;
|
|
1356
2581
|
if (v && typeof v === "object") {
|
|
1357
2582
|
return Object.entries(v).map(([name, def]) => ({ name, ...def }));
|
|
@@ -1360,7 +2585,7 @@ function asArray11(v) {
|
|
|
1360
2585
|
}
|
|
1361
2586
|
function validateSemanticRoles(stack) {
|
|
1362
2587
|
const findings = [];
|
|
1363
|
-
const objects =
|
|
2588
|
+
const objects = asArray15(stack.objects);
|
|
1364
2589
|
for (let i = 0; i < objects.length; i++) {
|
|
1365
2590
|
const obj = objects[i];
|
|
1366
2591
|
if (!obj || typeof obj !== "object") continue;
|
|
@@ -1455,7 +2680,7 @@ function validateSemanticRoles(stack) {
|
|
|
1455
2680
|
// src/validate-form-layout.ts
|
|
1456
2681
|
var FORM_FIELD_UNKNOWN = "form-field-unknown";
|
|
1457
2682
|
var FORM_COLSPAN_ABSOLUTE = "absolute-colspan-discouraged";
|
|
1458
|
-
function
|
|
2683
|
+
function asArray16(v) {
|
|
1459
2684
|
if (Array.isArray(v)) return v;
|
|
1460
2685
|
if (v && typeof v === "object") {
|
|
1461
2686
|
return Object.entries(v).map(([name, def]) => ({ name, ...def }));
|
|
@@ -1480,13 +2705,13 @@ function boundObject(view) {
|
|
|
1480
2705
|
function validateFormLayout(stack) {
|
|
1481
2706
|
const findings = [];
|
|
1482
2707
|
const objectFields = /* @__PURE__ */ new Map();
|
|
1483
|
-
for (const obj of
|
|
2708
|
+
for (const obj of asArray16(stack.objects)) {
|
|
1484
2709
|
const name = typeof obj.name === "string" ? obj.name : void 0;
|
|
1485
2710
|
if (!name) continue;
|
|
1486
2711
|
const fields = obj.fields && typeof obj.fields === "object" && !Array.isArray(obj.fields) ? Object.keys(obj.fields) : [];
|
|
1487
2712
|
objectFields.set(name, new Set(fields));
|
|
1488
2713
|
}
|
|
1489
|
-
const views =
|
|
2714
|
+
const views = asArray16(stack.views);
|
|
1490
2715
|
for (let i = 0; i < views.length; i++) {
|
|
1491
2716
|
const view = views[i];
|
|
1492
2717
|
if (!view || typeof view !== "object") continue;
|
|
@@ -1536,7 +2761,7 @@ var VISIBILITY_ALIAS_DEPRECATED = "visibility-alias-deprecated";
|
|
|
1536
2761
|
var VISIBILITY_ROOT_MISLAYERED = "visibility-root-mislayered";
|
|
1537
2762
|
var CANONICAL = "visibleWhen";
|
|
1538
2763
|
var ALIASES = ["visibleOn", "visibility"];
|
|
1539
|
-
function
|
|
2764
|
+
function asArray17(v) {
|
|
1540
2765
|
if (Array.isArray(v)) return v;
|
|
1541
2766
|
if (v && typeof v === "object") {
|
|
1542
2767
|
return Object.entries(v).map(([name, def]) => ({ name, ...def }));
|
|
@@ -1598,7 +2823,7 @@ function isFieldObject(entry) {
|
|
|
1598
2823
|
function validateVisibilityPredicates(stack, opts = {}) {
|
|
1599
2824
|
const layer = opts.layer ?? "runtime";
|
|
1600
2825
|
const findings = [];
|
|
1601
|
-
const views =
|
|
2826
|
+
const views = asArray17(stack.views);
|
|
1602
2827
|
for (let i = 0; i < views.length; i++) {
|
|
1603
2828
|
const view = views[i];
|
|
1604
2829
|
if (!view || typeof view !== "object") continue;
|
|
@@ -1621,7 +2846,7 @@ function validateVisibilityPredicates(stack, opts = {}) {
|
|
|
1621
2846
|
}
|
|
1622
2847
|
}
|
|
1623
2848
|
}
|
|
1624
|
-
const pages =
|
|
2849
|
+
const pages = asArray17(stack.pages);
|
|
1625
2850
|
for (let i = 0; i < pages.length; i++) {
|
|
1626
2851
|
const page = pages[i];
|
|
1627
2852
|
if (!page || typeof page !== "object") continue;
|
|
@@ -1645,7 +2870,7 @@ function validateVisibilityPredicates(stack, opts = {}) {
|
|
|
1645
2870
|
// src/validate-capability-references.ts
|
|
1646
2871
|
var import_security = require("@objectstack/spec/security");
|
|
1647
2872
|
var CAPABILITY_REFERENCE_UNKNOWN = "capability-reference-unknown";
|
|
1648
|
-
function
|
|
2873
|
+
function asArray18(v) {
|
|
1649
2874
|
if (Array.isArray(v)) return v;
|
|
1650
2875
|
if (v && typeof v === "object") {
|
|
1651
2876
|
return Object.entries(v).map(([name, def]) => ({ name, ...def }));
|
|
@@ -1670,13 +2895,13 @@ function validateCapabilityReferences(stack) {
|
|
|
1670
2895
|
const findings = [];
|
|
1671
2896
|
if (!stack || typeof stack !== "object") return findings;
|
|
1672
2897
|
const known = new Set(import_security.PLATFORM_CAPABILITY_NAMES);
|
|
1673
|
-
for (const cap of
|
|
2898
|
+
for (const cap of asArray18(stack.capabilities)) {
|
|
1674
2899
|
if (typeof cap.name === "string" && cap.name.length > 0) known.add(cap.name);
|
|
1675
2900
|
}
|
|
1676
|
-
for (const ps of
|
|
2901
|
+
for (const ps of asArray18(stack.permissions)) {
|
|
1677
2902
|
for (const cap of asCapArray(ps.systemPermissions)) known.add(cap);
|
|
1678
2903
|
}
|
|
1679
|
-
for (const seed of
|
|
2904
|
+
for (const seed of asArray18(stack.data)) {
|
|
1680
2905
|
if (seed.object !== "sys_capability") continue;
|
|
1681
2906
|
for (const rec of Array.isArray(seed.records) ? seed.records : []) {
|
|
1682
2907
|
const name = rec?.name;
|
|
@@ -1695,7 +2920,7 @@ function validateCapabilityReferences(stack) {
|
|
|
1695
2920
|
hint
|
|
1696
2921
|
});
|
|
1697
2922
|
};
|
|
1698
|
-
const objects =
|
|
2923
|
+
const objects = asArray18(stack.objects);
|
|
1699
2924
|
for (let i = 0; i < objects.length; i++) {
|
|
1700
2925
|
const obj = objects[i];
|
|
1701
2926
|
if (!obj || typeof obj !== "object") continue;
|
|
@@ -1704,27 +2929,27 @@ function validateCapabilityReferences(stack) {
|
|
|
1704
2929
|
for (const { cap, key } of flattenObjectRequired(obj.requiredPermissions)) {
|
|
1705
2930
|
flag(cap, `object "${objName}"`, `${objPath}.requiredPermissions${key ? `.${key}` : ""}`);
|
|
1706
2931
|
}
|
|
1707
|
-
const fields =
|
|
2932
|
+
const fields = asArray18(obj.fields);
|
|
1708
2933
|
for (const f of fields) {
|
|
1709
2934
|
const fname = typeof f.name === "string" ? f.name : "(field)";
|
|
1710
2935
|
for (const cap of asCapArray(f.requiredPermissions)) {
|
|
1711
2936
|
flag(cap, `field "${objName}.${fname}"`, `${objPath}.fields.${fname}.requiredPermissions`);
|
|
1712
2937
|
}
|
|
1713
2938
|
}
|
|
1714
|
-
for (const [ai, action] of
|
|
2939
|
+
for (const [ai, action] of asArray18(obj.actions).entries()) {
|
|
1715
2940
|
const aName = typeof action.name === "string" ? action.name : `(action ${ai})`;
|
|
1716
2941
|
for (const cap of asCapArray(action.requiredPermissions)) {
|
|
1717
2942
|
flag(cap, `action "${objName}.${aName}"`, `${objPath}.actions[${ai}].requiredPermissions`);
|
|
1718
2943
|
}
|
|
1719
2944
|
}
|
|
1720
2945
|
}
|
|
1721
|
-
for (const [i, action] of
|
|
2946
|
+
for (const [i, action] of asArray18(stack.actions).entries()) {
|
|
1722
2947
|
const aName = typeof action.name === "string" ? action.name : `(action ${i})`;
|
|
1723
2948
|
for (const cap of asCapArray(action.requiredPermissions)) {
|
|
1724
2949
|
flag(cap, `action "${aName}"`, `actions[${i}].requiredPermissions`);
|
|
1725
2950
|
}
|
|
1726
2951
|
}
|
|
1727
|
-
const apps =
|
|
2952
|
+
const apps = asArray18(stack.apps);
|
|
1728
2953
|
for (let i = 0; i < apps.length; i++) {
|
|
1729
2954
|
const app = apps[i];
|
|
1730
2955
|
if (!app || typeof app !== "object") continue;
|
|
@@ -1751,17 +2976,29 @@ function validateCapabilityReferences(stack) {
|
|
|
1751
2976
|
}
|
|
1752
2977
|
|
|
1753
2978
|
// src/validate-approval-approvers.ts
|
|
1754
|
-
var
|
|
2979
|
+
var import_automation3 = require("@objectstack/spec/automation");
|
|
2980
|
+
var import_spec = require("@objectstack/spec");
|
|
2981
|
+
var import_formula2 = require("@objectstack/formula");
|
|
1755
2982
|
var APPROVAL_APPROVER_NOT_MEMBERSHIP_TIER = "approval-approver-not-membership-tier";
|
|
1756
2983
|
var APPROVAL_APPROVER_TYPE_DEPRECATED = "approval-approver-type-deprecated";
|
|
1757
2984
|
var APPROVAL_APPROVER_TYPE_UNKNOWN = "approval-approver-type-unknown";
|
|
2985
|
+
var APPROVAL_APPROVER_TYPE_UNSUPPORTED = "approval-approver-type-unsupported";
|
|
1758
2986
|
var APPROVAL_ESCALATION_REASSIGN_NO_TARGET = "approval-escalation-reassign-no-target";
|
|
1759
|
-
var
|
|
2987
|
+
var APPROVAL_APPROVERS_MAY_RESOLVE_EMPTY = "approval-approvers-may-resolve-empty";
|
|
2988
|
+
var APPROVAL_EXPRESSION_INVALID = "approval-expression-invalid";
|
|
2989
|
+
var APPROVAL_EXPRESSION_NO_EMPTY_POLICY = "approval-expression-no-empty-policy";
|
|
2990
|
+
var APPROVAL_DECISION_OUTPUTS_RESERVED = "approval-decision-outputs-reserved";
|
|
2991
|
+
var APPROVAL_APPROVER_CROSS_ORG_UNSUPPORTED = "approval-approver-cross-org-unsupported";
|
|
2992
|
+
var EXPRESSION_ROOTS = /* @__PURE__ */ new Set(["current", "trigger", "vars"]);
|
|
2993
|
+
var RESERVED_OUTPUT_KEYS = /* @__PURE__ */ new Set(["decision", "requestId"]);
|
|
2994
|
+
var GROUP_ROUTED_TYPES = /* @__PURE__ */ new Set(["position", "team", "department"]);
|
|
2995
|
+
var MEMBERSHIP_TIERS = new Set(import_spec.BUILTIN_MEMBERSHIP_ROLES);
|
|
2996
|
+
var MEMBERSHIP_TIER_LIST = import_spec.BUILTIN_MEMBERSHIP_ROLES.join("/");
|
|
1760
2997
|
var TYPE_FIX = {
|
|
1761
2998
|
business_unit: "department",
|
|
1762
2999
|
bu: "department"
|
|
1763
3000
|
};
|
|
1764
|
-
function
|
|
3001
|
+
function asArray19(v) {
|
|
1765
3002
|
if (Array.isArray(v)) return v;
|
|
1766
3003
|
if (v && typeof v === "object") {
|
|
1767
3004
|
return Object.entries(v).map(([name, def]) => ({ name, ...def }));
|
|
@@ -1771,16 +3008,16 @@ function asArray15(v) {
|
|
|
1771
3008
|
function validateApprovalApprovers(stack) {
|
|
1772
3009
|
const findings = [];
|
|
1773
3010
|
if (!stack || typeof stack !== "object") return findings;
|
|
1774
|
-
const flows =
|
|
1775
|
-
const validTypes = new Set(
|
|
3011
|
+
const flows = asArray19(stack.flows);
|
|
3012
|
+
const validTypes = new Set(import_automation3.ApproverType.options);
|
|
1776
3013
|
for (let fi = 0; fi < flows.length; fi++) {
|
|
1777
3014
|
const flow = flows[fi];
|
|
1778
3015
|
if (!flow || typeof flow !== "object") continue;
|
|
1779
3016
|
const flowName = typeof flow.name === "string" ? flow.name : `(flow ${fi})`;
|
|
1780
|
-
const
|
|
1781
|
-
for (let ni = 0; ni <
|
|
1782
|
-
const node =
|
|
1783
|
-
if (!node || node.type !==
|
|
3017
|
+
const walked = walkFlowNodes(flow, `flows[${fi}]`);
|
|
3018
|
+
for (let ni = 0; ni < walked.length; ni++) {
|
|
3019
|
+
const { node, path: nodePath } = walked[ni];
|
|
3020
|
+
if (!node || node.type !== import_automation3.APPROVAL_NODE_TYPE) continue;
|
|
1784
3021
|
const nodeId = typeof node.id === "string" ? node.id : `(node ${ni})`;
|
|
1785
3022
|
const cfg = node.config ?? {};
|
|
1786
3023
|
const approvers = Array.isArray(cfg.approvers) ? cfg.approvers : [];
|
|
@@ -1790,7 +3027,7 @@ function validateApprovalApprovers(stack) {
|
|
|
1790
3027
|
if (!a || typeof a !== "object") continue;
|
|
1791
3028
|
const type = typeof a.type === "string" ? a.type : "";
|
|
1792
3029
|
const value = typeof a.value === "string" ? a.value : "";
|
|
1793
|
-
const path =
|
|
3030
|
+
const path = `${nodePath}.config.approvers[${ai}]`;
|
|
1794
3031
|
if (type && !validTypes.has(type)) {
|
|
1795
3032
|
const fix = TYPE_FIX[type];
|
|
1796
3033
|
findings.push({
|
|
@@ -1798,23 +3035,70 @@ function validateApprovalApprovers(stack) {
|
|
|
1798
3035
|
rule: APPROVAL_APPROVER_TYPE_UNKNOWN,
|
|
1799
3036
|
where,
|
|
1800
3037
|
path: `${path}.type`,
|
|
1801
|
-
message: `approver type '${type}' is not an ApproverType (${
|
|
3038
|
+
message: `approver type '${type}' is not an ApproverType (${import_automation3.ApproverType.options.join(" | ")}).`,
|
|
1802
3039
|
hint: fix ? `Use the spec value: { type: '${fix}', value: '${value}' }.` : `Pick one of the spec values; unmapped types degrade to an inert '${type}:${value}' literal at runtime.`
|
|
1803
3040
|
});
|
|
1804
3041
|
continue;
|
|
1805
3042
|
}
|
|
1806
|
-
const canonical = (0,
|
|
3043
|
+
const canonical = (0, import_automation3.canonicalApproverType)(type);
|
|
3044
|
+
if (canonical === "expression") {
|
|
3045
|
+
const source = value.trim();
|
|
3046
|
+
if (!source) {
|
|
3047
|
+
findings.push({
|
|
3048
|
+
severity: "error",
|
|
3049
|
+
rule: APPROVAL_EXPRESSION_INVALID,
|
|
3050
|
+
where,
|
|
3051
|
+
path: `${path}.value`,
|
|
3052
|
+
message: `expression approver has an empty expression \u2014 the node fails at entry.`,
|
|
3053
|
+
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.`
|
|
3054
|
+
});
|
|
3055
|
+
} else {
|
|
3056
|
+
const parsed = (0, import_formula2.collectCelRootIdentifiers)(source);
|
|
3057
|
+
if (!parsed.ok) {
|
|
3058
|
+
findings.push({
|
|
3059
|
+
severity: "error",
|
|
3060
|
+
rule: APPROVAL_EXPRESSION_INVALID,
|
|
3061
|
+
where,
|
|
3062
|
+
path: `${path}.value`,
|
|
3063
|
+
message: `expression approver does not parse as CEL: ${parsed.error}.`,
|
|
3064
|
+
hint: `Approver expressions are bare CEL (no {\u2026} template braces), e.g. current.approvers_dynamic or vars.get_reviewers.record.owner_id.`
|
|
3065
|
+
});
|
|
3066
|
+
} else {
|
|
3067
|
+
const illegal = parsed.roots.filter((r) => !EXPRESSION_ROOTS.has(r));
|
|
3068
|
+
if (illegal.length) {
|
|
3069
|
+
const wantsRecord = illegal.includes("record") || illegal.includes("previous");
|
|
3070
|
+
findings.push({
|
|
3071
|
+
severity: "error",
|
|
3072
|
+
rule: APPROVAL_EXPRESSION_INVALID,
|
|
3073
|
+
where,
|
|
3074
|
+
path: `${path}.value`,
|
|
3075
|
+
message: `expression approver references \`${illegal.join("`, `")}\` \u2014 only current.*, trigger.* and vars.* are available, and the node fails at entry on any other root.`,
|
|
3076
|
+
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)?`
|
|
3077
|
+
});
|
|
3078
|
+
}
|
|
3079
|
+
}
|
|
3080
|
+
}
|
|
3081
|
+
} else if (a.resolveAs != null) {
|
|
3082
|
+
findings.push({
|
|
3083
|
+
severity: "info",
|
|
3084
|
+
rule: APPROVAL_EXPRESSION_INVALID,
|
|
3085
|
+
where,
|
|
3086
|
+
path: `${path}.resolveAs`,
|
|
3087
|
+
message: `resolveAs has no effect on a '${type}' approver \u2014 it only applies to type 'expression'.`,
|
|
3088
|
+
hint: `Remove it, or switch this approver to { type: 'expression', value: '<CEL>', resolveAs: '${String(a.resolveAs)}' }.`
|
|
3089
|
+
});
|
|
3090
|
+
}
|
|
1807
3091
|
if (canonical === "org_membership_level" && value && !MEMBERSHIP_TIERS.has(value.toLowerCase())) {
|
|
1808
3092
|
findings.push({
|
|
1809
3093
|
severity: "warning",
|
|
1810
3094
|
rule: APPROVAL_APPROVER_NOT_MEMBERSHIP_TIER,
|
|
1811
3095
|
where,
|
|
1812
3096
|
path: `${path}.value`,
|
|
1813
|
-
message: `approver { type: '${type}', value: '${value}' } resolves against the better-auth org-membership tier (sys_member.role:
|
|
1814
|
-
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 (
|
|
3097
|
+
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.`,
|
|
3098
|
+
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.`
|
|
1815
3099
|
});
|
|
1816
|
-
} else if (type in
|
|
1817
|
-
const fix = (0,
|
|
3100
|
+
} else if (type in import_automation3.DEPRECATED_APPROVER_TYPES) {
|
|
3101
|
+
const fix = (0, import_automation3.canonicalApproverType)(type);
|
|
1818
3102
|
findings.push({
|
|
1819
3103
|
severity: "warning",
|
|
1820
3104
|
rule: APPROVAL_APPROVER_TYPE_DEPRECATED,
|
|
@@ -1823,8 +3107,67 @@ function validateApprovalApprovers(stack) {
|
|
|
1823
3107
|
message: `approver type '${type}' is the deprecated spelling of '${fix}' (ADR-0090 D3) and is removed in the next major.`,
|
|
1824
3108
|
hint: `Author { type: '${fix}', value: '${value}' }. It resolves identically today.`
|
|
1825
3109
|
});
|
|
3110
|
+
} else if (import_automation3.APPROVER_VALUE_BINDINGS[canonical]?.source === "unsupported") {
|
|
3111
|
+
findings.push({
|
|
3112
|
+
severity: "warning",
|
|
3113
|
+
rule: APPROVAL_APPROVER_TYPE_UNSUPPORTED,
|
|
3114
|
+
where,
|
|
3115
|
+
path: `${path}.type`,
|
|
3116
|
+
message: `approver type '${type}' is declared but not implemented by the runtime (#3508) \u2014 the slot resolves to nobody and the request stalls.`,
|
|
3117
|
+
hint: `Route to people the engine can expand: { type: 'team' | 'department' | 'position', ... }. Queue approvers need a real ownership-queue implementation before they take effect.`
|
|
3118
|
+
});
|
|
3119
|
+
}
|
|
3120
|
+
const declaredOrg = a.organization;
|
|
3121
|
+
if (typeof declaredOrg === "string" && declaredOrg.trim() !== "" && import_automation3.ApproverType.options.includes(canonical) && !(0, import_automation3.approverTypeIsOrgScoped)(canonical)) {
|
|
3122
|
+
findings.push({
|
|
3123
|
+
severity: "error",
|
|
3124
|
+
rule: APPROVAL_APPROVER_CROSS_ORG_UNSUPPORTED,
|
|
3125
|
+
where,
|
|
3126
|
+
path: `${path}.organization`,
|
|
3127
|
+
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.`,
|
|
3128
|
+
hint: `Drop 'organization' here. Cross-organization targeting applies to 'position', 'org_membership_level', 'department' and 'expression' approvers.`
|
|
3129
|
+
});
|
|
1826
3130
|
}
|
|
1827
3131
|
}
|
|
3132
|
+
const routable = approvers.filter(
|
|
3133
|
+
(a) => a && typeof a === "object" && typeof a.type === "string"
|
|
3134
|
+
);
|
|
3135
|
+
if (routable.length > 0 && routable.every((a) => GROUP_ROUTED_TYPES.has((0, import_automation3.canonicalApproverType)(String(a.type))))) {
|
|
3136
|
+
const locks = cfg.lockRecord !== false;
|
|
3137
|
+
findings.push({
|
|
3138
|
+
severity: "info",
|
|
3139
|
+
rule: APPROVAL_APPROVERS_MAY_RESOLVE_EMPTY,
|
|
3140
|
+
where,
|
|
3141
|
+
path: `${nodePath}.config.approvers`,
|
|
3142
|
+
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.` : `.`),
|
|
3143
|
+
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).`
|
|
3144
|
+
});
|
|
3145
|
+
}
|
|
3146
|
+
const hasExpression = approvers.some(
|
|
3147
|
+
(a) => a && typeof a === "object" && (0, import_automation3.canonicalApproverType)(String(a.type ?? "")) === "expression"
|
|
3148
|
+
);
|
|
3149
|
+
if (hasExpression && cfg.onEmptyApprovers == null) {
|
|
3150
|
+
findings.push({
|
|
3151
|
+
severity: "info",
|
|
3152
|
+
rule: APPROVAL_EXPRESSION_NO_EMPTY_POLICY,
|
|
3153
|
+
where,
|
|
3154
|
+
path: `${nodePath}.config`,
|
|
3155
|
+
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).`,
|
|
3156
|
+
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).`
|
|
3157
|
+
});
|
|
3158
|
+
}
|
|
3159
|
+
const declaredOutputs = (0, import_automation3.normalizeDecisionOutputs)(cfg.decisionOutputs).map((d) => d.key);
|
|
3160
|
+
const reserved = declaredOutputs.filter((k) => RESERVED_OUTPUT_KEYS.has(k));
|
|
3161
|
+
if (reserved.length) {
|
|
3162
|
+
findings.push({
|
|
3163
|
+
severity: "error",
|
|
3164
|
+
rule: APPROVAL_DECISION_OUTPUTS_RESERVED,
|
|
3165
|
+
where,
|
|
3166
|
+
path: `${nodePath}.config.decisionOutputs`,
|
|
3167
|
+
message: `decisionOutputs declares reserved key(s) \`${reserved.join("`, `")}\` \u2014 the resume envelope owns them, so every decide carrying them is rejected.`,
|
|
3168
|
+
hint: `Rename the output key(s); any name other than 'decision'/'requestId' works.`
|
|
3169
|
+
});
|
|
3170
|
+
}
|
|
1828
3171
|
const escalation = cfg.escalation ?? null;
|
|
1829
3172
|
if (escalation && typeof escalation === "object" && escalation.action === "reassign") {
|
|
1830
3173
|
const target = typeof escalation.escalateTo === "string" ? escalation.escalateTo.trim() : "";
|
|
@@ -1833,7 +3176,7 @@ function validateApprovalApprovers(stack) {
|
|
|
1833
3176
|
severity: "warning",
|
|
1834
3177
|
rule: APPROVAL_ESCALATION_REASSIGN_NO_TARGET,
|
|
1835
3178
|
where,
|
|
1836
|
-
path:
|
|
3179
|
+
path: `${nodePath}.config.escalation.escalateTo`,
|
|
1837
3180
|
message: `escalation.action is 'reassign' but escalateTo is empty \u2014 at runtime the escalation degrades to a notify and the request stays with the original approvers.`,
|
|
1838
3181
|
hint: `Set escalateTo to a position machine name (expanded via sys_user_position, ADR-0090 D3) or a specific user id, or change action to 'notify'.`
|
|
1839
3182
|
});
|
|
@@ -1844,6 +3187,96 @@ function validateApprovalApprovers(stack) {
|
|
|
1844
3187
|
return findings;
|
|
1845
3188
|
}
|
|
1846
3189
|
|
|
3190
|
+
// src/validate-seed-replay-safety.ts
|
|
3191
|
+
var SEED_INSERT_MODE_DUPLICATES_ON_REPLAY = "seed-insert-mode-duplicates-on-replay";
|
|
3192
|
+
function validateSeedReplaySafety(stack) {
|
|
3193
|
+
const out = [];
|
|
3194
|
+
const seeds = Array.isArray(stack.data) ? stack.data : [];
|
|
3195
|
+
seeds.forEach((seed, i) => {
|
|
3196
|
+
if (!seed || typeof seed !== "object") return;
|
|
3197
|
+
if (seed.mode !== "insert") return;
|
|
3198
|
+
const object = typeof seed.object === "string" ? seed.object : void 0;
|
|
3199
|
+
const where = object ? `seed "${object}"` : `data[${i}]`;
|
|
3200
|
+
out.push({
|
|
3201
|
+
severity: "warning",
|
|
3202
|
+
rule: SEED_INSERT_MODE_DUPLICATES_ON_REPLAY,
|
|
3203
|
+
where,
|
|
3204
|
+
path: `data[${i}].mode`,
|
|
3205
|
+
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.",
|
|
3206
|
+
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']`)."
|
|
3207
|
+
});
|
|
3208
|
+
});
|
|
3209
|
+
return out;
|
|
3210
|
+
}
|
|
3211
|
+
|
|
3212
|
+
// src/validate-seed-state-machine.ts
|
|
3213
|
+
var SEED_VALUE_OUTSIDE_STATE_MACHINE = "seed-value-outside-state-machine";
|
|
3214
|
+
function fsmRulesByObject(objects) {
|
|
3215
|
+
const map = /* @__PURE__ */ new Map();
|
|
3216
|
+
for (const obj of objects) {
|
|
3217
|
+
if (!obj || typeof obj !== "object") continue;
|
|
3218
|
+
const name = typeof obj.name === "string" ? obj.name : void 0;
|
|
3219
|
+
if (!name) continue;
|
|
3220
|
+
const validations = Array.isArray(obj.validations) ? obj.validations : [];
|
|
3221
|
+
const rules = [];
|
|
3222
|
+
for (const v of validations) {
|
|
3223
|
+
if (!v || typeof v !== "object" || v.type !== "state_machine") continue;
|
|
3224
|
+
const field = typeof v.field === "string" ? v.field : void 0;
|
|
3225
|
+
if (!field) continue;
|
|
3226
|
+
const transitions = v.transitions && typeof v.transitions === "object" ? v.transitions : {};
|
|
3227
|
+
const states = /* @__PURE__ */ new Set();
|
|
3228
|
+
for (const s of Array.isArray(v.initialStates) ? v.initialStates : []) states.add(String(s));
|
|
3229
|
+
for (const from of Object.keys(transitions)) {
|
|
3230
|
+
states.add(String(from));
|
|
3231
|
+
const targets = transitions[from];
|
|
3232
|
+
for (const to of Array.isArray(targets) ? targets : []) states.add(String(to));
|
|
3233
|
+
}
|
|
3234
|
+
if (states.size > 0) rules.push({ field, states });
|
|
3235
|
+
}
|
|
3236
|
+
if (rules.length > 0) map.set(name, rules);
|
|
3237
|
+
}
|
|
3238
|
+
return map;
|
|
3239
|
+
}
|
|
3240
|
+
function recordLabel(record, externalId, index) {
|
|
3241
|
+
const keys = Array.isArray(externalId) ? externalId.map(String) : typeof externalId === "string" ? [externalId] : ["name"];
|
|
3242
|
+
const parts = keys.map((k) => record[k]).filter((v) => v != null && v !== "");
|
|
3243
|
+
return parts.length > 0 ? parts.map(String).join(" \xB7 ") : `#${index}`;
|
|
3244
|
+
}
|
|
3245
|
+
function validateSeedStateMachine(stack) {
|
|
3246
|
+
const out = [];
|
|
3247
|
+
const objects = Array.isArray(stack.objects) ? stack.objects : [];
|
|
3248
|
+
const seeds = Array.isArray(stack.data) ? stack.data : [];
|
|
3249
|
+
if (objects.length === 0 || seeds.length === 0) return out;
|
|
3250
|
+
const rulesByObject = fsmRulesByObject(objects);
|
|
3251
|
+
if (rulesByObject.size === 0) return out;
|
|
3252
|
+
seeds.forEach((seed, i) => {
|
|
3253
|
+
if (!seed || typeof seed !== "object") return;
|
|
3254
|
+
const objectName = typeof seed.object === "string" ? seed.object : void 0;
|
|
3255
|
+
if (!objectName) return;
|
|
3256
|
+
const rules = rulesByObject.get(objectName);
|
|
3257
|
+
if (!rules) return;
|
|
3258
|
+
const records = Array.isArray(seed.records) ? seed.records : [];
|
|
3259
|
+
records.forEach((record, j) => {
|
|
3260
|
+
if (!record || typeof record !== "object") return;
|
|
3261
|
+
for (const rule of rules) {
|
|
3262
|
+
const value = record[rule.field];
|
|
3263
|
+
if (value == null || value === "") continue;
|
|
3264
|
+
if (typeof value !== "string") continue;
|
|
3265
|
+
if (rule.states.has(value)) continue;
|
|
3266
|
+
out.push({
|
|
3267
|
+
severity: "warning",
|
|
3268
|
+
rule: SEED_VALUE_OUTSIDE_STATE_MACHINE,
|
|
3269
|
+
where: `seed "${objectName}" (${recordLabel(record, seed.externalId, j)})`,
|
|
3270
|
+
path: `data[${i}].records[${j}].${rule.field}`,
|
|
3271
|
+
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.`,
|
|
3272
|
+
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.`
|
|
3273
|
+
});
|
|
3274
|
+
}
|
|
3275
|
+
});
|
|
3276
|
+
});
|
|
3277
|
+
return out;
|
|
3278
|
+
}
|
|
3279
|
+
|
|
1847
3280
|
// src/validate-security-posture.ts
|
|
1848
3281
|
var import_security2 = require("@objectstack/spec/security");
|
|
1849
3282
|
var SECURITY_OWD_UNSET = "security-owd-unset";
|
|
@@ -1870,7 +3303,7 @@ var OWD_WIDTH = {
|
|
|
1870
3303
|
public_read: 1,
|
|
1871
3304
|
public_read_write: 2
|
|
1872
3305
|
};
|
|
1873
|
-
function
|
|
3306
|
+
function asArray20(v) {
|
|
1874
3307
|
if (Array.isArray(v)) return v;
|
|
1875
3308
|
if (v && typeof v === "object") {
|
|
1876
3309
|
return Object.entries(v).map(([name, def]) => ({ name, ...def }));
|
|
@@ -1887,16 +3320,16 @@ function identifierHasRoleToken(name) {
|
|
|
1887
3320
|
if (typeof name !== "string") return false;
|
|
1888
3321
|
return name.toLowerCase().split(/[^a-z0-9]+/).some((tok) => tok === "role" || tok === "roles");
|
|
1889
3322
|
}
|
|
1890
|
-
function labelHasRoleWord(
|
|
1891
|
-
if (typeof
|
|
1892
|
-
return /\brole(s)?\b/i.test(
|
|
3323
|
+
function labelHasRoleWord(label2) {
|
|
3324
|
+
if (typeof label2 !== "string") return false;
|
|
3325
|
+
return /\brole(s)?\b/i.test(label2);
|
|
1893
3326
|
}
|
|
1894
3327
|
function refOf(def) {
|
|
1895
3328
|
const r = def.reference ?? def.reference_to;
|
|
1896
3329
|
return typeof r === "string" && r ? r : void 0;
|
|
1897
3330
|
}
|
|
1898
3331
|
function firstMasterDetailField(obj) {
|
|
1899
|
-
for (const f of
|
|
3332
|
+
for (const f of asArray20(obj.fields)) {
|
|
1900
3333
|
if (f.type === "master_detail") {
|
|
1901
3334
|
return { name: String(f.name ?? "?"), parent: refOf(f) };
|
|
1902
3335
|
}
|
|
@@ -1909,8 +3342,8 @@ function grantsObjectAccess(p) {
|
|
|
1909
3342
|
function validateSecurityPosture(stack, opts) {
|
|
1910
3343
|
const findings = [];
|
|
1911
3344
|
if (!stack || typeof stack !== "object") return findings;
|
|
1912
|
-
const objects =
|
|
1913
|
-
const permissionSets =
|
|
3345
|
+
const objects = asArray20(stack.objects);
|
|
3346
|
+
const permissionSets = asArray20(stack.permissions);
|
|
1914
3347
|
for (let i = 0; i < objects.length; i++) {
|
|
1915
3348
|
const obj = objects[i];
|
|
1916
3349
|
if (!obj || typeof obj !== "object") continue;
|
|
@@ -2013,7 +3446,7 @@ function validateSecurityPosture(stack, opts) {
|
|
|
2013
3446
|
}
|
|
2014
3447
|
}
|
|
2015
3448
|
}
|
|
2016
|
-
const flagRole = (kind, name,
|
|
3449
|
+
const flagRole = (kind, name, label2, where, path) => {
|
|
2017
3450
|
if (identifierHasRoleToken(name)) {
|
|
2018
3451
|
findings.push({
|
|
2019
3452
|
severity: "error",
|
|
@@ -2023,13 +3456,13 @@ function validateSecurityPosture(stack, opts) {
|
|
|
2023
3456
|
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).`,
|
|
2024
3457
|
hint: `Rename using 'position' for distribution groups or a domain word (e.g. 'function', 'duty').`
|
|
2025
3458
|
});
|
|
2026
|
-
} else if (labelHasRoleWord(
|
|
3459
|
+
} else if (labelHasRoleWord(label2)) {
|
|
2027
3460
|
findings.push({
|
|
2028
3461
|
severity: "error",
|
|
2029
3462
|
rule: SECURITY_ROLE_WORD,
|
|
2030
3463
|
where,
|
|
2031
3464
|
path: `${path.replace(/\.name$/, "")}.label`,
|
|
2032
|
-
message: `${kind} label "${String(
|
|
3465
|
+
message: `${kind} label "${String(label2)}" uses the reserved word "role" (ADR-0090 D3).`,
|
|
2033
3466
|
hint: `Relabel with 'Position' (distribution) or a domain word \u2014 admins must meet ONE vocabulary.`
|
|
2034
3467
|
});
|
|
2035
3468
|
}
|
|
@@ -2039,10 +3472,10 @@ function validateSecurityPosture(stack, opts) {
|
|
|
2039
3472
|
if (!obj || typeof obj !== "object" || isSystemObject(obj)) continue;
|
|
2040
3473
|
const objName = typeof obj.name === "string" ? obj.name : `(object ${i})`;
|
|
2041
3474
|
flagRole("object", obj.name, obj.label, `object "${objName}"`, `objects[${i}].name`);
|
|
2042
|
-
for (const f of
|
|
3475
|
+
for (const f of asArray20(obj.fields)) {
|
|
2043
3476
|
flagRole("field", f.name, f.label, `field "${objName}.${String(f.name ?? "?")}"`, `objects[${i}].fields.${String(f.name ?? "?")}.name`);
|
|
2044
3477
|
}
|
|
2045
|
-
for (const [ai, action] of
|
|
3478
|
+
for (const [ai, action] of asArray20(obj.actions).entries()) {
|
|
2046
3479
|
flagRole("action", action.name, action.label, `action "${objName}.${String(action.name ?? "?")}"`, `objects[${i}].actions[${ai}].name`);
|
|
2047
3480
|
}
|
|
2048
3481
|
}
|
|
@@ -2051,19 +3484,19 @@ function validateSecurityPosture(stack, opts) {
|
|
|
2051
3484
|
if (!ps || typeof ps !== "object") continue;
|
|
2052
3485
|
flagRole("permission set", ps.name, ps.label, `permission set "${String(ps.name ?? i)}"`, `permissions[${i}].name`);
|
|
2053
3486
|
}
|
|
2054
|
-
for (const [i, pos] of
|
|
3487
|
+
for (const [i, pos] of asArray20(stack.positions).entries()) {
|
|
2055
3488
|
flagRole("position", pos.name, pos.label, `position "${String(pos.name ?? i)}"`, `positions[${i}].name`);
|
|
2056
3489
|
}
|
|
2057
|
-
for (const [i, app] of
|
|
3490
|
+
for (const [i, app] of asArray20(stack.apps).entries()) {
|
|
2058
3491
|
flagRole("app", app.name, app.label, `app "${String(app.name ?? i)}"`, `apps[${i}].name`);
|
|
2059
3492
|
}
|
|
2060
|
-
for (const [i, book] of
|
|
3493
|
+
for (const [i, book] of asArray20(stack.books).entries()) {
|
|
2061
3494
|
flagRole("book", book.name, book.label, `book "${String(book.name ?? i)}"`, `books[${i}].name`);
|
|
2062
3495
|
}
|
|
2063
3496
|
const stackSetNames = new Set(
|
|
2064
3497
|
permissionSets.map((ps) => typeof ps.name === "string" ? ps.name : void 0).filter((n) => !!n)
|
|
2065
3498
|
);
|
|
2066
|
-
for (const [i, book] of
|
|
3499
|
+
for (const [i, book] of asArray20(stack.books).entries()) {
|
|
2067
3500
|
const audience = book.audience;
|
|
2068
3501
|
if (!audience || typeof audience !== "object") continue;
|
|
2069
3502
|
const setName = audience.permissionSet;
|
|
@@ -2141,7 +3574,7 @@ function validateSecurityPosture(stack, opts) {
|
|
|
2141
3574
|
}
|
|
2142
3575
|
const GRANT_SEED_OBJECTS = /* @__PURE__ */ new Set(["sys_user_position", "sys_user_permission_set"]);
|
|
2143
3576
|
const nowMs = opts?.nowMs ?? Date.now();
|
|
2144
|
-
for (const [i, seed] of
|
|
3577
|
+
for (const [i, seed] of asArray20(stack.data).entries()) {
|
|
2145
3578
|
const seedObject = typeof seed.object === "string" ? seed.object : "";
|
|
2146
3579
|
if (!GRANT_SEED_OBJECTS.has(seedObject)) continue;
|
|
2147
3580
|
const records = Array.isArray(seed.records) ? seed.records : [];
|
|
@@ -2181,17 +3614,110 @@ function validateSecurityPosture(stack, opts) {
|
|
|
2181
3614
|
return findings;
|
|
2182
3615
|
}
|
|
2183
3616
|
|
|
3617
|
+
// src/validate-org-axis-red-lines.ts
|
|
3618
|
+
var ORG_AXIS_PERMISSION_INHERITANCE = "org-axis-permission-inheritance";
|
|
3619
|
+
var ORG_AXIS_CROSS_ORG_BU_GRANT = "org-axis-cross-org-bu-grant";
|
|
3620
|
+
var ORG_PARENT_FIELD = "parent_organization_id";
|
|
3621
|
+
function asArray21(v) {
|
|
3622
|
+
if (Array.isArray(v)) return v;
|
|
3623
|
+
if (v && typeof v === "object") {
|
|
3624
|
+
return Object.entries(v).map(([name, def]) => ({ name, ...def }));
|
|
3625
|
+
}
|
|
3626
|
+
return [];
|
|
3627
|
+
}
|
|
3628
|
+
function str(v) {
|
|
3629
|
+
return typeof v === "string" ? v : "";
|
|
3630
|
+
}
|
|
3631
|
+
function isTenancyDisabled(object) {
|
|
3632
|
+
const tenancy = object.tenancy;
|
|
3633
|
+
if (tenancy && typeof tenancy === "object" && tenancy.enabled === false) return true;
|
|
3634
|
+
const systemFields = object.systemFields;
|
|
3635
|
+
if (systemFields && typeof systemFields === "object" && systemFields.tenant === false) return true;
|
|
3636
|
+
return false;
|
|
3637
|
+
}
|
|
3638
|
+
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\`).`;
|
|
3639
|
+
function validateOrgAxisRedLines(stack) {
|
|
3640
|
+
const findings = [];
|
|
3641
|
+
const cfg = stack ?? {};
|
|
3642
|
+
const permissionSets = asArray21(cfg.permissions ?? cfg.permissionSets);
|
|
3643
|
+
permissionSets.forEach((ps, psIndex) => {
|
|
3644
|
+
asArray21(ps.rowLevelSecurity).forEach((policy, pIndex) => {
|
|
3645
|
+
for (const clause of ["using", "check"]) {
|
|
3646
|
+
if (!str(policy[clause]).includes(ORG_PARENT_FIELD)) continue;
|
|
3647
|
+
findings.push({
|
|
3648
|
+
severity: "error",
|
|
3649
|
+
rule: ORG_AXIS_PERMISSION_INHERITANCE,
|
|
3650
|
+
where: `permission set "${str(ps.name) || psIndex}" policy "${str(policy.name) || pIndex}"`,
|
|
3651
|
+
path: `permissions[${psIndex}].rowLevelSecurity[${pIndex}].${clause}`,
|
|
3652
|
+
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.`,
|
|
3653
|
+
hint: INHERITANCE_HINT
|
|
3654
|
+
});
|
|
3655
|
+
}
|
|
3656
|
+
});
|
|
3657
|
+
});
|
|
3658
|
+
const objects = asArray21(cfg.objects);
|
|
3659
|
+
objects.forEach((object, oIndex) => {
|
|
3660
|
+
const objectName = str(object.name) || String(oIndex);
|
|
3661
|
+
asArray21(object.rowLevelSecurity ?? object.rls).forEach((policy, pIndex) => {
|
|
3662
|
+
for (const clause of ["using", "check"]) {
|
|
3663
|
+
if (!str(policy[clause]).includes(ORG_PARENT_FIELD)) continue;
|
|
3664
|
+
findings.push({
|
|
3665
|
+
severity: "error",
|
|
3666
|
+
rule: ORG_AXIS_PERMISSION_INHERITANCE,
|
|
3667
|
+
where: `object "${objectName}" policy "${str(policy.name) || pIndex}"`,
|
|
3668
|
+
path: `objects[${oIndex}].rowLevelSecurity[${pIndex}].${clause}`,
|
|
3669
|
+
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.`,
|
|
3670
|
+
hint: INHERITANCE_HINT
|
|
3671
|
+
});
|
|
3672
|
+
}
|
|
3673
|
+
});
|
|
3674
|
+
});
|
|
3675
|
+
asArray21(cfg.sharingRules ?? cfg.sharing).forEach((rule, rIndex) => {
|
|
3676
|
+
const criteria = JSON.stringify(rule.criteria ?? rule.filter ?? "");
|
|
3677
|
+
const sharedTo = JSON.stringify(rule.sharedTo ?? rule.recipient ?? "");
|
|
3678
|
+
if (criteria.includes(ORG_PARENT_FIELD) || sharedTo.includes(ORG_PARENT_FIELD)) {
|
|
3679
|
+
findings.push({
|
|
3680
|
+
severity: "error",
|
|
3681
|
+
rule: ORG_AXIS_PERMISSION_INHERITANCE,
|
|
3682
|
+
where: `sharing rule "${str(rule.name) || rIndex}"`,
|
|
3683
|
+
path: `sharingRules[${rIndex}]`,
|
|
3684
|
+
message: `Sharing rule reads \`${ORG_PARENT_FIELD}\`, granting access by walking the organization tree. ADR-0105 D6 forbids permission inheritance along the org axis.`,
|
|
3685
|
+
hint: INHERITANCE_HINT
|
|
3686
|
+
});
|
|
3687
|
+
}
|
|
3688
|
+
});
|
|
3689
|
+
const tenancyDisabledObjects = new Set(
|
|
3690
|
+
objects.filter((o) => isTenancyDisabled(o)).map((o) => str(o.name)).filter(Boolean)
|
|
3691
|
+
);
|
|
3692
|
+
asArray21(cfg.sharingRules ?? cfg.sharing).forEach((rule, rIndex) => {
|
|
3693
|
+
const target = str(rule.object ?? rule.objectName);
|
|
3694
|
+
if (!target || !tenancyDisabledObjects.has(target)) return;
|
|
3695
|
+
const sharedTo = rule.sharedTo ?? rule.recipient;
|
|
3696
|
+
const recipientType = str(sharedTo?.type);
|
|
3697
|
+
if (recipientType !== "business_unit") return;
|
|
3698
|
+
findings.push({
|
|
3699
|
+
severity: "error",
|
|
3700
|
+
rule: ORG_AXIS_CROSS_ORG_BU_GRANT,
|
|
3701
|
+
where: `sharing rule "${str(rule.name) || rIndex}" on object "${target}"`,
|
|
3702
|
+
path: `sharingRules[${rIndex}].sharedTo`,
|
|
3703
|
+
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).`,
|
|
3704
|
+
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.`
|
|
3705
|
+
});
|
|
3706
|
+
});
|
|
3707
|
+
return findings;
|
|
3708
|
+
}
|
|
3709
|
+
|
|
2184
3710
|
// src/validate-dashboard-action-refs.ts
|
|
2185
3711
|
var DASHBOARD_ACTION_TARGET_UNDEFINED = "dashboard-action-target-undefined";
|
|
2186
3712
|
var DASHBOARD_ACTION_ROUTE_UNRESOLVED = "dashboard-action-route-unresolved";
|
|
2187
|
-
function
|
|
3713
|
+
function asArray22(v) {
|
|
2188
3714
|
if (Array.isArray(v)) return v;
|
|
2189
3715
|
if (v && typeof v === "object") {
|
|
2190
3716
|
return Object.entries(v).map(([name, def]) => ({ name, ...def }));
|
|
2191
3717
|
}
|
|
2192
3718
|
return [];
|
|
2193
3719
|
}
|
|
2194
|
-
function
|
|
3720
|
+
function strName5(v) {
|
|
2195
3721
|
return typeof v === "string" && v.length > 0 ? v : void 0;
|
|
2196
3722
|
}
|
|
2197
3723
|
var MODAL_VERB_RE = /^(?:create|new|add|edit|update)_(.+)$/;
|
|
@@ -2208,7 +3734,7 @@ var URL_COLLECTION_TO_STACK_KEY = {
|
|
|
2208
3734
|
views: "views"
|
|
2209
3735
|
};
|
|
2210
3736
|
function viewContainerName(item) {
|
|
2211
|
-
return
|
|
3737
|
+
return strName5(item.name) ?? strName5(item.id) ?? strName5(item.object) ?? strName5(item.list?.data && item.list.data.object) ?? strName5(item.form?.data && item.form.data.object);
|
|
2212
3738
|
}
|
|
2213
3739
|
function collectKnownTargets(stack) {
|
|
2214
3740
|
const actions = /* @__PURE__ */ new Set();
|
|
@@ -2218,22 +3744,22 @@ function collectKnownTargets(stack) {
|
|
|
2218
3744
|
const pages = /* @__PURE__ */ new Set();
|
|
2219
3745
|
const views = /* @__PURE__ */ new Set();
|
|
2220
3746
|
const collectNames = (v, into, name) => {
|
|
2221
|
-
for (const item of
|
|
3747
|
+
for (const item of asArray22(v)) {
|
|
2222
3748
|
if (!item || typeof item !== "object") continue;
|
|
2223
3749
|
const n = name(item);
|
|
2224
3750
|
if (n) into.add(n);
|
|
2225
3751
|
}
|
|
2226
3752
|
};
|
|
2227
|
-
collectNames(stack.actions, actions, (a) =>
|
|
2228
|
-
for (const obj of
|
|
3753
|
+
collectNames(stack.actions, actions, (a) => strName5(a.name));
|
|
3754
|
+
for (const obj of asArray22(stack.objects)) {
|
|
2229
3755
|
if (!obj || typeof obj !== "object") continue;
|
|
2230
|
-
const n =
|
|
3756
|
+
const n = strName5(obj.name);
|
|
2231
3757
|
if (n) objects.add(n);
|
|
2232
|
-
collectNames(obj.actions, actions, (a) =>
|
|
3758
|
+
collectNames(obj.actions, actions, (a) => strName5(a.name));
|
|
2233
3759
|
}
|
|
2234
|
-
collectNames(stack.reports, reports, (r) =>
|
|
2235
|
-
collectNames(stack.dashboards, dashboards, (d) =>
|
|
2236
|
-
collectNames(stack.pages, pages, (p) =>
|
|
3760
|
+
collectNames(stack.reports, reports, (r) => strName5(r.name));
|
|
3761
|
+
collectNames(stack.dashboards, dashboards, (d) => strName5(d.name));
|
|
3762
|
+
collectNames(stack.pages, pages, (p) => strName5(p.name));
|
|
2237
3763
|
collectNames(stack.views, views, viewContainerName);
|
|
2238
3764
|
for (const o of objects) views.add(o);
|
|
2239
3765
|
return { actions, objects, reports, dashboards, pages, views };
|
|
@@ -2265,14 +3791,14 @@ function resolveUrlRoute(target, known) {
|
|
|
2265
3791
|
function validateDashboardActionRefs(stack) {
|
|
2266
3792
|
const findings = [];
|
|
2267
3793
|
if (!stack || typeof stack !== "object") return findings;
|
|
2268
|
-
const dashboards =
|
|
3794
|
+
const dashboards = asArray22(stack.dashboards);
|
|
2269
3795
|
if (dashboards.length === 0) return findings;
|
|
2270
3796
|
const known = collectKnownTargets(stack);
|
|
2271
3797
|
const checkOne = (action, where, path) => {
|
|
2272
|
-
const target =
|
|
3798
|
+
const target = strName5(action.actionUrl);
|
|
2273
3799
|
if (!target) return;
|
|
2274
3800
|
if (target.includes("${")) return;
|
|
2275
|
-
const actionType =
|
|
3801
|
+
const actionType = strName5(action.actionType) ?? "url";
|
|
2276
3802
|
if (actionType === "script" || actionType === "modal") {
|
|
2277
3803
|
if (resolveActionTarget(actionType, target, known)) return;
|
|
2278
3804
|
const kindWord = actionType === "script" ? "script" : "modal";
|
|
@@ -2303,25 +3829,25 @@ function validateDashboardActionRefs(stack) {
|
|
|
2303
3829
|
for (let di = 0; di < dashboards.length; di++) {
|
|
2304
3830
|
const dash = dashboards[di];
|
|
2305
3831
|
if (!dash || typeof dash !== "object") continue;
|
|
2306
|
-
const dashName =
|
|
3832
|
+
const dashName = strName5(dash.name) ?? `(dashboard ${di})`;
|
|
2307
3833
|
const dashPath = `dashboards[${di}]`;
|
|
2308
|
-
const headerActions =
|
|
3834
|
+
const headerActions = asArray22(dash.header?.actions);
|
|
2309
3835
|
for (let ai = 0; ai < headerActions.length; ai++) {
|
|
2310
3836
|
const action = headerActions[ai];
|
|
2311
3837
|
if (!action || typeof action !== "object") continue;
|
|
2312
|
-
const
|
|
3838
|
+
const label2 = strName5(action.label) ?? strName5(action.actionUrl) ?? `#${ai}`;
|
|
2313
3839
|
checkOne(
|
|
2314
3840
|
action,
|
|
2315
|
-
`dashboard "${dashName}" \xB7 header action "${
|
|
3841
|
+
`dashboard "${dashName}" \xB7 header action "${label2}"`,
|
|
2316
3842
|
`${dashPath}.header.actions[${ai}].actionUrl`
|
|
2317
3843
|
);
|
|
2318
3844
|
}
|
|
2319
|
-
const widgets =
|
|
3845
|
+
const widgets = asArray22(dash.widgets);
|
|
2320
3846
|
for (let wi = 0; wi < widgets.length; wi++) {
|
|
2321
3847
|
const widget = widgets[wi];
|
|
2322
3848
|
if (!widget || typeof widget !== "object") continue;
|
|
2323
|
-
if (!
|
|
2324
|
-
const widgetId =
|
|
3849
|
+
if (!strName5(widget.actionUrl)) continue;
|
|
3850
|
+
const widgetId = strName5(widget.id) ?? `#${wi}`;
|
|
2325
3851
|
checkOne(
|
|
2326
3852
|
{ actionType: widget.actionType, actionUrl: widget.actionUrl },
|
|
2327
3853
|
`dashboard "${dashName}" \xB7 widget "${widgetId}" action`,
|
|
@@ -2332,115 +3858,2165 @@ function validateDashboardActionRefs(stack) {
|
|
|
2332
3858
|
return findings;
|
|
2333
3859
|
}
|
|
2334
3860
|
|
|
2335
|
-
// src/
|
|
2336
|
-
|
|
3861
|
+
// src/validate-filter-tokens.ts
|
|
3862
|
+
var import_data5 = require("@objectstack/spec/data");
|
|
3863
|
+
var FILTER_TOKEN_UNKNOWN = "filter-token-unknown";
|
|
3864
|
+
var FILTER_KEYS = /* @__PURE__ */ new Set(["filter", "filters", "runtimeFilter"]);
|
|
3865
|
+
function asArray23(v) {
|
|
2337
3866
|
if (Array.isArray(v)) return v;
|
|
2338
3867
|
if (v && typeof v === "object") {
|
|
2339
3868
|
return Object.entries(v).map(([name, def]) => ({ name, ...def }));
|
|
2340
3869
|
}
|
|
2341
3870
|
return [];
|
|
2342
3871
|
}
|
|
2343
|
-
function
|
|
2344
|
-
|
|
2345
|
-
|
|
2346
|
-
|
|
2347
|
-
|
|
2348
|
-
|
|
2349
|
-
|
|
2350
|
-
const
|
|
2351
|
-
if (
|
|
2352
|
-
|
|
2353
|
-
|
|
2354
|
-
|
|
2355
|
-
|
|
2356
|
-
|
|
2357
|
-
|
|
2358
|
-
|
|
2359
|
-
|
|
2360
|
-
|
|
2361
|
-
object: objName,
|
|
2362
|
-
create: p.allowCreate === true,
|
|
2363
|
-
read: p.allowRead === true || p.viewAllRecords === true || p.modifyAllRecords === true,
|
|
2364
|
-
edit: p.allowEdit === true || p.modifyAllRecords === true,
|
|
2365
|
-
delete: p.allowDelete === true || p.modifyAllRecords === true,
|
|
2366
|
-
viewAllRecords: p.viewAllRecords === true,
|
|
2367
|
-
modifyAllRecords: p.modifyAllRecords === true
|
|
2368
|
-
};
|
|
2369
|
-
if (typeof p.readScope === "string") entry.readScope = p.readScope;
|
|
2370
|
-
if (typeof p.writeScope === "string") entry.writeScope = p.writeScope;
|
|
2371
|
-
const owd = owdByObject.get(objName);
|
|
2372
|
-
if (owd) entry.sharingModel = owd;
|
|
2373
|
-
entries.push(entry);
|
|
3872
|
+
function label(v, fallback) {
|
|
3873
|
+
return typeof v === "string" && v.length > 0 ? v : fallback;
|
|
3874
|
+
}
|
|
3875
|
+
var KNOWN_LIST = import_data5.CONTEXT_TOKENS.join("}, {");
|
|
3876
|
+
function walkFilterValues(node, path, where, out, seen) {
|
|
3877
|
+
if (node === null || node === void 0) return;
|
|
3878
|
+
if (typeof node === "string") {
|
|
3879
|
+
const cls = (0, import_data5.classifyFilterToken)(node);
|
|
3880
|
+
if (cls?.kind === "unknown") {
|
|
3881
|
+
const suggestion = cls.suggestion;
|
|
3882
|
+
out.push({
|
|
3883
|
+
severity: "error",
|
|
3884
|
+
rule: FILTER_TOKEN_UNKNOWN,
|
|
3885
|
+
where,
|
|
3886
|
+
path,
|
|
3887
|
+
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.`,
|
|
3888
|
+
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.`
|
|
3889
|
+
});
|
|
2374
3890
|
}
|
|
3891
|
+
return;
|
|
3892
|
+
}
|
|
3893
|
+
if (typeof node !== "object") return;
|
|
3894
|
+
if (seen.has(node)) return;
|
|
3895
|
+
seen.add(node);
|
|
3896
|
+
if (Array.isArray(node)) {
|
|
3897
|
+
node.forEach((v, i) => walkFilterValues(v, `${path}[${i}]`, where, out, seen));
|
|
3898
|
+
return;
|
|
3899
|
+
}
|
|
3900
|
+
for (const [k, v] of Object.entries(node)) {
|
|
3901
|
+
walkFilterValues(v, `${path}.${k}`, where, out, seen);
|
|
2375
3902
|
}
|
|
2376
|
-
entries.sort(
|
|
2377
|
-
(a, b) => a.permissionSet === b.permissionSet ? a.object.localeCompare(b.object) : a.permissionSet.localeCompare(b.permissionSet)
|
|
2378
|
-
);
|
|
2379
|
-
return { version: 1, entries };
|
|
2380
3903
|
}
|
|
2381
|
-
|
|
2382
|
-
|
|
2383
|
-
|
|
2384
|
-
|
|
2385
|
-
|
|
2386
|
-
|
|
2387
|
-
|
|
2388
|
-
];
|
|
2389
|
-
function diffAccessMatrix(before, after) {
|
|
2390
|
-
const lines = [];
|
|
2391
|
-
const key = (e) => `${e.permissionSet}\0${e.object}`;
|
|
2392
|
-
const beforeMap = new Map((before?.entries ?? []).map((e) => [key(e), e]));
|
|
2393
|
-
const afterMap = new Map((after?.entries ?? []).map((e) => [key(e), e]));
|
|
2394
|
-
for (const [k, b] of beforeMap) {
|
|
2395
|
-
if (!afterMap.has(k)) {
|
|
2396
|
-
lines.push(`'${b.permissionSet}' loses ALL access to '${b.object}' (entry removed)`);
|
|
2397
|
-
}
|
|
3904
|
+
function scanForFilters(node, path, where, out, seen) {
|
|
3905
|
+
if (!node || typeof node !== "object") return;
|
|
3906
|
+
if (seen.has(node)) return;
|
|
3907
|
+
seen.add(node);
|
|
3908
|
+
if (Array.isArray(node)) {
|
|
3909
|
+
node.forEach((v, i) => scanForFilters(v, `${path}[${i}]`, where, out, seen));
|
|
3910
|
+
return;
|
|
2398
3911
|
}
|
|
2399
|
-
for (const [k,
|
|
2400
|
-
const
|
|
2401
|
-
if (
|
|
2402
|
-
|
|
2403
|
-
lines.push(`'${a.permissionSet}' gains access to '${a.object}' (${grants.join(", ") || "no bits set"})`);
|
|
3912
|
+
for (const [k, v] of Object.entries(node)) {
|
|
3913
|
+
const childPath = `${path}.${k}`;
|
|
3914
|
+
if (FILTER_KEYS.has(k)) {
|
|
3915
|
+
walkFilterValues(v, childPath, where, out, /* @__PURE__ */ new Set());
|
|
2404
3916
|
continue;
|
|
2405
3917
|
}
|
|
2406
|
-
|
|
2407
|
-
|
|
2408
|
-
|
|
3918
|
+
scanForFilters(v, childPath, where, out, seen);
|
|
3919
|
+
}
|
|
3920
|
+
}
|
|
3921
|
+
function validateFilterTokens(stack) {
|
|
3922
|
+
if (!stack || typeof stack !== "object") return [];
|
|
3923
|
+
const out = [];
|
|
3924
|
+
const surfaces = [
|
|
3925
|
+
["dashboards", "dashboard"],
|
|
3926
|
+
["objects", "object"],
|
|
3927
|
+
["views", "view"],
|
|
3928
|
+
["reports", "report"],
|
|
3929
|
+
["datasets", "dataset"],
|
|
3930
|
+
["pages", "page"],
|
|
3931
|
+
["apps", "app"]
|
|
3932
|
+
];
|
|
3933
|
+
for (const [key, kind] of surfaces) {
|
|
3934
|
+
const items = asArray23(stack[key]);
|
|
3935
|
+
items.forEach((item, i) => {
|
|
3936
|
+
const name = label(item.name ?? item.id, `#${i}`);
|
|
3937
|
+
if (kind === "dashboard") {
|
|
3938
|
+
const widgets = Array.isArray(item.widgets) ? item.widgets : [];
|
|
3939
|
+
widgets.forEach((w, wi) => {
|
|
3940
|
+
const wName = label(w.id ?? w.title, `#${wi}`);
|
|
3941
|
+
scanForFilters(
|
|
3942
|
+
w,
|
|
3943
|
+
`${key}[${i}].widgets[${wi}]`,
|
|
3944
|
+
`dashboard "${name}" \xB7 widget "${wName}"`,
|
|
3945
|
+
out,
|
|
3946
|
+
/* @__PURE__ */ new Set()
|
|
3947
|
+
);
|
|
3948
|
+
});
|
|
3949
|
+
const { widgets: _skip, ...rest } = item;
|
|
3950
|
+
scanForFilters(rest, `${key}[${i}]`, `dashboard "${name}"`, out, /* @__PURE__ */ new Set());
|
|
3951
|
+
return;
|
|
2409
3952
|
}
|
|
3953
|
+
scanForFilters(item, `${key}[${i}]`, `${kind} "${name}"`, out, /* @__PURE__ */ new Set());
|
|
3954
|
+
});
|
|
3955
|
+
}
|
|
3956
|
+
return out;
|
|
3957
|
+
}
|
|
3958
|
+
|
|
3959
|
+
// src/validate-object-references.ts
|
|
3960
|
+
var import_system2 = require("@objectstack/spec/system");
|
|
3961
|
+
var PLATFORM_NAMES = [...import_system2.PLATFORM_PROVIDED_OBJECT_NAMES];
|
|
3962
|
+
var OBJECT_REFERENCE_UNKNOWN = "object-reference-unknown";
|
|
3963
|
+
var OBJECT_REFERENCE_UNREGISTERED_PLATFORM = "object-reference-unregistered-platform";
|
|
3964
|
+
function asArray24(v) {
|
|
3965
|
+
if (Array.isArray(v)) return v;
|
|
3966
|
+
if (v && typeof v === "object") {
|
|
3967
|
+
return Object.entries(v).map(([name, def]) => ({ name, ...def }));
|
|
3968
|
+
}
|
|
3969
|
+
return [];
|
|
3970
|
+
}
|
|
3971
|
+
function strName6(v) {
|
|
3972
|
+
return typeof v === "string" && v.length > 0 ? v : void 0;
|
|
3973
|
+
}
|
|
3974
|
+
function isInterpolated(target) {
|
|
3975
|
+
if (target.includes("${")) return true;
|
|
3976
|
+
const open = target.indexOf("{");
|
|
3977
|
+
return open !== -1 && target.indexOf("}", open + 2) !== -1;
|
|
3978
|
+
}
|
|
3979
|
+
function suggest3(target, known) {
|
|
3980
|
+
let best;
|
|
3981
|
+
let bestScore = Infinity;
|
|
3982
|
+
for (const candidate of known) {
|
|
3983
|
+
const d = distance2(target, candidate);
|
|
3984
|
+
if (d < bestScore) {
|
|
3985
|
+
bestScore = d;
|
|
3986
|
+
best = candidate;
|
|
2410
3987
|
}
|
|
2411
|
-
|
|
2412
|
-
|
|
3988
|
+
}
|
|
3989
|
+
const limit = Math.max(2, Math.floor(target.length / 3));
|
|
3990
|
+
return best && bestScore <= limit ? ` Did you mean "${best}"?` : "";
|
|
3991
|
+
}
|
|
3992
|
+
function distance2(a, b) {
|
|
3993
|
+
const m = a.length;
|
|
3994
|
+
const n = b.length;
|
|
3995
|
+
if (m === 0) return n;
|
|
3996
|
+
if (n === 0) return m;
|
|
3997
|
+
let prev = Array.from({ length: n + 1 }, (_, j) => j);
|
|
3998
|
+
for (let i = 1; i <= m; i++) {
|
|
3999
|
+
const curr = [i, ...new Array(n).fill(0)];
|
|
4000
|
+
for (let j = 1; j <= n; j++) {
|
|
4001
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
4002
|
+
curr[j] = Math.min(curr[j - 1] + 1, prev[j] + 1, prev[j - 1] + cost);
|
|
2413
4003
|
}
|
|
2414
|
-
|
|
2415
|
-
|
|
4004
|
+
prev = curr;
|
|
4005
|
+
}
|
|
4006
|
+
return prev[n];
|
|
4007
|
+
}
|
|
4008
|
+
function validateObjectReferences(stack) {
|
|
4009
|
+
const findings = [];
|
|
4010
|
+
if (!stack || typeof stack !== "object") return findings;
|
|
4011
|
+
const objects = asArray24(stack.objects);
|
|
4012
|
+
const ownObjects = /* @__PURE__ */ new Set();
|
|
4013
|
+
for (const obj of objects) {
|
|
4014
|
+
const n = strName6(obj.name);
|
|
4015
|
+
if (n) ownObjects.add(n);
|
|
4016
|
+
}
|
|
4017
|
+
const check = (target, where, path, subject, fix) => {
|
|
4018
|
+
const name = strName6(target);
|
|
4019
|
+
if (!name) return;
|
|
4020
|
+
if (isInterpolated(name)) return;
|
|
4021
|
+
if (ownObjects.has(name)) return;
|
|
4022
|
+
if ((0, import_system2.isPlatformProvidedObjectName)(name)) return;
|
|
4023
|
+
if ((0, import_system2.hasPlatformObjectPrefix)(name)) {
|
|
4024
|
+
findings.push({
|
|
4025
|
+
severity: "warning",
|
|
4026
|
+
rule: OBJECT_REFERENCE_UNREGISTERED_PLATFORM,
|
|
4027
|
+
where,
|
|
4028
|
+
path,
|
|
4029
|
+
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.` + suggest3(name, PLATFORM_NAMES),
|
|
4030
|
+
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}`
|
|
4031
|
+
});
|
|
4032
|
+
return;
|
|
2416
4033
|
}
|
|
2417
|
-
|
|
2418
|
-
|
|
4034
|
+
findings.push({
|
|
4035
|
+
severity: "error",
|
|
4036
|
+
rule: OBJECT_REFERENCE_UNKNOWN,
|
|
4037
|
+
where,
|
|
4038
|
+
path,
|
|
4039
|
+
message: `${subject} "${name}" resolves to no object defined in this stack. The reference is inert at runtime \u2014 nothing reports the miss.` + suggest3(name, ownObjects),
|
|
4040
|
+
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(", ")}.` : "")
|
|
4041
|
+
});
|
|
4042
|
+
};
|
|
4043
|
+
const checkActionParams2 = (action, actionPath, actionLabel) => {
|
|
4044
|
+
const params = asArray24(action.params);
|
|
4045
|
+
for (let pi = 0; pi < params.length; pi++) {
|
|
4046
|
+
const param = params[pi];
|
|
4047
|
+
if (!param || typeof param !== "object") continue;
|
|
4048
|
+
const paramLabel = strName6(param.name) ?? strName6(param.field) ?? `#${pi}`;
|
|
4049
|
+
const where = `${actionLabel} \xB7 param "${paramLabel}"`;
|
|
4050
|
+
check(
|
|
4051
|
+
strName6(param.reference),
|
|
4052
|
+
where,
|
|
4053
|
+
`${actionPath}.params[${pi}].reference`,
|
|
4054
|
+
"record-picker target",
|
|
4055
|
+
"Without a resolvable target the picker degrades to a raw record-id text input."
|
|
4056
|
+
);
|
|
4057
|
+
check(
|
|
4058
|
+
strName6(param.objectOverride),
|
|
4059
|
+
where,
|
|
4060
|
+
`${actionPath}.params[${pi}].objectOverride`,
|
|
4061
|
+
"field-backed param object",
|
|
4062
|
+
"The param inherits type/options from a field on this object, so an unknown object leaves it untyped."
|
|
4063
|
+
);
|
|
2419
4064
|
}
|
|
4065
|
+
};
|
|
4066
|
+
const globalActions = asArray24(stack.actions);
|
|
4067
|
+
for (let ai = 0; ai < globalActions.length; ai++) {
|
|
4068
|
+
const action = globalActions[ai];
|
|
4069
|
+
if (!action || typeof action !== "object") continue;
|
|
4070
|
+
checkActionParams2(action, `actions[${ai}]`, `action "${strName6(action.name) ?? `#${ai}`}"`);
|
|
2420
4071
|
}
|
|
2421
|
-
|
|
4072
|
+
for (let oi = 0; oi < objects.length; oi++) {
|
|
4073
|
+
const obj = objects[oi];
|
|
4074
|
+
if (!obj || typeof obj !== "object") continue;
|
|
4075
|
+
const objName = strName6(obj.name) ?? `#${oi}`;
|
|
4076
|
+
const objActions = asArray24(obj.actions);
|
|
4077
|
+
for (let ai = 0; ai < objActions.length; ai++) {
|
|
4078
|
+
const action = objActions[ai];
|
|
4079
|
+
if (!action || typeof action !== "object") continue;
|
|
4080
|
+
checkActionParams2(
|
|
4081
|
+
action,
|
|
4082
|
+
`objects[${oi}].actions[${ai}]`,
|
|
4083
|
+
`object "${objName}" \xB7 action "${strName6(action.name) ?? `#${ai}`}"`
|
|
4084
|
+
);
|
|
4085
|
+
}
|
|
4086
|
+
}
|
|
4087
|
+
const dashboards = asArray24(stack.dashboards);
|
|
4088
|
+
for (let di = 0; di < dashboards.length; di++) {
|
|
4089
|
+
const dash = dashboards[di];
|
|
4090
|
+
if (!dash || typeof dash !== "object") continue;
|
|
4091
|
+
const dashName = strName6(dash.name) ?? `#${di}`;
|
|
4092
|
+
const filters = asArray24(dash.globalFilters);
|
|
4093
|
+
for (let fi = 0; fi < filters.length; fi++) {
|
|
4094
|
+
const filter = filters[fi];
|
|
4095
|
+
if (!filter || typeof filter !== "object") continue;
|
|
4096
|
+
const optionsFrom = filter.optionsFrom;
|
|
4097
|
+
if (!optionsFrom || typeof optionsFrom !== "object") continue;
|
|
4098
|
+
check(
|
|
4099
|
+
strName6(optionsFrom.object),
|
|
4100
|
+
`dashboard "${dashName}" \xB7 filter "${strName6(filter.name) ?? `#${fi}`}"`,
|
|
4101
|
+
`dashboards[${di}].globalFilters[${fi}].optionsFrom.object`,
|
|
4102
|
+
"filter options source",
|
|
4103
|
+
"The dropdown fetches its options from this object; an unknown one renders an always-empty filter."
|
|
4104
|
+
);
|
|
4105
|
+
}
|
|
4106
|
+
}
|
|
4107
|
+
const apps = asArray24(stack.apps);
|
|
4108
|
+
for (let ai = 0; ai < apps.length; ai++) {
|
|
4109
|
+
const app = apps[ai];
|
|
4110
|
+
if (!app || typeof app !== "object") continue;
|
|
4111
|
+
const appName = strName6(app.name) ?? `#${ai}`;
|
|
4112
|
+
const walkNav = (items, basePath) => {
|
|
4113
|
+
const navItems = asArray24(items);
|
|
4114
|
+
for (let ni = 0; ni < navItems.length; ni++) {
|
|
4115
|
+
const nav = navItems[ni];
|
|
4116
|
+
if (!nav || typeof nav !== "object") continue;
|
|
4117
|
+
const navId = strName6(nav.id) ?? `#${ni}`;
|
|
4118
|
+
const where = `app "${appName}" \xB7 nav "${navId}"`;
|
|
4119
|
+
const navPath = `${basePath}[${ni}]`;
|
|
4120
|
+
check(
|
|
4121
|
+
strName6(nav.requiresObject),
|
|
4122
|
+
where,
|
|
4123
|
+
`${navPath}.requiresObject`,
|
|
4124
|
+
"capability gate object",
|
|
4125
|
+
"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."
|
|
4126
|
+
);
|
|
4127
|
+
if (nav.requiresObject && strName6(nav.objectName)) {
|
|
4128
|
+
check(
|
|
4129
|
+
strName6(nav.objectName),
|
|
4130
|
+
where,
|
|
4131
|
+
`${navPath}.objectName`,
|
|
4132
|
+
"navigation target",
|
|
4133
|
+
"Declaring `requiresObject` exempts this target from the build-time check, so it is only verified here."
|
|
4134
|
+
);
|
|
4135
|
+
}
|
|
4136
|
+
if (Array.isArray(nav.children)) walkNav(nav.children, `${navPath}.children`);
|
|
4137
|
+
}
|
|
4138
|
+
};
|
|
4139
|
+
walkNav(app.navigation, `apps[${ai}].navigation`);
|
|
4140
|
+
const areas = asArray24(app.areas);
|
|
4141
|
+
for (let ri = 0; ri < areas.length; ri++) {
|
|
4142
|
+
walkNav(areas[ri]?.navigation, `apps[${ai}].areas[${ri}].navigation`);
|
|
4143
|
+
}
|
|
4144
|
+
}
|
|
4145
|
+
return findings;
|
|
4146
|
+
}
|
|
4147
|
+
|
|
4148
|
+
// src/validate-action-name-refs.ts
|
|
4149
|
+
var ACTION_NAME_UNDEFINED = "action-name-undefined";
|
|
4150
|
+
function asArray25(v) {
|
|
4151
|
+
if (Array.isArray(v)) return v;
|
|
4152
|
+
if (v && typeof v === "object") {
|
|
4153
|
+
return Object.entries(v).map(([name, def]) => ({ name, ...def }));
|
|
4154
|
+
}
|
|
4155
|
+
return [];
|
|
4156
|
+
}
|
|
4157
|
+
function strName7(v) {
|
|
4158
|
+
return typeof v === "string" && v.length > 0 ? v : void 0;
|
|
4159
|
+
}
|
|
4160
|
+
function strList(v) {
|
|
4161
|
+
return Array.isArray(v) ? v.filter((x) => typeof x === "string" && x.length > 0) : [];
|
|
4162
|
+
}
|
|
4163
|
+
function distance3(a, b) {
|
|
4164
|
+
const m = a.length;
|
|
4165
|
+
const n = b.length;
|
|
4166
|
+
if (m === 0) return n;
|
|
4167
|
+
if (n === 0) return m;
|
|
4168
|
+
let prev = Array.from({ length: n + 1 }, (_, j) => j);
|
|
4169
|
+
for (let i = 1; i <= m; i++) {
|
|
4170
|
+
const curr = [i, ...new Array(n).fill(0)];
|
|
4171
|
+
for (let j = 1; j <= n; j++) {
|
|
4172
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
4173
|
+
curr[j] = Math.min(curr[j - 1] + 1, prev[j] + 1, prev[j - 1] + cost);
|
|
4174
|
+
}
|
|
4175
|
+
prev = curr;
|
|
4176
|
+
}
|
|
4177
|
+
return prev[n];
|
|
4178
|
+
}
|
|
4179
|
+
function suggest4(target, known) {
|
|
4180
|
+
let best;
|
|
4181
|
+
let bestScore = Infinity;
|
|
4182
|
+
for (const candidate of known) {
|
|
4183
|
+
const d = distance3(target, candidate);
|
|
4184
|
+
if (d < bestScore) {
|
|
4185
|
+
bestScore = d;
|
|
4186
|
+
best = candidate;
|
|
4187
|
+
}
|
|
4188
|
+
}
|
|
4189
|
+
const limit = Math.max(2, Math.floor(target.length / 3));
|
|
4190
|
+
return best && bestScore <= limit ? ` Did you mean "${best}"?` : "";
|
|
4191
|
+
}
|
|
4192
|
+
function collectActionNames(stack) {
|
|
4193
|
+
const names = /* @__PURE__ */ new Set();
|
|
4194
|
+
for (const action of asArray25(stack.actions)) {
|
|
4195
|
+
const n = strName7(action?.name);
|
|
4196
|
+
if (n) names.add(n);
|
|
4197
|
+
}
|
|
4198
|
+
for (const obj of asArray25(stack.objects)) {
|
|
4199
|
+
if (!obj || typeof obj !== "object") continue;
|
|
4200
|
+
for (const action of asArray25(obj.actions)) {
|
|
4201
|
+
const n = strName7(action?.name);
|
|
4202
|
+
if (n) names.add(n);
|
|
4203
|
+
}
|
|
4204
|
+
}
|
|
4205
|
+
return names;
|
|
4206
|
+
}
|
|
4207
|
+
function validateActionNameRefs(stack) {
|
|
4208
|
+
const findings = [];
|
|
4209
|
+
if (!stack || typeof stack !== "object") return findings;
|
|
4210
|
+
const known = collectActionNames(stack);
|
|
4211
|
+
const check = (name, where, path, surface) => {
|
|
4212
|
+
if (known.has(name)) return;
|
|
4213
|
+
findings.push({
|
|
4214
|
+
severity: "error",
|
|
4215
|
+
rule: ACTION_NAME_UNDEFINED,
|
|
4216
|
+
where,
|
|
4217
|
+
path,
|
|
4218
|
+
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.` + suggest4(name, known),
|
|
4219
|
+
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(", ")}.` : "")
|
|
4220
|
+
});
|
|
4221
|
+
};
|
|
4222
|
+
const views = asArray25(stack.views);
|
|
4223
|
+
for (let vi = 0; vi < views.length; vi++) {
|
|
4224
|
+
const view = views[vi];
|
|
4225
|
+
if (!view || typeof view !== "object") continue;
|
|
4226
|
+
const viewName = strName7(view.name) ?? strName7(view.object) ?? `#${vi}`;
|
|
4227
|
+
const checkListContainer = (container, label2, path) => {
|
|
4228
|
+
if (!container || typeof container !== "object") return;
|
|
4229
|
+
const list3 = container;
|
|
4230
|
+
for (const key of ["rowActions", "bulkActions"]) {
|
|
4231
|
+
const names = strList(list3[key]);
|
|
4232
|
+
for (let ai = 0; ai < names.length; ai++) {
|
|
4233
|
+
check(
|
|
4234
|
+
names[ai],
|
|
4235
|
+
`view "${viewName}" \xB7 ${label2} \xB7 ${key}`,
|
|
4236
|
+
`${path}.${key}[${ai}]`,
|
|
4237
|
+
key === "bulkActions" ? "Bulk-action menu" : "Row-action menu"
|
|
4238
|
+
);
|
|
4239
|
+
}
|
|
4240
|
+
}
|
|
4241
|
+
};
|
|
4242
|
+
checkListContainer(view.list, "list", `views[${vi}].list`);
|
|
4243
|
+
const listViews = view.listViews;
|
|
4244
|
+
if (listViews && typeof listViews === "object" && !Array.isArray(listViews)) {
|
|
4245
|
+
for (const [key, lv] of Object.entries(listViews)) {
|
|
4246
|
+
checkListContainer(lv, `listViews.${key}`, `views[${vi}].listViews.${key}`);
|
|
4247
|
+
}
|
|
4248
|
+
}
|
|
4249
|
+
}
|
|
4250
|
+
const pages = asArray25(stack.pages);
|
|
4251
|
+
for (let pi = 0; pi < pages.length; pi++) {
|
|
4252
|
+
const page = pages[pi];
|
|
4253
|
+
if (!page || typeof page !== "object") continue;
|
|
4254
|
+
const pageName = strName7(page.name) ?? `#${pi}`;
|
|
4255
|
+
for (const { component, path } of walkPageComponents(page, `pages[${pi}]`)) {
|
|
4256
|
+
const props = component.properties;
|
|
4257
|
+
if (!props || typeof props !== "object") continue;
|
|
4258
|
+
const names = strList(props.actionNames);
|
|
4259
|
+
for (let ai = 0; ai < names.length; ai++) {
|
|
4260
|
+
check(
|
|
4261
|
+
names[ai],
|
|
4262
|
+
`page "${pageName}" \xB7 component "${strName7(component.type) ?? "?"}"`,
|
|
4263
|
+
`${path}.properties.actionNames[${ai}]`,
|
|
4264
|
+
"Quick-actions bar"
|
|
4265
|
+
);
|
|
4266
|
+
}
|
|
4267
|
+
}
|
|
4268
|
+
}
|
|
4269
|
+
const apps = asArray25(stack.apps);
|
|
4270
|
+
for (let ai = 0; ai < apps.length; ai++) {
|
|
4271
|
+
const app = apps[ai];
|
|
4272
|
+
if (!app || typeof app !== "object") continue;
|
|
4273
|
+
const appName = strName7(app.name) ?? `#${ai}`;
|
|
4274
|
+
const walkNav = (items, basePath) => {
|
|
4275
|
+
const navItems = asArray25(items);
|
|
4276
|
+
for (let ni = 0; ni < navItems.length; ni++) {
|
|
4277
|
+
const nav = navItems[ni];
|
|
4278
|
+
if (!nav || typeof nav !== "object") continue;
|
|
4279
|
+
const navPath = `${basePath}[${ni}]`;
|
|
4280
|
+
const actionDef = nav.actionDef;
|
|
4281
|
+
const actionName = strName7(actionDef?.actionName);
|
|
4282
|
+
if (nav.type === "action" && actionName) {
|
|
4283
|
+
check(
|
|
4284
|
+
actionName,
|
|
4285
|
+
`app "${appName}" \xB7 nav "${strName7(nav.id) ?? `#${ni}`}"`,
|
|
4286
|
+
`${navPath}.actionDef.actionName`,
|
|
4287
|
+
"Navigation action item"
|
|
4288
|
+
);
|
|
4289
|
+
}
|
|
4290
|
+
if (Array.isArray(nav.children)) walkNav(nav.children, `${navPath}.children`);
|
|
4291
|
+
}
|
|
4292
|
+
};
|
|
4293
|
+
walkNav(app.navigation, `apps[${ai}].navigation`);
|
|
4294
|
+
const areas = asArray25(app.areas);
|
|
4295
|
+
for (let ri = 0; ri < areas.length; ri++) {
|
|
4296
|
+
walkNav(areas[ri]?.navigation, `apps[${ai}].areas[${ri}].navigation`);
|
|
4297
|
+
}
|
|
4298
|
+
}
|
|
4299
|
+
return findings;
|
|
4300
|
+
}
|
|
4301
|
+
|
|
4302
|
+
// src/validate-chart-bindings.ts
|
|
4303
|
+
var CHART_DIMENSION_UNKNOWN = "chart-dimension-unknown";
|
|
4304
|
+
var CHART_MEASURE_UNKNOWN = "chart-measure-unknown";
|
|
4305
|
+
var CHART_DATASET_UNKNOWN = "chart-dataset-unknown";
|
|
4306
|
+
var CHART_AXIS_NOT_SELECTED = "chart-axis-not-selected";
|
|
4307
|
+
function asArray26(v) {
|
|
4308
|
+
if (Array.isArray(v)) return v;
|
|
4309
|
+
if (v && typeof v === "object") {
|
|
4310
|
+
return Object.entries(v).map(([name, def]) => ({ name, ...def }));
|
|
4311
|
+
}
|
|
4312
|
+
return [];
|
|
4313
|
+
}
|
|
4314
|
+
function strName8(v) {
|
|
4315
|
+
return typeof v === "string" && v.length > 0 ? v : void 0;
|
|
4316
|
+
}
|
|
4317
|
+
function strList2(v) {
|
|
4318
|
+
return Array.isArray(v) ? v.filter((x) => typeof x === "string" && x.length > 0) : [];
|
|
4319
|
+
}
|
|
4320
|
+
function isRec6(v) {
|
|
4321
|
+
return !!v && typeof v === "object" && !Array.isArray(v);
|
|
4322
|
+
}
|
|
4323
|
+
function distance4(a, b) {
|
|
4324
|
+
const m = a.length;
|
|
4325
|
+
const n = b.length;
|
|
4326
|
+
if (m === 0) return n;
|
|
4327
|
+
if (n === 0) return m;
|
|
4328
|
+
let prev = Array.from({ length: n + 1 }, (_, j) => j);
|
|
4329
|
+
for (let i = 1; i <= m; i++) {
|
|
4330
|
+
const curr = [i, ...new Array(n).fill(0)];
|
|
4331
|
+
for (let j = 1; j <= n; j++) {
|
|
4332
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
4333
|
+
curr[j] = Math.min(curr[j - 1] + 1, prev[j] + 1, prev[j - 1] + cost);
|
|
4334
|
+
}
|
|
4335
|
+
prev = curr;
|
|
4336
|
+
}
|
|
4337
|
+
return prev[n];
|
|
4338
|
+
}
|
|
4339
|
+
function suggest5(target, known) {
|
|
4340
|
+
let best;
|
|
4341
|
+
let bestScore = Infinity;
|
|
4342
|
+
for (const c of known) {
|
|
4343
|
+
const d = distance4(target, c);
|
|
4344
|
+
if (d < bestScore) {
|
|
4345
|
+
bestScore = d;
|
|
4346
|
+
best = c;
|
|
4347
|
+
}
|
|
4348
|
+
}
|
|
4349
|
+
const limit = Math.max(2, Math.floor(target.length / 3));
|
|
4350
|
+
return best && bestScore <= limit ? ` Did you mean "${best}"?` : "";
|
|
4351
|
+
}
|
|
4352
|
+
function list2(names) {
|
|
4353
|
+
const all = [...names].sort();
|
|
4354
|
+
return all.length ? all.join(", ") : "(none)";
|
|
4355
|
+
}
|
|
4356
|
+
function indexDatasets(stack) {
|
|
4357
|
+
const out = /* @__PURE__ */ new Map();
|
|
4358
|
+
for (const ds of asArray26(stack.datasets)) {
|
|
4359
|
+
const name = strName8(ds.name);
|
|
4360
|
+
if (!name) continue;
|
|
4361
|
+
const dimensions = /* @__PURE__ */ new Set();
|
|
4362
|
+
for (const d of asArray26(ds.dimensions)) {
|
|
4363
|
+
const n = strName8(d.name);
|
|
4364
|
+
if (n) dimensions.add(n);
|
|
4365
|
+
}
|
|
4366
|
+
const measures = /* @__PURE__ */ new Set();
|
|
4367
|
+
for (const m of asArray26(ds.measures)) {
|
|
4368
|
+
const n = strName8(m.name);
|
|
4369
|
+
if (n) measures.add(n);
|
|
4370
|
+
}
|
|
4371
|
+
out.set(name, { dimensions, measures });
|
|
4372
|
+
}
|
|
4373
|
+
return out;
|
|
4374
|
+
}
|
|
4375
|
+
function validateChartBindings(stack) {
|
|
4376
|
+
const findings = [];
|
|
4377
|
+
if (!stack || typeof stack !== "object") return findings;
|
|
4378
|
+
const datasets = indexDatasets(stack);
|
|
4379
|
+
if (datasets.size === 0 && !stack.reports && !stack.views && !stack.pages) return findings;
|
|
4380
|
+
const check = (binding) => {
|
|
4381
|
+
const dsName = binding.dataset;
|
|
4382
|
+
if (!dsName) return;
|
|
4383
|
+
const ds = datasets.get(dsName);
|
|
4384
|
+
if (!ds) {
|
|
4385
|
+
findings.push({
|
|
4386
|
+
severity: "error",
|
|
4387
|
+
rule: CHART_DATASET_UNKNOWN,
|
|
4388
|
+
where: binding.where,
|
|
4389
|
+
path: `${binding.path}.dataset`,
|
|
4390
|
+
message: `binds dataset "${dsName}", which resolves to no declared dataset \u2014 the chart has no data to render.`,
|
|
4391
|
+
hint: `Declared datasets: ${list2(datasets.keys())}.${suggest5(dsName, datasets.keys())} Define it with defineDataset() or fix the reference (ADR-0021).`
|
|
4392
|
+
});
|
|
4393
|
+
return;
|
|
4394
|
+
}
|
|
4395
|
+
const dimensionRef = (name, path) => {
|
|
4396
|
+
if (ds.dimensions.has(name)) return;
|
|
4397
|
+
findings.push({
|
|
4398
|
+
severity: "error",
|
|
4399
|
+
rule: CHART_DIMENSION_UNKNOWN,
|
|
4400
|
+
where: binding.where,
|
|
4401
|
+
path,
|
|
4402
|
+
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.`,
|
|
4403
|
+
hint: `Dataset dimensions: ${list2(ds.dimensions)}.${suggest5(name, ds.dimensions)} Declare the dimension on the dataset, or bind an existing one.`
|
|
4404
|
+
});
|
|
4405
|
+
};
|
|
4406
|
+
const measureRef = (name, path, selected2) => {
|
|
4407
|
+
if (!ds.measures.has(name)) {
|
|
4408
|
+
findings.push({
|
|
4409
|
+
severity: "error",
|
|
4410
|
+
rule: CHART_MEASURE_UNKNOWN,
|
|
4411
|
+
where: binding.where,
|
|
4412
|
+
path,
|
|
4413
|
+
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.`,
|
|
4414
|
+
hint: `Dataset measures: ${list2(ds.measures)}.${suggest5(name, ds.measures)} Declare the measure on the dataset, or bind an existing one.`
|
|
4415
|
+
});
|
|
4416
|
+
return;
|
|
4417
|
+
}
|
|
4418
|
+
if (selected2 && selected2.size > 0 && !selected2.has(name)) {
|
|
4419
|
+
findings.push({
|
|
4420
|
+
severity: "warning",
|
|
4421
|
+
rule: CHART_AXIS_NOT_SELECTED,
|
|
4422
|
+
where: binding.where,
|
|
4423
|
+
path,
|
|
4424
|
+
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.`,
|
|
4425
|
+
hint: `Add "${name}" to \`values\`, or point the axis at a selected measure.`
|
|
4426
|
+
});
|
|
4427
|
+
}
|
|
4428
|
+
};
|
|
4429
|
+
const dimSel = binding.dimensions;
|
|
4430
|
+
if (dimSel) {
|
|
4431
|
+
for (let i = 0; i < dimSel.names.length; i++) {
|
|
4432
|
+
dimensionRef(dimSel.names[i], `${dimSel.path}[${i}]`);
|
|
4433
|
+
}
|
|
4434
|
+
}
|
|
4435
|
+
const valSel = binding.values;
|
|
4436
|
+
const selected = new Set(valSel?.names ?? []);
|
|
4437
|
+
if (valSel) {
|
|
4438
|
+
for (let i = 0; i < valSel.names.length; i++) {
|
|
4439
|
+
measureRef(valSel.names[i], `${valSel.path}[${i}]`);
|
|
4440
|
+
}
|
|
4441
|
+
}
|
|
4442
|
+
if (binding.xAxis) dimensionRef(binding.xAxis.name, binding.xAxis.path);
|
|
4443
|
+
if (binding.yAxis) measureRef(binding.yAxis.name, binding.yAxis.path, selected);
|
|
4444
|
+
for (const s of binding.series ?? []) measureRef(s.name, s.path, selected);
|
|
4445
|
+
};
|
|
4446
|
+
const reports = asArray26(stack.reports);
|
|
4447
|
+
for (let ri = 0; ri < reports.length; ri++) {
|
|
4448
|
+
const report = reports[ri];
|
|
4449
|
+
if (!isRec6(report)) continue;
|
|
4450
|
+
const reportName = strName8(report.name) ?? `#${ri}`;
|
|
4451
|
+
const checkReportChart = (chart, dataset, values, where, path) => {
|
|
4452
|
+
if (!isRec6(chart)) return;
|
|
4453
|
+
check({
|
|
4454
|
+
dataset,
|
|
4455
|
+
// `values` is the report's measure SELECTION, not a chart ref; feeding
|
|
4456
|
+
// it in lets the yAxis "declared but not selected" check work without
|
|
4457
|
+
// reporting the selection itself twice.
|
|
4458
|
+
values: { names: values, path: `${path}.values` },
|
|
4459
|
+
xAxis: strName8(chart.xAxis) ? { name: strName8(chart.xAxis), path: `${path}.chart.xAxis` } : void 0,
|
|
4460
|
+
yAxis: strName8(chart.yAxis) ? { name: strName8(chart.yAxis), path: `${path}.chart.yAxis` } : void 0,
|
|
4461
|
+
series: asArray26(chart.series).map((s, si) => ({ name: strName8(s.name), path: `${path}.chart.series[${si}].name` })).filter((s) => !!s.name),
|
|
4462
|
+
where,
|
|
4463
|
+
path: `${path}.chart`
|
|
4464
|
+
});
|
|
4465
|
+
};
|
|
4466
|
+
checkReportChart(
|
|
4467
|
+
report.chart,
|
|
4468
|
+
strName8(report.dataset),
|
|
4469
|
+
strList2(report.values),
|
|
4470
|
+
`report "${reportName}" \xB7 chart`,
|
|
4471
|
+
`reports[${ri}]`
|
|
4472
|
+
);
|
|
4473
|
+
const blocks = Array.isArray(report.blocks) ? report.blocks : [];
|
|
4474
|
+
for (let bi = 0; bi < blocks.length; bi++) {
|
|
4475
|
+
const block = blocks[bi];
|
|
4476
|
+
if (!isRec6(block)) continue;
|
|
4477
|
+
checkReportChart(
|
|
4478
|
+
block.chart,
|
|
4479
|
+
strName8(block.dataset),
|
|
4480
|
+
strList2(block.values),
|
|
4481
|
+
`report "${reportName}" \xB7 block "${strName8(block.name) ?? `#${bi}`}" chart`,
|
|
4482
|
+
`reports[${ri}].blocks[${bi}]`
|
|
4483
|
+
);
|
|
4484
|
+
}
|
|
4485
|
+
}
|
|
4486
|
+
const checkListChart = (container, where, path) => {
|
|
4487
|
+
if (!isRec6(container)) return;
|
|
4488
|
+
const chart = container.chart;
|
|
4489
|
+
if (!isRec6(chart)) return;
|
|
4490
|
+
check({
|
|
4491
|
+
dataset: strName8(chart.dataset),
|
|
4492
|
+
dimensions: { names: strList2(chart.dimensions), path: `${path}.chart.dimensions` },
|
|
4493
|
+
values: { names: strList2(chart.values), path: `${path}.chart.values` },
|
|
4494
|
+
where,
|
|
4495
|
+
path: `${path}.chart`
|
|
4496
|
+
});
|
|
4497
|
+
};
|
|
4498
|
+
const views = asArray26(stack.views);
|
|
4499
|
+
for (let vi = 0; vi < views.length; vi++) {
|
|
4500
|
+
const view = views[vi];
|
|
4501
|
+
if (!isRec6(view)) continue;
|
|
4502
|
+
const viewName = strName8(view.name) ?? strName8(view.objectName) ?? `#${vi}`;
|
|
4503
|
+
checkListChart(view.list, `view "${viewName}" \xB7 list chart`, `views[${vi}].list`);
|
|
4504
|
+
if (isRec6(view.listViews)) {
|
|
4505
|
+
for (const [key, lv] of Object.entries(view.listViews)) {
|
|
4506
|
+
checkListChart(lv, `view "${viewName}" \xB7 listViews.${key} chart`, `views[${vi}].listViews.${key}`);
|
|
4507
|
+
}
|
|
4508
|
+
}
|
|
4509
|
+
}
|
|
4510
|
+
const objects = asArray26(stack.objects);
|
|
4511
|
+
for (let oi = 0; oi < objects.length; oi++) {
|
|
4512
|
+
const obj = objects[oi];
|
|
4513
|
+
if (!isRec6(obj) || !isRec6(obj.listViews)) continue;
|
|
4514
|
+
const objName = strName8(obj.name) ?? `#${oi}`;
|
|
4515
|
+
for (const [key, lv] of Object.entries(obj.listViews)) {
|
|
4516
|
+
checkListChart(
|
|
4517
|
+
lv,
|
|
4518
|
+
`object "${objName}" \xB7 listViews.${key} chart`,
|
|
4519
|
+
`objects[${oi}].listViews.${key}`
|
|
4520
|
+
);
|
|
4521
|
+
}
|
|
4522
|
+
}
|
|
4523
|
+
const pages = asArray26(stack.pages);
|
|
4524
|
+
for (let pi = 0; pi < pages.length; pi++) {
|
|
4525
|
+
const page = pages[pi];
|
|
4526
|
+
if (!isRec6(page)) continue;
|
|
4527
|
+
const pageName = strName8(page.name) ?? `#${pi}`;
|
|
4528
|
+
for (const { component, path } of walkPageComponents(page, `pages[${pi}]`)) {
|
|
4529
|
+
const props = isRec6(component.properties) ? component.properties : void 0;
|
|
4530
|
+
if (!props || !strName8(props.dataset)) continue;
|
|
4531
|
+
const axisRefs = asArray26(props.yAxis).map((a, ai) => ({ name: strName8(a.field), path: `${path}.properties.yAxis[${ai}].field` })).filter((a) => !!a.name);
|
|
4532
|
+
const seriesRefs = asArray26(props.series).map((s, si) => ({ name: strName8(s.name), path: `${path}.properties.series[${si}].name` })).filter((s) => !!s.name);
|
|
4533
|
+
check({
|
|
4534
|
+
dataset: strName8(props.dataset),
|
|
4535
|
+
dimensions: { names: strList2(props.dimensions), path: `${path}.properties.dimensions` },
|
|
4536
|
+
values: { names: strList2(props.values), path: `${path}.properties.values` },
|
|
4537
|
+
series: [...axisRefs, ...seriesRefs],
|
|
4538
|
+
where: `page "${pageName}" \xB7 ${strName8(component.type) ?? "chart"}`,
|
|
4539
|
+
path: `${path}.properties`
|
|
4540
|
+
});
|
|
4541
|
+
}
|
|
4542
|
+
}
|
|
4543
|
+
return findings;
|
|
4544
|
+
}
|
|
4545
|
+
|
|
4546
|
+
// src/validate-nav-access.ts
|
|
4547
|
+
var import_system3 = require("@objectstack/spec/system");
|
|
4548
|
+
|
|
4549
|
+
// src/build-access-matrix.ts
|
|
4550
|
+
function asArray27(v) {
|
|
4551
|
+
if (Array.isArray(v)) return v;
|
|
4552
|
+
if (v && typeof v === "object") {
|
|
4553
|
+
return Object.entries(v).map(([name, def]) => ({ name, ...def }));
|
|
4554
|
+
}
|
|
4555
|
+
return [];
|
|
4556
|
+
}
|
|
4557
|
+
function buildAccessMatrix(stack) {
|
|
4558
|
+
const entries = [];
|
|
4559
|
+
if (!stack || typeof stack !== "object") return { version: 1, entries };
|
|
4560
|
+
const owdByObject = /* @__PURE__ */ new Map();
|
|
4561
|
+
for (const obj of asArray27(stack.objects)) {
|
|
4562
|
+
const name = typeof obj.name === "string" ? obj.name : "";
|
|
4563
|
+
if (!name) continue;
|
|
4564
|
+
const owd = obj.sharingModel ?? obj.security?.sharingModel;
|
|
4565
|
+
if (typeof owd === "string") owdByObject.set(name, owd);
|
|
4566
|
+
}
|
|
4567
|
+
for (const ps of asArray27(stack.permissions)) {
|
|
4568
|
+
const psName = typeof ps.name === "string" ? ps.name : "";
|
|
4569
|
+
if (!psName) continue;
|
|
4570
|
+
const objects = ps.objects && typeof ps.objects === "object" ? ps.objects : {};
|
|
4571
|
+
for (const [objName, rawPerm] of Object.entries(objects)) {
|
|
4572
|
+
const p = rawPerm ?? {};
|
|
4573
|
+
const entry = {
|
|
4574
|
+
permissionSet: psName,
|
|
4575
|
+
object: objName,
|
|
4576
|
+
create: p.allowCreate === true,
|
|
4577
|
+
read: p.allowRead === true || p.viewAllRecords === true || p.modifyAllRecords === true,
|
|
4578
|
+
edit: p.allowEdit === true || p.modifyAllRecords === true,
|
|
4579
|
+
delete: p.allowDelete === true || p.modifyAllRecords === true,
|
|
4580
|
+
viewAllRecords: p.viewAllRecords === true,
|
|
4581
|
+
modifyAllRecords: p.modifyAllRecords === true
|
|
4582
|
+
};
|
|
4583
|
+
if (typeof p.readScope === "string") entry.readScope = p.readScope;
|
|
4584
|
+
if (typeof p.writeScope === "string") entry.writeScope = p.writeScope;
|
|
4585
|
+
const owd = owdByObject.get(objName);
|
|
4586
|
+
if (owd) entry.sharingModel = owd;
|
|
4587
|
+
entries.push(entry);
|
|
4588
|
+
}
|
|
4589
|
+
}
|
|
4590
|
+
entries.sort(
|
|
4591
|
+
(a, b) => a.permissionSet === b.permissionSet ? a.object.localeCompare(b.object) : a.permissionSet.localeCompare(b.permissionSet)
|
|
4592
|
+
);
|
|
4593
|
+
return { version: 1, entries };
|
|
4594
|
+
}
|
|
4595
|
+
var BIT_LABELS = [
|
|
4596
|
+
["create", "create"],
|
|
4597
|
+
["read", "read"],
|
|
4598
|
+
["edit", "edit"],
|
|
4599
|
+
["delete", "delete"],
|
|
4600
|
+
["viewAllRecords", "View All Data"],
|
|
4601
|
+
["modifyAllRecords", "Modify All Data"]
|
|
4602
|
+
];
|
|
4603
|
+
function diffAccessMatrix(before, after) {
|
|
4604
|
+
const lines = [];
|
|
4605
|
+
const key = (e) => `${e.permissionSet}\0${e.object}`;
|
|
4606
|
+
const beforeMap = new Map((before?.entries ?? []).map((e) => [key(e), e]));
|
|
4607
|
+
const afterMap = new Map((after?.entries ?? []).map((e) => [key(e), e]));
|
|
4608
|
+
for (const [k, b] of beforeMap) {
|
|
4609
|
+
if (!afterMap.has(k)) {
|
|
4610
|
+
lines.push(`'${b.permissionSet}' loses ALL access to '${b.object}' (entry removed)`);
|
|
4611
|
+
}
|
|
4612
|
+
}
|
|
4613
|
+
for (const [k, a] of afterMap) {
|
|
4614
|
+
const b = beforeMap.get(k);
|
|
4615
|
+
if (!b) {
|
|
4616
|
+
const grants = BIT_LABELS.filter(([bit]) => a[bit] === true).map(([, label2]) => label2);
|
|
4617
|
+
lines.push(`'${a.permissionSet}' gains access to '${a.object}' (${grants.join(", ") || "no bits set"})`);
|
|
4618
|
+
continue;
|
|
4619
|
+
}
|
|
4620
|
+
for (const [bit, label2] of BIT_LABELS) {
|
|
4621
|
+
if (b[bit] !== a[bit]) {
|
|
4622
|
+
lines.push(`'${a.permissionSet}' ${a[bit] ? "gains" : "loses"} ${label2} on '${a.object}'`);
|
|
4623
|
+
}
|
|
4624
|
+
}
|
|
4625
|
+
if ((b.readScope ?? "own") !== (a.readScope ?? "own")) {
|
|
4626
|
+
lines.push(`'${a.permissionSet}' read depth on '${a.object}': ${b.readScope ?? "own"} \u2192 ${a.readScope ?? "own"}`);
|
|
4627
|
+
}
|
|
4628
|
+
if ((b.writeScope ?? "own") !== (a.writeScope ?? "own")) {
|
|
4629
|
+
lines.push(`'${a.permissionSet}' write depth on '${a.object}': ${b.writeScope ?? "own"} \u2192 ${a.writeScope ?? "own"}`);
|
|
4630
|
+
}
|
|
4631
|
+
if ((b.sharingModel ?? "") !== (a.sharingModel ?? "")) {
|
|
4632
|
+
lines.push(`'${a.object}' record baseline (OWD): ${b.sharingModel ?? "(unset)"} \u2192 ${a.sharingModel ?? "(unset)"} (affects every principal)`);
|
|
4633
|
+
}
|
|
4634
|
+
}
|
|
4635
|
+
return lines;
|
|
4636
|
+
}
|
|
4637
|
+
|
|
4638
|
+
// src/validate-nav-access.ts
|
|
4639
|
+
var NAV_OBJECT_UNGRANTED = "nav-object-ungranted";
|
|
4640
|
+
function asArray28(v) {
|
|
4641
|
+
if (Array.isArray(v)) return v;
|
|
4642
|
+
if (v && typeof v === "object") {
|
|
4643
|
+
return Object.entries(v).map(([name, def]) => ({ name, ...def }));
|
|
4644
|
+
}
|
|
4645
|
+
return [];
|
|
4646
|
+
}
|
|
4647
|
+
function strName9(v) {
|
|
4648
|
+
return typeof v === "string" && v.length > 0 ? v : void 0;
|
|
4649
|
+
}
|
|
4650
|
+
function collectNavExposures(stack) {
|
|
4651
|
+
const out = [];
|
|
4652
|
+
const apps = asArray28(stack.apps);
|
|
4653
|
+
for (let ai = 0; ai < apps.length; ai++) {
|
|
4654
|
+
const app = apps[ai];
|
|
4655
|
+
if (!app || typeof app !== "object") continue;
|
|
4656
|
+
const appName = strName9(app.name) ?? `#${ai}`;
|
|
4657
|
+
const walk = (items, basePath) => {
|
|
4658
|
+
const navItems = asArray28(items);
|
|
4659
|
+
for (let ni = 0; ni < navItems.length; ni++) {
|
|
4660
|
+
const nav = navItems[ni];
|
|
4661
|
+
if (!nav || typeof nav !== "object") continue;
|
|
4662
|
+
const navPath = `${basePath}[${ni}]`;
|
|
4663
|
+
const objectName = strName9(nav.objectName);
|
|
4664
|
+
if (nav.type === "object" && objectName) {
|
|
4665
|
+
out.push({
|
|
4666
|
+
objectName,
|
|
4667
|
+
where: `app "${appName}" \xB7 nav "${strName9(nav.id) ?? `#${ni}`}"`,
|
|
4668
|
+
path: `${navPath}.objectName`
|
|
4669
|
+
});
|
|
4670
|
+
}
|
|
4671
|
+
if (Array.isArray(nav.children)) walk(nav.children, `${navPath}.children`);
|
|
4672
|
+
}
|
|
4673
|
+
};
|
|
4674
|
+
walk(app.navigation, `apps[${ai}].navigation`);
|
|
4675
|
+
const areas = asArray28(app.areas);
|
|
4676
|
+
for (let ri = 0; ri < areas.length; ri++) {
|
|
4677
|
+
walk(areas[ri]?.navigation, `apps[${ai}].areas[${ri}].navigation`);
|
|
4678
|
+
}
|
|
4679
|
+
}
|
|
4680
|
+
return out;
|
|
4681
|
+
}
|
|
4682
|
+
function validateNavAccess(stack) {
|
|
4683
|
+
const findings = [];
|
|
4684
|
+
if (!stack || typeof stack !== "object") return findings;
|
|
4685
|
+
const permissionSets = asArray28(stack.permissions);
|
|
4686
|
+
if (permissionSets.length === 0) return findings;
|
|
4687
|
+
const exposures = collectNavExposures(stack);
|
|
4688
|
+
if (exposures.length === 0) return findings;
|
|
4689
|
+
const ownObjects = /* @__PURE__ */ new Set();
|
|
4690
|
+
for (const obj of asArray28(stack.objects)) {
|
|
4691
|
+
const n = strName9(obj.name);
|
|
4692
|
+
if (n) ownObjects.add(n);
|
|
4693
|
+
}
|
|
4694
|
+
const readable = /* @__PURE__ */ new Set();
|
|
4695
|
+
for (const entry of buildAccessMatrix(stack).entries) {
|
|
4696
|
+
if (entry.read) readable.add(entry.object);
|
|
4697
|
+
}
|
|
4698
|
+
if (readable.has("*")) return findings;
|
|
4699
|
+
const reported = /* @__PURE__ */ new Set();
|
|
4700
|
+
for (const exposure of exposures) {
|
|
4701
|
+
const { objectName } = exposure;
|
|
4702
|
+
if (reported.has(objectName)) continue;
|
|
4703
|
+
if ((0, import_system3.isPlatformProvidedObjectName)(objectName)) continue;
|
|
4704
|
+
if (!ownObjects.has(objectName)) continue;
|
|
4705
|
+
if (readable.has(objectName)) continue;
|
|
4706
|
+
reported.add(objectName);
|
|
4707
|
+
findings.push({
|
|
4708
|
+
severity: "warning",
|
|
4709
|
+
rule: NAV_OBJECT_UNGRANTED,
|
|
4710
|
+
where: exposure.where,
|
|
4711
|
+
path: exposure.path,
|
|
4712
|
+
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.`,
|
|
4713
|
+
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.`
|
|
4714
|
+
});
|
|
4715
|
+
}
|
|
4716
|
+
return findings;
|
|
4717
|
+
}
|
|
4718
|
+
|
|
4719
|
+
// src/validate-translation-references.ts
|
|
4720
|
+
var import_system4 = require("@objectstack/spec/system");
|
|
4721
|
+
var TRANSLATION_TARGET_UNKNOWN = "translation-target-unknown";
|
|
4722
|
+
var TRANSLATION_OPTION_KEY_UNKNOWN = "translation-option-key-unknown";
|
|
4723
|
+
function isRec7(v) {
|
|
4724
|
+
return !!v && typeof v === "object" && !Array.isArray(v);
|
|
4725
|
+
}
|
|
4726
|
+
function asArray29(v) {
|
|
4727
|
+
if (Array.isArray(v)) return v;
|
|
4728
|
+
if (isRec7(v)) return Object.entries(v).map(([name, def]) => ({ name, ...isRec7(def) ? def : {} }));
|
|
4729
|
+
return [];
|
|
4730
|
+
}
|
|
4731
|
+
function strName10(v) {
|
|
4732
|
+
return typeof v === "string" && v.length > 0 ? v : void 0;
|
|
4733
|
+
}
|
|
4734
|
+
function distance5(a, b) {
|
|
4735
|
+
const m = a.length;
|
|
4736
|
+
const n = b.length;
|
|
4737
|
+
if (m === 0) return n;
|
|
4738
|
+
if (n === 0) return m;
|
|
4739
|
+
let prev = Array.from({ length: n + 1 }, (_, j) => j);
|
|
4740
|
+
for (let i = 1; i <= m; i++) {
|
|
4741
|
+
const curr = [i, ...new Array(n).fill(0)];
|
|
4742
|
+
for (let j = 1; j <= n; j++) {
|
|
4743
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
4744
|
+
curr[j] = Math.min(curr[j - 1] + 1, prev[j] + 1, prev[j - 1] + cost);
|
|
4745
|
+
}
|
|
4746
|
+
prev = curr;
|
|
4747
|
+
}
|
|
4748
|
+
return prev[n];
|
|
4749
|
+
}
|
|
4750
|
+
function suggest6(target, known) {
|
|
4751
|
+
const names = [...known];
|
|
4752
|
+
const segmentMatch = names.find(
|
|
4753
|
+
(candidate) => candidate.endsWith(`_${target}`) || candidate.startsWith(`${target}_`)
|
|
4754
|
+
);
|
|
4755
|
+
if (segmentMatch) return ` Did you mean "${segmentMatch}"?`;
|
|
4756
|
+
let best;
|
|
4757
|
+
let bestScore = Infinity;
|
|
4758
|
+
for (const candidate of names) {
|
|
4759
|
+
const d = distance5(target, candidate);
|
|
4760
|
+
if (d < bestScore) {
|
|
4761
|
+
bestScore = d;
|
|
4762
|
+
best = candidate;
|
|
4763
|
+
}
|
|
4764
|
+
}
|
|
4765
|
+
const limit = Math.max(2, Math.floor(target.length / 3));
|
|
4766
|
+
return best && bestScore <= limit ? ` Did you mean "${best}"?` : "";
|
|
4767
|
+
}
|
|
4768
|
+
function listNames(names, max = 12) {
|
|
4769
|
+
const all = [...names].sort();
|
|
4770
|
+
if (all.length === 0) return "";
|
|
4771
|
+
const shown = all.slice(0, max).join(", ");
|
|
4772
|
+
return all.length > max ? `${shown}, \u2026 (${all.length} total)` : shown;
|
|
4773
|
+
}
|
|
4774
|
+
var IMPLICIT_FIELDS = /* @__PURE__ */ new Set([
|
|
4775
|
+
...SYSTEM_FIELDS,
|
|
4776
|
+
"_id",
|
|
4777
|
+
"name",
|
|
4778
|
+
"space"
|
|
4779
|
+
]);
|
|
4780
|
+
function emptyFacts() {
|
|
4781
|
+
return { fields: /* @__PURE__ */ new Map(), views: /* @__PURE__ */ new Set(), actions: /* @__PURE__ */ new Map(), sections: /* @__PURE__ */ new Set() };
|
|
4782
|
+
}
|
|
4783
|
+
function collectViewRecord(view, factsFor) {
|
|
4784
|
+
const recordObject = viewObjectName(view);
|
|
4785
|
+
const bindingOf = (container) => viewObjectName(container) ?? recordObject;
|
|
4786
|
+
const addView = (objectName, name) => {
|
|
4787
|
+
if (objectName && name) factsFor(objectName).views.add(name);
|
|
4788
|
+
};
|
|
4789
|
+
const listBinding = isRec7(view.list) ? bindingOf(view.list) : void 0;
|
|
4790
|
+
if (isRec7(view.list)) addView(listBinding, strName10(view.list.name));
|
|
4791
|
+
addView(recordObject ?? listBinding, strName10(view.name));
|
|
4792
|
+
for (const key of ["listViews", "formViews"]) {
|
|
4793
|
+
const container = view[key];
|
|
4794
|
+
if (!isRec7(container)) continue;
|
|
4795
|
+
for (const [subKey, sub] of Object.entries(container)) {
|
|
4796
|
+
if (!isRec7(sub)) continue;
|
|
4797
|
+
const binding = bindingOf(sub) ?? listBinding;
|
|
4798
|
+
addView(binding, subKey);
|
|
4799
|
+
addView(binding, strName10(sub.name));
|
|
4800
|
+
if (binding) {
|
|
4801
|
+
for (const section of asArray29(sub.sections)) {
|
|
4802
|
+
const sectionName = strName10(section.name);
|
|
4803
|
+
if (sectionName) factsFor(binding).sections.add(sectionName);
|
|
4804
|
+
}
|
|
4805
|
+
}
|
|
4806
|
+
}
|
|
4807
|
+
}
|
|
4808
|
+
const sectionBinding = recordObject ?? listBinding;
|
|
4809
|
+
if (sectionBinding) {
|
|
4810
|
+
for (const section of asArray29(view.sections)) {
|
|
4811
|
+
const sectionName = strName10(section.name);
|
|
4812
|
+
if (sectionName) factsFor(sectionBinding).sections.add(sectionName);
|
|
4813
|
+
}
|
|
4814
|
+
}
|
|
4815
|
+
}
|
|
4816
|
+
function viewObjectName(view) {
|
|
4817
|
+
return strName10(view.objectName) ?? strName10(view.object) ?? (isRec7(view.data) ? strName10(view.data.object) : void 0);
|
|
4818
|
+
}
|
|
4819
|
+
function readOptions(field) {
|
|
4820
|
+
const raw = field.options;
|
|
4821
|
+
const values = /* @__PURE__ */ new Set();
|
|
4822
|
+
const byLabel = /* @__PURE__ */ new Map();
|
|
4823
|
+
if (Array.isArray(raw)) {
|
|
4824
|
+
for (const opt of raw) {
|
|
4825
|
+
if (typeof opt === "string") {
|
|
4826
|
+
values.add(opt);
|
|
4827
|
+
continue;
|
|
4828
|
+
}
|
|
4829
|
+
if (!isRec7(opt)) continue;
|
|
4830
|
+
const value = strName10(opt.value);
|
|
4831
|
+
if (!value) continue;
|
|
4832
|
+
values.add(value);
|
|
4833
|
+
const label2 = strName10(opt.label);
|
|
4834
|
+
if (label2) byLabel.set(label2.toLowerCase(), value);
|
|
4835
|
+
}
|
|
4836
|
+
} else if (isRec7(raw)) {
|
|
4837
|
+
for (const [value, label2] of Object.entries(raw)) {
|
|
4838
|
+
values.add(value);
|
|
4839
|
+
if (typeof label2 === "string" && label2.length > 0) byLabel.set(label2.toLowerCase(), value);
|
|
4840
|
+
}
|
|
4841
|
+
} else {
|
|
4842
|
+
return void 0;
|
|
4843
|
+
}
|
|
4844
|
+
return values.size > 0 ? { values, byLabel } : void 0;
|
|
4845
|
+
}
|
|
4846
|
+
function buildUniverse(stack) {
|
|
4847
|
+
const objects = /* @__PURE__ */ new Map();
|
|
4848
|
+
const factsFor = (name) => {
|
|
4849
|
+
let facts = objects.get(name);
|
|
4850
|
+
if (!facts) {
|
|
4851
|
+
facts = emptyFacts();
|
|
4852
|
+
objects.set(name, facts);
|
|
4853
|
+
}
|
|
4854
|
+
return facts;
|
|
4855
|
+
};
|
|
4856
|
+
for (const obj of asArray29(stack.objects)) {
|
|
4857
|
+
const objectName = strName10(obj.name);
|
|
4858
|
+
if (!objectName) continue;
|
|
4859
|
+
const facts = factsFor(objectName);
|
|
4860
|
+
for (const field of asArray29(obj.fields)) {
|
|
4861
|
+
const fieldName = strName10(field.name);
|
|
4862
|
+
if (fieldName) facts.fields.set(fieldName, field);
|
|
4863
|
+
}
|
|
4864
|
+
for (const action of asArray29(obj.actions)) {
|
|
4865
|
+
const actionName = strName10(action.name);
|
|
4866
|
+
if (actionName) facts.actions.set(actionName, action);
|
|
4867
|
+
}
|
|
4868
|
+
for (const view of asArray29(obj.views)) {
|
|
4869
|
+
collectViewRecord({ ...view, object: strName10(view.object) ?? objectName }, factsFor);
|
|
4870
|
+
}
|
|
4871
|
+
collectViewRecord({ object: objectName, listViews: obj.listViews }, factsFor);
|
|
4872
|
+
for (const group of asArray29(obj.fieldGroups)) {
|
|
4873
|
+
const key = strName10(group.key) ?? strName10(group.name);
|
|
4874
|
+
if (key) facts.sections.add(key);
|
|
4875
|
+
}
|
|
4876
|
+
}
|
|
4877
|
+
for (const view of asArray29(stack.views)) {
|
|
4878
|
+
collectViewRecord(view, factsFor);
|
|
4879
|
+
}
|
|
4880
|
+
const pages = asArray29(stack.pages);
|
|
4881
|
+
for (let pi = 0; pi < pages.length; pi++) {
|
|
4882
|
+
for (const walked of walkPageComponents(pages[pi], `pages[${pi}]`)) {
|
|
4883
|
+
if (!walked.objectName) continue;
|
|
4884
|
+
const props = isRec7(walked.component.properties) ? walked.component.properties : void 0;
|
|
4885
|
+
if (!props) continue;
|
|
4886
|
+
for (const section of asArray29(props.sections)) {
|
|
4887
|
+
const sectionName = strName10(section.name);
|
|
4888
|
+
if (sectionName) factsFor(walked.objectName).sections.add(sectionName);
|
|
4889
|
+
}
|
|
4890
|
+
}
|
|
4891
|
+
}
|
|
4892
|
+
const globalActions = /* @__PURE__ */ new Map();
|
|
4893
|
+
const actionOwners = /* @__PURE__ */ new Map();
|
|
4894
|
+
for (const action of asArray29(stack.actions)) {
|
|
4895
|
+
const actionName = strName10(action.name);
|
|
4896
|
+
if (!actionName) continue;
|
|
4897
|
+
const owner = strName10(action.objectName) ?? strName10(action.object);
|
|
4898
|
+
if (owner) {
|
|
4899
|
+
factsFor(owner).actions.set(actionName, action);
|
|
4900
|
+
actionOwners.set(actionName, owner);
|
|
4901
|
+
} else {
|
|
4902
|
+
globalActions.set(actionName, action);
|
|
4903
|
+
}
|
|
4904
|
+
}
|
|
4905
|
+
for (const [objectName, facts] of objects) {
|
|
4906
|
+
for (const actionName of facts.actions.keys()) {
|
|
4907
|
+
if (!actionOwners.has(actionName)) actionOwners.set(actionName, objectName);
|
|
4908
|
+
}
|
|
4909
|
+
}
|
|
4910
|
+
const apps = /* @__PURE__ */ new Map();
|
|
4911
|
+
for (const app of asArray29(stack.apps)) {
|
|
4912
|
+
const appName = strName10(app.name);
|
|
4913
|
+
if (!appName) continue;
|
|
4914
|
+
const navIds = apps.get(appName) ?? /* @__PURE__ */ new Set();
|
|
4915
|
+
const walkNav = (items) => {
|
|
4916
|
+
for (const item of asArray29(items)) {
|
|
4917
|
+
const id = strName10(item.id);
|
|
4918
|
+
if (id) navIds.add(id);
|
|
4919
|
+
if (item.children) walkNav(item.children);
|
|
4920
|
+
}
|
|
4921
|
+
};
|
|
4922
|
+
walkNav(app.navigation);
|
|
4923
|
+
for (const area of asArray29(app.areas)) {
|
|
4924
|
+
const areaId = strName10(area.id);
|
|
4925
|
+
if (areaId) navIds.add(areaId);
|
|
4926
|
+
walkNav(area.navigation);
|
|
4927
|
+
}
|
|
4928
|
+
apps.set(appName, navIds);
|
|
4929
|
+
}
|
|
4930
|
+
const dashboards = /* @__PURE__ */ new Map();
|
|
4931
|
+
for (const dash of asArray29(stack.dashboards)) {
|
|
4932
|
+
const dashName = strName10(dash.name);
|
|
4933
|
+
if (!dashName) continue;
|
|
4934
|
+
const widgets = /* @__PURE__ */ new Set();
|
|
4935
|
+
for (const widget of asArray29(dash.widgets)) {
|
|
4936
|
+
const id = strName10(widget.id) ?? strName10(widget.name);
|
|
4937
|
+
if (id) widgets.add(id);
|
|
4938
|
+
}
|
|
4939
|
+
const actions = /* @__PURE__ */ new Set();
|
|
4940
|
+
const headerActions = [
|
|
4941
|
+
...asArray29(isRec7(dash.header) ? dash.header.actions : void 0),
|
|
4942
|
+
...asArray29(dash.actions)
|
|
4943
|
+
];
|
|
4944
|
+
for (const action of headerActions) {
|
|
4945
|
+
const key = strName10(action.actionUrl) ?? strName10(action.url) ?? strName10(action.name);
|
|
4946
|
+
if (key) actions.add(key);
|
|
4947
|
+
}
|
|
4948
|
+
dashboards.set(dashName, { widgets, actions });
|
|
4949
|
+
}
|
|
4950
|
+
return { objects, apps, dashboards, globalActions, actionOwners };
|
|
4951
|
+
}
|
|
4952
|
+
function localePath(bundleIndex, locale) {
|
|
4953
|
+
return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(locale) ? `translations[${bundleIndex}].${locale}` : `translations[${bundleIndex}]["${locale}"]`;
|
|
4954
|
+
}
|
|
4955
|
+
function validateTranslationReferences(stack) {
|
|
4956
|
+
const findings = [];
|
|
4957
|
+
if (!isRec7(stack)) return findings;
|
|
4958
|
+
const bundles = Array.isArray(stack.translations) ? stack.translations : [];
|
|
4959
|
+
if (bundles.length === 0) return findings;
|
|
4960
|
+
const universe = buildUniverse(stack);
|
|
4961
|
+
const orphan = (where, path, message, hint) => {
|
|
4962
|
+
findings.push({ severity: "warning", rule: TRANSLATION_TARGET_UNKNOWN, where, path, message, hint });
|
|
4963
|
+
};
|
|
4964
|
+
for (let bi = 0; bi < bundles.length; bi++) {
|
|
4965
|
+
const bundle = bundles[bi];
|
|
4966
|
+
if (!isRec7(bundle)) continue;
|
|
4967
|
+
for (const [locale, rawData] of Object.entries(bundle)) {
|
|
4968
|
+
if (!isRec7(rawData)) continue;
|
|
4969
|
+
const base = localePath(bi, locale);
|
|
4970
|
+
const inLocale = `locale "${locale}"`;
|
|
4971
|
+
for (const [objectName, rawNode] of Object.entries(asRecord(rawData.objects))) {
|
|
4972
|
+
if (!isRec7(rawNode)) continue;
|
|
4973
|
+
const objPath = `${base}.objects.${objectName}`;
|
|
4974
|
+
const facts = universe.objects.get(objectName);
|
|
4975
|
+
if (!facts) {
|
|
4976
|
+
if ((0, import_system4.isPlatformProvidedObjectName)(objectName)) continue;
|
|
4977
|
+
orphan(
|
|
4978
|
+
`${inLocale} \xB7 object "${objectName}"`,
|
|
4979
|
+
objPath,
|
|
4980
|
+
(0, import_system4.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.` + suggest6(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.` + suggest6(objectName, universe.objects.keys()),
|
|
4981
|
+
`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())}.` : "")
|
|
4982
|
+
);
|
|
4983
|
+
continue;
|
|
4984
|
+
}
|
|
4985
|
+
for (const [fieldName, rawField] of Object.entries(asRecord(rawNode.fields))) {
|
|
4986
|
+
const fieldPath = `${objPath}.fields.${fieldName}`;
|
|
4987
|
+
const field = facts.fields.get(fieldName);
|
|
4988
|
+
if (!field) {
|
|
4989
|
+
if (IMPLICIT_FIELDS.has(fieldName)) continue;
|
|
4990
|
+
orphan(
|
|
4991
|
+
`${inLocale} \xB7 object "${objectName}" \xB7 field "${fieldName}"`,
|
|
4992
|
+
fieldPath,
|
|
4993
|
+
`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.` + suggest6(fieldName, facts.fields.keys()),
|
|
4994
|
+
`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())}.` : "")
|
|
4995
|
+
);
|
|
4996
|
+
continue;
|
|
4997
|
+
}
|
|
4998
|
+
if (!isRec7(rawField)) continue;
|
|
4999
|
+
checkOptionKeys(findings, {
|
|
5000
|
+
optionMap: rawField.options,
|
|
5001
|
+
field,
|
|
5002
|
+
fieldName,
|
|
5003
|
+
objectName,
|
|
5004
|
+
path: `${fieldPath}.options`,
|
|
5005
|
+
where: `${inLocale} \xB7 object "${objectName}" \xB7 field "${fieldName}"`
|
|
5006
|
+
});
|
|
5007
|
+
}
|
|
5008
|
+
for (const viewName of Object.keys(asRecord(rawNode._views))) {
|
|
5009
|
+
if (facts.views.has(viewName)) continue;
|
|
5010
|
+
orphan(
|
|
5011
|
+
`${inLocale} \xB7 object "${objectName}" \xB7 view "${viewName}"`,
|
|
5012
|
+
`${objPath}._views.${viewName}`,
|
|
5013
|
+
`Translations are keyed to view "${viewName}", which no view of object "${objectName}" declares. The view tab keeps its source-locale label.` + suggest6(viewName, facts.views),
|
|
5014
|
+
`Match the key to the view's \`name\` (not its label), or drop it.` + (facts.views.size > 0 ? ` Declared views: ${listNames(facts.views)}.` : "")
|
|
5015
|
+
);
|
|
5016
|
+
}
|
|
5017
|
+
for (const sectionName of Object.keys(asRecord(rawNode._sections))) {
|
|
5018
|
+
if (facts.sections.has(sectionName)) continue;
|
|
5019
|
+
orphan(
|
|
5020
|
+
`${inLocale} \xB7 object "${objectName}" \xB7 section "${sectionName}"`,
|
|
5021
|
+
`${objPath}._sections.${sectionName}`,
|
|
5022
|
+
`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.` + suggest6(sectionName, facts.sections),
|
|
5023
|
+
`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.`)
|
|
5024
|
+
);
|
|
5025
|
+
}
|
|
5026
|
+
for (const [actionName, rawAction] of Object.entries(asRecord(rawNode._actions))) {
|
|
5027
|
+
const actionPath = `${objPath}._actions.${actionName}`;
|
|
5028
|
+
const action = facts.actions.get(actionName);
|
|
5029
|
+
if (!action) {
|
|
5030
|
+
orphan(
|
|
5031
|
+
`${inLocale} \xB7 object "${objectName}" \xB7 action "${actionName}"`,
|
|
5032
|
+
actionPath,
|
|
5033
|
+
`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.` + suggest6(actionName, facts.actions.keys()),
|
|
5034
|
+
`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())}.` : "")
|
|
5035
|
+
);
|
|
5036
|
+
continue;
|
|
5037
|
+
}
|
|
5038
|
+
checkActionParams(findings, {
|
|
5039
|
+
rawAction,
|
|
5040
|
+
action,
|
|
5041
|
+
path: actionPath,
|
|
5042
|
+
where: `${inLocale} \xB7 object "${objectName}" \xB7 action "${actionName}"`,
|
|
5043
|
+
subject: `action "${actionName}"`
|
|
5044
|
+
});
|
|
5045
|
+
}
|
|
5046
|
+
}
|
|
5047
|
+
for (const [actionName, rawAction] of Object.entries(asRecord(rawData.globalActions))) {
|
|
5048
|
+
const actionPath = `${base}.globalActions.${actionName}`;
|
|
5049
|
+
const action = universe.globalActions.get(actionName);
|
|
5050
|
+
if (!action) {
|
|
5051
|
+
const owner = universe.actionOwners.get(actionName);
|
|
5052
|
+
orphan(
|
|
5053
|
+
`${inLocale} \xB7 global action "${actionName}"`,
|
|
5054
|
+
actionPath,
|
|
5055
|
+
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.` + suggest6(actionName, universe.globalActions.keys()),
|
|
5056
|
+
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())}.` : "")
|
|
5057
|
+
);
|
|
5058
|
+
continue;
|
|
5059
|
+
}
|
|
5060
|
+
checkActionParams(findings, {
|
|
5061
|
+
rawAction,
|
|
5062
|
+
action,
|
|
5063
|
+
path: actionPath,
|
|
5064
|
+
where: `${inLocale} \xB7 global action "${actionName}"`,
|
|
5065
|
+
subject: `action "${actionName}"`
|
|
5066
|
+
});
|
|
5067
|
+
}
|
|
5068
|
+
for (const [appName, rawApp] of Object.entries(asRecord(rawData.apps))) {
|
|
5069
|
+
const appPath = `${base}.apps.${appName}`;
|
|
5070
|
+
const navIds = universe.apps.get(appName);
|
|
5071
|
+
if (!navIds) {
|
|
5072
|
+
orphan(
|
|
5073
|
+
`${inLocale} \xB7 app "${appName}"`,
|
|
5074
|
+
appPath,
|
|
5075
|
+
`Translations are keyed to app "${appName}", which this stack does not define. The app launcher shows the source-locale label.` + suggest6(appName, universe.apps.keys()),
|
|
5076
|
+
`Match the key to an app's \`name\`, or drop it.` + (universe.apps.size > 0 ? ` Defined apps: ${listNames(universe.apps.keys())}.` : "")
|
|
5077
|
+
);
|
|
5078
|
+
continue;
|
|
5079
|
+
}
|
|
5080
|
+
if (!isRec7(rawApp)) continue;
|
|
5081
|
+
for (const navId of Object.keys(asRecord(rawApp.navigation))) {
|
|
5082
|
+
if (navIds.has(navId)) continue;
|
|
5083
|
+
orphan(
|
|
5084
|
+
`${inLocale} \xB7 app "${appName}" \xB7 navigation "${navId}"`,
|
|
5085
|
+
`${appPath}.navigation.${navId}`,
|
|
5086
|
+
`Translations are keyed to navigation item "${navId}", which app "${appName}" does not declare. The menu entry keeps its source-locale label.` + suggest6(navId, navIds),
|
|
5087
|
+
`Match the key to the navigation item's \`id\`, or drop it.` + (navIds.size > 0 ? ` Declared navigation ids: ${listNames(navIds)}.` : "")
|
|
5088
|
+
);
|
|
5089
|
+
}
|
|
5090
|
+
}
|
|
5091
|
+
for (const [dashName, rawDash] of Object.entries(asRecord(rawData.dashboards))) {
|
|
5092
|
+
const dashPath = `${base}.dashboards.${dashName}`;
|
|
5093
|
+
const dash = universe.dashboards.get(dashName);
|
|
5094
|
+
if (!dash) {
|
|
5095
|
+
orphan(
|
|
5096
|
+
`${inLocale} \xB7 dashboard "${dashName}"`,
|
|
5097
|
+
dashPath,
|
|
5098
|
+
`Translations are keyed to dashboard "${dashName}", which this stack does not define. The dashboard title stays in the source locale.` + suggest6(dashName, universe.dashboards.keys()),
|
|
5099
|
+
`Match the key to a dashboard's \`name\`, or drop it.` + (universe.dashboards.size > 0 ? ` Defined dashboards: ${listNames(universe.dashboards.keys())}.` : "")
|
|
5100
|
+
);
|
|
5101
|
+
continue;
|
|
5102
|
+
}
|
|
5103
|
+
if (!isRec7(rawDash)) continue;
|
|
5104
|
+
for (const widgetId of Object.keys(asRecord(rawDash.widgets))) {
|
|
5105
|
+
if (dash.widgets.has(widgetId)) continue;
|
|
5106
|
+
orphan(
|
|
5107
|
+
`${inLocale} \xB7 dashboard "${dashName}" \xB7 widget "${widgetId}"`,
|
|
5108
|
+
`${dashPath}.widgets.${widgetId}`,
|
|
5109
|
+
`Translations are keyed to widget "${widgetId}", which dashboard "${dashName}" does not declare. The widget title stays in the source locale.` + suggest6(widgetId, dash.widgets),
|
|
5110
|
+
`Match the key to the widget's \`id\`, or drop it.` + (dash.widgets.size > 0 ? ` Declared widget ids: ${listNames(dash.widgets)}.` : "")
|
|
5111
|
+
);
|
|
5112
|
+
}
|
|
5113
|
+
for (const actionKey of Object.keys(asRecord(rawDash.actions))) {
|
|
5114
|
+
if (dash.actions.has(actionKey)) continue;
|
|
5115
|
+
orphan(
|
|
5116
|
+
`${inLocale} \xB7 dashboard "${dashName}" \xB7 action "${actionKey}"`,
|
|
5117
|
+
`${dashPath}.actions.${actionKey}`,
|
|
5118
|
+
`Translations are keyed to header action "${actionKey}", which dashboard "${dashName}" does not declare. The button keeps its source-locale label.` + suggest6(actionKey, dash.actions),
|
|
5119
|
+
`Header-action translations are keyed by the action's \`actionUrl\`, not its label.` + (dash.actions.size > 0 ? ` Declared header actions: ${listNames(dash.actions)}.` : "")
|
|
5120
|
+
);
|
|
5121
|
+
}
|
|
5122
|
+
}
|
|
5123
|
+
}
|
|
5124
|
+
}
|
|
5125
|
+
return findings;
|
|
5126
|
+
}
|
|
5127
|
+
function asRecord(v) {
|
|
5128
|
+
return isRec7(v) ? v : {};
|
|
5129
|
+
}
|
|
5130
|
+
function checkOptionKeys(findings, ctx) {
|
|
5131
|
+
const optionKeys = Object.keys(asRecord(ctx.optionMap));
|
|
5132
|
+
if (optionKeys.length === 0) return;
|
|
5133
|
+
const declared = readOptions(ctx.field);
|
|
5134
|
+
if (!declared) {
|
|
5135
|
+
findings.push({
|
|
5136
|
+
severity: "warning",
|
|
5137
|
+
rule: TRANSLATION_OPTION_KEY_UNKNOWN,
|
|
5138
|
+
where: ctx.where,
|
|
5139
|
+
path: ctx.path,
|
|
5140
|
+
message: `Option translations are keyed under field "${ctx.fieldName}" of object "${ctx.objectName}", which declares no \`options\` at all (field type "${strName10(ctx.field.type) ?? "unknown"}"). Nothing reads this map.`,
|
|
5141
|
+
hint: `Declare the options on the field, move the translations to the field that owns them, or drop them.`
|
|
5142
|
+
});
|
|
5143
|
+
return;
|
|
5144
|
+
}
|
|
5145
|
+
for (const key of optionKeys) {
|
|
5146
|
+
if (declared.values.has(key)) continue;
|
|
5147
|
+
const byLabel = declared.byLabel.get(key.toLowerCase());
|
|
5148
|
+
findings.push({
|
|
5149
|
+
severity: "warning",
|
|
5150
|
+
rule: TRANSLATION_OPTION_KEY_UNKNOWN,
|
|
5151
|
+
where: ctx.where,
|
|
5152
|
+
path: `${ctx.path}.${key}`,
|
|
5153
|
+
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.` + suggest6(key, declared.values),
|
|
5154
|
+
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)}.`
|
|
5155
|
+
});
|
|
5156
|
+
}
|
|
5157
|
+
}
|
|
5158
|
+
function checkActionParams(findings, ctx) {
|
|
5159
|
+
const rawParams = Object.keys(asRecord(isRec7(ctx.rawAction) ? ctx.rawAction.params : void 0));
|
|
5160
|
+
if (rawParams.length === 0) return;
|
|
5161
|
+
const declared = /* @__PURE__ */ new Set();
|
|
5162
|
+
for (const param of asArray29(ctx.action.params)) {
|
|
5163
|
+
const name = strName10(param.name) ?? strName10(param.field);
|
|
5164
|
+
if (name) declared.add(name);
|
|
5165
|
+
}
|
|
5166
|
+
for (const paramName of rawParams) {
|
|
5167
|
+
if (declared.has(paramName)) continue;
|
|
5168
|
+
findings.push({
|
|
5169
|
+
severity: "warning",
|
|
5170
|
+
rule: TRANSLATION_TARGET_UNKNOWN,
|
|
5171
|
+
where: `${ctx.where} \xB7 param "${paramName}"`,
|
|
5172
|
+
path: `${ctx.path}.params.${paramName}`,
|
|
5173
|
+
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.` + suggest6(paramName, declared),
|
|
5174
|
+
hint: `Match the key to a declared param \`name\`, or drop it.` + (declared.size > 0 ? ` Declared params: ${listNames(declared)}.` : "")
|
|
5175
|
+
});
|
|
5176
|
+
}
|
|
5177
|
+
}
|
|
5178
|
+
|
|
5179
|
+
// src/validate-ai-surface-affinity.ts
|
|
5180
|
+
var AI_SKILL_SURFACE_MISMATCH = "ai-skill-surface-mismatch";
|
|
5181
|
+
function asArray30(v) {
|
|
5182
|
+
if (Array.isArray(v)) return v.filter((x) => !!x && typeof x === "object");
|
|
5183
|
+
if (v && typeof v === "object") {
|
|
5184
|
+
return Object.entries(v).map(([name, def]) => ({ name, ...def }));
|
|
5185
|
+
}
|
|
5186
|
+
return [];
|
|
5187
|
+
}
|
|
5188
|
+
function strName11(v) {
|
|
5189
|
+
return typeof v === "string" && v.length > 0 ? v : void 0;
|
|
5190
|
+
}
|
|
5191
|
+
function surfaceOf(v) {
|
|
5192
|
+
return typeof v === "string" && v.length > 0 ? v : "ask";
|
|
5193
|
+
}
|
|
5194
|
+
function validateAiSurfaceAffinity(stack) {
|
|
5195
|
+
const findings = [];
|
|
5196
|
+
if (!stack || typeof stack !== "object") return findings;
|
|
5197
|
+
const skillsByName = /* @__PURE__ */ new Map();
|
|
5198
|
+
for (const skill of asArray30(stack.skills)) {
|
|
5199
|
+
const n = strName11(skill.name);
|
|
5200
|
+
if (n) skillsByName.set(n, skill);
|
|
5201
|
+
}
|
|
5202
|
+
const agents = asArray30(stack.agents);
|
|
5203
|
+
for (let ai = 0; ai < agents.length; ai++) {
|
|
5204
|
+
const agent = agents[ai];
|
|
5205
|
+
const agentName = strName11(agent.name) ?? `#${ai}`;
|
|
5206
|
+
const agentSurface = surfaceOf(agent.surface);
|
|
5207
|
+
const skillRefs = Array.isArray(agent.skills) ? agent.skills : [];
|
|
5208
|
+
for (let si = 0; si < skillRefs.length; si++) {
|
|
5209
|
+
const ref = strName11(skillRefs[si]);
|
|
5210
|
+
if (!ref) continue;
|
|
5211
|
+
const skill = skillsByName.get(ref);
|
|
5212
|
+
if (!skill) continue;
|
|
5213
|
+
const skillSurface = surfaceOf(skill.surface);
|
|
5214
|
+
if (skillSurface === "both" || skillSurface === agentSurface) continue;
|
|
5215
|
+
findings.push({
|
|
5216
|
+
severity: "error",
|
|
5217
|
+
rule: AI_SKILL_SURFACE_MISMATCH,
|
|
5218
|
+
where: `agent "${agentName}" \xB7 skills`,
|
|
5219
|
+
path: `agents[${ai}].skills[${si}]`,
|
|
5220
|
+
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.`,
|
|
5221
|
+
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'\`.`
|
|
5222
|
+
});
|
|
5223
|
+
}
|
|
5224
|
+
}
|
|
5225
|
+
return findings;
|
|
5226
|
+
}
|
|
5227
|
+
|
|
5228
|
+
// src/validate-ai-tool-references.ts
|
|
5229
|
+
var import_system5 = require("@objectstack/spec/system");
|
|
5230
|
+
var AI_SKILL_TOOL_UNRESOLVED = "ai-skill-tool-unresolved";
|
|
5231
|
+
function asArray31(v) {
|
|
5232
|
+
if (Array.isArray(v)) return v.filter((x) => !!x && typeof x === "object");
|
|
5233
|
+
if (v && typeof v === "object") {
|
|
5234
|
+
return Object.entries(v).map(([name, def]) => ({ name, ...def }));
|
|
5235
|
+
}
|
|
5236
|
+
return [];
|
|
5237
|
+
}
|
|
5238
|
+
function strName12(v) {
|
|
5239
|
+
return typeof v === "string" && v.length > 0 ? v : void 0;
|
|
5240
|
+
}
|
|
5241
|
+
function distance6(a, b) {
|
|
5242
|
+
const m = a.length;
|
|
5243
|
+
const n = b.length;
|
|
5244
|
+
if (m === 0) return n;
|
|
5245
|
+
if (n === 0) return m;
|
|
5246
|
+
let prev = Array.from({ length: n + 1 }, (_, j) => j);
|
|
5247
|
+
for (let i = 1; i <= m; i++) {
|
|
5248
|
+
const curr = [i, ...new Array(n).fill(0)];
|
|
5249
|
+
for (let j = 1; j <= n; j++) {
|
|
5250
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
5251
|
+
curr[j] = Math.min(curr[j - 1] + 1, prev[j] + 1, prev[j - 1] + cost);
|
|
5252
|
+
}
|
|
5253
|
+
prev = curr;
|
|
5254
|
+
}
|
|
5255
|
+
return prev[n];
|
|
5256
|
+
}
|
|
5257
|
+
function suggest7(target, known) {
|
|
5258
|
+
for (const prefix of import_system5.PLATFORM_TOOL_FAMILY_PREFIXES) {
|
|
5259
|
+
if (known.has(`${prefix}${target}`)) return ` Did you mean "${prefix}${target}"?`;
|
|
5260
|
+
}
|
|
5261
|
+
let best;
|
|
5262
|
+
let bestScore = Infinity;
|
|
5263
|
+
for (const candidate of known) {
|
|
5264
|
+
const d = distance6(target, candidate);
|
|
5265
|
+
if (d < bestScore) {
|
|
5266
|
+
bestScore = d;
|
|
5267
|
+
best = candidate;
|
|
5268
|
+
}
|
|
5269
|
+
}
|
|
5270
|
+
const limit = Math.max(2, Math.floor(target.length / 3));
|
|
5271
|
+
return best && bestScore <= limit ? ` Did you mean "${best}"?` : "";
|
|
5272
|
+
}
|
|
5273
|
+
var HEADLESS_ACTION_TYPES = /* @__PURE__ */ new Set(["script", "api", "flow"]);
|
|
5274
|
+
function materialisesAsTool(action) {
|
|
5275
|
+
const ai = action.ai;
|
|
5276
|
+
if (!ai || typeof ai !== "object") return false;
|
|
5277
|
+
const aiRec = ai;
|
|
5278
|
+
if (aiRec.exposed !== true) return false;
|
|
5279
|
+
if (!strName12(aiRec.description)) return false;
|
|
5280
|
+
const type = strName12(action.type);
|
|
5281
|
+
if (!type || !HEADLESS_ACTION_TYPES.has(type)) return false;
|
|
5282
|
+
if (type === "script") return Boolean(action.target || action.body);
|
|
5283
|
+
return Boolean(action.target);
|
|
5284
|
+
}
|
|
5285
|
+
function collectToolUniverse(stack) {
|
|
5286
|
+
const universe = new Set(import_system5.PLATFORM_PROVIDED_TOOL_NAMES);
|
|
5287
|
+
for (const tool of asArray31(stack.tools)) {
|
|
5288
|
+
const n = strName12(tool.name);
|
|
5289
|
+
if (n) universe.add(n);
|
|
5290
|
+
}
|
|
5291
|
+
const addActionFamily = (actions) => {
|
|
5292
|
+
for (const action of asArray31(actions)) {
|
|
5293
|
+
const n = strName12(action.name);
|
|
5294
|
+
if (n && materialisesAsTool(action)) universe.add(`action_${n}`);
|
|
5295
|
+
}
|
|
5296
|
+
};
|
|
5297
|
+
addActionFamily(stack.actions);
|
|
5298
|
+
for (const obj of asArray31(stack.objects)) {
|
|
5299
|
+
addActionFamily(obj.actions);
|
|
5300
|
+
}
|
|
5301
|
+
return universe;
|
|
5302
|
+
}
|
|
5303
|
+
function collectUnexposedActionNames(stack) {
|
|
5304
|
+
const names = /* @__PURE__ */ new Set();
|
|
5305
|
+
const scan = (actions) => {
|
|
5306
|
+
for (const action of asArray31(actions)) {
|
|
5307
|
+
const n = strName12(action.name);
|
|
5308
|
+
if (n && !materialisesAsTool(action)) names.add(n);
|
|
5309
|
+
}
|
|
5310
|
+
};
|
|
5311
|
+
scan(stack.actions);
|
|
5312
|
+
for (const obj of asArray31(stack.objects)) scan(obj.actions);
|
|
5313
|
+
return names;
|
|
5314
|
+
}
|
|
5315
|
+
function validateAiToolReferences(stack) {
|
|
5316
|
+
const findings = [];
|
|
5317
|
+
if (!stack || typeof stack !== "object") return findings;
|
|
5318
|
+
const universe = collectToolUniverse(stack);
|
|
5319
|
+
const unexposedActions = collectUnexposedActionNames(stack);
|
|
5320
|
+
const resolves = (ref) => {
|
|
5321
|
+
if (ref.endsWith("*")) {
|
|
5322
|
+
const prefix = ref.slice(0, -1);
|
|
5323
|
+
for (const name of universe) {
|
|
5324
|
+
if (name.startsWith(prefix)) return true;
|
|
5325
|
+
}
|
|
5326
|
+
return false;
|
|
5327
|
+
}
|
|
5328
|
+
return universe.has(ref);
|
|
5329
|
+
};
|
|
5330
|
+
const skills = asArray31(stack.skills);
|
|
5331
|
+
for (let si = 0; si < skills.length; si++) {
|
|
5332
|
+
const skill = skills[si];
|
|
5333
|
+
const skillName = strName12(skill.name) ?? `#${si}`;
|
|
5334
|
+
const refs = Array.isArray(skill.tools) ? skill.tools : [];
|
|
5335
|
+
for (let ti = 0; ti < refs.length; ti++) {
|
|
5336
|
+
const ref = strName12(refs[ti]);
|
|
5337
|
+
if (!ref || resolves(ref)) continue;
|
|
5338
|
+
const isPattern = ref.endsWith("*");
|
|
5339
|
+
const unexposed = !isPattern && ref.startsWith("action_") && unexposedActions.has(ref.slice("action_".length)) ? ref.slice("action_".length) : void 0;
|
|
5340
|
+
findings.push({
|
|
5341
|
+
severity: "warning",
|
|
5342
|
+
rule: AI_SKILL_TOOL_UNRESOLVED,
|
|
5343
|
+
where: `skill "${skillName}" \xB7 tools`,
|
|
5344
|
+
path: `skills[${si}].tools[${ti}]`,
|
|
5345
|
+
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.` + suggest7(ref, universe),
|
|
5346
|
+
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_system5.PLATFORM_TOOL_FAMILY_PREFIXES.join(", ")}.`
|
|
5347
|
+
});
|
|
5348
|
+
}
|
|
5349
|
+
}
|
|
5350
|
+
return findings;
|
|
5351
|
+
}
|
|
5352
|
+
|
|
5353
|
+
// src/validate-ai-agent-authoring.ts
|
|
5354
|
+
var AGENT_AUTHORING_WITHDRAWN = "agent-authoring-withdrawn";
|
|
5355
|
+
function asArray32(v) {
|
|
5356
|
+
if (Array.isArray(v)) return v.filter((x) => !!x && typeof x === "object");
|
|
5357
|
+
if (v && typeof v === "object") {
|
|
5358
|
+
return Object.entries(v).map(([name, def]) => ({ name, ...def }));
|
|
5359
|
+
}
|
|
5360
|
+
return [];
|
|
5361
|
+
}
|
|
5362
|
+
function strName13(v) {
|
|
5363
|
+
return typeof v === "string" && v.length > 0 ? v : void 0;
|
|
5364
|
+
}
|
|
5365
|
+
var PLATFORM_AGENT_NAMES = /* @__PURE__ */ new Set(["ask", "build", "data_chat", "metadata_assistant"]);
|
|
5366
|
+
function validateAiAgentAuthoring(stack) {
|
|
5367
|
+
const findings = [];
|
|
5368
|
+
if (!stack || typeof stack !== "object") return findings;
|
|
5369
|
+
const agents = asArray32(stack.agents);
|
|
5370
|
+
for (let ai = 0; ai < agents.length; ai++) {
|
|
5371
|
+
const agent = agents[ai];
|
|
5372
|
+
const name = strName13(agent.name) ?? `#${ai}`;
|
|
5373
|
+
const isPlatformName = PLATFORM_AGENT_NAMES.has(name);
|
|
5374
|
+
const skillCount = Array.isArray(agent.skills) ? agent.skills.length : 0;
|
|
5375
|
+
findings.push({
|
|
5376
|
+
severity: "warning",
|
|
5377
|
+
rule: AGENT_AUTHORING_WITHDRAWN,
|
|
5378
|
+
where: `agent "${name}"`,
|
|
5379
|
+
path: `agents[${ai}]`,
|
|
5380
|
+
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.`,
|
|
5381
|
+
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.` : ``)
|
|
5382
|
+
});
|
|
5383
|
+
}
|
|
5384
|
+
return findings;
|
|
5385
|
+
}
|
|
5386
|
+
|
|
5387
|
+
// src/validate-hook-body-writes.ts
|
|
5388
|
+
var import_node_module3 = require("module");
|
|
5389
|
+
var import_shared = require("@objectstack/spec/shared");
|
|
5390
|
+
var import_meta3 = {};
|
|
5391
|
+
var cachedTs2 = null;
|
|
5392
|
+
function loadTypeScript2() {
|
|
5393
|
+
if (cachedTs2) return cachedTs2;
|
|
5394
|
+
const anchor = typeof import_meta3 !== "undefined" && import_meta3.url ? import_meta3.url : typeof __filename !== "undefined" ? __filename : process.cwd() + "/";
|
|
5395
|
+
try {
|
|
5396
|
+
cachedTs2 = (0, import_node_module3.createRequire)(anchor)("typescript");
|
|
5397
|
+
} catch (err) {
|
|
5398
|
+
throw new Error(
|
|
5399
|
+
`@objectstack/lint: checking an L2 (language:'js') hook body requires the "typescript" package, which could not be loaded (${err instanceof Error ? err.message : String(err)}). It is a declared dependency of @objectstack/lint \u2014 if this deployment prunes packages, keep "typescript" in the image; it is only loaded when a hook with a JS body is validated.`
|
|
5400
|
+
);
|
|
5401
|
+
}
|
|
5402
|
+
return cachedTs2;
|
|
5403
|
+
}
|
|
5404
|
+
var HOOK_BODY_WRITE_UNKNOWN_FIELD = "hook-body-write-unknown-field";
|
|
5405
|
+
var HOOK_BODY_WRITE_PATTERNS = [
|
|
5406
|
+
{
|
|
5407
|
+
id: "input-property-assign",
|
|
5408
|
+
syntax: "ctx.input.<field> = \u2026 | ctx.input['<field>'] \u27E8op\u27E9= \u2026",
|
|
5409
|
+
example: {
|
|
5410
|
+
// Compound (`+=`) and logical (`??=`) assignment operators write their
|
|
5411
|
+
// LHS exactly like `=` does — the example pins the whole operator range.
|
|
5412
|
+
source: "ctx.input.total = 0; ctx.input['status'] ??= 'open'; ctx.input.retries += 1;",
|
|
5413
|
+
writes: [{ field: "total" }, { field: "status" }, { field: "retries" }]
|
|
5414
|
+
}
|
|
5415
|
+
},
|
|
5416
|
+
{
|
|
5417
|
+
id: "input-object-assign",
|
|
5418
|
+
syntax: "Object.assign(ctx.input, { <field>: \u2026 })",
|
|
5419
|
+
example: {
|
|
5420
|
+
source: "Object.assign(ctx.input, { total: 5, 'status': 'open', discount });",
|
|
5421
|
+
writes: [{ field: "total" }, { field: "status" }, { field: "discount" }]
|
|
5422
|
+
}
|
|
5423
|
+
},
|
|
5424
|
+
{
|
|
5425
|
+
// ACTION-only shape (the hook sandbox context has no `ctx.record` at all).
|
|
5426
|
+
// Declared here because this ledger is the extractor's shape inventory, not
|
|
5427
|
+
// any one rule's; every consumer declares which shapes it consumes.
|
|
5428
|
+
id: "record-property-assign",
|
|
5429
|
+
syntax: "ctx.record.<field> = \u2026 | ctx.record['<field>'] \u27E8op\u27E9= \u2026",
|
|
5430
|
+
example: {
|
|
5431
|
+
source: "ctx.record.stage = 'won'; ctx.record['amount'] += 1;",
|
|
5432
|
+
writes: [{ field: "stage" }, { field: "amount" }]
|
|
5433
|
+
}
|
|
5434
|
+
},
|
|
5435
|
+
{
|
|
5436
|
+
id: "api-crud-literal",
|
|
5437
|
+
syntax: "ctx.api.object('<object>').insert({\u2026}) | .create({\u2026}) | .update({\u2026}) | .updateById(id, {\u2026})",
|
|
5438
|
+
example: {
|
|
5439
|
+
// Real ObjectRepository signatures: the record payload is argument 0 for
|
|
5440
|
+
// insert/create/update and argument 1 for updateById. (`update(data)` —
|
|
5441
|
+
// NOT `update(id, data)`; the id travels inside the payload/options.)
|
|
5442
|
+
source: "await ctx.api.object('audit_log').insert({ event: 'won' }); await ctx.api.object('crm_deal').updateById(id, { stage: 'won' });",
|
|
5443
|
+
writes: [
|
|
5444
|
+
{ field: "event", object: "audit_log" },
|
|
5445
|
+
{ field: "stage", object: "crm_deal" }
|
|
5446
|
+
]
|
|
5447
|
+
}
|
|
5448
|
+
}
|
|
5449
|
+
];
|
|
5450
|
+
var HOOK_BODY_WRITE_PATTERN_IDS = [
|
|
5451
|
+
"input-property-assign",
|
|
5452
|
+
"input-object-assign",
|
|
5453
|
+
"api-crud-literal"
|
|
5454
|
+
];
|
|
5455
|
+
var HOOK_BODY_WRITE_EXCLUSIONS = [
|
|
5456
|
+
{
|
|
5457
|
+
id: "record-property-assign",
|
|
5458
|
+
reason: "a hook sandbox context has no `ctx.record` at all \u2014 `buildSandboxContext` never sets it (a hook\u2019s record IS `ctx.input`), so the expression throws at run time rather than silently no-op\u2019ing. A loud failure the author sees on the first run is not this advisory rule\u2019s business"
|
|
5459
|
+
}
|
|
5460
|
+
];
|
|
5461
|
+
var HOOK_APPLICABLE_IDS = new Set(HOOK_BODY_WRITE_PATTERN_IDS);
|
|
5462
|
+
var API_WRITE_METHODS = /* @__PURE__ */ new Map([
|
|
5463
|
+
["insert", 0],
|
|
5464
|
+
["create", 0],
|
|
5465
|
+
["update", 0],
|
|
5466
|
+
["updateById", 1]
|
|
5467
|
+
]);
|
|
5468
|
+
var INPUT_ENVELOPE_KEYS = /* @__PURE__ */ new Set(["id", "options", "ast", "data"]);
|
|
5469
|
+
var IMPLICIT_FIELDS2 = /* @__PURE__ */ new Set([
|
|
5470
|
+
...SYSTEM_FIELDS,
|
|
5471
|
+
"_id",
|
|
5472
|
+
"name",
|
|
5473
|
+
"space",
|
|
5474
|
+
"owner",
|
|
5475
|
+
"record_type"
|
|
5476
|
+
]);
|
|
5477
|
+
var isRec8 = (v) => !!v && typeof v === "object" && !Array.isArray(v);
|
|
5478
|
+
function asArray33(v) {
|
|
5479
|
+
if (Array.isArray(v)) return v.filter((x) => isRec8(x));
|
|
5480
|
+
if (isRec8(v)) {
|
|
5481
|
+
return Object.entries(v).map(([name, def]) => ({
|
|
5482
|
+
name,
|
|
5483
|
+
...isRec8(def) ? def : {}
|
|
5484
|
+
}));
|
|
5485
|
+
}
|
|
5486
|
+
return [];
|
|
5487
|
+
}
|
|
5488
|
+
function indexObjectFields2(stack) {
|
|
5489
|
+
const out = /* @__PURE__ */ new Map();
|
|
5490
|
+
for (const obj of asArray33(stack.objects)) {
|
|
5491
|
+
const name = typeof obj.name === "string" ? obj.name : void 0;
|
|
5492
|
+
if (!name) continue;
|
|
5493
|
+
const names = /* @__PURE__ */ new Set();
|
|
5494
|
+
for (const f of asArray33(obj.fields)) {
|
|
5495
|
+
if (typeof f.name === "string" && f.name) names.add(f.name);
|
|
5496
|
+
}
|
|
5497
|
+
out.set(name, names);
|
|
5498
|
+
}
|
|
5499
|
+
return out;
|
|
5500
|
+
}
|
|
5501
|
+
function judgeableFieldsOf(index, objectName) {
|
|
5502
|
+
const declared = index.get(objectName);
|
|
5503
|
+
if (!declared || declared.size === 0) return void 0;
|
|
5504
|
+
return declared;
|
|
5505
|
+
}
|
|
5506
|
+
function extractHookBodyWrites(source) {
|
|
5507
|
+
return extractHookBodyWriteSet(source).writes;
|
|
5508
|
+
}
|
|
5509
|
+
function extractHookBodyWriteSet(source) {
|
|
5510
|
+
if (!/\bctx\b/.test(source) && !/\bObject\b/.test(source)) {
|
|
5511
|
+
return { writes: [], ctxRecordEscapes: false };
|
|
5512
|
+
}
|
|
5513
|
+
const tsc = loadTypeScript2();
|
|
5514
|
+
const sf = tsc.createSourceFile(
|
|
5515
|
+
"hook-body.ts",
|
|
5516
|
+
`async function __body(ctx) {
|
|
5517
|
+
${source}
|
|
5518
|
+
}`,
|
|
5519
|
+
tsc.ScriptTarget.Latest,
|
|
5520
|
+
/* setParentNodes */
|
|
5521
|
+
false,
|
|
5522
|
+
tsc.ScriptKind.TS
|
|
5523
|
+
);
|
|
5524
|
+
const writes = [];
|
|
5525
|
+
const recordRefs = [];
|
|
5526
|
+
const consumedRecordRefs = /* @__PURE__ */ new Set();
|
|
5527
|
+
const isCtxDot = (node, prop) => tsc.isPropertyAccessExpression(node) && tsc.isIdentifier(node.expression) && node.expression.text === "ctx" && node.name.text === prop;
|
|
5528
|
+
const fieldOfCtxLhs = (lhs, prop) => {
|
|
5529
|
+
if (tsc.isPropertyAccessExpression(lhs) && tsc.isIdentifier(lhs.name) && isCtxDot(lhs.expression, prop)) {
|
|
5530
|
+
return lhs.name.text;
|
|
5531
|
+
}
|
|
5532
|
+
if (tsc.isElementAccessExpression(lhs) && isCtxDot(lhs.expression, prop)) {
|
|
5533
|
+
const arg = lhs.argumentExpression;
|
|
5534
|
+
if (tsc.isStringLiteral(arg) || tsc.isNoSubstitutionTemplateLiteral(arg)) return arg.text;
|
|
5535
|
+
}
|
|
5536
|
+
return void 0;
|
|
5537
|
+
};
|
|
5538
|
+
const literalObjectKeys = (node) => {
|
|
5539
|
+
if (!tsc.isObjectLiteralExpression(node)) return [];
|
|
5540
|
+
const keys = [];
|
|
5541
|
+
for (const p of node.properties) {
|
|
5542
|
+
if (tsc.isPropertyAssignment(p)) {
|
|
5543
|
+
if (tsc.isIdentifier(p.name) || tsc.isStringLiteral(p.name)) keys.push(p.name.text);
|
|
5544
|
+
} else if (tsc.isShorthandPropertyAssignment(p)) {
|
|
5545
|
+
keys.push(p.name.text);
|
|
5546
|
+
}
|
|
5547
|
+
}
|
|
5548
|
+
return keys;
|
|
5549
|
+
};
|
|
5550
|
+
const visit = (node) => {
|
|
5551
|
+
if (tsc.isBinaryExpression(node) && node.operatorToken.kind >= tsc.SyntaxKind.FirstAssignment && node.operatorToken.kind <= tsc.SyntaxKind.LastAssignment) {
|
|
5552
|
+
const inputField = fieldOfCtxLhs(node.left, "input");
|
|
5553
|
+
if (inputField !== void 0 && !INPUT_ENVELOPE_KEYS.has(inputField)) {
|
|
5554
|
+
writes.push({ patternId: "input-property-assign", field: inputField });
|
|
5555
|
+
}
|
|
5556
|
+
const recordField = fieldOfCtxLhs(node.left, "record");
|
|
5557
|
+
if (recordField !== void 0) {
|
|
5558
|
+
writes.push({ patternId: "record-property-assign", field: recordField });
|
|
5559
|
+
}
|
|
5560
|
+
}
|
|
5561
|
+
if (tsc.isPropertyAccessExpression(node) || tsc.isElementAccessExpression(node)) {
|
|
5562
|
+
if (isCtxDot(node.expression, "record")) consumedRecordRefs.add(node.expression);
|
|
5563
|
+
}
|
|
5564
|
+
if (tsc.isBinaryExpression(node)) {
|
|
5565
|
+
const op = node.operatorToken.kind;
|
|
5566
|
+
if ((op === tsc.SyntaxKind.AmpersandAmpersandToken || op === tsc.SyntaxKind.BarBarToken || op === tsc.SyntaxKind.QuestionQuestionToken) && isCtxDot(node.left, "record")) {
|
|
5567
|
+
consumedRecordRefs.add(node.left);
|
|
5568
|
+
}
|
|
5569
|
+
}
|
|
5570
|
+
if (tsc.isPrefixUnaryExpression(node) && node.operator === tsc.SyntaxKind.ExclamationToken) {
|
|
5571
|
+
if (isCtxDot(node.operand, "record")) consumedRecordRefs.add(node.operand);
|
|
5572
|
+
}
|
|
5573
|
+
if (tsc.isTypeOfExpression(node) && isCtxDot(node.expression, "record")) {
|
|
5574
|
+
consumedRecordRefs.add(node.expression);
|
|
5575
|
+
}
|
|
5576
|
+
if ((tsc.isIfStatement(node) || tsc.isWhileStatement(node) || tsc.isDoStatement(node)) && isCtxDot(node.expression, "record")) {
|
|
5577
|
+
consumedRecordRefs.add(node.expression);
|
|
5578
|
+
}
|
|
5579
|
+
if (tsc.isConditionalExpression(node) && isCtxDot(node.condition, "record")) {
|
|
5580
|
+
consumedRecordRefs.add(node.condition);
|
|
5581
|
+
}
|
|
5582
|
+
if (isCtxDot(node, "record")) recordRefs.push(node);
|
|
5583
|
+
if (tsc.isCallExpression(node)) {
|
|
5584
|
+
const callee = node.expression;
|
|
5585
|
+
if (tsc.isPropertyAccessExpression(callee) && tsc.isIdentifier(callee.expression) && callee.expression.text === "Object" && callee.name.text === "assign" && node.arguments.length >= 2 && isCtxDot(node.arguments[0], "input")) {
|
|
5586
|
+
for (const arg of node.arguments.slice(1)) {
|
|
5587
|
+
for (const field of literalObjectKeys(arg)) {
|
|
5588
|
+
if (!INPUT_ENVELOPE_KEYS.has(field)) {
|
|
5589
|
+
writes.push({ patternId: "input-object-assign", field });
|
|
5590
|
+
}
|
|
5591
|
+
}
|
|
5592
|
+
}
|
|
5593
|
+
}
|
|
5594
|
+
if (tsc.isPropertyAccessExpression(callee) && tsc.isIdentifier(callee.name)) {
|
|
5595
|
+
const payloadIndex = API_WRITE_METHODS.get(callee.name.text);
|
|
5596
|
+
const recv = callee.expression;
|
|
5597
|
+
if (payloadIndex !== void 0 && tsc.isCallExpression(recv) && tsc.isPropertyAccessExpression(recv.expression) && recv.expression.name.text === "object" && isCtxDot(recv.expression.expression, "api") && recv.arguments.length === 1) {
|
|
5598
|
+
const objArg = recv.arguments[0];
|
|
5599
|
+
const objectName = tsc.isStringLiteral(objArg) || tsc.isNoSubstitutionTemplateLiteral(objArg) ? objArg.text : void 0;
|
|
5600
|
+
const payload = node.arguments[payloadIndex];
|
|
5601
|
+
if (objectName && payload !== void 0) {
|
|
5602
|
+
for (const field of literalObjectKeys(payload)) {
|
|
5603
|
+
writes.push({
|
|
5604
|
+
patternId: "api-crud-literal",
|
|
5605
|
+
object: objectName,
|
|
5606
|
+
method: callee.name.text,
|
|
5607
|
+
field
|
|
5608
|
+
});
|
|
5609
|
+
}
|
|
5610
|
+
}
|
|
5611
|
+
}
|
|
5612
|
+
}
|
|
5613
|
+
}
|
|
5614
|
+
tsc.forEachChild(node, visit);
|
|
5615
|
+
};
|
|
5616
|
+
visit(sf);
|
|
5617
|
+
return {
|
|
5618
|
+
writes,
|
|
5619
|
+
ctxRecordEscapes: recordRefs.some((ref) => !consumedRecordRefs.has(ref))
|
|
5620
|
+
};
|
|
5621
|
+
}
|
|
5622
|
+
function validateHookBodyWrites(stack) {
|
|
5623
|
+
const findings = [];
|
|
5624
|
+
const hooks = asArray33(stack.hooks);
|
|
5625
|
+
if (hooks.length === 0) return findings;
|
|
5626
|
+
let objectFields = null;
|
|
5627
|
+
hooks.forEach((hook, hookIndex) => {
|
|
5628
|
+
const body = hook.body;
|
|
5629
|
+
if (!isRec8(body) || body.language !== "js") return;
|
|
5630
|
+
const source = body.source;
|
|
5631
|
+
if (typeof source !== "string" || source.trim() === "") return;
|
|
5632
|
+
const writes = extractHookBodyWrites(source).filter((w) => HOOK_APPLICABLE_IDS.has(w.patternId));
|
|
5633
|
+
if (writes.length === 0) return;
|
|
5634
|
+
objectFields ?? (objectFields = indexObjectFields2(stack));
|
|
5635
|
+
const hookName = typeof hook.name === "string" && hook.name ? hook.name : `#${hookIndex}`;
|
|
5636
|
+
const targets = (Array.isArray(hook.object) ? hook.object : [hook.object]).filter(
|
|
5637
|
+
(o) => typeof o === "string" && o.trim() !== ""
|
|
5638
|
+
);
|
|
5639
|
+
const targetSets = targets.map((t) => judgeableFieldsOf(objectFields, t));
|
|
5640
|
+
const inputJudgeable = targets.length > 0 && !targets.includes("*") && targetSets.every((s) => s !== void 0);
|
|
5641
|
+
const where = `hook "${hookName}" \u203A body`;
|
|
5642
|
+
const path = `hooks[${hookIndex}].body.source`;
|
|
5643
|
+
const reported = /* @__PURE__ */ new Set();
|
|
5644
|
+
for (const w of writes) {
|
|
5645
|
+
const dedupeKey = `${w.object ?? ""}\0${w.field}`;
|
|
5646
|
+
if (reported.has(dedupeKey)) continue;
|
|
5647
|
+
if (w.object === void 0) {
|
|
5648
|
+
if (!inputJudgeable) continue;
|
|
5649
|
+
if (IMPLICIT_FIELDS2.has(w.field)) continue;
|
|
5650
|
+
if (targetSets.some((s) => s.has(w.field))) continue;
|
|
5651
|
+
reported.add(dedupeKey);
|
|
5652
|
+
const objDesc = targets.length === 1 ? `object '${targets[0]}'` : `none of its target objects (${targets.join(", ")})`;
|
|
5653
|
+
const declares = targets.length === 1 ? "declares no such field" : "declare that field";
|
|
5654
|
+
findings.push({
|
|
5655
|
+
severity: "warning",
|
|
5656
|
+
rule: HOOK_BODY_WRITE_UNKNOWN_FIELD,
|
|
5657
|
+
where,
|
|
5658
|
+
path,
|
|
5659
|
+
message: `body writes '${w.field}' to its input, but ${objDesc} ${declares}. The sandboxed script runs clean and the value is copied back onto the record payload unfiltered \u2014 on a SQL driver the stray column then fails the WHOLE write with a driver-level error far from here; on a schemaless driver (memory, MongoDB) it is persisted as an undeclared key (#4271).`,
|
|
5660
|
+
hint: fixHint(w.field, unionCandidates(targetSets))
|
|
5661
|
+
});
|
|
5662
|
+
} else {
|
|
5663
|
+
const known = judgeableFieldsOf(objectFields, w.object);
|
|
5664
|
+
if (!known) continue;
|
|
5665
|
+
if (IMPLICIT_FIELDS2.has(w.field) || known.has(w.field)) continue;
|
|
5666
|
+
reported.add(dedupeKey);
|
|
5667
|
+
findings.push({
|
|
5668
|
+
severity: "warning",
|
|
5669
|
+
rule: HOOK_BODY_WRITE_UNKNOWN_FIELD,
|
|
5670
|
+
where,
|
|
5671
|
+
path,
|
|
5672
|
+
message: `body calls ctx.api.object('${w.object}').${w.method ?? "update"}(\u2026) writing '${w.field}', but object '${w.object}' declares no such field. The write-path validator skips the unknown key \u2014 on a SQL driver the whole call then fails with a driver-level error far from here; on a schemaless driver (memory, MongoDB) the stray key is persisted (#4271).`,
|
|
5673
|
+
hint: fixHint(w.field, [...known])
|
|
5674
|
+
});
|
|
5675
|
+
}
|
|
5676
|
+
}
|
|
5677
|
+
});
|
|
5678
|
+
return findings;
|
|
5679
|
+
}
|
|
5680
|
+
function unionCandidates(targetSets) {
|
|
5681
|
+
const out = /* @__PURE__ */ new Set();
|
|
5682
|
+
for (const s of targetSets) for (const f of s ?? []) out.add(f);
|
|
5683
|
+
return [...out];
|
|
5684
|
+
}
|
|
5685
|
+
function fixHint(field, declared) {
|
|
5686
|
+
const suggestion = (0, import_shared.formatSuggestion)((0, import_shared.findClosestMatches)(field, [...declared, ...IMPLICIT_FIELDS2]));
|
|
5687
|
+
return (suggestion ? `${suggestion} ` : "") + `Fix the field name, or declare '${field}' on the object. Only the literal write patterns in HOOK_BODY_WRITE_PATTERNS are checked \u2014 computed keys, spreads and aliased input are not \u2014 and this warning never blocks a build.`;
|
|
5688
|
+
}
|
|
5689
|
+
|
|
5690
|
+
// src/validate-action-body-writes.ts
|
|
5691
|
+
var import_shared2 = require("@objectstack/spec/shared");
|
|
5692
|
+
var ACTION_BODY_WRITE_UNKNOWN_FIELD = "action-body-write-unknown-field";
|
|
5693
|
+
var ACTION_RECORD_WRITE_DISCARDED = "action-record-write-discarded";
|
|
5694
|
+
var ACTION_BODY_WRITE_PATTERN_IDS = ["api-crud-literal"];
|
|
5695
|
+
var ACTION_RECORD_WRITE_PATTERN_IDS = ["record-property-assign"];
|
|
5696
|
+
var ACTION_BODY_WRITE_EXCLUSIONS = [
|
|
5697
|
+
{
|
|
5698
|
+
id: "input-property-assign",
|
|
5699
|
+
reason: "an action's ctx.input is its params bag (`input: unwrapProxyToPlain(actionCtx?.params)`), not a record \u2014 `ctx.input.<name>` writes a declared PARAMETER, which object fields cannot judge"
|
|
5700
|
+
},
|
|
5701
|
+
{
|
|
5702
|
+
id: "input-object-assign",
|
|
5703
|
+
reason: "same surface as input-property-assign \u2014 Object.assign(ctx.input, \u2026) targets the params bag"
|
|
5704
|
+
}
|
|
5705
|
+
];
|
|
5706
|
+
var ACTION_BODY_WRITE_PATTERNS = HOOK_BODY_WRITE_PATTERNS.filter((p) => ACTION_BODY_WRITE_PATTERN_IDS.includes(p.id));
|
|
5707
|
+
var ACTION_RECORD_WRITE_PATTERNS = HOOK_BODY_WRITE_PATTERNS.filter((p) => ACTION_RECORD_WRITE_PATTERN_IDS.includes(p.id));
|
|
5708
|
+
var APPLICABLE_IDS = new Set(ACTION_BODY_WRITE_PATTERN_IDS);
|
|
5709
|
+
var RECORD_WRITE_IDS = new Set(ACTION_RECORD_WRITE_PATTERN_IDS);
|
|
5710
|
+
var isRec9 = (v) => !!v && typeof v === "object" && !Array.isArray(v);
|
|
5711
|
+
function asArray34(v) {
|
|
5712
|
+
if (Array.isArray(v)) return v.filter((x) => isRec9(x));
|
|
5713
|
+
if (isRec9(v)) {
|
|
5714
|
+
return Object.entries(v).map(([name, def]) => ({
|
|
5715
|
+
name,
|
|
5716
|
+
...isRec9(def) ? def : {}
|
|
5717
|
+
}));
|
|
5718
|
+
}
|
|
5719
|
+
return [];
|
|
5720
|
+
}
|
|
5721
|
+
function actionObjectBinding(action, parentObject) {
|
|
5722
|
+
if (typeof action.object === "string" && action.object) return action.object;
|
|
5723
|
+
if (typeof action.objectName === "string" && action.objectName) return action.objectName;
|
|
5724
|
+
return parentObject;
|
|
5725
|
+
}
|
|
5726
|
+
function collectActionBodies(stack) {
|
|
5727
|
+
const sites = [];
|
|
5728
|
+
const seen = /* @__PURE__ */ new Set();
|
|
5729
|
+
const collect = (actions, pathPrefix, parentObject) => {
|
|
5730
|
+
asArray34(actions).forEach((action, index) => {
|
|
5731
|
+
const body = action.body;
|
|
5732
|
+
if (!isRec9(body) || body.language !== "js") return;
|
|
5733
|
+
const source = body.source;
|
|
5734
|
+
if (typeof source !== "string" || source.trim() === "") return;
|
|
5735
|
+
const name = typeof action.name === "string" && action.name ? action.name : `#${index}`;
|
|
5736
|
+
const key = `${actionObjectBinding(action, parentObject) ?? ""}\0${name}\0${source}`;
|
|
5737
|
+
if (seen.has(key)) return;
|
|
5738
|
+
seen.add(key);
|
|
5739
|
+
sites.push({ name, source, path: `${pathPrefix}[${index}].body.source` });
|
|
5740
|
+
});
|
|
5741
|
+
};
|
|
5742
|
+
collect(stack.actions, "actions");
|
|
5743
|
+
asArray34(stack.objects).forEach((obj, objIndex) => {
|
|
5744
|
+
const parentObject = typeof obj.name === "string" && obj.name ? obj.name : void 0;
|
|
5745
|
+
collect(obj.actions, `objects[${objIndex}].actions`, parentObject);
|
|
5746
|
+
});
|
|
5747
|
+
return sites;
|
|
5748
|
+
}
|
|
5749
|
+
function validateActionBodyWrites(stack) {
|
|
5750
|
+
const findings = [];
|
|
5751
|
+
if (!isRec9(stack)) return findings;
|
|
5752
|
+
const sites = collectActionBodies(stack);
|
|
5753
|
+
if (sites.length === 0) return findings;
|
|
5754
|
+
let objectFields = null;
|
|
5755
|
+
for (const site of sites) {
|
|
5756
|
+
if (!/\bapi\b/.test(site.source) && !/\brecord\b/.test(site.source)) continue;
|
|
5757
|
+
const { writes: allWrites, ctxRecordEscapes } = extractHookBodyWriteSet(site.source);
|
|
5758
|
+
const writes = allWrites.filter((w) => APPLICABLE_IDS.has(w.patternId));
|
|
5759
|
+
const recordWrites = allWrites.filter((w) => RECORD_WRITE_IDS.has(w.patternId));
|
|
5760
|
+
if (writes.length === 0 && recordWrites.length === 0) continue;
|
|
5761
|
+
const where = `action "${site.name}" \u203A body`;
|
|
5762
|
+
if (recordWrites.length > 0 && !ctxRecordEscapes) {
|
|
5763
|
+
const reportedFields = /* @__PURE__ */ new Set();
|
|
5764
|
+
for (const w of recordWrites) {
|
|
5765
|
+
if (reportedFields.has(w.field)) continue;
|
|
5766
|
+
reportedFields.add(w.field);
|
|
5767
|
+
findings.push({
|
|
5768
|
+
severity: "warning",
|
|
5769
|
+
rule: ACTION_RECORD_WRITE_DISCARDED,
|
|
5770
|
+
where,
|
|
5771
|
+
path: site.path,
|
|
5772
|
+
message: `body assigns ctx.record.${w.field}, but an action's ctx.record is a plain snapshot the runtime never writes back \u2014 the action returns success and the assignment is discarded, whether or not '${w.field}' is a declared field (#4345).`,
|
|
5773
|
+
hint: `To persist it, write through the API: ctx.api.object('<object>').updateById(ctx.recordId, { ${w.field}: \u2026 }). Reported only because ctx.record is never passed anywhere in this body \u2014 mutating the snapshot and then handing it to an API write is a live payload and is not flagged. This warning never blocks a build.`
|
|
5774
|
+
});
|
|
5775
|
+
}
|
|
5776
|
+
}
|
|
5777
|
+
if (writes.length === 0) continue;
|
|
5778
|
+
objectFields ?? (objectFields = indexObjectFields2(stack));
|
|
5779
|
+
const reported = /* @__PURE__ */ new Set();
|
|
5780
|
+
for (const w of writes) {
|
|
5781
|
+
if (w.object === void 0) continue;
|
|
5782
|
+
const dedupeKey = `${w.object}\0${w.field}`;
|
|
5783
|
+
if (reported.has(dedupeKey)) continue;
|
|
5784
|
+
const known = judgeableFieldsOf(objectFields, w.object);
|
|
5785
|
+
if (!known) continue;
|
|
5786
|
+
if (IMPLICIT_FIELDS2.has(w.field) || known.has(w.field)) continue;
|
|
5787
|
+
reported.add(dedupeKey);
|
|
5788
|
+
findings.push({
|
|
5789
|
+
severity: "warning",
|
|
5790
|
+
rule: ACTION_BODY_WRITE_UNKNOWN_FIELD,
|
|
5791
|
+
where,
|
|
5792
|
+
path: site.path,
|
|
5793
|
+
message: `body calls ctx.api.object('${w.object}').${w.method ?? "update"}(\u2026) writing '${w.field}', but object '${w.object}' declares no such field. The write-path validator skips the unknown key \u2014 on a SQL driver the whole action then fails with a driver-level error far from here; on a schemaless driver (memory, MongoDB) the stray key is persisted (#4271).`,
|
|
5794
|
+
hint: fixHint2(w.field, [...known])
|
|
5795
|
+
});
|
|
5796
|
+
}
|
|
5797
|
+
}
|
|
5798
|
+
return findings;
|
|
5799
|
+
}
|
|
5800
|
+
function fixHint2(field, declared) {
|
|
5801
|
+
const suggestion = (0, import_shared2.formatSuggestion)((0, import_shared2.findClosestMatches)(field, [...declared, ...IMPLICIT_FIELDS2]));
|
|
5802
|
+
return (suggestion ? `${suggestion} ` : "") + `Fix the field name, or declare '${field}' on the object. Only the literal write patterns in ACTION_BODY_WRITE_PATTERNS are checked \u2014 an action's ctx.input is its params bag, so it is not a record-write surface and is never resolved against fields \u2014 and this warning never blocks a build.`;
|
|
5803
|
+
}
|
|
5804
|
+
|
|
5805
|
+
// src/validate-flow-node-writes.ts
|
|
5806
|
+
var import_shared3 = require("@objectstack/spec/shared");
|
|
5807
|
+
var FLOW_NODE_WRITE_UNKNOWN_FIELD = "flow-node-write-unknown-field";
|
|
5808
|
+
var FLOW_WRITE_NODE_TYPES = ["update_record", "create_record"];
|
|
5809
|
+
var FLOW_WRITE_NODE_TYPES_DEFERRED = [];
|
|
5810
|
+
var isRec10 = (v) => !!v && typeof v === "object" && !Array.isArray(v);
|
|
5811
|
+
function asArray35(v) {
|
|
5812
|
+
if (Array.isArray(v)) return v.filter((x) => isRec10(x));
|
|
5813
|
+
if (isRec10(v)) {
|
|
5814
|
+
return Object.entries(v).map(([name, def]) => ({
|
|
5815
|
+
name,
|
|
5816
|
+
...isRec10(def) ? def : {}
|
|
5817
|
+
}));
|
|
5818
|
+
}
|
|
5819
|
+
return [];
|
|
5820
|
+
}
|
|
5821
|
+
function readLiteralObjectName2(config) {
|
|
5822
|
+
const raw = config.objectName ?? config.object;
|
|
5823
|
+
if (typeof raw !== "string" || raw.includes("{")) return void 0;
|
|
5824
|
+
return raw || void 0;
|
|
5825
|
+
}
|
|
5826
|
+
var COVERED_TYPES = new Set(FLOW_WRITE_NODE_TYPES);
|
|
5827
|
+
function validateFlowNodeWrites(stack) {
|
|
5828
|
+
const findings = [];
|
|
5829
|
+
if (!isRec10(stack)) return findings;
|
|
5830
|
+
const flows = asArray35(stack.flows);
|
|
5831
|
+
if (flows.length === 0) return findings;
|
|
5832
|
+
let objectFields = null;
|
|
5833
|
+
flows.forEach((flow, flowIndex) => {
|
|
5834
|
+
const flowName = typeof flow.name === "string" && flow.name ? flow.name : `#${flowIndex}`;
|
|
5835
|
+
const walked = walkFlowNodes(flow, `flows[${flowIndex}]`);
|
|
5836
|
+
walked.forEach(({ node, path: nodePath, regionTrail }, walkIndex) => {
|
|
5837
|
+
if (typeof node.type !== "string" || !COVERED_TYPES.has(node.type)) return;
|
|
5838
|
+
const config = isRec10(node.config) ? node.config : void 0;
|
|
5839
|
+
if (!config) return;
|
|
5840
|
+
const fields = config.fields;
|
|
5841
|
+
if (!isRec10(fields)) return;
|
|
5842
|
+
const written = Object.keys(fields);
|
|
5843
|
+
if (written.length === 0) return;
|
|
5844
|
+
const objectName = readLiteralObjectName2(config);
|
|
5845
|
+
if (!objectName) return;
|
|
5846
|
+
objectFields ?? (objectFields = indexObjectFields2(stack));
|
|
5847
|
+
const known = judgeableFieldsOf(objectFields, objectName);
|
|
5848
|
+
if (!known) return;
|
|
5849
|
+
const nodeName = flowNodeLabel(node, walkIndex);
|
|
5850
|
+
const nodeWhere = regionTrail ? `${regionTrail} \u203A node "${nodeName}"` : `node "${nodeName}"`;
|
|
5851
|
+
for (const fieldName of written) {
|
|
5852
|
+
if (known.has(fieldName) || IMPLICIT_FIELDS2.has(fieldName)) continue;
|
|
5853
|
+
if (fieldName.includes(".")) continue;
|
|
5854
|
+
findings.push({
|
|
5855
|
+
severity: "error",
|
|
5856
|
+
rule: FLOW_NODE_WRITE_UNKNOWN_FIELD,
|
|
5857
|
+
where: `flow "${flowName}" \u203A ${nodeWhere}`,
|
|
5858
|
+
path: `${nodePath}.config.fields.${fieldName}`,
|
|
5859
|
+
message: `${node.type} writes '${fieldName}', but object '${objectName}' declares no such field. Nothing between the node and storage removes the key: on a SQL datasource the driver rejects the whole statement ('no such column'), so the correctly named fields in this same payload never land either${node.type === "create_record" ? " and the record is never created at all" : ""}; on a schemaless one the stray key is persisted into a column no read surface returns.`,
|
|
5860
|
+
hint: fixHint3(fieldName, [...known])
|
|
5861
|
+
});
|
|
5862
|
+
}
|
|
5863
|
+
});
|
|
5864
|
+
});
|
|
5865
|
+
return findings;
|
|
5866
|
+
}
|
|
5867
|
+
function fixHint3(field, declared) {
|
|
5868
|
+
const suggestion = (0, import_shared3.formatSuggestion)((0, import_shared3.findClosestMatches)(field, [...declared, ...IMPLICIT_FIELDS2]));
|
|
5869
|
+
return (suggestion ? `${suggestion} ` : "") + `Fix the field name, or declare '${field}' on the object. This gates the build rather than warning: the key is literal and so is the object, so unlike the hook/action body rules there is nothing here that could have been mis-extracted.`;
|
|
5870
|
+
}
|
|
5871
|
+
|
|
5872
|
+
// src/reference-integrity-suite.ts
|
|
5873
|
+
var REFERENCE_INTEGRITY_RULES = [
|
|
5874
|
+
{ name: "validateObjectReferences", run: validateObjectReferences },
|
|
5875
|
+
{ name: "validateSearchableFields", run: validateSearchableFields },
|
|
5876
|
+
{ name: "validateActionNameRefs", run: validateActionNameRefs },
|
|
5877
|
+
{ name: "validatePageFieldBindings", run: validatePageFieldBindings },
|
|
5878
|
+
{ name: "validateChartBindings", run: validateChartBindings },
|
|
5879
|
+
{ name: "validateNavAccess", run: validateNavAccess },
|
|
5880
|
+
{ name: "validateTranslationReferences", run: validateTranslationReferences },
|
|
5881
|
+
{ name: "validateFlowTemplatePaths", run: validateFlowTemplatePaths },
|
|
5882
|
+
{ name: "validateAiSurfaceAffinity", run: validateAiSurfaceAffinity },
|
|
5883
|
+
{ name: "validateAiToolReferences", run: validateAiToolReferences },
|
|
5884
|
+
{ name: "validateAiAgentAuthoring", run: validateAiAgentAuthoring },
|
|
5885
|
+
// Field names WRITTEN by an L2 hook body (`ctx.input.x = …`,
|
|
5886
|
+
// `ctx.api.object('y').update({ x })`), resolved against the target object's
|
|
5887
|
+
// declared fields — the write-side counterpart of validateFlowTemplatePaths'
|
|
5888
|
+
// read-side membership (#4271). Lazy: only a hook that actually carries a
|
|
5889
|
+
// `language:'js'` body loads the TypeScript parser.
|
|
5890
|
+
{ name: "validateHookBodyWrites", run: validateHookBodyWrites },
|
|
5891
|
+
// The same check on the other surface that carries a `HookBodySchema` body:
|
|
5892
|
+
// action bodies, run by the same sandbox. Only the `ctx.api` write family
|
|
5893
|
+
// carries over — an action's `ctx.input` is its params bag, not a record
|
|
5894
|
+
// (see that module's ledger). Lazy on the same terms.
|
|
5895
|
+
//
|
|
5896
|
+
// The first member here to emit more than one rule id (`validateReactPageProps`
|
|
5897
|
+
// below is the other, and carries the most). Besides resolving `ctx.api`
|
|
5898
|
+
// writes against declared fields (`action-body-write-unknown-field`), it
|
|
5899
|
+
// reports a `ctx.record` write that can reach nothing
|
|
5900
|
+
// (`action-record-write-discarded`, #4345) — not a resolution question, so
|
|
5901
|
+
// by the charter above it does not belong in the suite. It rides along
|
|
5902
|
+
// anyway because it falls out of the SAME parse of the SAME source: a
|
|
5903
|
+
// separate member would parse every action body twice to say two things
|
|
5904
|
+
// about one walk, and hand-wiring it into the CLI instead is exactly the
|
|
5905
|
+
// drift this suite exists to end — which `validateReadonlyFlowWrites` was
|
|
5906
|
+
// the standing proof of, until it joined the suite below.
|
|
5907
|
+
{ name: "validateActionBodyWrites", run: validateActionBodyWrites },
|
|
5908
|
+
// The third surface that writes a record field set: a flow `update_record`
|
|
5909
|
+
// node's `config.fields`. Same question as the two rules above, but the map
|
|
5910
|
+
// is structural metadata rather than parsed JS, so a finding is a certainty
|
|
5911
|
+
// and gates (`error`) — see that module for why, and why the docs' long-
|
|
5912
|
+
// standing "prefer a flow node, it's checked" advice was the least true of
|
|
5913
|
+
// the three until it landed.
|
|
5914
|
+
{ name: "validateFlowNodeWrites", run: validateFlowNodeWrites },
|
|
5915
|
+
// The OTHER question about that same `config.fields` map: not "does this
|
|
5916
|
+
// field exist?" but "is it writable?" — a `runAs:'user'` update_record
|
|
5917
|
+
// writing a static-`readonly` field is stripped by the engine and the step
|
|
5918
|
+
// still reports success (#2948/#3425). It walks the identical map the rule
|
|
5919
|
+
// above walks, so the two splitting call sites was never defensible: hand-
|
|
5920
|
+
// wired into `validate` and `compile` only, it left `os lint` PASSING a flow
|
|
5921
|
+
// `os validate` refuses — and this one gates, so the divergence shipped a
|
|
5922
|
+
// build the other command would have stopped. Joining the suite is the whole
|
|
5923
|
+
// fix; the two hand-wired call sites are deleted with it (#4345 follow-up).
|
|
5924
|
+
{ name: "validateReadonlyFlowWrites", run: validateReadonlyFlowWrites },
|
|
5925
|
+
// The `kind:'react'` page surface. Every prop a react block binds BY FIELD
|
|
5926
|
+
// NAME is resolved against the object it names (#4340) — `<ListView columns>`,
|
|
5927
|
+
// `<ObjectForm fields>`, the `record:*` family through the SAME
|
|
5928
|
+
// `COMPONENT_FIELD_SPECS` table `validatePageFieldBindings` walks one surface
|
|
5929
|
+
// over, plus `<ObjectChart>`'s aggregate/axes (#3701/#3729) and
|
|
5930
|
+
// `searchableFields` (#4329). Squarely the charter's question, on the surface
|
|
5931
|
+
// where it had no answer at all.
|
|
5932
|
+
//
|
|
5933
|
+
// It was hand-wired into `os validate` ALONE, so `os lint` and `os compile`
|
|
5934
|
+
// accepted a react page whose every field binding was stale — including the
|
|
5935
|
+
// gating ones (a missing required binding, a filter position naming no field:
|
|
5936
|
+
// the predicate can never match and the list comes back empty). That is
|
|
5937
|
+
// `validateReadonlyFlowWrites`' divergence again, one surface over, and it is
|
|
5938
|
+
// the reason this entry exists rather than a fourth hand-wiring.
|
|
5939
|
+
//
|
|
5940
|
+
// Like `validateActionBodyWrites` above, it emits ids that are not resolution
|
|
5941
|
+
// questions — `react-prop-missing-required` and `react-prop-typo` are shape,
|
|
5942
|
+
// and by the charter belong outside. They ride along for the same reason: they
|
|
5943
|
+
// fall out of the SAME TypeScript parse of the SAME page source, and splitting
|
|
5944
|
+
// them into a second member would parse every react page twice to say two
|
|
5945
|
+
// things about one walk. Lazy on the same terms as the hook/action body rules
|
|
5946
|
+
// — only a page that is actually `kind:'react'` loads the compiler.
|
|
5947
|
+
{ name: "validateReactPageProps", run: validateReactPageProps }
|
|
5948
|
+
];
|
|
5949
|
+
function validateReferenceIntegrity(stack) {
|
|
5950
|
+
const findings = [];
|
|
5951
|
+
for (const rule of REFERENCE_INTEGRITY_RULES) {
|
|
5952
|
+
findings.push(...rule.run(stack));
|
|
5953
|
+
}
|
|
5954
|
+
return findings;
|
|
2422
5955
|
}
|
|
2423
5956
|
// Annotate the CommonJS export names for ESM import in node:
|
|
2424
5957
|
0 && (module.exports = {
|
|
5958
|
+
ACTION_BODY_WRITE_EXCLUSIONS,
|
|
5959
|
+
ACTION_BODY_WRITE_PATTERNS,
|
|
5960
|
+
ACTION_BODY_WRITE_PATTERN_IDS,
|
|
5961
|
+
ACTION_BODY_WRITE_UNKNOWN_FIELD,
|
|
5962
|
+
ACTION_NAME_UNDEFINED,
|
|
5963
|
+
ACTION_RECORD_WRITE_DISCARDED,
|
|
5964
|
+
ACTION_RECORD_WRITE_PATTERNS,
|
|
5965
|
+
ACTION_RECORD_WRITE_PATTERN_IDS,
|
|
5966
|
+
AGENT_AUTHORING_WITHDRAWN,
|
|
5967
|
+
AI_SKILL_SURFACE_MISMATCH,
|
|
5968
|
+
AI_SKILL_TOOL_UNRESOLVED,
|
|
5969
|
+
APPROVAL_APPROVERS_MAY_RESOLVE_EMPTY,
|
|
5970
|
+
APPROVAL_APPROVER_CROSS_ORG_UNSUPPORTED,
|
|
2425
5971
|
APPROVAL_APPROVER_NOT_MEMBERSHIP_TIER,
|
|
2426
5972
|
APPROVAL_APPROVER_TYPE_DEPRECATED,
|
|
2427
5973
|
APPROVAL_APPROVER_TYPE_UNKNOWN,
|
|
5974
|
+
APPROVAL_DECISION_OUTPUTS_RESERVED,
|
|
2428
5975
|
APPROVAL_ESCALATION_REASSIGN_NO_TARGET,
|
|
5976
|
+
APPROVAL_EXPRESSION_INVALID,
|
|
5977
|
+
APPROVAL_EXPRESSION_NO_EMPTY_POLICY,
|
|
2429
5978
|
CAPABILITY_REFERENCE_UNKNOWN,
|
|
5979
|
+
CHART_AXIS_NOT_SELECTED,
|
|
2430
5980
|
CHART_CONFIG_MISSING,
|
|
5981
|
+
CHART_DATASET_UNKNOWN,
|
|
5982
|
+
CHART_DIMENSION_UNKNOWN,
|
|
2431
5983
|
CHART_FIELD_UNKNOWN,
|
|
5984
|
+
CHART_MEASURE_UNKNOWN,
|
|
2432
5985
|
DASHBOARD_ACTION_ROUTE_UNRESOLVED,
|
|
2433
5986
|
DASHBOARD_ACTION_TARGET_UNDEFINED,
|
|
2434
5987
|
DASHBOARD_FILTER_FIELD_UNKNOWN,
|
|
2435
5988
|
FIELD_GROUP_EMPTY,
|
|
2436
5989
|
FIELD_GROUP_UNDECLARED,
|
|
5990
|
+
FILTER_TOKEN_UNKNOWN,
|
|
2437
5991
|
FLOW_DRAFT_STATUS_AMBIGUOUS,
|
|
5992
|
+
FLOW_NODE_WRITE_UNKNOWN_FIELD,
|
|
5993
|
+
FLOW_TEMPLATE_LOOKUP_TRAVERSAL,
|
|
5994
|
+
FLOW_TEMPLATE_UNKNOWN_FIELD,
|
|
2438
5995
|
FLOW_TRIGGER_UNKNOWN_OBJECT,
|
|
5996
|
+
FLOW_UPDATE_READONLY_FIELD,
|
|
5997
|
+
FLOW_UPDATE_READONLY_WHEN_FIELD,
|
|
5998
|
+
FLOW_WRITE_NODE_TYPES,
|
|
5999
|
+
FLOW_WRITE_NODE_TYPES_DEFERRED,
|
|
2439
6000
|
FORM_COLSPAN_ABSOLUTE,
|
|
2440
6001
|
FORM_FIELD_UNKNOWN,
|
|
6002
|
+
HOOK_BODY_WRITE_EXCLUSIONS,
|
|
6003
|
+
HOOK_BODY_WRITE_PATTERNS,
|
|
6004
|
+
HOOK_BODY_WRITE_PATTERN_IDS,
|
|
6005
|
+
HOOK_BODY_WRITE_UNKNOWN_FIELD,
|
|
2441
6006
|
LIST_VIEW_FILTERS_IN_VIEWS_MODE,
|
|
2442
6007
|
MEASURE_AGGREGATE_INCOHERENT,
|
|
6008
|
+
NAV_OBJECT_UNGRANTED,
|
|
6009
|
+
OBJECT_REFERENCE_UNKNOWN,
|
|
6010
|
+
OBJECT_REFERENCE_UNREGISTERED_PLATFORM,
|
|
6011
|
+
ORG_AXIS_CROSS_ORG_BU_GRANT,
|
|
6012
|
+
ORG_AXIS_PERMISSION_INHERITANCE,
|
|
6013
|
+
PAGE_FIELD_UNKNOWN,
|
|
2443
6014
|
PAGE_SOURCE_CLASSNAME,
|
|
6015
|
+
REACT_CHART_AGGREGATE_INVALID,
|
|
6016
|
+
REACT_CHART_AXIS_UNKNOWN,
|
|
6017
|
+
REACT_CHART_FIELD_UNKNOWN,
|
|
6018
|
+
REFERENCE_INTEGRITY_RULES,
|
|
6019
|
+
SEARCHABLE_FIELD_UNKNOWN,
|
|
2444
6020
|
SECURITY_ANCHOR_HIGH_PRIVILEGE,
|
|
2445
6021
|
SECURITY_BOOK_AUDIENCE_UNKNOWN_SET,
|
|
2446
6022
|
SECURITY_DELEGATION_MISSING_REASON,
|
|
@@ -2452,6 +6028,8 @@ function diffAccessMatrix(before, after) {
|
|
|
2452
6028
|
SECURITY_PRIVATE_NO_READSCOPE,
|
|
2453
6029
|
SECURITY_ROLE_WORD,
|
|
2454
6030
|
SECURITY_WILDCARD_VAMA,
|
|
6031
|
+
SEED_INSERT_MODE_DUPLICATES_ON_REPLAY,
|
|
6032
|
+
SEED_VALUE_OUTSIDE_STATE_MACHINE,
|
|
2455
6033
|
SEMANTIC_ROLE_FIELD_UNKNOWN,
|
|
2456
6034
|
STYLE_CLASSNAME_TAILWIND,
|
|
2457
6035
|
STYLE_NODE_MISSING_ID,
|
|
@@ -2461,6 +6039,8 @@ function diffAccessMatrix(before, after) {
|
|
|
2461
6039
|
TABLE_COUNT_ONLY,
|
|
2462
6040
|
TITLE_FORMAT_RETIRED,
|
|
2463
6041
|
TITLE_UNRESOLVABLE,
|
|
6042
|
+
TRANSLATION_OPTION_KEY_UNKNOWN,
|
|
6043
|
+
TRANSLATION_TARGET_UNKNOWN,
|
|
2464
6044
|
VIEW_CONTAINER_SHAPE,
|
|
2465
6045
|
VISIBILITY_ALIAS_DEPRECATED,
|
|
2466
6046
|
VISIBILITY_ROOT_MISLAYERED,
|
|
@@ -2469,21 +6049,43 @@ function diffAccessMatrix(before, after) {
|
|
|
2469
6049
|
WIDGET_MEASURE_UNKNOWN,
|
|
2470
6050
|
buildAccessMatrix,
|
|
2471
6051
|
diffAccessMatrix,
|
|
6052
|
+
extractHookBodyWriteSet,
|
|
6053
|
+
extractHookBodyWrites,
|
|
6054
|
+
validateActionBodyWrites,
|
|
6055
|
+
validateActionNameRefs,
|
|
6056
|
+
validateAiAgentAuthoring,
|
|
6057
|
+
validateAiSurfaceAffinity,
|
|
6058
|
+
validateAiToolReferences,
|
|
2472
6059
|
validateApprovalApprovers,
|
|
2473
6060
|
validateCapabilityReferences,
|
|
6061
|
+
validateChartBindings,
|
|
2474
6062
|
validateDashboardActionRefs,
|
|
6063
|
+
validateFilterTokens,
|
|
6064
|
+
validateFlowNodeWrites,
|
|
6065
|
+
validateFlowTemplatePaths,
|
|
2475
6066
|
validateFlowTriggerReadiness,
|
|
2476
6067
|
validateFormLayout,
|
|
6068
|
+
validateHookBodyWrites,
|
|
2477
6069
|
validateJsxPages,
|
|
2478
6070
|
validateListViewMode,
|
|
6071
|
+
validateNavAccess,
|
|
6072
|
+
validateObjectReferences,
|
|
6073
|
+
validateOrgAxisRedLines,
|
|
6074
|
+
validatePageFieldBindings,
|
|
2479
6075
|
validatePageSourceStyling,
|
|
2480
6076
|
validateReactPageProps,
|
|
2481
6077
|
validateReactPages,
|
|
6078
|
+
validateReadonlyFlowWrites,
|
|
2482
6079
|
validateRecordTitle,
|
|
6080
|
+
validateReferenceIntegrity,
|
|
2483
6081
|
validateResponsiveStyles,
|
|
6082
|
+
validateSearchableFields,
|
|
2484
6083
|
validateSecurityPosture,
|
|
6084
|
+
validateSeedReplaySafety,
|
|
6085
|
+
validateSeedStateMachine,
|
|
2485
6086
|
validateSemanticRoles,
|
|
2486
6087
|
validateStackExpressions,
|
|
6088
|
+
validateTranslationReferences,
|
|
2487
6089
|
validateViewContainers,
|
|
2488
6090
|
validateVisibilityPredicates,
|
|
2489
6091
|
validateWidgetBindings
|