@objectstack/lint 17.0.0-rc.2 → 17.0.0-rc.4
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 +1929 -0
- package/dist/index.cjs +1651 -459
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +527 -155
- package/dist/index.d.ts +527 -155
- package/dist/index.js +1601 -440
- package/dist/index.js.map +1 -1
- package/dist/runtime.cjs +1466 -350
- package/dist/runtime.cjs.map +1 -1
- package/dist/runtime.js +1449 -328
- package/dist/runtime.js.map +1 -1
- package/package.json +8 -6
package/dist/runtime.js
CHANGED
|
@@ -1,19 +1,23 @@
|
|
|
1
1
|
// src/validate-expressions.ts
|
|
2
|
-
import { validateExpression } from "@objectstack/formula";
|
|
2
|
+
import { validateExpression, collectCelRootIdentifiers } from "@objectstack/formula";
|
|
3
3
|
import { collectFlowGraphs, resolveFlowNodeExpressions } from "@objectstack/spec/automation";
|
|
4
4
|
|
|
5
|
+
// src/system-fields.ts
|
|
6
|
+
import { FIELD_GROUP_SYSTEM_FIELDS, resolveInjectedSystemColumns } from "@objectstack/spec/data";
|
|
7
|
+
import { SystemFieldName } from "@objectstack/spec/system";
|
|
8
|
+
var SYSTEM_FIELDS = /* @__PURE__ */ new Set([
|
|
9
|
+
...FIELD_GROUP_SYSTEM_FIELDS,
|
|
10
|
+
...Object.values(SystemFieldName)
|
|
11
|
+
]);
|
|
12
|
+
function injectedColumnsFor(objectDef) {
|
|
13
|
+
return resolveInjectedSystemColumns(objectDef).names;
|
|
14
|
+
}
|
|
15
|
+
|
|
5
16
|
// src/validate-null-guards.ts
|
|
6
|
-
import {
|
|
17
|
+
import { parseCelToAst } from "@objectstack/formula";
|
|
7
18
|
var NULL_GUARD_HINT = `Guard it with '!= null' \u2014 'has(x)' does NOT do that: a declared field holding null is still PRESENT, so has(x) is true.`;
|
|
8
19
|
var FAULTING_BINARY_OPS = /* @__PURE__ */ new Set(["<", "<=", ">", ">=", "+", "-", "*", "/", "%"]);
|
|
9
20
|
var DEFAULT_RECORD_ROOTS = ["record", "previous"];
|
|
10
|
-
var parseEnv;
|
|
11
|
-
function getParseEnv() {
|
|
12
|
-
if (!parseEnv) {
|
|
13
|
-
parseEnv = new Environment({ unlistedVariablesAreDyn: true, enableOptionalTypes: true });
|
|
14
|
-
}
|
|
15
|
-
return parseEnv;
|
|
16
|
-
}
|
|
17
21
|
function isNode(v) {
|
|
18
22
|
return !!v && typeof v === "object" && typeof v.op === "string";
|
|
19
23
|
}
|
|
@@ -146,12 +150,8 @@ function findUnguardedNullableOperands(source, opts) {
|
|
|
146
150
|
if (typeof source !== "string" || !source.trim()) return [];
|
|
147
151
|
if (opts.nullableFields.size === 0) return [];
|
|
148
152
|
const roots = opts.roots ?? DEFAULT_RECORD_ROOTS;
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
ast = getParseEnv().parse(source).ast;
|
|
152
|
-
} catch {
|
|
153
|
-
return [];
|
|
154
|
-
}
|
|
153
|
+
const ast = parseCelToAst(source);
|
|
154
|
+
if (!ast) return [];
|
|
155
155
|
const hasOperands = /* @__PURE__ */ new Set();
|
|
156
156
|
collectHasOperands(ast, roots, hasOperands);
|
|
157
157
|
const findings = [];
|
|
@@ -214,10 +214,14 @@ function findUnguardedNullableOperands(source, opts) {
|
|
|
214
214
|
visit(ast, /* @__PURE__ */ new Set());
|
|
215
215
|
return findings;
|
|
216
216
|
}
|
|
217
|
-
|
|
217
|
+
var OUTCOME_CLAUSE = {
|
|
218
|
+
"fail-closed": "so the rule enforces nothing and the write is rejected fail-closed (#4649/#4763)",
|
|
219
|
+
"fail-open": "so the predicate is SKIPPED fail-open \u2014 the field is never actually required, the write proceeds unchecked, and the only trace is a `requiredWhen \u2026 failed to evaluate \u2014 skipped` log line (#4649/#4811)"
|
|
220
|
+
};
|
|
221
|
+
function nullGuardMessage(subject, objectName, finding, outcome = "fail-closed") {
|
|
218
222
|
const owner = objectName ? `'${objectName}'` : "this object";
|
|
219
223
|
const hasNote = finding.hasOnlyGuard ? ` \`has(${finding.operand})\` does not guard it.` : "";
|
|
220
|
-
return `${subject} applies \`${finding.operator}\` to \`${finding.operand}\`, which ${owner} declares as nullable (no \`required: true\`, no \`defaultValue\`).${hasNote} At runtime the operand is null, CEL has no \`${finding.operator}\` overload for null, and the whole predicate aborts \u2014
|
|
224
|
+
return `${subject} applies \`${finding.operator}\` to \`${finding.operand}\`, which ${owner} declares as nullable (no \`required: true\`, no \`defaultValue\`).${hasNote} At runtime the operand is null, CEL has no \`${finding.operator}\` overload for null, and the whole predicate aborts \u2014 ${OUTCOME_CLAUSE[outcome]}. The predicate compares a value that is null. ${NULL_GUARD_HINT}`;
|
|
221
225
|
}
|
|
222
226
|
|
|
223
227
|
// src/validate-expressions.ts
|
|
@@ -237,7 +241,7 @@ function buildFieldIndex(objects) {
|
|
|
237
241
|
let names = [];
|
|
238
242
|
if (Array.isArray(fields)) names = fields.map((f) => f.name).filter((n) => typeof n === "string");
|
|
239
243
|
else if (fields && typeof fields === "object") names = Object.keys(fields);
|
|
240
|
-
idx.set(name, names);
|
|
244
|
+
idx.set(name, [.../* @__PURE__ */ new Set([...names, ...injectedColumnsFor(obj)])]);
|
|
241
245
|
}
|
|
242
246
|
return idx;
|
|
243
247
|
}
|
|
@@ -297,6 +301,19 @@ function buildNullableFieldIndex(objects) {
|
|
|
297
301
|
}
|
|
298
302
|
return idx;
|
|
299
303
|
}
|
|
304
|
+
function readsParentRoot(source) {
|
|
305
|
+
const roots = collectCelRootIdentifiers(source);
|
|
306
|
+
return roots.ok && roots.roots.includes("parent");
|
|
307
|
+
}
|
|
308
|
+
function masterDetailCount(obj) {
|
|
309
|
+
let n = 0;
|
|
310
|
+
for (const [, def] of fieldEntries(obj)) {
|
|
311
|
+
if (def.type !== "master_detail") continue;
|
|
312
|
+
const ref = def.reference;
|
|
313
|
+
if (typeof ref === "string" && ref.trim() !== "") n += 1;
|
|
314
|
+
}
|
|
315
|
+
return n;
|
|
316
|
+
}
|
|
300
317
|
function celSourceOf(raw) {
|
|
301
318
|
if (typeof raw === "string") return raw;
|
|
302
319
|
if (raw && typeof raw === "object") {
|
|
@@ -310,7 +327,7 @@ function rulePredicates(rule, path) {
|
|
|
310
327
|
const out = [];
|
|
311
328
|
const name = typeof rule.name === "string" ? rule.name : "?";
|
|
312
329
|
const here = path ? `${path} \u2192 '${name}'` : `'${name}'`;
|
|
313
|
-
const main = rule.
|
|
330
|
+
const main = rule.condition;
|
|
314
331
|
if (main != null) out.push({ label: `validation rule ${here}`, raw: main });
|
|
315
332
|
if (rule.when != null) out.push({ label: `validation rule ${here} when-predicate`, raw: rule.when });
|
|
316
333
|
for (const branch of ["then", "otherwise"]) {
|
|
@@ -327,7 +344,7 @@ function validateStackExpressions(stack) {
|
|
|
327
344
|
const fieldIndex = buildFieldIndex(objects);
|
|
328
345
|
const fieldTypeIndex = buildFieldTypeIndex(objects);
|
|
329
346
|
const nullableIndex = buildNullableFieldIndex(objects);
|
|
330
|
-
const checkNullGuards = (where, subject, raw, objectName) => {
|
|
347
|
+
const checkNullGuards = (where, subject, raw, objectName, outcome = "fail-closed") => {
|
|
331
348
|
if (!objectName) return;
|
|
332
349
|
const nullableFields = nullableIndex.get(objectName);
|
|
333
350
|
if (!nullableFields || nullableFields.size === 0) return;
|
|
@@ -336,7 +353,7 @@ function validateStackExpressions(stack) {
|
|
|
336
353
|
for (const finding of findUnguardedNullableOperands(source, { nullableFields })) {
|
|
337
354
|
issues.push({
|
|
338
355
|
where,
|
|
339
|
-
message: nullGuardMessage(subject, objectName, finding),
|
|
356
|
+
message: nullGuardMessage(subject, objectName, finding, outcome),
|
|
340
357
|
source,
|
|
341
358
|
severity: "error"
|
|
342
359
|
});
|
|
@@ -405,31 +422,45 @@ function validateStackExpressions(stack) {
|
|
|
405
422
|
}
|
|
406
423
|
for (const obj of objects) {
|
|
407
424
|
const objectName = typeof obj.name === "string" ? obj.name : void 0;
|
|
408
|
-
const validations = obj.validations
|
|
425
|
+
const validations = obj.validations;
|
|
409
426
|
for (const rule of asArray(validations)) {
|
|
410
427
|
const where = `object '${objectName}' \xB7 validation '${rule.name ?? "?"}'`;
|
|
411
|
-
check(where, rule.
|
|
428
|
+
check(where, rule.condition, objectName, "record");
|
|
412
429
|
check(`${where} when`, rule.when, objectName, "record");
|
|
413
430
|
for (const p of rulePredicates(rule, "")) {
|
|
414
431
|
checkNullGuards(`object '${objectName}' \xB7 ${p.label}`, p.label, p.raw, objectName);
|
|
415
432
|
}
|
|
416
433
|
}
|
|
417
434
|
const fields = obj.fields;
|
|
418
|
-
const fieldList = Array.isArray(fields) ? fields : fields && typeof fields === "object" ? Object.
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
435
|
+
const fieldList = Array.isArray(fields) ? fields.filter((f) => !!f && typeof f === "object").map((f) => [typeof f.name === "string" ? f.name : "?", f]) : fields && typeof fields === "object" ? Object.entries(fields).filter(([, def]) => !!def && typeof def === "object").map(([n, def]) => [n, def]) : [];
|
|
436
|
+
const masters = masterDetailCount(obj);
|
|
437
|
+
for (const [fname, f] of fieldList) {
|
|
438
|
+
for (const key of ["requiredWhen", "readonlyWhen", "conditionalRequired", "visibleWhen"]) {
|
|
439
|
+
check(`object '${objectName}' \xB7 field '${fname}' ${key}`, f[key], objectName, "record");
|
|
440
|
+
}
|
|
441
|
+
const roWhenSource = celSourceOf(f.readonlyWhen);
|
|
442
|
+
if (masters !== 1 && roWhenSource && readsParentRoot(roWhenSource)) {
|
|
443
|
+
issues.push({
|
|
444
|
+
where: `object '${objectName}' \xB7 field '${fname}' readonlyWhen`,
|
|
445
|
+
message: `\`readonlyWhen\` reads \`parent\`, but object '${objectName}' declares ${masters === 0 ? "no" : `${masters}`} \`master_detail\` relationship${masters === 1 ? "" : "s"} \u2014 so the server has no header record to bind as \`parent\` and the field would be locked on every write. ` + (masters === 0 ? `Declare the owning relationship as \`Field.masterDetail('<master>')\`, or rewrite the predicate against \`record\`.` : `\`parent\` needs exactly one master; name the header explicitly through \`record.<fk>\` state instead, or model the extra relationship as a \`lookup\`.`),
|
|
446
|
+
source: roWhenSource,
|
|
447
|
+
severity: "error"
|
|
448
|
+
});
|
|
425
449
|
}
|
|
426
|
-
|
|
450
|
+
checkNullGuards(
|
|
451
|
+
`object '${objectName}' \xB7 field '${fname}' requiredWhen`,
|
|
452
|
+
`field '${fname}' requiredWhen`,
|
|
453
|
+
f.requiredWhen,
|
|
454
|
+
objectName,
|
|
455
|
+
"fail-open"
|
|
456
|
+
);
|
|
457
|
+
if (f.expression) {
|
|
427
458
|
const res = validateExpression(
|
|
428
459
|
"value",
|
|
429
|
-
f.
|
|
460
|
+
f.expression,
|
|
430
461
|
objectName ? { objectName, fields: fieldIndex.get(objectName), fieldTypes: fieldTypeIndex.get(objectName), scope: "record" } : { scope: "record" }
|
|
431
462
|
);
|
|
432
|
-
const fieldWhere = `object '${objectName}' \xB7 field '${
|
|
463
|
+
const fieldWhere = `object '${objectName}' \xB7 field '${fname}' expression`;
|
|
433
464
|
for (const e of res.errors) issues.push({ where: fieldWhere, message: e.message, source: e.source, severity: "error" });
|
|
434
465
|
for (const w of res.warnings) issues.push({ where: fieldWhere, message: w.message, source: w.source, severity: "warning" });
|
|
435
466
|
}
|
|
@@ -437,7 +468,7 @@ function validateStackExpressions(stack) {
|
|
|
437
468
|
}
|
|
438
469
|
const seenActions = /* @__PURE__ */ new Set();
|
|
439
470
|
const checkAction = (where, action, objectName) => {
|
|
440
|
-
const obj = objectName ?? (typeof action.objectName === "string" ? action.objectName : void 0)
|
|
471
|
+
const obj = objectName ?? (typeof action.objectName === "string" ? action.objectName : void 0);
|
|
441
472
|
const name = typeof action.name === "string" ? action.name : "?";
|
|
442
473
|
const key = `${obj ?? ""}:${name}`;
|
|
443
474
|
if (seenActions.has(key)) return;
|
|
@@ -456,10 +487,10 @@ function validateStackExpressions(stack) {
|
|
|
456
487
|
checkAction(`object '${objectName}'`, action, objectName);
|
|
457
488
|
}
|
|
458
489
|
}
|
|
459
|
-
for (const
|
|
460
|
-
const ruleObj = typeof
|
|
461
|
-
const where = `sharingRule '${
|
|
462
|
-
check(where,
|
|
490
|
+
for (const sharingRule of asArray(stack.sharingRules)) {
|
|
491
|
+
const ruleObj = typeof sharingRule.object === "string" ? sharingRule.object : void 0;
|
|
492
|
+
const where = `sharingRule '${sharingRule.name ?? "?"}'${ruleObj ? ` (${ruleObj})` : ""} condition`;
|
|
493
|
+
check(where, sharingRule.condition, ruleObj, "record");
|
|
463
494
|
}
|
|
464
495
|
for (const hook of asArray(stack.hooks)) {
|
|
465
496
|
const hookName = hook.name ?? "?";
|
|
@@ -681,16 +712,6 @@ function validateViewContainers(stack) {
|
|
|
681
712
|
// src/validate-widget-bindings.ts
|
|
682
713
|
import { isIncoherentAggregate } from "@objectstack/spec/data";
|
|
683
714
|
import { ChartTypeSchema } from "@objectstack/spec/ui";
|
|
684
|
-
|
|
685
|
-
// src/system-fields.ts
|
|
686
|
-
import { FIELD_GROUP_SYSTEM_FIELDS } from "@objectstack/spec/data";
|
|
687
|
-
import { SystemFieldName } from "@objectstack/spec/system";
|
|
688
|
-
var SYSTEM_FIELDS = /* @__PURE__ */ new Set([
|
|
689
|
-
...FIELD_GROUP_SYSTEM_FIELDS,
|
|
690
|
-
...Object.values(SystemFieldName)
|
|
691
|
-
]);
|
|
692
|
-
|
|
693
|
-
// src/validate-widget-bindings.ts
|
|
694
715
|
var WIDGET_DATASET_UNKNOWN = "widget-dataset-unknown";
|
|
695
716
|
var WIDGET_DIMENSION_UNKNOWN = "widget-dimension-unknown";
|
|
696
717
|
var WIDGET_MEASURE_UNKNOWN = "widget-measure-unknown";
|
|
@@ -1144,18 +1165,6 @@ function validateDashboardActionRefs(stack) {
|
|
|
1144
1165
|
`${dashPath}.header.actions[${ai}].actionUrl`
|
|
1145
1166
|
);
|
|
1146
1167
|
}
|
|
1147
|
-
const widgets = asArray4(dash.widgets);
|
|
1148
|
-
for (let wi = 0; wi < widgets.length; wi++) {
|
|
1149
|
-
const widget = widgets[wi];
|
|
1150
|
-
if (!widget || typeof widget !== "object") continue;
|
|
1151
|
-
if (!strName(widget.actionUrl)) continue;
|
|
1152
|
-
const widgetId = strName(widget.id) ?? `#${wi}`;
|
|
1153
|
-
checkOne(
|
|
1154
|
-
{ actionType: widget.actionType, actionUrl: widget.actionUrl },
|
|
1155
|
-
`dashboard "${dashName}" \xB7 widget "${widgetId}" action`,
|
|
1156
|
-
`${dashPath}.widgets[${wi}].actionUrl`
|
|
1157
|
-
);
|
|
1158
|
-
}
|
|
1159
1168
|
}
|
|
1160
1169
|
return findings;
|
|
1161
1170
|
}
|
|
@@ -1654,13 +1663,13 @@ function validateSearchableFields(stack) {
|
|
|
1654
1663
|
for (let vi = 0; vi < views.length; vi++) {
|
|
1655
1664
|
const view = views[vi];
|
|
1656
1665
|
if (!isRec2(view)) continue;
|
|
1657
|
-
const
|
|
1666
|
+
const viewLabel2 = strName3(view.name) ?? strName3(view.objectName) ?? `#${vi}`;
|
|
1658
1667
|
const viewObject = strName3(view.objectName) ?? strName3(view.object);
|
|
1659
1668
|
if (isRec2(view.list)) {
|
|
1660
1669
|
check(
|
|
1661
1670
|
view.list.searchableFields,
|
|
1662
1671
|
listViewObject(view.list) ?? viewObject,
|
|
1663
|
-
`view "${
|
|
1672
|
+
`view "${viewLabel2}" \u203A list`,
|
|
1664
1673
|
`views[${vi}].list.searchableFields`,
|
|
1665
1674
|
"list-view searchableFields",
|
|
1666
1675
|
"narrowing"
|
|
@@ -1672,7 +1681,7 @@ function validateSearchableFields(stack) {
|
|
|
1672
1681
|
check(
|
|
1673
1682
|
lv.searchableFields,
|
|
1674
1683
|
listViewObject(lv) ?? viewObject,
|
|
1675
|
-
`view "${
|
|
1684
|
+
`view "${viewLabel2}" \u203A listViews.${key}`,
|
|
1676
1685
|
`views[${vi}].listViews.${key}.searchableFields`,
|
|
1677
1686
|
"list-view searchableFields",
|
|
1678
1687
|
"narrowing"
|
|
@@ -1980,8 +1989,11 @@ function sortFieldRefs(value, basePath) {
|
|
|
1980
1989
|
}
|
|
1981
1990
|
var COMPONENT_FIELD_SPECS = {
|
|
1982
1991
|
"record:highlights": { props: ["fields"] },
|
|
1983
|
-
// `sections
|
|
1984
|
-
//
|
|
1992
|
+
// `sections` (object form) and `hideFields` are what every real page authors,
|
|
1993
|
+
// and since #5611 they are what `RecordDetailsProps` declares — this model and
|
|
1994
|
+
// the spec agree. (Before that, `sections` was declared as an ID `string[]`
|
|
1995
|
+
// and `hideFields` not at all; both survived only because `properties` is
|
|
1996
|
+
// unvalidated.)
|
|
1985
1997
|
"record:details": { props: ["fields", "hideFields"], nestedSections: ["sections"] },
|
|
1986
1998
|
"record:path": { props: ["statusField"] },
|
|
1987
1999
|
"element:number": { props: ["field"] },
|
|
@@ -2044,7 +2056,7 @@ function indexObjectFields(stack) {
|
|
|
2044
2056
|
}
|
|
2045
2057
|
return objectFields;
|
|
2046
2058
|
}
|
|
2047
|
-
function checkFieldRefs(refs, objectName, objectFields, where,
|
|
2059
|
+
function checkFieldRefs(refs, objectName, objectFields, where, consequence2 = "skipped") {
|
|
2048
2060
|
const findings = [];
|
|
2049
2061
|
if (!objectName) return findings;
|
|
2050
2062
|
const known = objectFields.get(objectName);
|
|
@@ -2053,11 +2065,11 @@ function checkFieldRefs(refs, objectName, objectFields, where, consequence = "sk
|
|
|
2053
2065
|
if (ref.name.includes(".")) continue;
|
|
2054
2066
|
if (known.has(ref.name) || SYSTEM_FIELDS.has(ref.name)) continue;
|
|
2055
2067
|
findings.push({
|
|
2056
|
-
severity:
|
|
2068
|
+
severity: consequence2 === "queried" ? "error" : "warning",
|
|
2057
2069
|
rule: PAGE_FIELD_UNKNOWN,
|
|
2058
2070
|
where,
|
|
2059
2071
|
path: ref.path,
|
|
2060
|
-
message: `field "${ref.name}" is not a field on object "${objectName}" \u2014 ` + (
|
|
2072
|
+
message: `field "${ref.name}" is not a field on object "${objectName}" \u2014 ` + (consequence2 === "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."),
|
|
2061
2073
|
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(", ")}.` : "")
|
|
2062
2074
|
});
|
|
2063
2075
|
}
|
|
@@ -2626,6 +2638,13 @@ function collectViewRecord(view, factsFor) {
|
|
|
2626
2638
|
const addView = (objectName, name) => {
|
|
2627
2639
|
if (objectName && name) factsFor(objectName).views.add(name);
|
|
2628
2640
|
};
|
|
2641
|
+
const addSections = (container, binding) => {
|
|
2642
|
+
if (!binding) return;
|
|
2643
|
+
for (const section of asArray14(container.sections)) {
|
|
2644
|
+
const sectionName = strName10(section.name);
|
|
2645
|
+
if (sectionName) factsFor(binding).sections.add(sectionName);
|
|
2646
|
+
}
|
|
2647
|
+
};
|
|
2629
2648
|
const listBinding = isRec7(view.list) ? bindingOf(view.list) : void 0;
|
|
2630
2649
|
if (isRec7(view.list)) addView(listBinding, strName10(view.list.name));
|
|
2631
2650
|
addView(recordObject ?? listBinding, strName10(view.name));
|
|
@@ -2637,21 +2656,11 @@ function collectViewRecord(view, factsFor) {
|
|
|
2637
2656
|
const binding = bindingOf(sub) ?? listBinding;
|
|
2638
2657
|
addView(binding, subKey);
|
|
2639
2658
|
addView(binding, strName10(sub.name));
|
|
2640
|
-
|
|
2641
|
-
for (const section of asArray14(sub.sections)) {
|
|
2642
|
-
const sectionName = strName10(section.name);
|
|
2643
|
-
if (sectionName) factsFor(binding).sections.add(sectionName);
|
|
2644
|
-
}
|
|
2645
|
-
}
|
|
2646
|
-
}
|
|
2647
|
-
}
|
|
2648
|
-
const sectionBinding = recordObject ?? listBinding;
|
|
2649
|
-
if (sectionBinding) {
|
|
2650
|
-
for (const section of asArray14(view.sections)) {
|
|
2651
|
-
const sectionName = strName10(section.name);
|
|
2652
|
-
if (sectionName) factsFor(sectionBinding).sections.add(sectionName);
|
|
2659
|
+
addSections(sub, binding);
|
|
2653
2660
|
}
|
|
2654
2661
|
}
|
|
2662
|
+
if (isRec7(view.form)) addSections(view.form, bindingOf(view.form) ?? listBinding);
|
|
2663
|
+
addSections(view, recordObject ?? listBinding);
|
|
2655
2664
|
}
|
|
2656
2665
|
function viewObjectName(view) {
|
|
2657
2666
|
return strName10(view.objectName) ?? strName10(view.object) ?? (isRec7(view.data) ? strName10(view.data.object) : void 0);
|
|
@@ -3016,24 +3025,169 @@ function checkActionParams(findings, ctx) {
|
|
|
3016
3025
|
}
|
|
3017
3026
|
}
|
|
3018
3027
|
|
|
3019
|
-
// src/
|
|
3020
|
-
|
|
3028
|
+
// src/validate-translatable-sections.ts
|
|
3029
|
+
var TRANSLATION_SECTION_NAME_MISSING = "translation-section-name-missing";
|
|
3021
3030
|
function isRec8(v) {
|
|
3022
3031
|
return !!v && typeof v === "object" && !Array.isArray(v);
|
|
3023
3032
|
}
|
|
3024
3033
|
function strName11(v) {
|
|
3025
3034
|
return typeof v === "string" && v.length > 0 ? v : void 0;
|
|
3026
3035
|
}
|
|
3036
|
+
function viewObjectName2(view) {
|
|
3037
|
+
return strName11(view.objectName) ?? strName11(view.object) ?? (isRec8(view.data) ? strName11(view.data.object) : void 0);
|
|
3038
|
+
}
|
|
3039
|
+
function collectionEntries(v, base) {
|
|
3040
|
+
if (Array.isArray(v)) {
|
|
3041
|
+
const out = [];
|
|
3042
|
+
for (let i = 0; i < v.length; i++) {
|
|
3043
|
+
if (isRec8(v[i])) out.push({ rec: v[i], path: `${base}[${i}]` });
|
|
3044
|
+
}
|
|
3045
|
+
return out;
|
|
3046
|
+
}
|
|
3047
|
+
if (isRec8(v)) {
|
|
3048
|
+
return Object.entries(v).filter(([, def]) => isRec8(def)).map(([name, def]) => ({ rec: { name, ...def }, path: `${base}.${name}` }));
|
|
3049
|
+
}
|
|
3050
|
+
return [];
|
|
3051
|
+
}
|
|
3052
|
+
function viewLabel(view) {
|
|
3053
|
+
const name = strName11(view.name);
|
|
3054
|
+
return name ? `view "${name}"` : "";
|
|
3055
|
+
}
|
|
3056
|
+
function joinWhere(...parts) {
|
|
3057
|
+
return parts.filter((p) => p.length > 0).join(" \xB7 ");
|
|
3058
|
+
}
|
|
3059
|
+
function collectViewSites(view, basePath, label2, sites) {
|
|
3060
|
+
const recordObject = viewObjectName2(view);
|
|
3061
|
+
const listBinding = isRec8(view.list) ? viewObjectName2(view.list) ?? recordObject : void 0;
|
|
3062
|
+
const bindingOf = (container) => viewObjectName2(container) ?? recordObject;
|
|
3063
|
+
sites.push({
|
|
3064
|
+
path: `${basePath}.sections`,
|
|
3065
|
+
surface: label2,
|
|
3066
|
+
objectName: recordObject ?? listBinding,
|
|
3067
|
+
sections: view.sections
|
|
3068
|
+
});
|
|
3069
|
+
if (isRec8(view.form)) {
|
|
3070
|
+
sites.push({
|
|
3071
|
+
path: `${basePath}.form.sections`,
|
|
3072
|
+
surface: joinWhere(label2, "form"),
|
|
3073
|
+
objectName: bindingOf(view.form) ?? listBinding,
|
|
3074
|
+
sections: view.form.sections
|
|
3075
|
+
});
|
|
3076
|
+
}
|
|
3077
|
+
for (const key of ["listViews", "formViews"]) {
|
|
3078
|
+
const container = view[key];
|
|
3079
|
+
if (!isRec8(container)) continue;
|
|
3080
|
+
for (const [subKey, sub] of Object.entries(container)) {
|
|
3081
|
+
if (!isRec8(sub)) continue;
|
|
3082
|
+
sites.push({
|
|
3083
|
+
path: `${basePath}.${key}.${subKey}.sections`,
|
|
3084
|
+
surface: joinWhere(label2, `${key}.${subKey}`),
|
|
3085
|
+
objectName: bindingOf(sub) ?? listBinding,
|
|
3086
|
+
sections: sub.sections
|
|
3087
|
+
});
|
|
3088
|
+
}
|
|
3089
|
+
}
|
|
3090
|
+
}
|
|
3091
|
+
function translatedObjectNames(stack) {
|
|
3092
|
+
const out = /* @__PURE__ */ new Set();
|
|
3093
|
+
const bundles = Array.isArray(stack.translations) ? stack.translations : [];
|
|
3094
|
+
for (const bundle of bundles) {
|
|
3095
|
+
if (!isRec8(bundle)) continue;
|
|
3096
|
+
for (const data of Object.values(bundle)) {
|
|
3097
|
+
if (!isRec8(data) || !isRec8(data.objects)) continue;
|
|
3098
|
+
for (const [objectName, node] of Object.entries(data.objects)) {
|
|
3099
|
+
if (isRec8(node)) out.add(objectName);
|
|
3100
|
+
}
|
|
3101
|
+
}
|
|
3102
|
+
}
|
|
3103
|
+
return out;
|
|
3104
|
+
}
|
|
3105
|
+
function suggestedName(label2) {
|
|
3106
|
+
const slug = label2.toLowerCase().replace(/&/g, " and ").replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "");
|
|
3107
|
+
return slug.length > 0 ? slug : void 0;
|
|
3108
|
+
}
|
|
3109
|
+
function validateTranslatableSections(stack) {
|
|
3110
|
+
const findings = [];
|
|
3111
|
+
if (!isRec8(stack)) return findings;
|
|
3112
|
+
const translated = translatedObjectNames(stack);
|
|
3113
|
+
if (translated.size === 0) return findings;
|
|
3114
|
+
const sites = [];
|
|
3115
|
+
for (const { rec: obj, path: objPath } of collectionEntries(stack.objects, "objects")) {
|
|
3116
|
+
const objectName = strName11(obj.name);
|
|
3117
|
+
if (!objectName) continue;
|
|
3118
|
+
for (const { rec: view, path } of collectionEntries(obj.views, `${objPath}.views`)) {
|
|
3119
|
+
collectViewSites(
|
|
3120
|
+
{ ...view, object: strName11(view.object) ?? objectName },
|
|
3121
|
+
path,
|
|
3122
|
+
viewLabel(view),
|
|
3123
|
+
sites
|
|
3124
|
+
);
|
|
3125
|
+
}
|
|
3126
|
+
if (isRec8(obj.listViews)) {
|
|
3127
|
+
collectViewSites({ object: objectName, listViews: obj.listViews }, objPath, "", sites);
|
|
3128
|
+
}
|
|
3129
|
+
}
|
|
3130
|
+
for (const { rec: view, path } of collectionEntries(stack.views, "views")) {
|
|
3131
|
+
collectViewSites(view, path, viewLabel(view), sites);
|
|
3132
|
+
}
|
|
3133
|
+
for (const { rec: page, path: pagePath } of collectionEntries(stack.pages, "pages")) {
|
|
3134
|
+
const pageName = strName11(page.name);
|
|
3135
|
+
const pageLabel = pageName ? `page "${pageName}"` : "";
|
|
3136
|
+
for (const walked of walkPageComponents(page, pagePath)) {
|
|
3137
|
+
if (!walked.objectName) continue;
|
|
3138
|
+
const props = isRec8(walked.component.properties) ? walked.component.properties : void 0;
|
|
3139
|
+
if (!props) continue;
|
|
3140
|
+
const type = strName11(walked.component.type) ?? "component";
|
|
3141
|
+
sites.push({
|
|
3142
|
+
path: `${walked.path}.properties.sections`,
|
|
3143
|
+
surface: joinWhere(pageLabel, type),
|
|
3144
|
+
objectName: walked.objectName,
|
|
3145
|
+
sections: props.sections
|
|
3146
|
+
});
|
|
3147
|
+
}
|
|
3148
|
+
}
|
|
3149
|
+
for (const site of sites) {
|
|
3150
|
+
const objectName = site.objectName;
|
|
3151
|
+
if (!objectName || !translated.has(objectName)) continue;
|
|
3152
|
+
if (!Array.isArray(site.sections)) continue;
|
|
3153
|
+
for (let i = 0; i < site.sections.length; i++) {
|
|
3154
|
+
const section = site.sections[i];
|
|
3155
|
+
if (!isRec8(section)) continue;
|
|
3156
|
+
if (strName11(section.name)) continue;
|
|
3157
|
+
const heading = strName11(section.label) ?? strName11(section.title);
|
|
3158
|
+
if (!heading) continue;
|
|
3159
|
+
const slug = suggestedName(heading);
|
|
3160
|
+
findings.push({
|
|
3161
|
+
severity: "warning",
|
|
3162
|
+
rule: TRANSLATION_SECTION_NAME_MISSING,
|
|
3163
|
+
where: joinWhere(`object "${objectName}"`, site.surface, `section "${heading}"`),
|
|
3164
|
+
path: `${site.path}[${i}]`,
|
|
3165
|
+
message: `Section "${heading}" declares a label but no \`name\`. Headings resolve through \`objects.${objectName}._sections.<name>.label\`, so a section with no name has no key a bundle can carry \u2014 this heading can never be translated and renders in the source locale in EVERY locale. Object "${objectName}" IS translated, which is what makes the hole invisible: every neighbouring label resolves and only the heading stays behind. The i18n coverage report cannot see it either \u2014 it walks \`sections[].name\`, and a nameless section contributes nothing to walk.`,
|
|
3166
|
+
hint: `Give the section a stable \`name\` (snake_case)` + (slug ? `, e.g. \`name: '${slug}'\`` : "") + `, then translate it as \`objects.${objectName}._sections.${slug ?? "<name>"}.label\` in each locale bundle. The renderers look the heading up by name only \u2014 the name above is a suggestion to write down, never a key derived from the label, so renaming the heading later cannot break the lookup.`
|
|
3167
|
+
});
|
|
3168
|
+
}
|
|
3169
|
+
}
|
|
3170
|
+
return findings;
|
|
3171
|
+
}
|
|
3172
|
+
|
|
3173
|
+
// src/flow-walk.ts
|
|
3174
|
+
import { FLOW_REGION_SLOTS_BY_TYPE, FLOW_REGION_CONFIG_KEYS } from "@objectstack/spec/automation";
|
|
3175
|
+
function isRec9(v) {
|
|
3176
|
+
return !!v && typeof v === "object" && !Array.isArray(v);
|
|
3177
|
+
}
|
|
3178
|
+
function strName12(v) {
|
|
3179
|
+
return typeof v === "string" && v.length > 0 ? v : void 0;
|
|
3180
|
+
}
|
|
3027
3181
|
var REGION_SLOTS = new Map(
|
|
3028
3182
|
[...FLOW_REGION_SLOTS_BY_TYPE].map(([type, slots]) => [type, slots.map((s) => s.key)])
|
|
3029
3183
|
);
|
|
3030
3184
|
var REGION_CONFIG_KEYS = FLOW_REGION_CONFIG_KEYS;
|
|
3031
3185
|
var MAX_REGION_DEPTH = 16;
|
|
3032
3186
|
function flowNodeLabel(node, index) {
|
|
3033
|
-
return
|
|
3187
|
+
return strName12(node.label) ?? strName12(node.id) ?? `#${index}`;
|
|
3034
3188
|
}
|
|
3035
3189
|
function stripRegions(config) {
|
|
3036
|
-
if (!
|
|
3190
|
+
if (!isRec9(config)) return void 0;
|
|
3037
3191
|
let out;
|
|
3038
3192
|
for (const key of Object.keys(config)) {
|
|
3039
3193
|
if (!REGION_CONFIG_KEYS.has(key)) continue;
|
|
@@ -3044,11 +3198,11 @@ function stripRegions(config) {
|
|
|
3044
3198
|
}
|
|
3045
3199
|
function walkFlowNodes(flow, flowPath) {
|
|
3046
3200
|
const out = [];
|
|
3047
|
-
if (!
|
|
3201
|
+
if (!isRec9(flow)) return out;
|
|
3048
3202
|
const visitList = (nodes, basePath, trail, depth) => {
|
|
3049
3203
|
if (!Array.isArray(nodes) || depth > MAX_REGION_DEPTH) return;
|
|
3050
3204
|
nodes.forEach((raw, index) => {
|
|
3051
|
-
if (!
|
|
3205
|
+
if (!isRec9(raw)) return;
|
|
3052
3206
|
const path = `${basePath}[${index}]`;
|
|
3053
3207
|
out.push({
|
|
3054
3208
|
node: raw,
|
|
@@ -3057,9 +3211,9 @@ function walkFlowNodes(flow, flowPath) {
|
|
|
3057
3211
|
regionTrail: trail,
|
|
3058
3212
|
depth
|
|
3059
3213
|
});
|
|
3060
|
-
const type =
|
|
3214
|
+
const type = strName12(raw.type);
|
|
3061
3215
|
const slots = type ? REGION_SLOTS.get(type) : void 0;
|
|
3062
|
-
if (!slots || !
|
|
3216
|
+
if (!slots || !isRec9(raw.config)) return;
|
|
3063
3217
|
const config = raw.config;
|
|
3064
3218
|
const here = `${type} "${flowNodeLabel(raw, index)}"`;
|
|
3065
3219
|
for (const slot of slots) {
|
|
@@ -3067,8 +3221,8 @@ function walkFlowNodes(flow, flowPath) {
|
|
|
3067
3221
|
if (slot === "branches") {
|
|
3068
3222
|
if (!Array.isArray(value)) continue;
|
|
3069
3223
|
value.forEach((branch, b) => {
|
|
3070
|
-
if (!
|
|
3071
|
-
const branchName =
|
|
3224
|
+
if (!isRec9(branch)) return;
|
|
3225
|
+
const branchName = strName12(branch.name) ?? `#${b}`;
|
|
3072
3226
|
visitList(
|
|
3073
3227
|
branch.nodes,
|
|
3074
3228
|
`${path}.config.branches[${b}].nodes`,
|
|
@@ -3078,7 +3232,7 @@ function walkFlowNodes(flow, flowPath) {
|
|
|
3078
3232
|
});
|
|
3079
3233
|
continue;
|
|
3080
3234
|
}
|
|
3081
|
-
if (!
|
|
3235
|
+
if (!isRec9(value)) continue;
|
|
3082
3236
|
visitList(
|
|
3083
3237
|
value.nodes,
|
|
3084
3238
|
`${path}.config.${slot}.nodes`,
|
|
@@ -3303,7 +3457,7 @@ function asArray16(v) {
|
|
|
3303
3457
|
}
|
|
3304
3458
|
return [];
|
|
3305
3459
|
}
|
|
3306
|
-
function
|
|
3460
|
+
function strName13(v) {
|
|
3307
3461
|
return typeof v === "string" && v.length > 0 ? v : void 0;
|
|
3308
3462
|
}
|
|
3309
3463
|
function surfaceOf(v) {
|
|
@@ -3314,17 +3468,17 @@ function validateAiSurfaceAffinity(stack) {
|
|
|
3314
3468
|
if (!stack || typeof stack !== "object") return findings;
|
|
3315
3469
|
const skillsByName = /* @__PURE__ */ new Map();
|
|
3316
3470
|
for (const skill of asArray16(stack.skills)) {
|
|
3317
|
-
const n =
|
|
3471
|
+
const n = strName13(skill.name);
|
|
3318
3472
|
if (n) skillsByName.set(n, skill);
|
|
3319
3473
|
}
|
|
3320
3474
|
const agents = asArray16(stack.agents);
|
|
3321
3475
|
for (let ai = 0; ai < agents.length; ai++) {
|
|
3322
3476
|
const agent = agents[ai];
|
|
3323
|
-
const agentName =
|
|
3477
|
+
const agentName = strName13(agent.name) ?? `#${ai}`;
|
|
3324
3478
|
const agentSurface = surfaceOf(agent.surface);
|
|
3325
3479
|
const skillRefs = Array.isArray(agent.skills) ? agent.skills : [];
|
|
3326
3480
|
for (let si = 0; si < skillRefs.length; si++) {
|
|
3327
|
-
const ref =
|
|
3481
|
+
const ref = strName13(skillRefs[si]);
|
|
3328
3482
|
if (!ref) continue;
|
|
3329
3483
|
const skill = skillsByName.get(ref);
|
|
3330
3484
|
if (!skill) continue;
|
|
@@ -3353,7 +3507,7 @@ function asArray17(v) {
|
|
|
3353
3507
|
}
|
|
3354
3508
|
return [];
|
|
3355
3509
|
}
|
|
3356
|
-
function
|
|
3510
|
+
function strName14(v) {
|
|
3357
3511
|
return typeof v === "string" && v.length > 0 ? v : void 0;
|
|
3358
3512
|
}
|
|
3359
3513
|
function distance6(a, b) {
|
|
@@ -3394,8 +3548,8 @@ function materialisesAsTool(action) {
|
|
|
3394
3548
|
if (!ai || typeof ai !== "object") return false;
|
|
3395
3549
|
const aiRec = ai;
|
|
3396
3550
|
if (aiRec.exposed !== true) return false;
|
|
3397
|
-
if (!
|
|
3398
|
-
const type =
|
|
3551
|
+
if (!strName14(aiRec.description)) return false;
|
|
3552
|
+
const type = strName14(action.type);
|
|
3399
3553
|
if (!type || !HEADLESS_ACTION_TYPES.has(type)) return false;
|
|
3400
3554
|
if (type === "script") return Boolean(action.target || action.body);
|
|
3401
3555
|
return Boolean(action.target);
|
|
@@ -3403,12 +3557,12 @@ function materialisesAsTool(action) {
|
|
|
3403
3557
|
function collectToolUniverse(stack) {
|
|
3404
3558
|
const universe = new Set(PLATFORM_PROVIDED_TOOL_NAMES);
|
|
3405
3559
|
for (const tool of asArray17(stack.tools)) {
|
|
3406
|
-
const n =
|
|
3560
|
+
const n = strName14(tool.name);
|
|
3407
3561
|
if (n) universe.add(n);
|
|
3408
3562
|
}
|
|
3409
3563
|
const addActionFamily = (actions) => {
|
|
3410
3564
|
for (const action of asArray17(actions)) {
|
|
3411
|
-
const n =
|
|
3565
|
+
const n = strName14(action.name);
|
|
3412
3566
|
if (n && materialisesAsTool(action)) universe.add(`action_${n}`);
|
|
3413
3567
|
}
|
|
3414
3568
|
};
|
|
@@ -3422,7 +3576,7 @@ function collectUnexposedActionNames(stack) {
|
|
|
3422
3576
|
const names = /* @__PURE__ */ new Set();
|
|
3423
3577
|
const scan = (actions) => {
|
|
3424
3578
|
for (const action of asArray17(actions)) {
|
|
3425
|
-
const n =
|
|
3579
|
+
const n = strName14(action.name);
|
|
3426
3580
|
if (n && !materialisesAsTool(action)) names.add(n);
|
|
3427
3581
|
}
|
|
3428
3582
|
};
|
|
@@ -3448,10 +3602,10 @@ function validateAiToolReferences(stack) {
|
|
|
3448
3602
|
const skills = asArray17(stack.skills);
|
|
3449
3603
|
for (let si = 0; si < skills.length; si++) {
|
|
3450
3604
|
const skill = skills[si];
|
|
3451
|
-
const skillName =
|
|
3605
|
+
const skillName = strName14(skill.name) ?? `#${si}`;
|
|
3452
3606
|
const refs = Array.isArray(skill.tools) ? skill.tools : [];
|
|
3453
3607
|
for (let ti = 0; ti < refs.length; ti++) {
|
|
3454
|
-
const ref =
|
|
3608
|
+
const ref = strName14(refs[ti]);
|
|
3455
3609
|
if (!ref || resolves(ref)) continue;
|
|
3456
3610
|
const isPattern = ref.endsWith("*");
|
|
3457
3611
|
const unexposed = !isPattern && ref.startsWith("action_") && unexposedActions.has(ref.slice("action_".length)) ? ref.slice("action_".length) : void 0;
|
|
@@ -3477,7 +3631,7 @@ function asArray18(v) {
|
|
|
3477
3631
|
}
|
|
3478
3632
|
return [];
|
|
3479
3633
|
}
|
|
3480
|
-
function
|
|
3634
|
+
function strName15(v) {
|
|
3481
3635
|
return typeof v === "string" && v.length > 0 ? v : void 0;
|
|
3482
3636
|
}
|
|
3483
3637
|
var PLATFORM_AGENT_NAMES = /* @__PURE__ */ new Set(["ask", "build", "data_chat", "metadata_assistant"]);
|
|
@@ -3487,7 +3641,7 @@ function validateAiAgentAuthoring(stack) {
|
|
|
3487
3641
|
const agents = asArray18(stack.agents);
|
|
3488
3642
|
for (let ai = 0; ai < agents.length; ai++) {
|
|
3489
3643
|
const agent = agents[ai];
|
|
3490
|
-
const name =
|
|
3644
|
+
const name = strName15(agent.name) ?? `#${ai}`;
|
|
3491
3645
|
const isPlatformName = PLATFORM_AGENT_NAMES.has(name);
|
|
3492
3646
|
const skillCount = Array.isArray(agent.skills) ? agent.skills.length : 0;
|
|
3493
3647
|
findings.push({
|
|
@@ -3585,13 +3739,13 @@ var IMPLICIT_FIELDS2 = /* @__PURE__ */ new Set([
|
|
|
3585
3739
|
"owner",
|
|
3586
3740
|
"record_type"
|
|
3587
3741
|
]);
|
|
3588
|
-
var
|
|
3742
|
+
var isRec10 = (v) => !!v && typeof v === "object" && !Array.isArray(v);
|
|
3589
3743
|
function asArray19(v) {
|
|
3590
|
-
if (Array.isArray(v)) return v.filter((x) =>
|
|
3591
|
-
if (
|
|
3744
|
+
if (Array.isArray(v)) return v.filter((x) => isRec10(x));
|
|
3745
|
+
if (isRec10(v)) {
|
|
3592
3746
|
return Object.entries(v).map(([name, def]) => ({
|
|
3593
3747
|
name,
|
|
3594
|
-
...
|
|
3748
|
+
...isRec10(def) ? def : {}
|
|
3595
3749
|
}));
|
|
3596
3750
|
}
|
|
3597
3751
|
return [];
|
|
@@ -3737,7 +3891,7 @@ function validateHookBodyWrites(stack) {
|
|
|
3737
3891
|
let objectFields = null;
|
|
3738
3892
|
hooks.forEach((hook, hookIndex) => {
|
|
3739
3893
|
const body = hook.body;
|
|
3740
|
-
if (!
|
|
3894
|
+
if (!isRec10(body) || body.language !== "js") return;
|
|
3741
3895
|
const source = body.source;
|
|
3742
3896
|
if (typeof source !== "string" || source.trim() === "") return;
|
|
3743
3897
|
const writes = extractHookBodyWrites(source).filter((w) => HOOK_APPLICABLE_IDS.has(w.patternId));
|
|
@@ -3808,13 +3962,13 @@ var ACTION_BODY_WRITE_PATTERNS = HOOK_BODY_WRITE_PATTERNS.filter((p) => ACTION_B
|
|
|
3808
3962
|
var ACTION_RECORD_WRITE_PATTERNS = HOOK_BODY_WRITE_PATTERNS.filter((p) => ACTION_RECORD_WRITE_PATTERN_IDS.includes(p.id));
|
|
3809
3963
|
var APPLICABLE_IDS = new Set(ACTION_BODY_WRITE_PATTERN_IDS);
|
|
3810
3964
|
var RECORD_WRITE_IDS = new Set(ACTION_RECORD_WRITE_PATTERN_IDS);
|
|
3811
|
-
var
|
|
3965
|
+
var isRec11 = (v) => !!v && typeof v === "object" && !Array.isArray(v);
|
|
3812
3966
|
function asArray20(v) {
|
|
3813
|
-
if (Array.isArray(v)) return v.filter((x) =>
|
|
3814
|
-
if (
|
|
3967
|
+
if (Array.isArray(v)) return v.filter((x) => isRec11(x));
|
|
3968
|
+
if (isRec11(v)) {
|
|
3815
3969
|
return Object.entries(v).map(([name, def]) => ({
|
|
3816
3970
|
name,
|
|
3817
|
-
...
|
|
3971
|
+
...isRec11(def) ? def : {}
|
|
3818
3972
|
}));
|
|
3819
3973
|
}
|
|
3820
3974
|
return [];
|
|
@@ -3832,7 +3986,7 @@ function collectActionBodies(stack) {
|
|
|
3832
3986
|
const type = typeof action.type === "string" ? action.type : "script";
|
|
3833
3987
|
if (type !== "script") return;
|
|
3834
3988
|
const body = action.body;
|
|
3835
|
-
if (!
|
|
3989
|
+
if (!isRec11(body) || body.language !== "js") return;
|
|
3836
3990
|
const source = body.source;
|
|
3837
3991
|
if (typeof source !== "string" || source.trim() === "") return;
|
|
3838
3992
|
const name = typeof action.name === "string" && action.name ? action.name : `#${index}`;
|
|
@@ -3851,7 +4005,7 @@ function collectActionBodies(stack) {
|
|
|
3851
4005
|
}
|
|
3852
4006
|
function validateActionBodyWrites(stack) {
|
|
3853
4007
|
const findings = [];
|
|
3854
|
-
if (!
|
|
4008
|
+
if (!isRec11(stack)) return findings;
|
|
3855
4009
|
const sites = collectActionBodies(stack);
|
|
3856
4010
|
if (sites.length === 0) return findings;
|
|
3857
4011
|
let objectFields = null;
|
|
@@ -3909,13 +4063,13 @@ function fixHint2(field, declared) {
|
|
|
3909
4063
|
import { findClosestMatches as findClosestMatches3, formatSuggestion as formatSuggestion3 } from "@objectstack/spec/shared";
|
|
3910
4064
|
var FLOW_NODE_WRITE_UNKNOWN_FIELD = "flow-node-write-unknown-field";
|
|
3911
4065
|
var FLOW_WRITE_NODE_TYPES = ["update_record", "create_record"];
|
|
3912
|
-
var
|
|
4066
|
+
var isRec12 = (v) => !!v && typeof v === "object" && !Array.isArray(v);
|
|
3913
4067
|
function asArray21(v) {
|
|
3914
|
-
if (Array.isArray(v)) return v.filter((x) =>
|
|
3915
|
-
if (
|
|
4068
|
+
if (Array.isArray(v)) return v.filter((x) => isRec12(x));
|
|
4069
|
+
if (isRec12(v)) {
|
|
3916
4070
|
return Object.entries(v).map(([name, def]) => ({
|
|
3917
4071
|
name,
|
|
3918
|
-
...
|
|
4072
|
+
...isRec12(def) ? def : {}
|
|
3919
4073
|
}));
|
|
3920
4074
|
}
|
|
3921
4075
|
return [];
|
|
@@ -3928,7 +4082,7 @@ function readLiteralObjectName(config) {
|
|
|
3928
4082
|
var COVERED_TYPES = new Set(FLOW_WRITE_NODE_TYPES);
|
|
3929
4083
|
function validateFlowNodeWrites(stack) {
|
|
3930
4084
|
const findings = [];
|
|
3931
|
-
if (!
|
|
4085
|
+
if (!isRec12(stack)) return findings;
|
|
3932
4086
|
const flows = asArray21(stack.flows);
|
|
3933
4087
|
if (flows.length === 0) return findings;
|
|
3934
4088
|
let objectFields = null;
|
|
@@ -3937,10 +4091,10 @@ function validateFlowNodeWrites(stack) {
|
|
|
3937
4091
|
const walked = walkFlowNodes(flow, `flows[${flowIndex}]`);
|
|
3938
4092
|
walked.forEach(({ node, path: nodePath, regionTrail }, walkIndex) => {
|
|
3939
4093
|
if (typeof node.type !== "string" || !COVERED_TYPES.has(node.type)) return;
|
|
3940
|
-
const config =
|
|
4094
|
+
const config = isRec12(node.config) ? node.config : void 0;
|
|
3941
4095
|
if (!config) return;
|
|
3942
4096
|
const fields = config.fields;
|
|
3943
|
-
if (!
|
|
4097
|
+
if (!isRec12(fields)) return;
|
|
3944
4098
|
const written = Object.keys(fields);
|
|
3945
4099
|
if (written.length === 0) return;
|
|
3946
4100
|
const objectName = readLiteralObjectName(config);
|
|
@@ -4068,10 +4222,50 @@ import {
|
|
|
4068
4222
|
REACT_BLOCKS,
|
|
4069
4223
|
RECORD_CONTEXT_BLOCK_TAGS,
|
|
4070
4224
|
REACT_RECORD_BLOCK_ALTERNATIVES,
|
|
4225
|
+
ChartAggregateSchema,
|
|
4226
|
+
ChartDrillDownSchema,
|
|
4071
4227
|
chartAggregateResultKeys,
|
|
4072
4228
|
isRecordContextBlockType
|
|
4073
4229
|
} from "@objectstack/spec/ui";
|
|
4074
4230
|
import { VALID_AST_OPERATORS } from "@objectstack/spec/data";
|
|
4231
|
+
|
|
4232
|
+
// src/zod-issue-format.ts
|
|
4233
|
+
var isRec13 = (v) => !!v && typeof v === "object" && !Array.isArray(v);
|
|
4234
|
+
var valueAtPath = (root, path) => {
|
|
4235
|
+
let cur = root;
|
|
4236
|
+
for (const key of path) {
|
|
4237
|
+
if (!isRec13(cur) && !Array.isArray(cur)) return void 0;
|
|
4238
|
+
cur = cur[key];
|
|
4239
|
+
}
|
|
4240
|
+
return cur;
|
|
4241
|
+
};
|
|
4242
|
+
var preview = (value) => {
|
|
4243
|
+
let text;
|
|
4244
|
+
try {
|
|
4245
|
+
text = JSON.stringify(value) ?? String(value);
|
|
4246
|
+
} catch {
|
|
4247
|
+
text = String(value);
|
|
4248
|
+
}
|
|
4249
|
+
return text.length > 80 ? `${text.slice(0, 77)}\u2026` : text;
|
|
4250
|
+
};
|
|
4251
|
+
function describeIssue(issue, root, depth = 0) {
|
|
4252
|
+
const value = depth === 0 ? valueAtPath(root, issue.path) : void 0;
|
|
4253
|
+
const seen = depth > 0 || issue.code === "custom" || issue.message.includes("received ") ? "" : value === void 0 ? " (nothing is set there)" : ` (received ${preview(value)})`;
|
|
4254
|
+
const armIssues = issue.code === "invalid_union" ? issue.errors : void 0;
|
|
4255
|
+
if (!armIssues || armIssues.length === 0) {
|
|
4256
|
+
return `${issue.message}${seen}`;
|
|
4257
|
+
}
|
|
4258
|
+
const arms = armIssues.map(
|
|
4259
|
+
(arm) => arm.map((inner) => {
|
|
4260
|
+
const where = inner.path.length ? `${inner.path.join(".")} \u2014 ` : "";
|
|
4261
|
+
return `${where}${describeIssue(inner, root, depth + 1)}`;
|
|
4262
|
+
}).join("; ")
|
|
4263
|
+
).filter((text) => text.length > 0);
|
|
4264
|
+
if (arms.length === 0) return `${issue.message}${seen}`;
|
|
4265
|
+
return `${issue.message}${seen} \u2014 no accepted form matched: ` + arms.map((text, i) => `(${i + 1}) ${text}`).join(" ");
|
|
4266
|
+
}
|
|
4267
|
+
|
|
4268
|
+
// src/validate-react-page-props.ts
|
|
4075
4269
|
var cachedTs2 = null;
|
|
4076
4270
|
function loadTypeScript2() {
|
|
4077
4271
|
if (cachedTs2) return cachedTs2;
|
|
@@ -4175,35 +4369,78 @@ function filterAttrValue(tsc, sf, attr) {
|
|
|
4175
4369
|
var REACT_CHART_FIELD_UNKNOWN = "react-chart-field-unknown";
|
|
4176
4370
|
var REACT_CHART_AGGREGATE_INVALID = "react-chart-aggregate-invalid";
|
|
4177
4371
|
var REACT_CHART_AXIS_UNKNOWN = "react-chart-axis-unknown";
|
|
4178
|
-
var
|
|
4179
|
-
|
|
4372
|
+
var REACT_CHART_DRILLDOWN_INVALID = "react-chart-drilldown-invalid";
|
|
4373
|
+
function checkChartDrillDown(raw, push2) {
|
|
4374
|
+
if (raw === void 0 || raw === NOT_STATIC) return;
|
|
4375
|
+
if (!isRec14(raw)) {
|
|
4376
|
+
push2(
|
|
4377
|
+
"error",
|
|
4378
|
+
REACT_CHART_DRILLDOWN_INVALID,
|
|
4379
|
+
`drillDown must be a configuration object, not ${Array.isArray(raw) ? "an array" : typeof raw}.`,
|
|
4380
|
+
"Write drillDown={{ \u2026 }} \u2014 or, to turn the drill on with all defaults, drillDown={{}}. Omit the prop entirely to leave drill off."
|
|
4381
|
+
);
|
|
4382
|
+
return;
|
|
4383
|
+
}
|
|
4384
|
+
const parsed = ChartDrillDownSchema.safeParse(raw);
|
|
4385
|
+
if (parsed.success) return;
|
|
4386
|
+
for (const issue of parsed.error.issues) {
|
|
4387
|
+
const at = issue.path.length ? `drillDown.${issue.path.join(".")}` : "drillDown";
|
|
4388
|
+
push2(
|
|
4389
|
+
"error",
|
|
4390
|
+
REACT_CHART_DRILLDOWN_INVALID,
|
|
4391
|
+
`${at}: ${issue.message}`,
|
|
4392
|
+
"The drill config is declared by ChartDrillDownSchema (@objectstack/spec/ui) \u2014 the rejection above carries the fix."
|
|
4393
|
+
);
|
|
4394
|
+
}
|
|
4395
|
+
}
|
|
4396
|
+
function checkChartAggregate(raw, push2) {
|
|
4397
|
+
if (raw === void 0 || raw === NOT_STATIC) return;
|
|
4398
|
+
if (!isRec14(raw)) {
|
|
4399
|
+
push2(
|
|
4400
|
+
"error",
|
|
4401
|
+
REACT_CHART_AGGREGATE_INVALID,
|
|
4402
|
+
`aggregate must be a configuration object, not ${Array.isArray(raw) ? "an array" : typeof raw}.`,
|
|
4403
|
+
'Write aggregate={{ function: "count", groupBy: "<field>" }} \u2014 or bind data={\u2026} instead to chart precomputed rows.'
|
|
4404
|
+
);
|
|
4405
|
+
return;
|
|
4406
|
+
}
|
|
4407
|
+
const groupByAbsent = raw.groupBy === void 0;
|
|
4408
|
+
if (groupByAbsent) {
|
|
4409
|
+
push2(
|
|
4410
|
+
"warning",
|
|
4411
|
+
REACT_CHART_AGGREGATE_INVALID,
|
|
4412
|
+
"aggregate.groupBy is not set, so the aggregate returns ONE ungrouped row and the chart plots a single point.",
|
|
4413
|
+
"Add aggregate.groupBy (a field name, or { field, dateGranularity } to bucket dates) to give the chart a category axis. Deliberate single-value charts are tolerated at warning level for now: ChartAggregateSchema declares groupBy required while ObjectChart honours its absence by falling back to xAxisKey \u2014 objectstack#5583 decides which of the two moves."
|
|
4414
|
+
);
|
|
4415
|
+
}
|
|
4416
|
+
const parsed = ChartAggregateSchema.safeParse(raw);
|
|
4417
|
+
if (parsed.success) return;
|
|
4418
|
+
for (const issue of parsed.error.issues) {
|
|
4419
|
+
if (groupByAbsent && issue.path[0] === "groupBy") continue;
|
|
4420
|
+
const at = issue.path.length ? `aggregate.${issue.path.join(".")}` : "aggregate";
|
|
4421
|
+
push2(
|
|
4422
|
+
"error",
|
|
4423
|
+
REACT_CHART_AGGREGATE_INVALID,
|
|
4424
|
+
`${at}: ${describeIssue(issue, raw)}`,
|
|
4425
|
+
"The aggregate is declared by ChartAggregateSchema (@objectstack/spec/ui) \u2014 the rejection above carries the fix."
|
|
4426
|
+
);
|
|
4427
|
+
}
|
|
4428
|
+
}
|
|
4429
|
+
var isRec14 = (v) => !!v && typeof v === "object" && !Array.isArray(v);
|
|
4180
4430
|
var strOf = (v) => typeof v === "string" && v.length > 0 ? v : void 0;
|
|
4181
4431
|
function checkObjectChart(attrs, objectFields, findings) {
|
|
4182
4432
|
const { values, where, path } = attrs;
|
|
4183
4433
|
const push2 = (severity, rule, message, hint) => findings.push({ severity, rule, where, path, message, hint });
|
|
4434
|
+
checkChartDrillDown(values.get("drillDown"), push2);
|
|
4184
4435
|
if (values.has("data")) return;
|
|
4185
4436
|
const aggregate = values.get("aggregate");
|
|
4437
|
+
checkChartAggregate(aggregate, push2);
|
|
4186
4438
|
if (aggregate === void 0 || aggregate === NOT_STATIC) return;
|
|
4187
|
-
if (!
|
|
4439
|
+
if (!isRec14(aggregate)) return;
|
|
4188
4440
|
const fn = strOf(aggregate.function);
|
|
4189
4441
|
const field = strOf(aggregate.field);
|
|
4190
4442
|
const groupBy = aggregate.groupBy;
|
|
4191
|
-
const groupByField = strOf(groupBy) ?? (
|
|
4192
|
-
if (fn && !CHART_FUNCTIONS.includes(fn)) {
|
|
4193
|
-
push2(
|
|
4194
|
-
"error",
|
|
4195
|
-
REACT_CHART_AGGREGATE_INVALID,
|
|
4196
|
-
`aggregate.function "${fn}" is not an aggregation this chart can run.`,
|
|
4197
|
-
`Use one of: ${CHART_FUNCTIONS.join(", ")}.`
|
|
4198
|
-
);
|
|
4199
|
-
} else if (fn && fn !== "count" && !field) {
|
|
4200
|
-
push2(
|
|
4201
|
-
"error",
|
|
4202
|
-
REACT_CHART_AGGREGATE_INVALID,
|
|
4203
|
-
`aggregate.function "${fn}" has no "field" to aggregate.`,
|
|
4204
|
-
'Add aggregate.field, or use function "count" (the only one that may omit it).'
|
|
4205
|
-
);
|
|
4206
|
-
}
|
|
4443
|
+
const groupByField = strOf(groupBy) ?? (isRec14(groupBy) ? strOf(groupBy.field) : void 0);
|
|
4207
4444
|
const objectName = strOf(values.get("objectName"));
|
|
4208
4445
|
const known = objectName ? objectFields.get(objectName) : void 0;
|
|
4209
4446
|
if (objectName && known) {
|
|
@@ -4236,18 +4473,18 @@ function checkObjectChart(attrs, objectFields, findings) {
|
|
|
4236
4473
|
);
|
|
4237
4474
|
};
|
|
4238
4475
|
const xAxisRaw = values.get("xAxis");
|
|
4239
|
-
const categoryAxis = strOf(values.get("xAxisKey")) ?? strOf(xAxisRaw) ?? (
|
|
4476
|
+
const categoryAxis = strOf(values.get("xAxisKey")) ?? strOf(xAxisRaw) ?? (isRec14(xAxisRaw) ? strOf(xAxisRaw.field) : void 0);
|
|
4240
4477
|
const categoryProp = values.has("xAxisKey") ? "xAxisKey" : "xAxis.field";
|
|
4241
4478
|
axisRef(categoryAxis, categoryProp);
|
|
4242
4479
|
const yAxisRaw = values.get("yAxis");
|
|
4243
4480
|
const yAxisList = Array.isArray(yAxisRaw) ? yAxisRaw : yAxisRaw !== void 0 ? [yAxisRaw] : [];
|
|
4244
4481
|
for (const a of yAxisList) {
|
|
4245
|
-
axisRef(strOf(a) ?? (
|
|
4482
|
+
axisRef(strOf(a) ?? (isRec14(a) ? strOf(a.field) : void 0), "yAxis[].field");
|
|
4246
4483
|
}
|
|
4247
4484
|
const series = values.get("series");
|
|
4248
4485
|
if (Array.isArray(series)) {
|
|
4249
4486
|
for (const s of series) {
|
|
4250
|
-
if (!
|
|
4487
|
+
if (!isRec14(s)) continue;
|
|
4251
4488
|
const dataKey = strOf(s.dataKey);
|
|
4252
4489
|
axisRef(dataKey ?? strOf(s.name), dataKey ? "series[].dataKey" : "series[].name");
|
|
4253
4490
|
}
|
|
@@ -4303,7 +4540,7 @@ function subformFieldRefs(value, basePath) {
|
|
|
4303
4540
|
if (!Array.isArray(value)) return { child, parent };
|
|
4304
4541
|
for (let i = 0; i < value.length; i++) {
|
|
4305
4542
|
const sub = value[i];
|
|
4306
|
-
if (!
|
|
4543
|
+
if (!isRec14(sub)) continue;
|
|
4307
4544
|
const at = (key) => `${basePath}[${i}].${key}`;
|
|
4308
4545
|
child.push({
|
|
4309
4546
|
objectName: strOf(sub.childObject),
|
|
@@ -4348,20 +4585,20 @@ function reactFieldRefs(spec, values, basePath) {
|
|
|
4348
4585
|
}
|
|
4349
4586
|
for (const key of spec.nestedFields ?? []) {
|
|
4350
4587
|
const v = readable(key);
|
|
4351
|
-
if (
|
|
4588
|
+
if (isRec14(v)) own.push(...fieldRefsFrom(v.fields, at(`${key}.fields`)));
|
|
4352
4589
|
}
|
|
4353
4590
|
for (const key of spec.sections ?? []) {
|
|
4354
4591
|
const v = readable(key);
|
|
4355
4592
|
if (!Array.isArray(v)) continue;
|
|
4356
4593
|
for (let i = 0; i < v.length; i++) {
|
|
4357
4594
|
const section = v[i];
|
|
4358
|
-
if (!
|
|
4595
|
+
if (!isRec14(section)) continue;
|
|
4359
4596
|
own.push(...fieldRefsFrom(section.fields, at(`${key}[${i}].fields`)));
|
|
4360
4597
|
}
|
|
4361
4598
|
}
|
|
4362
4599
|
for (const key of spec.keyedByField ?? []) {
|
|
4363
4600
|
const v = readable(key);
|
|
4364
|
-
if (!
|
|
4601
|
+
if (!isRec14(v)) continue;
|
|
4365
4602
|
for (const k of Object.keys(v)) own.push({ name: k, path: at(`${key}.${k}`) });
|
|
4366
4603
|
}
|
|
4367
4604
|
for (const key of spec.filterArrays ?? []) {
|
|
@@ -4551,6 +4788,14 @@ var REFERENCE_INTEGRITY_RULES = [
|
|
|
4551
4788
|
// `component` (an unregistered ref renders a named diagnostic, not silence).
|
|
4552
4789
|
{ name: "validateNavTargetRefs", run: validateNavTargetRefs },
|
|
4553
4790
|
{ name: "validateTranslationReferences", run: validateTranslationReferences },
|
|
4791
|
+
// The same family from the other end (#5417). Its sibling above asks "does
|
|
4792
|
+
// this bundle key resolve?"; this one asks "is there a key at all?" — a form
|
|
4793
|
+
// section authored with a `label` and no `name` renders a heading that
|
|
4794
|
+
// `_sections` (keyed by name) can never address, so neither the orphan check
|
|
4795
|
+
// nor the coverage walk can see it. A reference that cannot be written is
|
|
4796
|
+
// still a reference question, and warning-only for the same reason its
|
|
4797
|
+
// sibling is: one heading stays in the source locale, nothing breaks.
|
|
4798
|
+
{ name: "validateTranslatableSections", run: validateTranslatableSections },
|
|
4554
4799
|
{ name: "validateFlowTemplatePaths", run: validateFlowTemplatePaths },
|
|
4555
4800
|
{ name: "validateAiSurfaceAffinity", run: validateAiSurfaceAffinity },
|
|
4556
4801
|
{ name: "validateAiToolReferences", run: validateAiToolReferences },
|
|
@@ -4635,6 +4880,90 @@ function validateReferenceIntegrity(stack) {
|
|
|
4635
4880
|
return findings;
|
|
4636
4881
|
}
|
|
4637
4882
|
|
|
4883
|
+
// src/validate-component-props.ts
|
|
4884
|
+
import { ComponentPropsMap } from "@objectstack/spec/ui";
|
|
4885
|
+
import { lintUnknownKeysAgainstSchema } from "@objectstack/spec";
|
|
4886
|
+
var COMPONENT_PROPS_UNKNOWN_KEY = "component-props-unknown-key";
|
|
4887
|
+
var COMPONENT_PROPS_INVALID = "component-props-invalid";
|
|
4888
|
+
function isRec15(v) {
|
|
4889
|
+
return !!v && typeof v === "object" && !Array.isArray(v);
|
|
4890
|
+
}
|
|
4891
|
+
function strName16(v) {
|
|
4892
|
+
return typeof v === "string" && v.length > 0 ? v : void 0;
|
|
4893
|
+
}
|
|
4894
|
+
function asArray24(v) {
|
|
4895
|
+
if (Array.isArray(v)) return v;
|
|
4896
|
+
if (v && typeof v === "object") {
|
|
4897
|
+
return Object.entries(v).map(([name, def]) => ({ name, ...def }));
|
|
4898
|
+
}
|
|
4899
|
+
return [];
|
|
4900
|
+
}
|
|
4901
|
+
var PROPS_SCHEMAS = ComponentPropsMap;
|
|
4902
|
+
var DATASOURCE_SUPPLIED_PROP = "object";
|
|
4903
|
+
function suppliedByDataSource(issue, component) {
|
|
4904
|
+
if (issue.path.length !== 1 || issue.path[0] !== DATASOURCE_SUPPLIED_PROP) return false;
|
|
4905
|
+
const dataSource = isRec15(component.dataSource) ? component.dataSource : void 0;
|
|
4906
|
+
return strName16(dataSource?.object) !== void 0;
|
|
4907
|
+
}
|
|
4908
|
+
function validateComponentProps(stack) {
|
|
4909
|
+
const findings = [];
|
|
4910
|
+
if (!isRec15(stack)) return findings;
|
|
4911
|
+
const pages = asArray24(stack.pages);
|
|
4912
|
+
for (let pi = 0; pi < pages.length; pi++) {
|
|
4913
|
+
const page = pages[pi];
|
|
4914
|
+
if (!isRec15(page)) continue;
|
|
4915
|
+
const pageName = strName16(page.name) ?? `#${pi}`;
|
|
4916
|
+
for (const { component, path } of walkPageComponents(page, `pages[${pi}]`)) {
|
|
4917
|
+
const type = strName16(component.type);
|
|
4918
|
+
if (!type) continue;
|
|
4919
|
+
const schema = PROPS_SCHEMAS[type];
|
|
4920
|
+
if (!schema) continue;
|
|
4921
|
+
const props = isRec15(component.properties) ? component.properties : void 0;
|
|
4922
|
+
if (!props) continue;
|
|
4923
|
+
const where = `page "${pageName}" \xB7 ${type}`;
|
|
4924
|
+
const base = `${path}.properties`;
|
|
4925
|
+
for (const f of lintUnknownKeysAgainstSchema(schema, props, type, base)) {
|
|
4926
|
+
findings.push({
|
|
4927
|
+
severity: "warning",
|
|
4928
|
+
rule: COMPONENT_PROPS_UNKNOWN_KEY,
|
|
4929
|
+
where,
|
|
4930
|
+
path: f.path,
|
|
4931
|
+
message: `\`${f.key}\` is not a prop \`${type}\` declares (ComponentPropsMap, @objectstack/spec/ui), so nothing verifies it: \`properties\` is an untyped bag, the renderer spreads whatever it carries, and a key it does not read is ignored in silence.` + (f.suggestion ? ` Did you mean \`${f.suggestion}\`?` : ""),
|
|
4932
|
+
hint: f.guidance ?? (f.suggestion ? `Rename \`${f.key}\` \u2192 \`${f.suggestion}\`.` : `Remove \`${f.key}\`, or \u2014 if the component really does honour it \u2014 declare it on \`${type}\`'s props schema so the declaration and the renderer agree.`)
|
|
4933
|
+
});
|
|
4934
|
+
}
|
|
4935
|
+
const parsed = schema.safeParse(props);
|
|
4936
|
+
if (parsed.success) continue;
|
|
4937
|
+
for (const issue of parsed.error?.issues ?? []) {
|
|
4938
|
+
if (suppliedByDataSource(issue, component)) continue;
|
|
4939
|
+
const at = issue.path.length ? `${base}.${issue.path.join(".")}` : base;
|
|
4940
|
+
if (issue.code === "unrecognized_keys") {
|
|
4941
|
+
for (const key of issue.keys ?? []) {
|
|
4942
|
+
findings.push({
|
|
4943
|
+
severity: "warning",
|
|
4944
|
+
rule: COMPONENT_PROPS_UNKNOWN_KEY,
|
|
4945
|
+
where,
|
|
4946
|
+
path: `${at}.${key}`,
|
|
4947
|
+
message: `\`${key}\` is not a prop \`${type}\` declares (ComponentPropsMap, @objectstack/spec/ui): ${issue.message}`,
|
|
4948
|
+
hint: `Remove \`${key}\`, or declare it on \`${type}\`'s props schema if the component honours it.`
|
|
4949
|
+
});
|
|
4950
|
+
}
|
|
4951
|
+
continue;
|
|
4952
|
+
}
|
|
4953
|
+
findings.push({
|
|
4954
|
+
severity: "warning",
|
|
4955
|
+
rule: COMPONENT_PROPS_INVALID,
|
|
4956
|
+
where,
|
|
4957
|
+
path: at,
|
|
4958
|
+
message: `${at.slice(base.length + 1) || "properties"}: ${describeIssue(issue, props)}`,
|
|
4959
|
+
hint: `\`${type}\`'s props are declared by ComponentPropsMap (@objectstack/spec/ui) \u2014 the rejection above carries the fix. Advisory for now: the props bag is not parsed on the storage path either, so nothing rejects this today (objectstack#5068).`
|
|
4960
|
+
});
|
|
4961
|
+
}
|
|
4962
|
+
}
|
|
4963
|
+
}
|
|
4964
|
+
return findings;
|
|
4965
|
+
}
|
|
4966
|
+
|
|
4638
4967
|
// src/validate-responsive-styles.ts
|
|
4639
4968
|
var STYLE_NODE_MISSING_ID = "style-node-missing-id";
|
|
4640
4969
|
var STYLE_CLASSNAME_TAILWIND = "style-classname-tailwind";
|
|
@@ -4838,7 +5167,7 @@ function looksLikeTailwind(className) {
|
|
|
4838
5167
|
return false;
|
|
4839
5168
|
});
|
|
4840
5169
|
}
|
|
4841
|
-
function
|
|
5170
|
+
function asArray25(v) {
|
|
4842
5171
|
if (Array.isArray(v)) return v;
|
|
4843
5172
|
if (v && typeof v === "object") {
|
|
4844
5173
|
return Object.entries(v).map(([name, def]) => ({ name, ...def }));
|
|
@@ -4931,13 +5260,13 @@ function checkNode(node, pageName, path, findings) {
|
|
|
4931
5260
|
}
|
|
4932
5261
|
function validateResponsiveStyles(stack) {
|
|
4933
5262
|
const findings = [];
|
|
4934
|
-
const pages =
|
|
5263
|
+
const pages = asArray25(stack.pages);
|
|
4935
5264
|
for (let p = 0; p < pages.length; p++) {
|
|
4936
5265
|
const page = pages[p];
|
|
4937
5266
|
const pageName = typeof page.name === "string" ? page.name : `pages[${p}]`;
|
|
4938
|
-
const regions =
|
|
5267
|
+
const regions = asArray25(page.regions);
|
|
4939
5268
|
for (let r = 0; r < regions.length; r++) {
|
|
4940
|
-
const components =
|
|
5269
|
+
const components = asArray25(regions[r].components);
|
|
4941
5270
|
for (let c = 0; c < components.length; c++) {
|
|
4942
5271
|
checkNode(components[c], pageName, `pages[${p}].regions[${r}].components[${c}]`, findings);
|
|
4943
5272
|
}
|
|
@@ -4948,10 +5277,10 @@ function validateResponsiveStyles(stack) {
|
|
|
4948
5277
|
|
|
4949
5278
|
// src/validate-jsx-pages.ts
|
|
4950
5279
|
import { parseJsx, compile } from "@objectstack/sdui-parser";
|
|
4951
|
-
var
|
|
5280
|
+
var asArray26 = (v) => Array.isArray(v) ? v : [];
|
|
4952
5281
|
function validateJsxPages(stack, opts = {}) {
|
|
4953
5282
|
const findings = [];
|
|
4954
|
-
const pages =
|
|
5283
|
+
const pages = asArray26(stack.pages);
|
|
4955
5284
|
for (let p = 0; p < pages.length; p++) {
|
|
4956
5285
|
const page = pages[p];
|
|
4957
5286
|
if (!page || page.kind !== "html" && page.kind !== "jsx") continue;
|
|
@@ -4998,10 +5327,10 @@ function loadSucraseTransform() {
|
|
|
4998
5327
|
}
|
|
4999
5328
|
return cachedTransform;
|
|
5000
5329
|
}
|
|
5001
|
-
var
|
|
5330
|
+
var asArray27 = (v) => Array.isArray(v) ? v : [];
|
|
5002
5331
|
function validateReactPages(stack) {
|
|
5003
5332
|
const findings = [];
|
|
5004
|
-
const pages =
|
|
5333
|
+
const pages = asArray27(stack.pages);
|
|
5005
5334
|
for (let p = 0; p < pages.length; p++) {
|
|
5006
5335
|
const page = pages[p];
|
|
5007
5336
|
if (!page || page.kind !== "react") continue;
|
|
@@ -5038,11 +5367,11 @@ function validateReactPages(stack) {
|
|
|
5038
5367
|
|
|
5039
5368
|
// src/validate-page-source-styling.ts
|
|
5040
5369
|
var PAGE_SOURCE_CLASSNAME = "page-source-className-tailwind";
|
|
5041
|
-
var
|
|
5370
|
+
var asArray28 = (v) => Array.isArray(v) ? v : [];
|
|
5042
5371
|
var CLASSNAME_ATTR = /\bclassName\s*=\s*["'{]/g;
|
|
5043
5372
|
function validatePageSourceStyling(stack) {
|
|
5044
5373
|
const findings = [];
|
|
5045
|
-
const pages =
|
|
5374
|
+
const pages = asArray28(stack.pages);
|
|
5046
5375
|
for (let p = 0; p < pages.length; p++) {
|
|
5047
5376
|
const page = pages[p];
|
|
5048
5377
|
if (!page) continue;
|
|
@@ -5070,7 +5399,7 @@ function validatePageSourceStyling(stack) {
|
|
|
5070
5399
|
// src/validate-capability-references.ts
|
|
5071
5400
|
import { PLATFORM_CAPABILITY_NAMES } from "@objectstack/spec/security";
|
|
5072
5401
|
var CAPABILITY_REFERENCE_UNKNOWN = "capability-reference-unknown";
|
|
5073
|
-
function
|
|
5402
|
+
function asArray29(v) {
|
|
5074
5403
|
if (Array.isArray(v)) return v;
|
|
5075
5404
|
if (v && typeof v === "object") {
|
|
5076
5405
|
return Object.entries(v).map(([name, def]) => ({ name, ...def }));
|
|
@@ -5095,13 +5424,13 @@ function validateCapabilityReferences(stack) {
|
|
|
5095
5424
|
const findings = [];
|
|
5096
5425
|
if (!stack || typeof stack !== "object") return findings;
|
|
5097
5426
|
const known = new Set(PLATFORM_CAPABILITY_NAMES);
|
|
5098
|
-
for (const cap of
|
|
5427
|
+
for (const cap of asArray29(stack.capabilities)) {
|
|
5099
5428
|
if (typeof cap.name === "string" && cap.name.length > 0) known.add(cap.name);
|
|
5100
5429
|
}
|
|
5101
|
-
for (const ps of
|
|
5430
|
+
for (const ps of asArray29(stack.permissions)) {
|
|
5102
5431
|
for (const cap of asCapArray(ps.systemPermissions)) known.add(cap);
|
|
5103
5432
|
}
|
|
5104
|
-
for (const seed of
|
|
5433
|
+
for (const seed of asArray29(stack.data)) {
|
|
5105
5434
|
if (seed.object !== "sys_capability") continue;
|
|
5106
5435
|
for (const rec of Array.isArray(seed.records) ? seed.records : []) {
|
|
5107
5436
|
const name = rec?.name;
|
|
@@ -5120,7 +5449,7 @@ function validateCapabilityReferences(stack) {
|
|
|
5120
5449
|
hint
|
|
5121
5450
|
});
|
|
5122
5451
|
};
|
|
5123
|
-
const objects =
|
|
5452
|
+
const objects = asArray29(stack.objects);
|
|
5124
5453
|
for (let i = 0; i < objects.length; i++) {
|
|
5125
5454
|
const obj = objects[i];
|
|
5126
5455
|
if (!obj || typeof obj !== "object") continue;
|
|
@@ -5129,27 +5458,27 @@ function validateCapabilityReferences(stack) {
|
|
|
5129
5458
|
for (const { cap, key } of flattenObjectRequired(obj.requiredPermissions)) {
|
|
5130
5459
|
flag(cap, `object "${objName}"`, `${objPath}.requiredPermissions${key ? `.${key}` : ""}`);
|
|
5131
5460
|
}
|
|
5132
|
-
const fields =
|
|
5461
|
+
const fields = asArray29(obj.fields);
|
|
5133
5462
|
for (const f of fields) {
|
|
5134
5463
|
const fname = typeof f.name === "string" ? f.name : "(field)";
|
|
5135
5464
|
for (const cap of asCapArray(f.requiredPermissions)) {
|
|
5136
5465
|
flag(cap, `field "${objName}.${fname}"`, `${objPath}.fields.${fname}.requiredPermissions`);
|
|
5137
5466
|
}
|
|
5138
5467
|
}
|
|
5139
|
-
for (const [ai, action] of
|
|
5468
|
+
for (const [ai, action] of asArray29(obj.actions).entries()) {
|
|
5140
5469
|
const aName = typeof action.name === "string" ? action.name : `(action ${ai})`;
|
|
5141
5470
|
for (const cap of asCapArray(action.requiredPermissions)) {
|
|
5142
5471
|
flag(cap, `action "${objName}.${aName}"`, `${objPath}.actions[${ai}].requiredPermissions`);
|
|
5143
5472
|
}
|
|
5144
5473
|
}
|
|
5145
5474
|
}
|
|
5146
|
-
for (const [i, action] of
|
|
5475
|
+
for (const [i, action] of asArray29(stack.actions).entries()) {
|
|
5147
5476
|
const aName = typeof action.name === "string" ? action.name : `(action ${i})`;
|
|
5148
5477
|
for (const cap of asCapArray(action.requiredPermissions)) {
|
|
5149
5478
|
flag(cap, `action "${aName}"`, `actions[${i}].requiredPermissions`);
|
|
5150
5479
|
}
|
|
5151
5480
|
}
|
|
5152
|
-
const apps =
|
|
5481
|
+
const apps = asArray29(stack.apps);
|
|
5153
5482
|
for (let i = 0; i < apps.length; i++) {
|
|
5154
5483
|
const app = apps[i];
|
|
5155
5484
|
if (!app || typeof app !== "object") continue;
|
|
@@ -5176,11 +5505,14 @@ function validateCapabilityReferences(stack) {
|
|
|
5176
5505
|
}
|
|
5177
5506
|
|
|
5178
5507
|
// src/validate-flow-trigger-readiness.ts
|
|
5508
|
+
import { TimeRelativeTriggerSchema } from "@objectstack/spec/automation";
|
|
5179
5509
|
var FLOW_TRIGGER_UNKNOWN_OBJECT = "flow-trigger-unknown-object";
|
|
5180
5510
|
var FLOW_DRAFT_STATUS_AMBIGUOUS = "flow-draft-status-ambiguous";
|
|
5181
5511
|
var FLOW_TRIGGER_UNKNOWN_EVENT = "flow-trigger-unknown-event";
|
|
5512
|
+
var FLOW_TIME_RELATIVE_DESCRIPTOR_INVALID = "flow-time-relative-descriptor-invalid";
|
|
5513
|
+
var FLOW_TIME_RELATIVE_DESCRIPTOR_UNROUTABLE = "flow-time-relative-descriptor-unroutable";
|
|
5182
5514
|
var VALID_RECORD_TRIGGER = /^record-(?:before|after)-(?:create|insert|update|delete|write)$/;
|
|
5183
|
-
function
|
|
5515
|
+
function asArray30(v) {
|
|
5184
5516
|
if (Array.isArray(v)) return v;
|
|
5185
5517
|
if (v && typeof v === "object") {
|
|
5186
5518
|
return Object.entries(v).map(([name, def]) => ({
|
|
@@ -5190,6 +5522,12 @@ function asArray29(v) {
|
|
|
5190
5522
|
}
|
|
5191
5523
|
return [];
|
|
5192
5524
|
}
|
|
5525
|
+
function renderNonObject(v) {
|
|
5526
|
+
const t = typeof v;
|
|
5527
|
+
if (t === "string" || t === "number" || t === "boolean") return `${JSON.stringify(v)} (a ${t})`;
|
|
5528
|
+
if (t === "bigint") return `${String(v)}n (a bigint)`;
|
|
5529
|
+
return `a ${t}`;
|
|
5530
|
+
}
|
|
5193
5531
|
function startNodeOf(flow) {
|
|
5194
5532
|
const nodes = Array.isArray(flow.nodes) ? flow.nodes : [];
|
|
5195
5533
|
const index = nodes.findIndex((n) => n?.type === "start");
|
|
@@ -5197,10 +5535,10 @@ function startNodeOf(flow) {
|
|
|
5197
5535
|
}
|
|
5198
5536
|
function validateFlowTriggerReadiness(stack) {
|
|
5199
5537
|
const findings = [];
|
|
5200
|
-
const flows =
|
|
5538
|
+
const flows = asArray30(stack.flows);
|
|
5201
5539
|
if (flows.length === 0) return findings;
|
|
5202
5540
|
const objectNames = new Set(
|
|
5203
|
-
|
|
5541
|
+
asArray30(stack.objects).map((o) => typeof o.name === "string" ? o.name : void 0).filter((n) => !!n)
|
|
5204
5542
|
);
|
|
5205
5543
|
flows.forEach((flow, flowIndex) => {
|
|
5206
5544
|
const flowName = typeof flow.name === "string" ? flow.name : `#${flowIndex}`;
|
|
@@ -5237,10 +5575,35 @@ function validateFlowTriggerReadiness(stack) {
|
|
|
5237
5575
|
hint: `Object names match exactly. Check config.timeRelative.object against the object's registered name. If the object comes from another installed package, this warning can be ignored.`
|
|
5238
5576
|
});
|
|
5239
5577
|
}
|
|
5578
|
+
const parsed = TimeRelativeTriggerSchema.safeParse(tr);
|
|
5579
|
+
if (!parsed.success) {
|
|
5580
|
+
const problems = parsed.error.issues.map((i) => `${i.path.join(".") || "(root)"}: ${i.message.replace(/\s+/g, " ").trim()}`).join("; ");
|
|
5581
|
+
findings.push({
|
|
5582
|
+
// `error` (#5762): the verdict is `TimeRelativeTriggerSchema`'s, and it
|
|
5583
|
+
// is the same schema the trigger safeParses at bind time. A descriptor
|
|
5584
|
+
// it refuses is refused at bind too — the sweep is never installed, on
|
|
5585
|
+
// every deployment, with no installed package able to change the
|
|
5586
|
+
// answer. Nothing is left for the author to weigh.
|
|
5587
|
+
severity: "error",
|
|
5588
|
+
rule: FLOW_TIME_RELATIVE_DESCRIPTOR_INVALID,
|
|
5589
|
+
where: `flow "${flowName}" \u203A start node`,
|
|
5590
|
+
path: `flows[${flowIndex}].nodes[${start.index}].config.timeRelative`,
|
|
5591
|
+
message: `has a config.timeRelative descriptor the time-relative trigger REFUSES at bind time, so the sweep is never installed \u2014 the flow declares a time-relative trigger and then never runs (the only trace is one warn in the server log). ${problems}`,
|
|
5592
|
+
hint: `Those messages are TimeRelativeTriggerSchema's own \u2014 the same schema the trigger safeParses at bind time, so a descriptor that satisfies them binds. An unrecognized key names the declared key it was probably meant to be; see content/docs/references/automation/time-relative-trigger.mdx.`
|
|
5593
|
+
});
|
|
5594
|
+
}
|
|
5240
5595
|
}
|
|
5241
5596
|
if (start && isRecordTriggered2 && !VALID_RECORD_TRIGGER.test((triggerType ?? "").trim())) {
|
|
5242
5597
|
findings.push({
|
|
5243
|
-
|
|
5598
|
+
// `error` (#5762). The token grammar is CLOSED and local: the engine
|
|
5599
|
+
// routes any `record-`-prefixed string to the record-change trigger by a
|
|
5600
|
+
// hardcoded prefix test (no registry lookup, so installing a package
|
|
5601
|
+
// cannot claim a new `record-*` token), and that trigger maps the token
|
|
5602
|
+
// with `triggerTypeToHookEvents` — the same regex this file's
|
|
5603
|
+
// `VALID_RECORD_TRIGGER` mirrors. Off-grammar means zero hook events,
|
|
5604
|
+
// which means bound-to-nothing on every deployment. Unlike an object
|
|
5605
|
+
// name, there is no other-package reading that rescues it.
|
|
5606
|
+
severity: "error",
|
|
5244
5607
|
rule: FLOW_TRIGGER_UNKNOWN_EVENT,
|
|
5245
5608
|
where: `flow "${flowName}" \u203A start node`,
|
|
5246
5609
|
path: `flows[${flowIndex}].nodes[${start.index}].config.triggerType`,
|
|
@@ -5250,7 +5613,14 @@ function validateFlowTriggerReadiness(stack) {
|
|
|
5250
5613
|
}
|
|
5251
5614
|
if (start && isArrayRecordTriggered) {
|
|
5252
5615
|
findings.push({
|
|
5253
|
-
|
|
5616
|
+
// `error` (#5762), same id and same reason as 1c: an array maps to no
|
|
5617
|
+
// hook event either. The engine routes it to the record-change trigger
|
|
5618
|
+
// for the express purpose of making it loud, and its own comment names
|
|
5619
|
+
// THIS rule as the primary catch — a primary catch that only warns is
|
|
5620
|
+
// the "declared ≠ enforced" shape the registry's tier exists to close.
|
|
5621
|
+
// Multi-event arrays are deferred, not unsupported-by-accident (#3457),
|
|
5622
|
+
// so if they land the grammar widens here in the same commit.
|
|
5623
|
+
severity: "error",
|
|
5254
5624
|
rule: FLOW_TRIGGER_UNKNOWN_EVENT,
|
|
5255
5625
|
where: `flow "${flowName}" \u203A start node`,
|
|
5256
5626
|
path: `flows[${flowIndex}].nodes[${start.index}].config.triggerType`,
|
|
@@ -5258,6 +5628,25 @@ function validateFlowTriggerReadiness(stack) {
|
|
|
5258
5628
|
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).`
|
|
5259
5629
|
});
|
|
5260
5630
|
}
|
|
5631
|
+
if (start && config.timeRelative != null && typeof config.timeRelative !== "object") {
|
|
5632
|
+
const fallback = isRecordTriggered2 || isArrayRecordTriggered ? "its record-change trigger" : config.schedule != null || flow.type === "schedule" ? "its plain `config.schedule` cadence" : triggerType === "api" || flow.type === "api" ? "its api trigger" : void 0;
|
|
5633
|
+
const consequence2 = fallback ? `The flow still binds through ${fallback}, so the descriptor is silently DROPPED \u2014 it fires on that trigger's terms (once per firing, with no record on the context) instead of once per matching record, and nothing anywhere reports the difference.` : `Nothing else on this start node declares a trigger either, so the flow binds to NOTHING and never fires \u2014 with zero diagnostics at any layer, not even the one bind-time warn a descriptor that IS an object gets when the trigger refuses it.`;
|
|
5634
|
+
findings.push({
|
|
5635
|
+
// `error` (#5762). The criterion IS the engine's routing predicate, so a
|
|
5636
|
+
// value that fails it is not routed to the time-relative trigger by any
|
|
5637
|
+
// deployment — the strongest verdict in this file, and the one case with
|
|
5638
|
+
// no runtime channel to fall back on (not even the bind-time warn 1b-ii
|
|
5639
|
+
// moves earlier). Note the two consequences below are both defects: one
|
|
5640
|
+
// never fires, the other silently drops the descriptor. Neither is a
|
|
5641
|
+
// shape the author can have meant, so both gate.
|
|
5642
|
+
severity: "error",
|
|
5643
|
+
rule: FLOW_TIME_RELATIVE_DESCRIPTOR_UNROUTABLE,
|
|
5644
|
+
where: `flow "${flowName}" \u203A start node`,
|
|
5645
|
+
path: `flows[${flowIndex}].nodes[${start.index}].config.timeRelative`,
|
|
5646
|
+
message: `has config.timeRelative = ${renderNonObject(config.timeRelative)}, which is not the descriptor OBJECT this slot takes \u2014 the engine routes a flow to the time-relative sweep only when config.timeRelative is an object, so this one is never routed there and the sweep is never installed. ${consequence2}`,
|
|
5647
|
+
hint: `config.timeRelative describes WHICH records to sweep \u2014 an object: { object, dateField, and exactly one of withinDays | offsetDays } (plus optional filter / maxRecords). A cadence like 'daily' is not a descriptor: HOW OFTEN the sweep runs is the sibling key config.schedule on the same start node (it defaults to daily, so it is usually omitted). See TimeRelativeTriggerSchema and content/docs/references/automation/time-relative-trigger.mdx.`
|
|
5648
|
+
});
|
|
5649
|
+
}
|
|
5261
5650
|
if (isAutoTriggered && (flow.status == null || flow.status === "draft")) {
|
|
5262
5651
|
findings.push({
|
|
5263
5652
|
severity: "warning",
|
|
@@ -5283,7 +5672,7 @@ import {
|
|
|
5283
5672
|
normalizeDecisionOutputs
|
|
5284
5673
|
} from "@objectstack/spec/automation";
|
|
5285
5674
|
import { BUILTIN_MEMBERSHIP_ROLES } from "@objectstack/spec";
|
|
5286
|
-
import { collectCelRootIdentifiers } from "@objectstack/formula";
|
|
5675
|
+
import { collectCelRootIdentifiers as collectCelRootIdentifiers2 } from "@objectstack/formula";
|
|
5287
5676
|
var APPROVAL_APPROVER_NOT_MEMBERSHIP_TIER = "approval-approver-not-membership-tier";
|
|
5288
5677
|
var APPROVAL_APPROVER_TYPE_DEPRECATED = "approval-approver-type-deprecated";
|
|
5289
5678
|
var APPROVAL_APPROVER_TYPE_UNKNOWN = "approval-approver-type-unknown";
|
|
@@ -5303,7 +5692,7 @@ var TYPE_FIX = {
|
|
|
5303
5692
|
business_unit: "department",
|
|
5304
5693
|
bu: "department"
|
|
5305
5694
|
};
|
|
5306
|
-
function
|
|
5695
|
+
function asArray31(v) {
|
|
5307
5696
|
if (Array.isArray(v)) return v;
|
|
5308
5697
|
if (v && typeof v === "object") {
|
|
5309
5698
|
return Object.entries(v).map(([name, def]) => ({ name, ...def }));
|
|
@@ -5313,7 +5702,7 @@ function asArray30(v) {
|
|
|
5313
5702
|
function validateApprovalApprovers(stack) {
|
|
5314
5703
|
const findings = [];
|
|
5315
5704
|
if (!stack || typeof stack !== "object") return findings;
|
|
5316
|
-
const flows =
|
|
5705
|
+
const flows = asArray31(stack.flows);
|
|
5317
5706
|
const validTypes = new Set(ApproverType.options);
|
|
5318
5707
|
for (let fi = 0; fi < flows.length; fi++) {
|
|
5319
5708
|
const flow = flows[fi];
|
|
@@ -5358,7 +5747,7 @@ function validateApprovalApprovers(stack) {
|
|
|
5358
5747
|
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.`
|
|
5359
5748
|
});
|
|
5360
5749
|
} else {
|
|
5361
|
-
const parsed =
|
|
5750
|
+
const parsed = collectCelRootIdentifiers2(source);
|
|
5362
5751
|
if (!parsed.ok) {
|
|
5363
5752
|
findings.push({
|
|
5364
5753
|
severity: "error",
|
|
@@ -5496,7 +5885,7 @@ function validateApprovalApprovers(stack) {
|
|
|
5496
5885
|
import { objectTitleCompleteness } from "@objectstack/spec/data";
|
|
5497
5886
|
var TITLE_FORMAT_RETIRED = "title-format-retired";
|
|
5498
5887
|
var TITLE_UNRESOLVABLE = "title-unresolvable";
|
|
5499
|
-
function
|
|
5888
|
+
function asArray32(v) {
|
|
5500
5889
|
if (Array.isArray(v)) return v;
|
|
5501
5890
|
if (v && typeof v === "object") {
|
|
5502
5891
|
return Object.entries(v).map(([name, def]) => ({ name, ...def }));
|
|
@@ -5505,7 +5894,7 @@ function asArray31(v) {
|
|
|
5505
5894
|
}
|
|
5506
5895
|
function validateRecordTitle(stack) {
|
|
5507
5896
|
const findings = [];
|
|
5508
|
-
const objects =
|
|
5897
|
+
const objects = asArray32(stack.objects);
|
|
5509
5898
|
for (let i = 0; i < objects.length; i++) {
|
|
5510
5899
|
const obj = objects[i];
|
|
5511
5900
|
const objName = typeof obj.name === "string" ? obj.name : `(object ${i})`;
|
|
@@ -5541,7 +5930,7 @@ var FIELD_GROUP_UNDECLARED = "field-group-undeclared";
|
|
|
5541
5930
|
var FIELD_GROUP_EMPTY = "field-group-empty";
|
|
5542
5931
|
var FIELD_GROUP_SHADOWED = "field-group-shadowed";
|
|
5543
5932
|
var SEMANTIC_ROLE_FIELD_UNKNOWN = "semantic-role-field-unknown";
|
|
5544
|
-
function
|
|
5933
|
+
function asArray33(v) {
|
|
5545
5934
|
if (Array.isArray(v)) return v;
|
|
5546
5935
|
if (v && typeof v === "object") {
|
|
5547
5936
|
return Object.entries(v).map(([name, def]) => ({ name, ...def }));
|
|
@@ -5550,7 +5939,7 @@ function asArray32(v) {
|
|
|
5550
5939
|
}
|
|
5551
5940
|
function validateSemanticRoles(stack) {
|
|
5552
5941
|
const findings = [];
|
|
5553
|
-
const objects =
|
|
5942
|
+
const objects = asArray33(stack.objects);
|
|
5554
5943
|
for (let i = 0; i < objects.length; i++) {
|
|
5555
5944
|
const obj = objects[i];
|
|
5556
5945
|
if (!obj || typeof obj !== "object") continue;
|
|
@@ -5558,7 +5947,7 @@ function validateSemanticRoles(stack) {
|
|
|
5558
5947
|
const where = `object "${objName}"`;
|
|
5559
5948
|
const path = `objects[${i}]`;
|
|
5560
5949
|
const fields = obj.fields && typeof obj.fields === "object" && !Array.isArray(obj.fields) ? obj.fields : {};
|
|
5561
|
-
const fieldNames = new Set(Object.keys(fields));
|
|
5950
|
+
const fieldNames = /* @__PURE__ */ new Set([...Object.keys(fields), ...injectedColumnsFor(obj)]);
|
|
5562
5951
|
const declaredGroups = new Set(
|
|
5563
5952
|
(Array.isArray(obj.fieldGroups) ? obj.fieldGroups : []).filter((g) => !!g && typeof g === "object").map((g) => g.key).filter((k) => typeof k === "string" && k.length > 0)
|
|
5564
5953
|
);
|
|
@@ -5645,7 +6034,7 @@ function validateSemanticRoles(stack) {
|
|
|
5645
6034
|
// src/validate-form-layout.ts
|
|
5646
6035
|
var FORM_FIELD_UNKNOWN = "form-field-unknown";
|
|
5647
6036
|
var FORM_COLSPAN_ABSOLUTE = "absolute-colspan-discouraged";
|
|
5648
|
-
function
|
|
6037
|
+
function asArray34(v) {
|
|
5649
6038
|
if (Array.isArray(v)) return v;
|
|
5650
6039
|
if (v && typeof v === "object") {
|
|
5651
6040
|
return Object.entries(v).map(([name, def]) => ({ name, ...def }));
|
|
@@ -5670,13 +6059,13 @@ function boundObject(view) {
|
|
|
5670
6059
|
function validateFormLayout(stack) {
|
|
5671
6060
|
const findings = [];
|
|
5672
6061
|
const objectFields = /* @__PURE__ */ new Map();
|
|
5673
|
-
for (const obj of
|
|
6062
|
+
for (const obj of asArray34(stack.objects)) {
|
|
5674
6063
|
const name = typeof obj.name === "string" ? obj.name : void 0;
|
|
5675
6064
|
if (!name) continue;
|
|
5676
6065
|
const fields = obj.fields && typeof obj.fields === "object" && !Array.isArray(obj.fields) ? Object.keys(obj.fields) : [];
|
|
5677
6066
|
objectFields.set(name, new Set(fields));
|
|
5678
6067
|
}
|
|
5679
|
-
const views =
|
|
6068
|
+
const views = asArray34(stack.views);
|
|
5680
6069
|
for (let i = 0; i < views.length; i++) {
|
|
5681
6070
|
const view = views[i];
|
|
5682
6071
|
if (!view || typeof view !== "object") continue;
|
|
@@ -5816,7 +6205,7 @@ var VISIBILITY_ALIAS_DEPRECATED = "visibility-alias-deprecated";
|
|
|
5816
6205
|
var VISIBILITY_ROOT_MISLAYERED = "visibility-root-mislayered";
|
|
5817
6206
|
var CANONICAL = "visibleWhen";
|
|
5818
6207
|
var ALIASES = ["visibleOn", "visibility"];
|
|
5819
|
-
function
|
|
6208
|
+
function asArray35(v) {
|
|
5820
6209
|
if (Array.isArray(v)) return v;
|
|
5821
6210
|
if (v && typeof v === "object") {
|
|
5822
6211
|
return Object.entries(v).map(([name, def]) => ({ name, ...def }));
|
|
@@ -5878,7 +6267,7 @@ function isFieldObject(entry) {
|
|
|
5878
6267
|
function validateVisibilityPredicates(stack, opts = {}) {
|
|
5879
6268
|
const layer = opts.layer ?? "runtime";
|
|
5880
6269
|
const findings = [];
|
|
5881
|
-
const views =
|
|
6270
|
+
const views = asArray35(stack.views);
|
|
5882
6271
|
for (let i = 0; i < views.length; i++) {
|
|
5883
6272
|
const view = views[i];
|
|
5884
6273
|
if (!view || typeof view !== "object") continue;
|
|
@@ -5901,7 +6290,7 @@ function validateVisibilityPredicates(stack, opts = {}) {
|
|
|
5901
6290
|
}
|
|
5902
6291
|
}
|
|
5903
6292
|
}
|
|
5904
|
-
const pages =
|
|
6293
|
+
const pages = asArray35(stack.pages);
|
|
5905
6294
|
for (let i = 0; i < pages.length; i++) {
|
|
5906
6295
|
const page = pages[i];
|
|
5907
6296
|
if (!page || typeof page !== "object") continue;
|
|
@@ -5948,7 +6337,7 @@ var OWD_WIDTH = {
|
|
|
5948
6337
|
public_read: 1,
|
|
5949
6338
|
public_read_write: 2
|
|
5950
6339
|
};
|
|
5951
|
-
function
|
|
6340
|
+
function asArray36(v) {
|
|
5952
6341
|
if (Array.isArray(v)) return v;
|
|
5953
6342
|
if (v && typeof v === "object") {
|
|
5954
6343
|
return Object.entries(v).map(([name, def]) => ({ name, ...def }));
|
|
@@ -5956,7 +6345,7 @@ function asArray35(v) {
|
|
|
5956
6345
|
return [];
|
|
5957
6346
|
}
|
|
5958
6347
|
function owdOf(obj) {
|
|
5959
|
-
return obj.sharingModel
|
|
6348
|
+
return obj.sharingModel;
|
|
5960
6349
|
}
|
|
5961
6350
|
function isSystemObject(obj) {
|
|
5962
6351
|
return obj.isSystem === true || String(obj.name ?? "").startsWith("sys_");
|
|
@@ -5970,11 +6359,11 @@ function labelHasRoleWord(label2) {
|
|
|
5970
6359
|
return /\brole(s)?\b/i.test(label2);
|
|
5971
6360
|
}
|
|
5972
6361
|
function refOf(def) {
|
|
5973
|
-
const r = def.reference
|
|
6362
|
+
const r = def.reference;
|
|
5974
6363
|
return typeof r === "string" && r ? r : void 0;
|
|
5975
6364
|
}
|
|
5976
6365
|
function firstMasterDetailField(obj) {
|
|
5977
|
-
for (const f of
|
|
6366
|
+
for (const f of asArray36(obj.fields)) {
|
|
5978
6367
|
if (f.type === "master_detail") {
|
|
5979
6368
|
return { name: String(f.name ?? "?"), parent: refOf(f) };
|
|
5980
6369
|
}
|
|
@@ -5987,8 +6376,8 @@ function grantsObjectAccess(p) {
|
|
|
5987
6376
|
function validateSecurityPosture(stack, opts) {
|
|
5988
6377
|
const findings = [];
|
|
5989
6378
|
if (!stack || typeof stack !== "object") return findings;
|
|
5990
|
-
const objects =
|
|
5991
|
-
const permissionSets =
|
|
6379
|
+
const objects = asArray36(stack.objects);
|
|
6380
|
+
const permissionSets = asArray36(stack.permissions);
|
|
5992
6381
|
for (let i = 0; i < objects.length; i++) {
|
|
5993
6382
|
const obj = objects[i];
|
|
5994
6383
|
if (!obj || typeof obj !== "object") continue;
|
|
@@ -6117,10 +6506,10 @@ function validateSecurityPosture(stack, opts) {
|
|
|
6117
6506
|
if (!obj || typeof obj !== "object" || isSystemObject(obj)) continue;
|
|
6118
6507
|
const objName = typeof obj.name === "string" ? obj.name : `(object ${i})`;
|
|
6119
6508
|
flagRole("object", obj.name, obj.label, `object "${objName}"`, `objects[${i}].name`);
|
|
6120
|
-
for (const f of
|
|
6509
|
+
for (const f of asArray36(obj.fields)) {
|
|
6121
6510
|
flagRole("field", f.name, f.label, `field "${objName}.${String(f.name ?? "?")}"`, `objects[${i}].fields.${String(f.name ?? "?")}.name`);
|
|
6122
6511
|
}
|
|
6123
|
-
for (const [ai, action] of
|
|
6512
|
+
for (const [ai, action] of asArray36(obj.actions).entries()) {
|
|
6124
6513
|
flagRole("action", action.name, action.label, `action "${objName}.${String(action.name ?? "?")}"`, `objects[${i}].actions[${ai}].name`);
|
|
6125
6514
|
}
|
|
6126
6515
|
}
|
|
@@ -6129,19 +6518,19 @@ function validateSecurityPosture(stack, opts) {
|
|
|
6129
6518
|
if (!ps || typeof ps !== "object") continue;
|
|
6130
6519
|
flagRole("permission set", ps.name, ps.label, `permission set "${String(ps.name ?? i)}"`, `permissions[${i}].name`);
|
|
6131
6520
|
}
|
|
6132
|
-
for (const [i, pos] of
|
|
6521
|
+
for (const [i, pos] of asArray36(stack.positions).entries()) {
|
|
6133
6522
|
flagRole("position", pos.name, pos.label, `position "${String(pos.name ?? i)}"`, `positions[${i}].name`);
|
|
6134
6523
|
}
|
|
6135
|
-
for (const [i, app] of
|
|
6524
|
+
for (const [i, app] of asArray36(stack.apps).entries()) {
|
|
6136
6525
|
flagRole("app", app.name, app.label, `app "${String(app.name ?? i)}"`, `apps[${i}].name`);
|
|
6137
6526
|
}
|
|
6138
|
-
for (const [i, book] of
|
|
6527
|
+
for (const [i, book] of asArray36(stack.books).entries()) {
|
|
6139
6528
|
flagRole("book", book.name, book.label, `book "${String(book.name ?? i)}"`, `books[${i}].name`);
|
|
6140
6529
|
}
|
|
6141
6530
|
const stackSetNames = new Set(
|
|
6142
6531
|
permissionSets.map((ps) => typeof ps.name === "string" ? ps.name : void 0).filter((n) => !!n)
|
|
6143
6532
|
);
|
|
6144
|
-
for (const [i, book] of
|
|
6533
|
+
for (const [i, book] of asArray36(stack.books).entries()) {
|
|
6145
6534
|
const audience = book.audience;
|
|
6146
6535
|
if (!audience || typeof audience !== "object") continue;
|
|
6147
6536
|
const setName = audience.permissionSet;
|
|
@@ -6219,7 +6608,7 @@ function validateSecurityPosture(stack, opts) {
|
|
|
6219
6608
|
}
|
|
6220
6609
|
const GRANT_SEED_OBJECTS = /* @__PURE__ */ new Set(["sys_user_position", "sys_user_permission_set"]);
|
|
6221
6610
|
const nowMs = opts?.nowMs ?? Date.now();
|
|
6222
|
-
for (const [i, seed] of
|
|
6611
|
+
for (const [i, seed] of asArray36(stack.data).entries()) {
|
|
6223
6612
|
const seedObject = typeof seed.object === "string" ? seed.object : "";
|
|
6224
6613
|
if (!GRANT_SEED_OBJECTS.has(seedObject)) continue;
|
|
6225
6614
|
const records = Array.isArray(seed.records) ? seed.records : [];
|
|
@@ -6263,7 +6652,8 @@ function validateSecurityPosture(stack, opts) {
|
|
|
6263
6652
|
var ORG_AXIS_PERMISSION_INHERITANCE = "org-axis-permission-inheritance";
|
|
6264
6653
|
var ORG_AXIS_CROSS_ORG_BU_GRANT = "org-axis-cross-org-bu-grant";
|
|
6265
6654
|
var ORG_PARENT_FIELD = "parent_organization_id";
|
|
6266
|
-
|
|
6655
|
+
var BU_TREE_RECIPIENT_TYPES = /* @__PURE__ */ new Set(["business_unit", "unit_and_subordinates"]);
|
|
6656
|
+
function asArray37(v) {
|
|
6267
6657
|
if (Array.isArray(v)) return v;
|
|
6268
6658
|
if (v && typeof v === "object") {
|
|
6269
6659
|
return Object.entries(v).map(([name, def]) => ({ name, ...def }));
|
|
@@ -6273,6 +6663,15 @@ function asArray36(v) {
|
|
|
6273
6663
|
function str(v) {
|
|
6274
6664
|
return typeof v === "string" ? v : "";
|
|
6275
6665
|
}
|
|
6666
|
+
function expressionText(v) {
|
|
6667
|
+
if (typeof v === "string") return v;
|
|
6668
|
+
if (v && typeof v === "object") {
|
|
6669
|
+
const rec = v;
|
|
6670
|
+
if (typeof rec.source === "string") return rec.source;
|
|
6671
|
+
if (rec.ast !== void 0) return JSON.stringify(rec.ast) ?? "";
|
|
6672
|
+
}
|
|
6673
|
+
return "";
|
|
6674
|
+
}
|
|
6276
6675
|
function isTenancyDisabled(object) {
|
|
6277
6676
|
const tenancy = object.tenancy;
|
|
6278
6677
|
if (tenancy && typeof tenancy === "object" && tenancy.enabled === false) return true;
|
|
@@ -6284,9 +6683,9 @@ var INHERITANCE_HINT = `Remove the ${ORG_PARENT_FIELD} reference. Cross-organiza
|
|
|
6284
6683
|
function validateOrgAxisRedLines(stack) {
|
|
6285
6684
|
const findings = [];
|
|
6286
6685
|
const cfg = stack ?? {};
|
|
6287
|
-
const permissionSets =
|
|
6686
|
+
const permissionSets = asArray37(cfg.permissions);
|
|
6288
6687
|
permissionSets.forEach((ps, psIndex) => {
|
|
6289
|
-
|
|
6688
|
+
asArray37(ps.rowLevelSecurity).forEach((policy, pIndex) => {
|
|
6290
6689
|
for (const clause of ["using", "check"]) {
|
|
6291
6690
|
if (!str(policy[clause]).includes(ORG_PARENT_FIELD)) continue;
|
|
6292
6691
|
findings.push({
|
|
@@ -6300,68 +6699,434 @@ function validateOrgAxisRedLines(stack) {
|
|
|
6300
6699
|
}
|
|
6301
6700
|
});
|
|
6302
6701
|
});
|
|
6303
|
-
|
|
6304
|
-
|
|
6305
|
-
|
|
6306
|
-
|
|
6307
|
-
|
|
6308
|
-
|
|
6309
|
-
|
|
6310
|
-
severity: "error",
|
|
6311
|
-
rule: ORG_AXIS_PERMISSION_INHERITANCE,
|
|
6312
|
-
where: `object "${objectName}" policy "${str(policy.name) || pIndex}"`,
|
|
6313
|
-
path: `objects[${oIndex}].rowLevelSecurity[${pIndex}].${clause}`,
|
|
6314
|
-
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.`,
|
|
6315
|
-
hint: INHERITANCE_HINT
|
|
6316
|
-
});
|
|
6317
|
-
}
|
|
6318
|
-
});
|
|
6319
|
-
});
|
|
6320
|
-
asArray36(cfg.sharingRules ?? cfg.sharing).forEach((rule, rIndex) => {
|
|
6321
|
-
const criteria = JSON.stringify(rule.criteria ?? rule.filter ?? "");
|
|
6322
|
-
const sharedTo = JSON.stringify(rule.sharedTo ?? rule.recipient ?? "");
|
|
6323
|
-
if (criteria.includes(ORG_PARENT_FIELD) || sharedTo.includes(ORG_PARENT_FIELD)) {
|
|
6702
|
+
asArray37(cfg.sharingRules).forEach((rule, rIndex) => {
|
|
6703
|
+
const slots = [
|
|
6704
|
+
{ key: "condition", text: expressionText(rule.condition) },
|
|
6705
|
+
{ key: "sharedWith", text: JSON.stringify(rule.sharedWith ?? "") ?? "" }
|
|
6706
|
+
];
|
|
6707
|
+
for (const slot of slots) {
|
|
6708
|
+
if (!slot.text.includes(ORG_PARENT_FIELD)) continue;
|
|
6324
6709
|
findings.push({
|
|
6325
6710
|
severity: "error",
|
|
6326
6711
|
rule: ORG_AXIS_PERMISSION_INHERITANCE,
|
|
6327
6712
|
where: `sharing rule "${str(rule.name) || rIndex}"`,
|
|
6328
|
-
path: `sharingRules[${rIndex}]`,
|
|
6329
|
-
message: `Sharing rule reads \`${ORG_PARENT_FIELD}\`, granting access by walking the organization tree. ADR-0105 D6 forbids permission inheritance along the org axis.`,
|
|
6713
|
+
path: `sharingRules[${rIndex}].${slot.key}`,
|
|
6714
|
+
message: `Sharing rule ${slot.key} reads \`${ORG_PARENT_FIELD}\`, granting access by walking the organization tree. ADR-0105 D6 forbids permission inheritance along the org axis.`,
|
|
6330
6715
|
hint: INHERITANCE_HINT
|
|
6331
6716
|
});
|
|
6332
6717
|
}
|
|
6333
6718
|
});
|
|
6334
6719
|
const tenancyDisabledObjects = new Set(
|
|
6335
|
-
objects.filter((o) => isTenancyDisabled(o)).map((o) => str(o.name)).filter(Boolean)
|
|
6720
|
+
asArray37(cfg.objects).filter((o) => isTenancyDisabled(o)).map((o) => str(o.name)).filter(Boolean)
|
|
6336
6721
|
);
|
|
6337
|
-
|
|
6338
|
-
const target = str(rule.object
|
|
6722
|
+
asArray37(cfg.sharingRules).forEach((rule, rIndex) => {
|
|
6723
|
+
const target = str(rule.object);
|
|
6339
6724
|
if (!target || !tenancyDisabledObjects.has(target)) return;
|
|
6340
|
-
const
|
|
6341
|
-
const recipientType = str(
|
|
6342
|
-
if (recipientType
|
|
6725
|
+
const sharedWith = rule.sharedWith;
|
|
6726
|
+
const recipientType = str(sharedWith?.type);
|
|
6727
|
+
if (!BU_TREE_RECIPIENT_TYPES.has(recipientType)) return;
|
|
6728
|
+
const reach = recipientType === "unit_and_subordinates" ? "a business unit AND every descendant unit" : "a business unit";
|
|
6343
6729
|
findings.push({
|
|
6344
6730
|
severity: "error",
|
|
6345
6731
|
rule: ORG_AXIS_CROSS_ORG_BU_GRANT,
|
|
6346
6732
|
where: `sharing rule "${str(rule.name) || rIndex}" on object "${target}"`,
|
|
6347
|
-
path: `sharingRules[${rIndex}].
|
|
6348
|
-
message: `
|
|
6349
|
-
hint: `Either scope the object to organizations (drop \`tenancy.enabled: false\` so Layer 0 walls it), or share it to a
|
|
6733
|
+
path: `sharingRules[${rIndex}].sharedWith`,
|
|
6734
|
+
message: `Sharing rule recipient \`${recipientType}\` (${reach}) 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).`,
|
|
6735
|
+
hint: `Either scope the object to organizations (drop \`tenancy.enabled: false\` so Layer 0 walls it), or share it to a \`user\` / \`team\` / \`position\` audience instead \u2014 those expand flat, with no business-unit tree to resolve. A platform-global catalog that everyone should read wants an OWD of \`public_read\`, not a BU grant.`
|
|
6736
|
+
});
|
|
6737
|
+
});
|
|
6738
|
+
return findings;
|
|
6739
|
+
}
|
|
6740
|
+
|
|
6741
|
+
// src/validate-sharing-rule-enforceability.ts
|
|
6742
|
+
import { compileCelToFilter } from "@objectstack/formula";
|
|
6743
|
+
var SHARING_RULE_UNLOWERABLE_CONDITION = "sharing-rule-unlowerable-condition";
|
|
6744
|
+
var SHARING_RULE_RUNTIME_VARIABLE_CONDITION = "sharing-rule-runtime-variable-condition";
|
|
6745
|
+
function asArray38(v) {
|
|
6746
|
+
if (Array.isArray(v)) return v;
|
|
6747
|
+
if (v && typeof v === "object") {
|
|
6748
|
+
return Object.entries(v).map(([name, def]) => ({ name, ...def }));
|
|
6749
|
+
}
|
|
6750
|
+
return [];
|
|
6751
|
+
}
|
|
6752
|
+
function str2(v) {
|
|
6753
|
+
return typeof v === "string" ? v : "";
|
|
6754
|
+
}
|
|
6755
|
+
function toCompilerInput(condition) {
|
|
6756
|
+
if (typeof condition === "string") return condition.trim() ? condition : null;
|
|
6757
|
+
if (condition && typeof condition === "object") {
|
|
6758
|
+
const source = condition.source;
|
|
6759
|
+
if (typeof source === "string" && source.trim()) return { source };
|
|
6760
|
+
}
|
|
6761
|
+
return null;
|
|
6762
|
+
}
|
|
6763
|
+
function sourceOf(condition) {
|
|
6764
|
+
const input = toCompilerInput(condition);
|
|
6765
|
+
if (typeof input === "string") return input;
|
|
6766
|
+
return str2(input?.source);
|
|
6767
|
+
}
|
|
6768
|
+
var PUSHDOWN_SUBSET = "The lowerable subset is: `==` `!=` `>` `<` `>=` `<=`, `in`, `&&` `||` `!`, `== null` / `!= null`, and the string methods `startsWith` / `endsWith` / `contains` \u2014 over SINGLE-column `record.<field>` paths (ADR-0058 D2).";
|
|
6769
|
+
function validateSharingRuleEnforceability(stack) {
|
|
6770
|
+
const findings = [];
|
|
6771
|
+
const cfg = stack ?? {};
|
|
6772
|
+
asArray38(cfg.sharingRules).forEach((rule, index) => {
|
|
6773
|
+
const input = toCompilerInput(rule.condition);
|
|
6774
|
+
if (input === null) return;
|
|
6775
|
+
const result = compileCelToFilter(input, { variables: {} });
|
|
6776
|
+
if (result.ok) return;
|
|
6777
|
+
if (result.reason === "parse-error") return;
|
|
6778
|
+
const name = str2(rule.name) || String(index);
|
|
6779
|
+
const object = str2(rule.object);
|
|
6780
|
+
const where = `sharing rule "${name}"${object ? ` on object "${object}"` : ""}`;
|
|
6781
|
+
const path = `sharingRules[${index}].condition`;
|
|
6782
|
+
const source = sourceOf(rule.condition);
|
|
6783
|
+
const skipped = "so `bootstrapDeclaredSharingRules` SKIPS the rule at boot: it is never written to `sys_sharing_rule`, no `sys_record_share` grant is ever materialised, and the only signal is one WARN line in the boot log. The rule is declared and grants nothing (ADR-0049: an unlowerable condition is never seeded as a permissive match-all).";
|
|
6784
|
+
if (result.reason === "unresolved-variable") {
|
|
6785
|
+
findings.push({
|
|
6786
|
+
severity: "error",
|
|
6787
|
+
rule: SHARING_RULE_RUNTIME_VARIABLE_CONDITION,
|
|
6788
|
+
where,
|
|
6789
|
+
path,
|
|
6790
|
+
message: `Sharing-rule condition \`${source}\` reads a runtime variable (${result.detail}), ` + skipped,
|
|
6791
|
+
hint: "A criteria sharing rule is MATERIALISED: the seeder compiles ONE static `criteria_json` per rule and the evaluator writes `sys_record_share` rows from it, so there is no \"current user\" for the condition to read. Express per-user access with the mechanism that runs per request instead \u2014 an RLS policy on a permission set (`rowLevelSecurity[].using`, where `current_user.*` IS resolved), or the record-ownership path. Keep this rule for the part of the predicate that is a property of the RECORD (e.g. `record.stage == 'closed_won'`) and name the audience through `sharedWith`."
|
|
6792
|
+
});
|
|
6793
|
+
return;
|
|
6794
|
+
}
|
|
6795
|
+
findings.push({
|
|
6796
|
+
severity: "error",
|
|
6797
|
+
rule: SHARING_RULE_UNLOWERABLE_CONDITION,
|
|
6798
|
+
where,
|
|
6799
|
+
path,
|
|
6800
|
+
message: `Sharing-rule condition \`${source}\` is outside the pushdown subset the runtime can compile (${result.detail}), ` + skipped,
|
|
6801
|
+
hint: "Rewrite the predicate inside the lowerable subset. " + PUSHDOWN_SUBSET + " Two traps in particular: (1) `has(record.x)` is correct in an object VALIDATION rule, which is INTERPRETED, and wrong here, where the condition is COMPILED \u2014 write the null test as `record.x != null`; (2) a related-record path (`record.account.region`) is a join, which the compiler refuses by design (ADR-0055) \u2014 denormalise the value onto this object (a formula/rollup field) and test that column, or share the related object instead."
|
|
6802
|
+
});
|
|
6803
|
+
});
|
|
6804
|
+
return findings;
|
|
6805
|
+
}
|
|
6806
|
+
|
|
6807
|
+
// src/validate-rls-predicate-enforceability.ts
|
|
6808
|
+
import { isPushdownableCel, isSupportedRlsExpression, sqlPredicateToCel } from "@objectstack/formula";
|
|
6809
|
+
var RLS_PREDICATE_UNENFORCEABLE = "rls-predicate-unenforceable";
|
|
6810
|
+
var RLS_PREDICATE_UNPARSEABLE = "rls-predicate-unparseable";
|
|
6811
|
+
function asArray39(v) {
|
|
6812
|
+
if (Array.isArray(v)) return v;
|
|
6813
|
+
if (v && typeof v === "object") {
|
|
6814
|
+
return Object.entries(v).map(([name, def]) => ({ name, ...def }));
|
|
6815
|
+
}
|
|
6816
|
+
return [];
|
|
6817
|
+
}
|
|
6818
|
+
function str3(v) {
|
|
6819
|
+
return typeof v === "string" ? v : "";
|
|
6820
|
+
}
|
|
6821
|
+
var PUSHDOWN_SUBSET2 = "The lowerable subset is: `==` `!=` `>` `<` `>=` `<=`, `in`, `&&` `||` `!`, `== null` / `!= null`, and the string methods `startsWith` / `endsWith` / `contains` \u2014 over SINGLE-column field paths (ADR-0058 D2), compared against a literal or a `current_user.*` value.";
|
|
6822
|
+
function consequence(clause) {
|
|
6823
|
+
const dropped = 'so `RLSCompiler` DROPS the policy at request time (one WARN line \u2014 "has an uncompilable predicate \u2026 and was DROPPED (no enforcement)" \u2014 is the only signal, and nothing reports it at authoring time). ';
|
|
6824
|
+
return clause === "using" ? dropped + "When it is the only applicable policy for that object and operation, `compileFilter` returns the `RLS_DENY_FILTER` sentinel instead, which is AND-ed onto the where clause: every select / update / delete on the object matches ZERO rows. When other policies also apply, this one just vanishes from the OR and grants none of the access it appears to." : dropped + "On the ADR-0058 D4 write path that leaves the post-image `check` as the `RLS_DENY_FILTER` sentinel, which no record can satisfy: every insert / update the policy governs fails with `PermissionDeniedError`. The policy reads as a write rule and behaves as a blanket refusal.";
|
|
6825
|
+
}
|
|
6826
|
+
function validateRlsPredicateEnforceability(stack) {
|
|
6827
|
+
const findings = [];
|
|
6828
|
+
const cfg = stack ?? {};
|
|
6829
|
+
asArray39(cfg.permissions).forEach((ps, psIndex) => {
|
|
6830
|
+
asArray39(ps.rowLevelSecurity).forEach((policy, pIndex) => {
|
|
6831
|
+
for (const clause of ["using", "check"]) {
|
|
6832
|
+
const source = str3(policy[clause]);
|
|
6833
|
+
if (!source.trim()) continue;
|
|
6834
|
+
if (isSupportedRlsExpression(source)) continue;
|
|
6835
|
+
const why = isPushdownableCel(sqlPredicateToCel(source));
|
|
6836
|
+
const detail = why.ok ? "" : why.detail;
|
|
6837
|
+
const parseError = !why.ok && why.reason === "parse-error";
|
|
6838
|
+
const psName = str3(ps.name) || String(psIndex);
|
|
6839
|
+
const policyName = str3(policy.name) || String(pIndex);
|
|
6840
|
+
const object = str3(policy.object);
|
|
6841
|
+
const where = `permission set "${psName}" policy "${policyName}"` + (object ? ` on object "${object}"` : "");
|
|
6842
|
+
const path = `permissions[${psIndex}].rowLevelSecurity[${pIndex}].${clause}`;
|
|
6843
|
+
if (parseError) {
|
|
6844
|
+
findings.push({
|
|
6845
|
+
severity: "error",
|
|
6846
|
+
rule: RLS_PREDICATE_UNPARSEABLE,
|
|
6847
|
+
where,
|
|
6848
|
+
path,
|
|
6849
|
+
message: `RLS ${clause} \`${source}\` does not parse as CEL even after the legacy SQL bridge (\`=\` \u2192 \`==\`, \`IN\` \u2192 \`in\`) has been applied (${detail}), ` + consequence(clause),
|
|
6850
|
+
hint: "Author the predicate in canonical CEL (ADR-0058 D1). The bridge covers only the historic SQL subset \u2014 a bare `=` and `IN` \u2014 so everything else must already be CEL: combine with `&&` / `||` rather than SQL `AND` / `OR`, negate with `!`, and use `startsWith` / `endsWith` / `contains` rather than `LIKE`. A subquery has no CEL spelling at all: RLS cannot join (ADR-0055), so pre-resolve the set into a membership key the runtime exposes (`field in current_user.<key>`, ADR-0105 D11) or denormalise the value onto this object."
|
|
6851
|
+
});
|
|
6852
|
+
continue;
|
|
6853
|
+
}
|
|
6854
|
+
findings.push({
|
|
6855
|
+
severity: "error",
|
|
6856
|
+
rule: RLS_PREDICATE_UNENFORCEABLE,
|
|
6857
|
+
where,
|
|
6858
|
+
path,
|
|
6859
|
+
message: `RLS ${clause} \`${source}\` is outside the pushdown subset the runtime can compile (${detail}), ` + consequence(clause),
|
|
6860
|
+
hint: "Rewrite the predicate inside the lowerable subset. " + PUSHDOWN_SUBSET2 + " Three traps in particular: (1) a function call \u2014 `size(record.tags) > 0`, `has(record.x)` \u2014 is correct in an object VALIDATION rule, which is INTERPRETED, and wrong here, where the predicate is COMPILED to a filter; write the null test as `field != null`. (2) A related-record path (`record.account.region`) is a join, which the compiler refuses by design (ADR-0055) \u2014 denormalise the value onto this object (a formula/rollup field) and test that column. (3) Arithmetic on a column (`amount * 2 > 100`) never lowers \u2014 precompute it into a field, or compare the column against the literal directly."
|
|
6861
|
+
});
|
|
6862
|
+
}
|
|
6350
6863
|
});
|
|
6351
6864
|
});
|
|
6352
6865
|
return findings;
|
|
6353
6866
|
}
|
|
6354
6867
|
|
|
6868
|
+
// src/validate-rule-compilability.ts
|
|
6869
|
+
import { createRequire as createRequire4 } from "module";
|
|
6870
|
+
var VALIDATION_RULE_REGEX_UNCOMPILABLE = "validation-rule-regex-uncompilable";
|
|
6871
|
+
var VALIDATION_RULE_SCHEMA_UNCOMPILABLE = "validation-rule-json-schema-uncompilable";
|
|
6872
|
+
var RUNTIME_AJV_OPTIONS = { allErrors: true, strict: false };
|
|
6873
|
+
var isRec16 = (v) => !!v && typeof v === "object" && !Array.isArray(v);
|
|
6874
|
+
function asArray40(v) {
|
|
6875
|
+
if (Array.isArray(v)) return v.filter(isRec16);
|
|
6876
|
+
if (isRec16(v)) {
|
|
6877
|
+
return Object.entries(v).filter(([, def]) => isRec16(def)).map(([name, def]) => ({ name, ...def }));
|
|
6878
|
+
}
|
|
6879
|
+
return [];
|
|
6880
|
+
}
|
|
6881
|
+
var cachedAjv = null;
|
|
6882
|
+
var cachedAddFormats = null;
|
|
6883
|
+
function loadAjv() {
|
|
6884
|
+
if (cachedAjv) return cachedAjv;
|
|
6885
|
+
const anchor = typeof import.meta !== "undefined" && import.meta.url ? import.meta.url : typeof __filename !== "undefined" ? __filename : process.cwd() + "/";
|
|
6886
|
+
let mod;
|
|
6887
|
+
try {
|
|
6888
|
+
mod = createRequire4(anchor)("ajv");
|
|
6889
|
+
} catch (err) {
|
|
6890
|
+
throw new Error(
|
|
6891
|
+
`@objectstack/lint: checking a \`json_schema\` validation rule requires the "ajv" 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 "ajv" in the image; it is only loaded when a stack declares a \`json_schema\` validation rule.`
|
|
6892
|
+
);
|
|
6893
|
+
}
|
|
6894
|
+
const ctor = isRec16(mod) && "default" in mod ? mod.default : mod;
|
|
6895
|
+
cachedAjv = ctor;
|
|
6896
|
+
return ctor;
|
|
6897
|
+
}
|
|
6898
|
+
function loadAddFormats() {
|
|
6899
|
+
if (cachedAddFormats) return cachedAddFormats;
|
|
6900
|
+
const anchor = typeof import.meta !== "undefined" && import.meta.url ? import.meta.url : typeof __filename !== "undefined" ? __filename : process.cwd() + "/";
|
|
6901
|
+
let mod;
|
|
6902
|
+
try {
|
|
6903
|
+
mod = createRequire4(anchor)("ajv-formats");
|
|
6904
|
+
} catch (err) {
|
|
6905
|
+
throw new Error(
|
|
6906
|
+
`@objectstack/lint: checking a \`json_schema\` validation rule requires the "ajv-formats" 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 "ajv-formats" in the image; it is only loaded when a stack declares a \`json_schema\` validation rule. The runtime registers it too, and this gate must compile in the SAME environment or it starts disagreeing with the write path.`
|
|
6907
|
+
);
|
|
6908
|
+
}
|
|
6909
|
+
const plugin = isRec16(mod) && "default" in mod ? mod.default : mod;
|
|
6910
|
+
cachedAddFormats = plugin;
|
|
6911
|
+
return plugin;
|
|
6912
|
+
}
|
|
6913
|
+
function createRuntimeAjv() {
|
|
6914
|
+
const instance = new (loadAjv())(RUNTIME_AJV_OPTIONS);
|
|
6915
|
+
loadAddFormats()(instance);
|
|
6916
|
+
return instance;
|
|
6917
|
+
}
|
|
6918
|
+
function registeredFormatNames() {
|
|
6919
|
+
const names = Object.keys(createRuntimeAjv().formats).sort();
|
|
6920
|
+
if (names.length === 0) {
|
|
6921
|
+
throw new Error(
|
|
6922
|
+
`@objectstack/lint: the runtime-parity ajv instance has no \`format\` registered. "ajv-formats" loaded but added nothing, so the set of legitimate format names is unknown \u2014 refusing to judge format names against an empty vocabulary, which would report every \`format\` in the stack as misspelled. Check that the installed "ajv-formats" is the real package and matches the version @objectstack/lint declares.`
|
|
6923
|
+
);
|
|
6924
|
+
}
|
|
6925
|
+
return names;
|
|
6926
|
+
}
|
|
6927
|
+
function errorText(err) {
|
|
6928
|
+
return err instanceof Error ? err.message : String(err);
|
|
6929
|
+
}
|
|
6930
|
+
var MAX_RULE_NESTING_DEPTH = 16;
|
|
6931
|
+
function flattenRules(rule, labelTrail, pathTrail, depth = 0) {
|
|
6932
|
+
const name = typeof rule.name === "string" && rule.name ? rule.name : "?";
|
|
6933
|
+
const label2 = labelTrail ? `${labelTrail} \u2192 '${name}'` : `'${name}'`;
|
|
6934
|
+
const path = pathTrail ? `${pathTrail}.${name}` : name;
|
|
6935
|
+
const out = [{ rule, label: label2, path }];
|
|
6936
|
+
if (depth >= MAX_RULE_NESTING_DEPTH) return out;
|
|
6937
|
+
for (const branch of ["then", "otherwise"]) {
|
|
6938
|
+
const nested = rule[branch];
|
|
6939
|
+
if (isRec16(nested)) out.push(...flattenRules(nested, label2, `${path}.${branch}`, depth + 1));
|
|
6940
|
+
}
|
|
6941
|
+
return out;
|
|
6942
|
+
}
|
|
6943
|
+
function walkObjectValidationRules(stack) {
|
|
6944
|
+
const walked = [];
|
|
6945
|
+
if (!isRec16(stack)) return walked;
|
|
6946
|
+
for (const obj of asArray40(stack.objects)) {
|
|
6947
|
+
const objectName = typeof obj.name === "string" ? obj.name : "(unnamed object)";
|
|
6948
|
+
const validations = obj.validations;
|
|
6949
|
+
for (const authored of asArray40(validations)) {
|
|
6950
|
+
for (const { rule, label: label2, path } of flattenRules(authored, "", "")) {
|
|
6951
|
+
walked.push({
|
|
6952
|
+
rule,
|
|
6953
|
+
objectName,
|
|
6954
|
+
label: label2,
|
|
6955
|
+
where: `object '${objectName}' \xB7 validation ${label2}`,
|
|
6956
|
+
basePath: `objects.${objectName}.validations.${path}`
|
|
6957
|
+
});
|
|
6958
|
+
}
|
|
6959
|
+
}
|
|
6960
|
+
}
|
|
6961
|
+
return walked;
|
|
6962
|
+
}
|
|
6963
|
+
function validateRuleCompilability(stack) {
|
|
6964
|
+
const findings = [];
|
|
6965
|
+
for (const { rule, objectName, label: label2, where, basePath } of walkObjectValidationRules(stack)) {
|
|
6966
|
+
if (rule.type === "format" && typeof rule.regex === "string" && rule.regex !== "") {
|
|
6967
|
+
try {
|
|
6968
|
+
new RegExp(rule.regex);
|
|
6969
|
+
} catch (err) {
|
|
6970
|
+
findings.push({
|
|
6971
|
+
severity: "error",
|
|
6972
|
+
rule: VALIDATION_RULE_REGEX_UNCOMPILABLE,
|
|
6973
|
+
where,
|
|
6974
|
+
path: `${basePath}.regex`,
|
|
6975
|
+
message: `\`format\` validation ${label2} on object '${objectName}' declares a \`regex\` that does not compile: ${errorText(err)}. The write path builds it with \`new RegExp(rule.regex)\` and SKIPS the rule when that throws (rule-validator.ts \`checkFormat\`), so the rule is declared, listed in the metadata, and enforces nothing on any record.`,
|
|
6976
|
+
hint: `Fix the pattern so \`new RegExp('${rule.regex}')\` compiles \u2014 a literal \`(\`, \`[\` or \`\\\` must be escaped (\`\\\\(\`, \`\\\\[\`, \`\\\\\\\\\`), and the source is a STRING, so a backslash is written twice in TypeScript ('^\\\\d{2}-\\\\d{7}$'). Or drop \`regex\` and use a named \`format\` ('email' | 'url' | 'phone' | 'json').`
|
|
6977
|
+
});
|
|
6978
|
+
}
|
|
6979
|
+
}
|
|
6980
|
+
if (rule.type === "json_schema" && isRec16(rule.schema)) {
|
|
6981
|
+
try {
|
|
6982
|
+
createRuntimeAjv().compile(rule.schema);
|
|
6983
|
+
} catch (err) {
|
|
6984
|
+
findings.push({
|
|
6985
|
+
severity: "error",
|
|
6986
|
+
rule: VALIDATION_RULE_SCHEMA_UNCOMPILABLE,
|
|
6987
|
+
where,
|
|
6988
|
+
path: `${basePath}.schema`,
|
|
6989
|
+
message: `\`json_schema\` validation ${label2} on object '${objectName}' declares a \`schema\` ajv cannot compile: ${errorText(err)}. The write path compiles it with the same ajv (\`new Ajv({ allErrors: true, strict: false })\` + \`ajv-formats\`) and SKIPS the rule when that throws (rule-validator.ts \`checkJsonSchema\`), so the rule is declared and enforces nothing on any record.`,
|
|
6990
|
+
hint: `Correct the schema so ajv compiles it \u2014 the message above names the offending keyword. \`type\` must be one of null|boolean|object|array|number|string|integer (or an array of those), \`required\` an array of strings, and every \`$ref\` must resolve. Vendor keywords are fine (the runtime runs \`strict: false\`); a MALFORMED standard keyword is not.`
|
|
6991
|
+
});
|
|
6992
|
+
}
|
|
6993
|
+
}
|
|
6994
|
+
}
|
|
6995
|
+
return findings;
|
|
6996
|
+
}
|
|
6997
|
+
|
|
6998
|
+
// src/validate-rule-schema-formats.ts
|
|
6999
|
+
var VALIDATION_RULE_SCHEMA_UNKNOWN_FORMAT = "validation-rule-json-schema-unknown-format";
|
|
7000
|
+
var isRec17 = (v) => !!v && typeof v === "object" && !Array.isArray(v);
|
|
7001
|
+
var SUBSCHEMA_KEYS = [
|
|
7002
|
+
"additionalItems",
|
|
7003
|
+
"additionalProperties",
|
|
7004
|
+
"contains",
|
|
7005
|
+
"propertyNames",
|
|
7006
|
+
"if",
|
|
7007
|
+
"then",
|
|
7008
|
+
"else",
|
|
7009
|
+
"not",
|
|
7010
|
+
"unevaluatedItems",
|
|
7011
|
+
"unevaluatedProperties"
|
|
7012
|
+
];
|
|
7013
|
+
var SUBSCHEMA_LIST_KEYS = ["allOf", "anyOf", "oneOf", "prefixItems"];
|
|
7014
|
+
var SUBSCHEMA_MAP_KEYS = [
|
|
7015
|
+
"properties",
|
|
7016
|
+
"patternProperties",
|
|
7017
|
+
"$defs",
|
|
7018
|
+
"definitions",
|
|
7019
|
+
"dependentSchemas"
|
|
7020
|
+
];
|
|
7021
|
+
var MAX_SCHEMA_WALK_DEPTH = 32;
|
|
7022
|
+
var escapePointerSegment = (segment) => segment.replace(/~/g, "~0").replace(/\//g, "~1");
|
|
7023
|
+
function collectFormatUses(schema, pointer, out, depth) {
|
|
7024
|
+
if (!isRec17(schema)) return;
|
|
7025
|
+
if (typeof schema.format === "string") {
|
|
7026
|
+
out.push({ pointer: `${pointer}/format`, name: schema.format });
|
|
7027
|
+
}
|
|
7028
|
+
if (depth >= MAX_SCHEMA_WALK_DEPTH) return;
|
|
7029
|
+
for (const key of SUBSCHEMA_KEYS) {
|
|
7030
|
+
if (key in schema) {
|
|
7031
|
+
collectFormatUses(schema[key], `${pointer}/${escapePointerSegment(key)}`, out, depth + 1);
|
|
7032
|
+
}
|
|
7033
|
+
}
|
|
7034
|
+
for (const key of SUBSCHEMA_LIST_KEYS) {
|
|
7035
|
+
const value = schema[key];
|
|
7036
|
+
if (!Array.isArray(value)) continue;
|
|
7037
|
+
value.forEach((entry, index) => {
|
|
7038
|
+
collectFormatUses(entry, `${pointer}/${escapePointerSegment(key)}/${index}`, out, depth + 1);
|
|
7039
|
+
});
|
|
7040
|
+
}
|
|
7041
|
+
for (const key of SUBSCHEMA_MAP_KEYS) {
|
|
7042
|
+
const value = schema[key];
|
|
7043
|
+
if (!isRec17(value)) continue;
|
|
7044
|
+
for (const [name, entry] of Object.entries(value)) {
|
|
7045
|
+
collectFormatUses(entry, `${pointer}/${escapePointerSegment(key)}/${escapePointerSegment(name)}`, out, depth + 1);
|
|
7046
|
+
}
|
|
7047
|
+
}
|
|
7048
|
+
const items = schema.items;
|
|
7049
|
+
if (Array.isArray(items)) {
|
|
7050
|
+
items.forEach((entry, index) => collectFormatUses(entry, `${pointer}/items/${index}`, out, depth + 1));
|
|
7051
|
+
} else if (isRec17(items)) {
|
|
7052
|
+
collectFormatUses(items, `${pointer}/items`, out, depth + 1);
|
|
7053
|
+
}
|
|
7054
|
+
const dependencies = schema.dependencies;
|
|
7055
|
+
if (isRec17(dependencies)) {
|
|
7056
|
+
for (const [name, entry] of Object.entries(dependencies)) {
|
|
7057
|
+
if (!isRec17(entry)) continue;
|
|
7058
|
+
collectFormatUses(entry, `${pointer}/dependencies/${escapePointerSegment(name)}`, out, depth + 1);
|
|
7059
|
+
}
|
|
7060
|
+
}
|
|
7061
|
+
}
|
|
7062
|
+
function editDistance2(a, b) {
|
|
7063
|
+
let previous = Array.from({ length: b.length + 1 }, (_, j) => j);
|
|
7064
|
+
for (let i = 1; i <= a.length; i++) {
|
|
7065
|
+
const current = [i];
|
|
7066
|
+
for (let j = 1; j <= b.length; j++) {
|
|
7067
|
+
current[j] = Math.min(
|
|
7068
|
+
previous[j] + 1,
|
|
7069
|
+
current[j - 1] + 1,
|
|
7070
|
+
previous[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1)
|
|
7071
|
+
);
|
|
7072
|
+
}
|
|
7073
|
+
previous = current;
|
|
7074
|
+
}
|
|
7075
|
+
return previous[b.length];
|
|
7076
|
+
}
|
|
7077
|
+
function nearestRegisteredFormat(name, registered) {
|
|
7078
|
+
const budget = Math.min(3, Math.floor(name.length / 2));
|
|
7079
|
+
if (budget < 1) return null;
|
|
7080
|
+
const authored = name.toLowerCase();
|
|
7081
|
+
let best = null;
|
|
7082
|
+
let bestDistance = Number.POSITIVE_INFINITY;
|
|
7083
|
+
for (const candidate of [...registered].sort()) {
|
|
7084
|
+
const distance7 = editDistance2(authored, candidate);
|
|
7085
|
+
if (distance7 < bestDistance) {
|
|
7086
|
+
bestDistance = distance7;
|
|
7087
|
+
best = candidate;
|
|
7088
|
+
}
|
|
7089
|
+
}
|
|
7090
|
+
return bestDistance <= budget ? best : null;
|
|
7091
|
+
}
|
|
7092
|
+
function validateRuleSchemaFormats(stack) {
|
|
7093
|
+
const findings = [];
|
|
7094
|
+
const pending = [];
|
|
7095
|
+
for (const { rule, objectName, label: label2, where, basePath } of walkObjectValidationRules(stack)) {
|
|
7096
|
+
if (rule.type !== "json_schema" || !isRec17(rule.schema)) continue;
|
|
7097
|
+
const uses = [];
|
|
7098
|
+
collectFormatUses(rule.schema, "", uses, 0);
|
|
7099
|
+
for (const use of uses) pending.push({ use, where, label: label2, objectName, basePath });
|
|
7100
|
+
}
|
|
7101
|
+
if (pending.length === 0) return findings;
|
|
7102
|
+
const registered = registeredFormatNames();
|
|
7103
|
+
const known = new Set(registered);
|
|
7104
|
+
for (const { use, where, label: label2, objectName, basePath } of pending) {
|
|
7105
|
+
if (known.has(use.name)) continue;
|
|
7106
|
+
const suggestion = nearestRegisteredFormat(use.name, registered);
|
|
7107
|
+
const pointer = `#${use.pointer}`;
|
|
7108
|
+
findings.push({
|
|
7109
|
+
severity: "error",
|
|
7110
|
+
rule: VALIDATION_RULE_SCHEMA_UNKNOWN_FORMAT,
|
|
7111
|
+
where,
|
|
7112
|
+
path: `${basePath}.schema${pointer}`,
|
|
7113
|
+
message: `\`json_schema\` validation ${label2} on object '${objectName}' names \`format: '${use.name}'\` at \`${pointer}\`, which is not a registered format. ajv logs \`unknown format "${use.name}" ignored\` once at compile time and DROPS the keyword \u2014 in the write path (rule-validator.ts, \`strict: false\`) and in the publish gate alike \u2014 so the schema compiles, the rule ships and runs on every write, its \`type\`/\`required\` keywords are enforced, and this constraint is enforced on no record, ever. The record is ACCEPTED, so nothing downstream reports the gap either.`,
|
|
7114
|
+
hint: (suggestion ? `Did you mean \`format: '${suggestion}'\`? ` : "") + `The registered names are: ${registered.join(", ")} \u2014 the default \`ajv-formats\` set, the one \`rule-validator.ts\` registers (#5029). Names are case-sensitive and hyphenated (\`date-time\`, not \`datetime\`). If you meant a constraint ajv has no format for, express it with \`pattern\` instead \u2014 a regex is enforced, an unknown format name is not.`
|
|
7115
|
+
});
|
|
7116
|
+
}
|
|
7117
|
+
return findings;
|
|
7118
|
+
}
|
|
7119
|
+
|
|
6355
7120
|
// src/validate-action-locations.ts
|
|
6356
7121
|
var ACTION_NO_PLACEMENT = "action-no-placement";
|
|
6357
|
-
function
|
|
7122
|
+
function asArray41(v) {
|
|
6358
7123
|
if (Array.isArray(v)) return v;
|
|
6359
7124
|
if (v && typeof v === "object") {
|
|
6360
7125
|
return Object.entries(v).map(([name, def]) => ({ name, ...def }));
|
|
6361
7126
|
}
|
|
6362
7127
|
return [];
|
|
6363
7128
|
}
|
|
6364
|
-
function
|
|
7129
|
+
function strName17(v) {
|
|
6365
7130
|
return typeof v === "string" && v.length > 0 ? v : void 0;
|
|
6366
7131
|
}
|
|
6367
7132
|
function strList3(v) {
|
|
@@ -6375,8 +7140,8 @@ function collectNamePlacedActions(stack) {
|
|
|
6375
7140
|
for (const key of ["rowActions", "bulkActions"]) {
|
|
6376
7141
|
for (const n of strList3(list3[key])) placed.add(n);
|
|
6377
7142
|
}
|
|
6378
|
-
for (const def of
|
|
6379
|
-
const n =
|
|
7143
|
+
for (const def of asArray41(list3.bulkActionDefs)) {
|
|
7144
|
+
const n = strName17(def?.name);
|
|
6380
7145
|
if (n) placed.add(n);
|
|
6381
7146
|
}
|
|
6382
7147
|
};
|
|
@@ -6384,12 +7149,12 @@ function collectNamePlacedActions(stack) {
|
|
|
6384
7149
|
if (!listViews || typeof listViews !== "object" || Array.isArray(listViews)) return;
|
|
6385
7150
|
for (const lv of Object.values(listViews)) harvest(lv);
|
|
6386
7151
|
};
|
|
6387
|
-
for (const view of
|
|
7152
|
+
for (const view of asArray41(stack.views)) {
|
|
6388
7153
|
if (!view || typeof view !== "object") continue;
|
|
6389
7154
|
harvest(view.list);
|
|
6390
7155
|
harvestListViews(view.listViews);
|
|
6391
7156
|
}
|
|
6392
|
-
for (const obj of
|
|
7157
|
+
for (const obj of asArray41(stack.objects)) {
|
|
6393
7158
|
if (!obj || typeof obj !== "object") continue;
|
|
6394
7159
|
harvestListViews(obj.listViews);
|
|
6395
7160
|
}
|
|
@@ -6402,7 +7167,7 @@ function validateActionLocations(stack) {
|
|
|
6402
7167
|
const check = (action, path) => {
|
|
6403
7168
|
if (!action || typeof action !== "object") return;
|
|
6404
7169
|
if ("locations" in action) return;
|
|
6405
|
-
const name =
|
|
7170
|
+
const name = strName17(action.name);
|
|
6406
7171
|
if (!name) return;
|
|
6407
7172
|
if (namePlaced.has(name)) return;
|
|
6408
7173
|
findings.push({
|
|
@@ -6414,20 +7179,25 @@ function validateActionLocations(stack) {
|
|
|
6414
7179
|
hint: "Add the surface it belongs on, e.g. `locations: ['record_header']` (or `list_item`, `list_toolbar`, `record_more`, `record_section`, `record_related`, `global_nav`); or place it from a list view's `bulkActions` / `bulkActionDefs` if it acts on a selection. If it is meant to be callable over REST / MCP / AI with no UI surface, say so explicitly with `locations: []` \u2014 an empty array is the documented headless shape and is never flagged."
|
|
6415
7180
|
});
|
|
6416
7181
|
};
|
|
6417
|
-
const actions =
|
|
7182
|
+
const actions = asArray41(stack.actions);
|
|
6418
7183
|
for (let i = 0; i < actions.length; i++) check(actions[i], `actions[${i}]`);
|
|
6419
|
-
const objects =
|
|
7184
|
+
const objects = asArray41(stack.objects);
|
|
6420
7185
|
for (let oi = 0; oi < objects.length; oi++) {
|
|
6421
7186
|
const obj = objects[oi];
|
|
6422
7187
|
if (!obj || typeof obj !== "object") continue;
|
|
6423
|
-
const own =
|
|
7188
|
+
const own = asArray41(obj.actions);
|
|
6424
7189
|
for (let ai = 0; ai < own.length; ai++) check(own[ai], `objects[${oi}].actions[${ai}]`);
|
|
6425
7190
|
}
|
|
6426
7191
|
return findings;
|
|
6427
7192
|
}
|
|
6428
7193
|
|
|
6429
7194
|
// src/lint-flow-patterns.ts
|
|
6430
|
-
|
|
7195
|
+
import {
|
|
7196
|
+
APPROVAL_NODE_TYPE as APPROVAL_NODE_TYPE2,
|
|
7197
|
+
APPROVAL_REVISE_NODE_TYPE,
|
|
7198
|
+
collectFlowGraphs as collectFlowGraphs2
|
|
7199
|
+
} from "@objectstack/spec/automation";
|
|
7200
|
+
function asArray42(v) {
|
|
6431
7201
|
if (Array.isArray(v)) return v;
|
|
6432
7202
|
if (v && typeof v === "object") return Object.entries(v).map(([name, def]) => ({ name, ...def }));
|
|
6433
7203
|
return [];
|
|
@@ -6450,6 +7220,7 @@ var FLOW_BARE_DOLLAR_REF = "flow-bare-dollar-reference";
|
|
|
6450
7220
|
var FLOW_APPROVAL_REVISE_DEAD_END = "flow-approval-revise-dead-end";
|
|
6451
7221
|
var FLOW_APPROVAL_REVISE_UNMARKED_BACKEDGE = "flow-approval-revise-unmarked-backedge";
|
|
6452
7222
|
var FLOW_APPROVAL_REVISE_DISABLED = "flow-approval-revise-disabled";
|
|
7223
|
+
var FLOW_APPROVAL_REVISE_TARGET_NOT_SERVICE_OWNED = "flow-approval-revise-target-not-service-owned";
|
|
6453
7224
|
var FLOW_RUNAS_UNSCOPED = "flow-runas-unscoped";
|
|
6454
7225
|
var FLOW_ERROR_LABEL_NOT_FAULT = "flow-error-label-not-fault";
|
|
6455
7226
|
var FLOW_BRANCH_LABEL_UNMATCHED = "flow-branch-label-unmatched";
|
|
@@ -6457,6 +7228,7 @@ var FLOW_DECISION_UNCONDITIONAL_BRANCH = "flow-decision-unconditional-branch";
|
|
|
6457
7228
|
var FLOW_DEFAULT_EDGE_WITH_CONDITION = "flow-default-edge-with-condition";
|
|
6458
7229
|
var FLOW_MULTIPLE_DEFAULT_EDGES = "flow-multiple-default-edges";
|
|
6459
7230
|
var FLOW_INERT_NODE_CONDITION = "flow-inert-node-condition";
|
|
7231
|
+
var FLOW_MULTI_WRITE_UNFILTERED = "flow-multi-write-unfiltered";
|
|
6460
7232
|
var INERT_CONDITION_NODE_TYPES = /* @__PURE__ */ new Set([
|
|
6461
7233
|
"decision",
|
|
6462
7234
|
"assignment",
|
|
@@ -6479,6 +7251,36 @@ var INERT_CONDITION_NODE_TYPES = /* @__PURE__ */ new Set([
|
|
|
6479
7251
|
"end"
|
|
6480
7252
|
]);
|
|
6481
7253
|
var DATA_NODE_TYPES = /* @__PURE__ */ new Set(["get_record", "create_record", "update_record", "delete_record"]);
|
|
7254
|
+
var RUNAS_EFFECTIVE_IDENTITY = "`runAs:'user'` (the default when none is declared)";
|
|
7255
|
+
function findDataNodeAnywhere(nodes, edges) {
|
|
7256
|
+
for (const graph of collectFlowGraphs2({
|
|
7257
|
+
nodes,
|
|
7258
|
+
edges
|
|
7259
|
+
})) {
|
|
7260
|
+
for (const node of graph.nodes) {
|
|
7261
|
+
if (DATA_NODE_TYPES.has(typeof node.type === "string" ? node.type : "")) {
|
|
7262
|
+
return { node, scope: graph.scope };
|
|
7263
|
+
}
|
|
7264
|
+
}
|
|
7265
|
+
}
|
|
7266
|
+
return null;
|
|
7267
|
+
}
|
|
7268
|
+
var BULK_WRITE_CONSEQUENCE = /* @__PURE__ */ new Map([
|
|
7269
|
+
["delete_record", {
|
|
7270
|
+
verb: "deleted",
|
|
7271
|
+
engineCall: "driver.deleteMany",
|
|
7272
|
+
// The delete dispatch is the one that is EXTRACTED and case-set-pinned
|
|
7273
|
+
// (`engine-delete-dispatch.ts`), so it can be cited by name.
|
|
7274
|
+
dispatchNote: "the engine's delete-dispatch case-set lists `multi with no predicate at all` as a legal `multi` call"
|
|
7275
|
+
}],
|
|
7276
|
+
["update_record", {
|
|
7277
|
+
verb: "overwritten",
|
|
7278
|
+
engineCall: "driver.updateMany",
|
|
7279
|
+
// Update has no extracted dispatch module, so the branch itself is the
|
|
7280
|
+
// authority — and its refusal fires only WITHOUT the declaration.
|
|
7281
|
+
dispatchNote: "the engine takes its bulk branch on `options.multi` alone (`Update requires an ID or options.multi=true` is refused only when the declaration is absent)"
|
|
7282
|
+
}]
|
|
7283
|
+
]);
|
|
6482
7284
|
var ERROR_LABELS = /* @__PURE__ */ new Set(["error", "fault", "failure", "failed", "catch", "on_error", "onerror", "on error"]);
|
|
6483
7285
|
var BRANCH_LABEL_NODE_TYPES = /* @__PURE__ */ new Set(["decision", "approval", "screen", "try_catch"]);
|
|
6484
7286
|
function isScheduleTriggered(flow, startCfg) {
|
|
@@ -6563,7 +7365,7 @@ function collectTemplateStrings(value, key, out) {
|
|
|
6563
7365
|
function edgeLabelOf(e) {
|
|
6564
7366
|
return typeof e.label === "string" ? e.label.trim().toLowerCase() : "";
|
|
6565
7367
|
}
|
|
6566
|
-
function scanErrorLabelledEdges(
|
|
7368
|
+
function scanErrorLabelledEdges(at, nodes, edges, findings) {
|
|
6567
7369
|
const typeById = /* @__PURE__ */ new Map();
|
|
6568
7370
|
for (const n of nodes) {
|
|
6569
7371
|
if (typeof n.id === "string") typeById.set(n.id, typeof n.type === "string" ? n.type : "");
|
|
@@ -6576,14 +7378,14 @@ function scanErrorLabelledEdges(flowName, nodes, edges, findings) {
|
|
|
6576
7378
|
const src = typeof e.source === "string" ? e.source : "";
|
|
6577
7379
|
if (BRANCH_LABEL_NODE_TYPES.has(typeById.get(src) ?? "")) continue;
|
|
6578
7380
|
findings.push({
|
|
6579
|
-
where:
|
|
7381
|
+
where: `${at} \xB7 edge '${src}' \u2192 '${String(e.target)}'`,
|
|
6580
7382
|
message: `edge is labelled '${String(e.label)}' but its type is '${String(e.type ?? "default")}', not 'fault' \u2014 so it is an ORDINARY out-edge. Unconditional out-edges all run in parallel, so '${String(e.target)}' executes on every SUCCESSFUL run of '${src}' and never on a failure. The error path the label describes does not exist, and the run still aborts when '${src}' fails.`,
|
|
6581
7383
|
hint: `Add \`type: 'fault'\` to this edge. Only runtime failures route \u2014 a guard refusal (a filter token that resolved to nothing, a missing required config key, an unscoped run) stays fatal by design and must be fixed in the metadata, not handled. (#3863)`,
|
|
6582
7384
|
rule: FLOW_ERROR_LABEL_NOT_FAULT
|
|
6583
7385
|
});
|
|
6584
7386
|
}
|
|
6585
7387
|
}
|
|
6586
|
-
function scanBranchRouting(
|
|
7388
|
+
function scanBranchRouting(at, nodes, edges, findings) {
|
|
6587
7389
|
const outEdgesBySource = /* @__PURE__ */ new Map();
|
|
6588
7390
|
for (const e of edges) {
|
|
6589
7391
|
if (e.type === "fault") continue;
|
|
@@ -6596,7 +7398,7 @@ function scanBranchRouting(flowName, nodes, edges, findings) {
|
|
|
6596
7398
|
for (const e of outs) {
|
|
6597
7399
|
if (e.isDefault === true && e.condition) {
|
|
6598
7400
|
findings.push({
|
|
6599
|
-
where:
|
|
7401
|
+
where: `${at} \xB7 edge '${src}' \u2192 '${String(e.target)}'`,
|
|
6600
7402
|
message: `edge sets \`isDefault: true\` AND a \`condition\` \u2014 contradictory. \`isDefault\` means "take this edge when NO sibling condition matched"; a condition makes it an ordinary guarded branch. The condition wins and the default marker routes nothing.`,
|
|
6601
7403
|
hint: `Drop one: keep \`condition\` for a guarded branch, or drop it and keep \`isDefault: true\` for the "otherwise" path. (#4414)`,
|
|
6602
7404
|
rule: FLOW_DEFAULT_EDGE_WITH_CONDITION,
|
|
@@ -6609,7 +7411,7 @@ function scanBranchRouting(flowName, nodes, edges, findings) {
|
|
|
6609
7411
|
const defaults = outs.filter((e) => e.isDefault === true && !e.condition);
|
|
6610
7412
|
if (defaults.length > 1) {
|
|
6611
7413
|
findings.push({
|
|
6612
|
-
where:
|
|
7414
|
+
where: `${at} \xB7 node '${src}'`,
|
|
6613
7415
|
message: `${defaults.length} out-edges are marked \`isDefault: true\` (${defaults.map((e) => `'${String(e.target)}'`).join(", ")}) \u2014 a node has at most ONE default path. All of them are traversed together when no condition matches, which is a parallel fan-out, not an "otherwise".`,
|
|
6614
7416
|
hint: `Keep \`isDefault: true\` on the single fallback edge and give the others a \`condition\` (or leave them unconditional if the fan-out really is intended). (#4414)`,
|
|
6615
7417
|
rule: FLOW_MULTIPLE_DEFAULT_EDGES
|
|
@@ -6622,7 +7424,7 @@ function scanBranchRouting(flowName, nodes, edges, findings) {
|
|
|
6622
7424
|
const cfg = node.config ?? {};
|
|
6623
7425
|
if (cfg.condition == null || conditionSource(cfg.condition).trim() === "") continue;
|
|
6624
7426
|
findings.push({
|
|
6625
|
-
where:
|
|
7427
|
+
where: `${at} \xB7 node '${String(node.id)}' (${nodeType})`,
|
|
6626
7428
|
message: `\`config.condition\` is set but nothing reads it \u2014 the key is the trigger gate on a \`start\` node and is ignored on every other node type, so this predicate never gates anything. (It is still parse-validated at registration, which is why a malformed one is caught and an inert one is not.)`,
|
|
6627
7429
|
hint: nodeType === "decision" ? `Branching lives on the OUT-EDGES: give each branch its own \`condition\` and mark the fallback \`isDefault: true\`. If the edges already carry the predicate, delete this copy. (#4414)` : `Delete it, or move the predicate to the incoming edge's \`condition\` if this step was meant to be conditional. (#4414)`,
|
|
6628
7430
|
rule: FLOW_INERT_NODE_CONDITION
|
|
@@ -6642,7 +7444,7 @@ function scanBranchRouting(flowName, nodes, edges, findings) {
|
|
|
6642
7444
|
const unclaimed = [...declaredLabels].filter((l) => !edgeLabels.has(l));
|
|
6643
7445
|
if (unclaimed.length > 0) {
|
|
6644
7446
|
findings.push({
|
|
6645
|
-
where:
|
|
7447
|
+
where: `${at} \xB7 decision '${nid}'`,
|
|
6646
7448
|
message: `declares branch label(s) ${unclaimed.map((l) => `'${l}'`).join(", ")} that no out-edge carries \u2014 out-edge labels are [${[...edgeLabels].map((l) => `'${l}'`).join(", ") || "none"}]. Traversal cannot honour a label nothing claims, so it falls back to considering EVERY out-edge and the branch the decision computed is ignored.`,
|
|
6647
7449
|
hint: `Make an out-edge's \`label\` match the declared branch exactly, or drop \`config.conditions\` and branch on the edges instead (\`condition\` per branch + \`isDefault: true\` on the fallback) \u2014 one mechanism per decision, never both. (#4414)`,
|
|
6648
7450
|
rule: FLOW_BRANCH_LABEL_UNMATCHED,
|
|
@@ -6658,7 +7460,7 @@ function scanBranchRouting(flowName, nodes, edges, findings) {
|
|
|
6658
7460
|
);
|
|
6659
7461
|
if (ungated.length > 0) {
|
|
6660
7462
|
findings.push({
|
|
6661
|
-
where:
|
|
7463
|
+
where: `${at} \xB7 decision '${nid}'`,
|
|
6662
7464
|
message: `has guarded out-edge(s) alongside unconditional one(s) (${ungated.map((e) => `'${String(e.target)}'`).join(", ")}) \u2014 an unconditional out-edge is traversed on EVERY pass, in parallel with whichever guarded branch matched, so the decision does not actually exclude it. A \`label\` alone does not select a path unless the decision declares a matching \`conditions[].label\`.`,
|
|
6663
7465
|
hint: `Mark the fallback \`isDefault: true\` so it is taken only when no sibling condition matched (BPMN default flow), or give it its own \`condition\`. (#4414)`,
|
|
6664
7466
|
rule: FLOW_DECISION_UNCONDITIONAL_BRANCH
|
|
@@ -6666,10 +7468,39 @@ function scanBranchRouting(flowName, nodes, edges, findings) {
|
|
|
6666
7468
|
}
|
|
6667
7469
|
}
|
|
6668
7470
|
}
|
|
6669
|
-
function
|
|
6670
|
-
|
|
7471
|
+
function filterCarriesNoCondition(filter) {
|
|
7472
|
+
if (filter === void 0 || filter === null) return true;
|
|
7473
|
+
if (typeof filter !== "object" || Array.isArray(filter)) return false;
|
|
7474
|
+
return Object.keys(filter).length === 0;
|
|
7475
|
+
}
|
|
7476
|
+
function scanUnboundedBulkWrites(at, nodes, findings) {
|
|
7477
|
+
for (const node of nodes) {
|
|
7478
|
+
const nodeType = typeof node.type === "string" ? node.type : "";
|
|
7479
|
+
const consequence2 = BULK_WRITE_CONSEQUENCE.get(nodeType);
|
|
7480
|
+
if (!consequence2) continue;
|
|
7481
|
+
const cfg = node.config ?? {};
|
|
7482
|
+
if (cfg.multi !== true) continue;
|
|
7483
|
+
if (!filterCarriesNoCondition(cfg.filter)) continue;
|
|
7484
|
+
const objectName = typeof cfg.objectName === "string" && cfg.objectName ? cfg.objectName : "(unnamed object)";
|
|
7485
|
+
const filterState = cfg.filter === void 0 || cfg.filter === null ? "no `filter` key" : "an EMPTY `filter`";
|
|
7486
|
+
findings.push({
|
|
7487
|
+
where: `${at} \xB7 node '${String(node.id)}' (${nodeType})`,
|
|
7488
|
+
message: `declares \`multi: true\` with ${filterState} \u2014 this is a WHOLE-OBJECT write, by declaration: every row of '${objectName}' is ${consequence2.verb} on every run. The executor forwards \`where: {}\` plus the bulk intent, ${consequence2.dispatchNote}, and it lands on \`${consequence2.engineCall}\` with no predicate. Nothing refuses it at run time, so the only feedback is the step's \`acted\` row count \u2014 reported AFTER the rows are gone.`,
|
|
7489
|
+
hint: `Write the constraint you mean into \`filter\` (e.g. \`{ status: 'closed' }\` \u2014 see examples/app-showcase \`showcase_inquiry_purge\`, bulk intent bounded by a predicate). If emptying '${objectName}' really is the intent, keep it: this is a warning, not a gate, and the run-time path stays open. Distinct from the #3810 erased-condition guard, which REFUSES this node at run time when a condition you WROTE interpolated to nothing \u2014 that guard is keyed on "a written condition is gone" and deliberately not on "the filter is empty", which is the fact this rule judges at authoring time. (#5482, #5393)`,
|
|
7490
|
+
// Warning, not `error`: see the severity policy at the top of this file.
|
|
7491
|
+
// The shape has a legitimate reading the engine grants on purpose, so it is
|
|
7492
|
+
// not provably wrong — unlike the gating members of this family.
|
|
7493
|
+
rule: FLOW_MULTI_WRITE_UNFILTERED
|
|
7494
|
+
});
|
|
7495
|
+
}
|
|
7496
|
+
}
|
|
7497
|
+
function scanApprovalReviseLoops(at, nodes, edges, findings) {
|
|
7498
|
+
const approvals = nodes.filter((n) => n.type === APPROVAL_NODE_TYPE2);
|
|
6671
7499
|
if (approvals.length === 0) return;
|
|
6672
7500
|
const nodeIds = new Set(nodes.map((n) => typeof n.id === "string" ? n.id : "").filter(Boolean));
|
|
7501
|
+
const nodeTypeById = new Map(
|
|
7502
|
+
nodes.filter((n) => typeof n.id === "string").map((n) => [n.id, typeof n.type === "string" ? n.type : ""])
|
|
7503
|
+
);
|
|
6673
7504
|
const outEdges = /* @__PURE__ */ new Map();
|
|
6674
7505
|
for (const e of edges) {
|
|
6675
7506
|
const src = typeof e.source === "string" ? e.source : "";
|
|
@@ -6682,7 +7513,18 @@ function scanApprovalReviseLoops(flowName, nodes, edges, findings) {
|
|
|
6682
7513
|
if (!aid) continue;
|
|
6683
7514
|
const reviseTargets = edges.filter((e) => e.source === aid && edgeLabelOf(e) === "revise").map((e) => typeof e.target === "string" ? e.target : "").filter((t) => t && nodeIds.has(t));
|
|
6684
7515
|
if (reviseTargets.length === 0) continue;
|
|
6685
|
-
const where =
|
|
7516
|
+
const where = `${at} \xB7 approval '${aid}'`;
|
|
7517
|
+
for (const target of reviseTargets) {
|
|
7518
|
+
const targetType = nodeTypeById.get(target) ?? "";
|
|
7519
|
+
if (targetType === APPROVAL_REVISE_NODE_TYPE) continue;
|
|
7520
|
+
findings.push({
|
|
7521
|
+
where,
|
|
7522
|
+
severity: "error",
|
|
7523
|
+
message: `has a 'revise' out-edge into node '${target}' of type '${targetType || "(untyped)"}' \u2014 the revise window must be an '${APPROVAL_REVISE_NODE_TYPE}' node. Send-back parks the run there while the record is unlocked, and only the approvals service may continue it (submitter-only, audited, and refusing a colliding pending request); \`sendBack\` refuses any other target, so this flow's revise branch cannot run.`,
|
|
7524
|
+
hint: `Set node '${target}' to \`type: '${APPROVAL_REVISE_NODE_TYPE}'\` (drop any \`waitEventConfig\` \u2014 the window is ended by POST /api/v1/approvals/requests/:id/resubmit, not by a signal). ADR-0044 D3 originally said 'wait' here; its 2026-07-28 amendment reversed that, because a 'wait' is resumable by anyone with the run id (#3823, #3801).`,
|
|
7525
|
+
rule: FLOW_APPROVAL_REVISE_TARGET_NOT_SERVICE_OWNED
|
|
7526
|
+
});
|
|
7527
|
+
}
|
|
6686
7528
|
const cfg = a.config ?? {};
|
|
6687
7529
|
if (cfg.maxRevisions === 0) {
|
|
6688
7530
|
findings.push({
|
|
@@ -6710,7 +7552,7 @@ function scanApprovalReviseLoops(flowName, nodes, edges, findings) {
|
|
|
6710
7552
|
findings.push({
|
|
6711
7553
|
where,
|
|
6712
7554
|
message: `has a 'revise' out-edge but no path loops back to it \u2014 the submitter reworks the record with nowhere to resubmit, so the revise branch dead-ends. (registerFlow accepts this \u2014 it's a valid DAG.)`,
|
|
6713
|
-
hint: `Close the loop: the 'revise' edge should reach
|
|
7555
|
+
hint: `Close the loop: the 'revise' edge should reach an '${APPROVAL_REVISE_NODE_TYPE}' node whose resubmit edge returns to '${aid}' marked \`type: 'back'\` (ADR-0044). See examples/app-showcase showcase_budget_approval.`,
|
|
6714
7556
|
rule: FLOW_APPROVAL_REVISE_DEAD_END
|
|
6715
7557
|
});
|
|
6716
7558
|
} else if (!returnEdges.some((e) => e.type === "back")) {
|
|
@@ -6725,7 +7567,7 @@ function scanApprovalReviseLoops(flowName, nodes, edges, findings) {
|
|
|
6725
7567
|
}
|
|
6726
7568
|
function lintFlowPatterns(stack) {
|
|
6727
7569
|
const findings = [];
|
|
6728
|
-
for (const flow of
|
|
7570
|
+
for (const flow of asArray42(stack.flows)) {
|
|
6729
7571
|
const flowName = typeof flow.name === "string" ? flow.name : "(unnamed flow)";
|
|
6730
7572
|
const nodes = Array.isArray(flow.nodes) ? flow.nodes : [];
|
|
6731
7573
|
const edges = Array.isArray(flow.edges) ? flow.edges : [];
|
|
@@ -6746,74 +7588,90 @@ function lintFlowPatterns(stack) {
|
|
|
6746
7588
|
const runAs = typeof flow.runAs === "string" ? flow.runAs : "user";
|
|
6747
7589
|
const userLessKind = userLessTriggerKind(flow, startCfg);
|
|
6748
7590
|
if (userLessKind && runAs !== "system") {
|
|
6749
|
-
const dataNode = nodes
|
|
7591
|
+
const dataNode = findDataNodeAnywhere(nodes, edges);
|
|
6750
7592
|
if (dataNode) {
|
|
6751
|
-
const
|
|
7593
|
+
const at = dataNode.scope ? `, in ${dataNode.scope},` : "";
|
|
6752
7594
|
findings.push({
|
|
6753
7595
|
where: `flow '${flowName}' \xB7 runAs`,
|
|
6754
|
-
message: `${userLessKind}-triggered flow runs
|
|
7596
|
+
message: `${userLessKind}-triggered flow runs under ${RUNAS_EFFECTIVE_IDENTITY}, but a ${userLessKind} run has no trigger user \u2014 so its data node '${dataNode.node.id}' (${dataNode.node.type})${at} has no identity to scope to and will be REFUSED at run time.`,
|
|
6755
7597
|
hint: `Declare \`runAs:'system'\` to make the elevation explicit and intended (the run reads/writes every record). A ${userLessKind} flow cannot scope to a user \u2014 there is none. (ADR-0049, ADR-0073 D5, #1888, #3760)`,
|
|
6756
7598
|
rule: FLOW_RUNAS_UNSCOPED,
|
|
6757
7599
|
severity: "error"
|
|
6758
7600
|
});
|
|
6759
7601
|
}
|
|
6760
7602
|
}
|
|
6761
|
-
for (const
|
|
6762
|
-
|
|
6763
|
-
|
|
6764
|
-
|
|
6765
|
-
|
|
6766
|
-
|
|
6767
|
-
|
|
6768
|
-
|
|
6769
|
-
|
|
6770
|
-
|
|
6771
|
-
|
|
6772
|
-
|
|
6773
|
-
|
|
6774
|
-
|
|
6775
|
-
const
|
|
6776
|
-
|
|
6777
|
-
|
|
6778
|
-
if (
|
|
6779
|
-
|
|
6780
|
-
|
|
6781
|
-
|
|
6782
|
-
|
|
6783
|
-
|
|
6784
|
-
|
|
7603
|
+
for (const graph of collectFlowGraphs2({
|
|
7604
|
+
// A cast, not a parse. `FlowNodeSchema.config` is an open `z.record`, so a
|
|
7605
|
+
// region's contents arrive as raw authored records even in a parsed stack —
|
|
7606
|
+
// a nested edge `condition` may still be a bare string where a top-level
|
|
7607
|
+
// one is an Expression envelope. Every rule below reads both
|
|
7608
|
+
// (`conditionSource`), and the walk itself only touches `type` / `config`.
|
|
7609
|
+
// The already-guarded arrays are passed rather than `flow` itself so a
|
|
7610
|
+
// non-array `nodes` still cannot throw: this function promises it never does.
|
|
7611
|
+
nodes,
|
|
7612
|
+
edges
|
|
7613
|
+
})) {
|
|
7614
|
+
const at = graph.scope ? `flow '${flowName}' \xB7 ${graph.scope}` : `flow '${flowName}'`;
|
|
7615
|
+
const graphNodes = graph.nodes;
|
|
7616
|
+
const graphEdges = graph.edges;
|
|
7617
|
+
for (const node of graphNodes) {
|
|
7618
|
+
const nodeWhere = `${at} \xB7 node '${node.id}' (${node.type})`;
|
|
7619
|
+
const cfg = node.config ?? {};
|
|
7620
|
+
if (cfg.filter) scanFilterForDateEquality(cfg.filter, `${nodeWhere} filter`, findings);
|
|
7621
|
+
for (const key of Object.keys(cfg)) {
|
|
7622
|
+
if (PHANTOM_AGG_KEYS.has(key)) {
|
|
7623
|
+
findings.push({
|
|
7624
|
+
where: nodeWhere,
|
|
7625
|
+
message: `node config has \`${key}\` \u2014 the automation engine has no aggregate node, so \`${key}\` is silently ignored and this node computes nothing at runtime.`,
|
|
7626
|
+
hint: `Aggregation belongs in the data layer: use \`Field.summary\` for a cross-object rollup (sum/count of children), or \`Field.formula\` for a per-record computed value. (#1870)`,
|
|
7627
|
+
rule: FLOW_PHANTOM_AGGREGATION
|
|
7628
|
+
});
|
|
7629
|
+
}
|
|
6785
7630
|
}
|
|
6786
|
-
|
|
6787
|
-
|
|
6788
|
-
|
|
6789
|
-
|
|
6790
|
-
|
|
6791
|
-
|
|
6792
|
-
|
|
7631
|
+
const strings = [];
|
|
7632
|
+
collectTemplateStrings(stripRegions(node.config), void 0, strings);
|
|
7633
|
+
for (const str4 of strings) {
|
|
7634
|
+
if (DOUBLE_BRACE.test(str4)) {
|
|
7635
|
+
findings.push({
|
|
7636
|
+
where: nodeWhere,
|
|
7637
|
+
message: `double-brace interpolation \`${str4.trim().slice(0, 80)}\` \u2014 flow node values use SINGLE braces.`,
|
|
7638
|
+
hint: `Use \`{var}\` (e.g. \`{record.title}\`). Double-brace \`{{ }}\` is the formula/template-field dialect, not flow node values. (#1315)`,
|
|
7639
|
+
rule: FLOW_DOUBLE_BRACE_INTERP
|
|
7640
|
+
});
|
|
7641
|
+
}
|
|
7642
|
+
if (BARE_DOLLAR_REF.test(str4)) {
|
|
7643
|
+
findings.push({
|
|
7644
|
+
where: nodeWhere,
|
|
7645
|
+
message: `\`${str4.trim().slice(0, 80)}\` looks like a reference written as a literal \u2014 a bare \`$ref.field\` is NOT interpolated.`,
|
|
7646
|
+
hint: `Wrap it and bind a variable: \`{source.id}\` (or \`{$User.Id}\` for the current user). (#1315)`,
|
|
7647
|
+
rule: FLOW_BARE_DOLLAR_REF
|
|
7648
|
+
});
|
|
7649
|
+
}
|
|
6793
7650
|
}
|
|
6794
7651
|
}
|
|
7652
|
+
scanApprovalReviseLoops(at, graphNodes, graphEdges, findings);
|
|
7653
|
+
scanErrorLabelledEdges(at, graphNodes, graphEdges, findings);
|
|
7654
|
+
scanBranchRouting(at, graphNodes, graphEdges, findings);
|
|
7655
|
+
scanUnboundedBulkWrites(at, graphNodes, findings);
|
|
6795
7656
|
}
|
|
6796
|
-
scanApprovalReviseLoops(flowName, nodes, edges, findings);
|
|
6797
|
-
scanErrorLabelledEdges(flowName, nodes, edges, findings);
|
|
6798
|
-
scanBranchRouting(flowName, nodes, edges, findings);
|
|
6799
7657
|
}
|
|
6800
7658
|
return findings;
|
|
6801
7659
|
}
|
|
6802
7660
|
|
|
6803
7661
|
// src/lint-liveness-properties.ts
|
|
6804
|
-
import { createRequire as
|
|
7662
|
+
import { createRequire as createRequire5 } from "module";
|
|
6805
7663
|
import { dirname, join } from "path";
|
|
6806
7664
|
import { existsSync, readFileSync } from "fs";
|
|
6807
7665
|
var LIVENESS_DEAD_PROPERTY = "liveness-dead-property";
|
|
6808
7666
|
var LIVENESS_EXPERIMENTAL_PROPERTY = "liveness-experimental-property";
|
|
6809
|
-
function
|
|
7667
|
+
function asArray43(v) {
|
|
6810
7668
|
if (Array.isArray(v)) return v;
|
|
6811
7669
|
if (v && typeof v === "object") return Object.entries(v).map(([name, def]) => ({ name, ...def }));
|
|
6812
7670
|
return [];
|
|
6813
7671
|
}
|
|
6814
7672
|
function resolveLivenessDir() {
|
|
6815
7673
|
try {
|
|
6816
|
-
const require2 =
|
|
7674
|
+
const require2 = createRequire5(import.meta.url);
|
|
6817
7675
|
const pkgJson = require2.resolve("@objectstack/spec/package.json");
|
|
6818
7676
|
const dir = join(dirname(pkgJson), "liveness");
|
|
6819
7677
|
return existsSync(dir) ? dir : null;
|
|
@@ -6919,7 +7777,16 @@ var TYPE_COLLECTIONS = [
|
|
|
6919
7777
|
{ type: "job", key: "jobs" },
|
|
6920
7778
|
{ type: "email_template", key: "emailTemplates" },
|
|
6921
7779
|
{ type: "mapping", key: "mappings" },
|
|
6922
|
-
{ type: "translation", key: "translations" }
|
|
7780
|
+
{ type: "translation", key: "translations" },
|
|
7781
|
+
// #4956 — dashboard joins the list the moment its ledger first warns on
|
|
7782
|
+
// anything, which is exactly the rule the comment above states. Drilling
|
|
7783
|
+
// `widgets` produced five warned keys (`colorVariant`, `actionUrl`,
|
|
7784
|
+
// `actionType`, `actionIcon`, `aria`), all under `widgets[]`; `getNested`
|
|
7785
|
+
// fans a dotted path out over an array level, so `widgets.colorVariant`
|
|
7786
|
+
// checks every widget on the dashboard. Registering it here is not optional
|
|
7787
|
+
// bookkeeping: without it the ledger would be newly correct and newly
|
|
7788
|
+
// silent, which is the shape this lint exists to prevent.
|
|
7789
|
+
{ type: "dashboard", key: "dashboards" }
|
|
6923
7790
|
];
|
|
6924
7791
|
function lintLivenessProperties(stack) {
|
|
6925
7792
|
const dir = resolveLivenessDir();
|
|
@@ -6927,11 +7794,11 @@ function lintLivenessProperties(stack) {
|
|
|
6927
7794
|
const findings = [];
|
|
6928
7795
|
const objectWarn = loadWarnMap(dir, "object");
|
|
6929
7796
|
const fieldWarn = loadWarnMap(dir, "field");
|
|
6930
|
-
for (const obj of
|
|
7797
|
+
for (const obj of asArray43(stack.objects)) {
|
|
6931
7798
|
const objName = typeof obj.name === "string" ? obj.name : "(unnamed object)";
|
|
6932
7799
|
if (objectWarn.size > 0) checkItem("object", obj, `object '${objName}'`, objectWarn, findings);
|
|
6933
7800
|
if (fieldWarn.size > 0) {
|
|
6934
|
-
for (const field of
|
|
7801
|
+
for (const field of asArray43(obj.fields)) {
|
|
6935
7802
|
const fieldName = typeof field.name === "string" ? field.name : "(unnamed field)";
|
|
6936
7803
|
checkItem("field", field, `object '${objName}' \xB7 field '${fieldName}'`, fieldWarn, findings);
|
|
6937
7804
|
}
|
|
@@ -6940,7 +7807,7 @@ function lintLivenessProperties(stack) {
|
|
|
6940
7807
|
for (const { type, key } of TYPE_COLLECTIONS) {
|
|
6941
7808
|
const warnMap = loadWarnMap(dir, type);
|
|
6942
7809
|
if (warnMap.size === 0) continue;
|
|
6943
|
-
for (const item of
|
|
7810
|
+
for (const item of asArray43(stack[key])) {
|
|
6944
7811
|
const name = typeof item.name === "string" ? item.name : typeof item.object === "string" ? item.object : `(unnamed ${type})`;
|
|
6945
7812
|
checkItem(type, item, `${type} '${name}'`, warnMap, findings);
|
|
6946
7813
|
}
|
|
@@ -6954,7 +7821,7 @@ var AUTONUMBER_UNKNOWN_FIELD = "autonumber-references-unknown-field";
|
|
|
6954
7821
|
var AUTONUMBER_OPTIONAL_FIELD = "autonumber-references-optional-field";
|
|
6955
7822
|
var AUTONUMBER_SELF_REFERENCE = "autonumber-references-self";
|
|
6956
7823
|
var AUTONUMBER_LITERAL_TOKEN = "autonumber-unrecognized-token";
|
|
6957
|
-
function
|
|
7824
|
+
function asArray44(v) {
|
|
6958
7825
|
if (Array.isArray(v)) return v;
|
|
6959
7826
|
if (v && typeof v === "object") {
|
|
6960
7827
|
return Object.entries(v).map(([name, def]) => ({ name, ...def }));
|
|
@@ -6963,9 +7830,9 @@ function asArray40(v) {
|
|
|
6963
7830
|
}
|
|
6964
7831
|
function lintAutonumberFormats(stack) {
|
|
6965
7832
|
const findings = [];
|
|
6966
|
-
for (const obj of
|
|
7833
|
+
for (const obj of asArray44(stack.objects)) {
|
|
6967
7834
|
const objectName = typeof obj.name === "string" ? obj.name : "(unnamed object)";
|
|
6968
|
-
const fields =
|
|
7835
|
+
const fields = asArray44(obj.fields);
|
|
6969
7836
|
const fieldMeta = /* @__PURE__ */ new Map();
|
|
6970
7837
|
for (const f of fields) {
|
|
6971
7838
|
if (typeof f.name === "string") fieldMeta.set(f.name, { required: f.required === true });
|
|
@@ -7031,7 +7898,7 @@ function lintAutonumberFormats(stack) {
|
|
|
7031
7898
|
|
|
7032
7899
|
// src/lint-view-refs.ts
|
|
7033
7900
|
import { expandViewContainerWithDiagnostics, isAggregatedViewContainer } from "@objectstack/spec";
|
|
7034
|
-
function
|
|
7901
|
+
function asArray45(v) {
|
|
7035
7902
|
if (Array.isArray(v)) return v;
|
|
7036
7903
|
if (v && typeof v === "object") return Object.entries(v).map(([name, def]) => ({ name, ...def }));
|
|
7037
7904
|
return [];
|
|
@@ -7059,7 +7926,7 @@ function lintViewRefs(stack) {
|
|
|
7059
7926
|
s.add(kind);
|
|
7060
7927
|
};
|
|
7061
7928
|
const containers = [];
|
|
7062
|
-
for (const v of
|
|
7929
|
+
for (const v of asArray45(stack.views)) {
|
|
7063
7930
|
if (v.viewKind) {
|
|
7064
7931
|
if (typeof v.name === "string") indexKind(v.name, v.viewKind === "form" ? "form" : "list");
|
|
7065
7932
|
continue;
|
|
@@ -7068,7 +7935,7 @@ function lintViewRefs(stack) {
|
|
|
7068
7935
|
const object = viewContainerObjectName(v);
|
|
7069
7936
|
if (object) containers.push({ object, container: v });
|
|
7070
7937
|
}
|
|
7071
|
-
for (const obj of
|
|
7938
|
+
for (const obj of asArray45(stack.objects)) {
|
|
7072
7939
|
const object = typeof obj.name === "string" ? obj.name : void 0;
|
|
7073
7940
|
if (!object) continue;
|
|
7074
7941
|
if (obj.list || obj.form || obj.listViews || obj.formViews) {
|
|
@@ -7122,11 +7989,11 @@ function lintViewRefs(stack) {
|
|
|
7122
7989
|
});
|
|
7123
7990
|
}
|
|
7124
7991
|
};
|
|
7125
|
-
for (const obj of
|
|
7992
|
+
for (const obj of asArray45(stack.objects)) {
|
|
7126
7993
|
const object = typeof obj.name === "string" ? obj.name : void 0;
|
|
7127
|
-
for (const action of
|
|
7994
|
+
for (const action of asArray45(obj.actions)) checkAction(action, object);
|
|
7128
7995
|
}
|
|
7129
|
-
for (const action of
|
|
7996
|
+
for (const action of asArray45(stack.actions)) checkAction(action);
|
|
7130
7997
|
return findings;
|
|
7131
7998
|
}
|
|
7132
7999
|
|
|
@@ -7139,8 +8006,43 @@ function fieldEntries2(fields) {
|
|
|
7139
8006
|
return Object.entries(fields).map(([name, def]) => ({ name, def }));
|
|
7140
8007
|
}
|
|
7141
8008
|
var UNIQUE_DOUBLE_DECLARATION = "unique/double-declaration";
|
|
8009
|
+
var UNIQUE_UNSCOPED_DECLARED_INDEX = "unique/unscoped-declared-index";
|
|
8010
|
+
var UNIQUE_LEGACY_ORGANIZATION_COMPOSITE = "unique/legacy-organization-composite";
|
|
8011
|
+
function authoredTenantColumn(obj) {
|
|
8012
|
+
const declared = obj?.tenancy?.tenantField;
|
|
8013
|
+
return typeof declared === "string" && declared.trim() ? declared.trim() : "organization_id";
|
|
8014
|
+
}
|
|
7142
8015
|
function uniqueDeclared(u) {
|
|
7143
|
-
return u === true || u === "global";
|
|
8016
|
+
return u === true || u === "global" || u === "organization";
|
|
8017
|
+
}
|
|
8018
|
+
function fieldUniqueScope(u) {
|
|
8019
|
+
return u === "global" ? "global" : "organization";
|
|
8020
|
+
}
|
|
8021
|
+
function indexUniqueScope(u) {
|
|
8022
|
+
return u === "organization" ? "organization" : "global";
|
|
8023
|
+
}
|
|
8024
|
+
function lintUnscopedDeclaredIndexes(objects) {
|
|
8025
|
+
const issues = [];
|
|
8026
|
+
if (!Array.isArray(objects) || objects.length === 0) return issues;
|
|
8027
|
+
for (let i = 0; i < objects.length; i++) {
|
|
8028
|
+
const obj = objects[i];
|
|
8029
|
+
if (!obj?.name) continue;
|
|
8030
|
+
const declaredIndexes = Array.isArray(obj.indexes) ? obj.indexes : [];
|
|
8031
|
+
for (let j = 0; j < declaredIndexes.length; j++) {
|
|
8032
|
+
const idx = declaredIndexes[j];
|
|
8033
|
+
if (idx?.unique !== true) continue;
|
|
8034
|
+
const cols = Array.isArray(idx?.fields) ? idx.fields.filter((f) => typeof f === "string").join(", ") : "";
|
|
8035
|
+
const indexLabel = typeof idx?.name === "string" && idx.name.trim() ? ` '${idx.name.trim()}'` : "";
|
|
8036
|
+
issues.push({
|
|
8037
|
+
severity: "warning",
|
|
8038
|
+
rule: UNIQUE_UNSCOPED_DECLARED_INDEX,
|
|
8039
|
+
message: `"${obj.name}" declares index${indexLabel} [${cols}] with bare \`unique: true\` \u2014 a unique index whose scope is unstated (ADR-0120). Today the bare spelling materializes over exactly its \`fields\`, i.e. installation-wide; an author who meant "unique per organization" gets no per-organization constraint and no error. Protocol 18 rejects this spelling (#5082).`,
|
|
8040
|
+
path: `objects[${i}].indexes[${j}]`,
|
|
8041
|
+
fix: `State the scope: \`unique: 'global'\` (installation-wide \u2014 exactly today's behavior) or \`unique: 'organization'\` (one holder per organization \u2014 the driver prepends the NULL-safe organization key part at registration).`
|
|
8042
|
+
});
|
|
8043
|
+
}
|
|
8044
|
+
}
|
|
8045
|
+
return issues;
|
|
7144
8046
|
}
|
|
7145
8047
|
function lintUniqueDeclarations(objects) {
|
|
7146
8048
|
const issues = [];
|
|
@@ -7160,16 +8062,59 @@ function lintUniqueDeclarations(objects) {
|
|
|
7160
8062
|
if (singleColumnUniqueIndexes.size === 0) continue;
|
|
7161
8063
|
for (const { name, def } of fieldEntries2(obj.fields)) {
|
|
7162
8064
|
if (!uniqueDeclared(def?.unique)) continue;
|
|
7163
|
-
if (def.unique === "global") continue;
|
|
7164
8065
|
const idx = singleColumnUniqueIndexes.get(name);
|
|
7165
8066
|
if (!idx) continue;
|
|
8067
|
+
const fScope = fieldUniqueScope(def.unique);
|
|
8068
|
+
const iScope = indexUniqueScope(idx.unique);
|
|
7166
8069
|
const indexLabel = typeof idx?.name === "string" && idx.name.trim() ? ` '${idx.name.trim()}'` : "";
|
|
8070
|
+
const fieldSpelling = `\`unique: ${typeof def.unique === "string" ? `'${def.unique}'` : def.unique}\``;
|
|
8071
|
+
const indexSpelling = `\`unique: ${typeof idx.unique === "string" ? `'${idx.unique}'` : idx.unique}\``;
|
|
8072
|
+
let message;
|
|
8073
|
+
let fix;
|
|
8074
|
+
if (fScope === iScope) {
|
|
8075
|
+
const boundary = fScope === "global" ? "installation-wide" : "per-organization";
|
|
8076
|
+
message = `"${obj.name}.${name}" declares field-level ${fieldSpelling} AND a single-column unique index${indexLabel} (${indexSpelling}) on the same column. Both ask for the same ${boundary} boundary \u2014 the same unique index declared twice (ADR-0120 D5b). Redundant, not contradictory: drop one so the intent has a single home.`;
|
|
8077
|
+
fix = fScope === "global" ? `Keep ONE spelling of installation-wide uniqueness: \`unique: 'global'\` on '${name}', or the declared index \u2014 not both.` : `Keep ONE spelling of per-organization uniqueness: \`unique: 'organization'\` on '${name}' (preferred), or the declared \`'organization'\` index \u2014 not both.`;
|
|
8078
|
+
} else {
|
|
8079
|
+
const globalSide = fScope === "global" ? `field-level ${fieldSpelling}` : `declared index${indexLabel} (${indexSpelling})`;
|
|
8080
|
+
const orgSide = fScope === "global" ? `declared index${indexLabel} (${indexSpelling})` : `field-level ${fieldSpelling}`;
|
|
8081
|
+
message = `"${obj.name}.${name}" declares an installation-wide unique (${globalSide}) AND a per-organization unique (${orgSide}) on the same column \u2014 the two scopes CONTRADICT (ADR-0120 D5b). The installation-wide index is physically stricter and wins; the per-organization constraint can never be tripped, so one of the two intents you wrote is silently dead.`;
|
|
8082
|
+
fix = `Pick ONE scope and say it once: for installation-wide uniqueness keep \`unique: 'global'\` and drop the per-organization declaration; for per-organization uniqueness set \`unique: 'organization'\` (field-level on '${name}', or on the declared index) and drop the installation-wide one.`;
|
|
8083
|
+
}
|
|
7167
8084
|
issues.push({
|
|
7168
8085
|
severity: "warning",
|
|
7169
8086
|
rule: UNIQUE_DOUBLE_DECLARATION,
|
|
7170
|
-
message
|
|
8087
|
+
message,
|
|
7171
8088
|
path: `objects[${i}]`,
|
|
7172
|
-
fix
|
|
8089
|
+
fix
|
|
8090
|
+
});
|
|
8091
|
+
}
|
|
8092
|
+
}
|
|
8093
|
+
return issues;
|
|
8094
|
+
}
|
|
8095
|
+
function lintLegacyOrganizationComposites(objects) {
|
|
8096
|
+
const issues = [];
|
|
8097
|
+
if (!Array.isArray(objects) || objects.length === 0) return issues;
|
|
8098
|
+
for (let i = 0; i < objects.length; i++) {
|
|
8099
|
+
const obj = objects[i];
|
|
8100
|
+
if (!obj?.name) continue;
|
|
8101
|
+
const tenantColumn = authoredTenantColumn(obj);
|
|
8102
|
+
const declaredIndexes = Array.isArray(obj.indexes) ? obj.indexes : [];
|
|
8103
|
+
for (let j = 0; j < declaredIndexes.length; j++) {
|
|
8104
|
+
const idx = declaredIndexes[j];
|
|
8105
|
+
if (!uniqueDeclared(idx?.unique) || idx.unique === "organization") continue;
|
|
8106
|
+
const cols = Array.isArray(idx?.fields) ? idx.fields.filter((f) => typeof f === "string") : [];
|
|
8107
|
+
if (cols.length < 2) continue;
|
|
8108
|
+
if (!cols.includes(tenantColumn)) continue;
|
|
8109
|
+
const indexLabel = typeof idx?.name === "string" && idx.name.trim() ? ` '${idx.name.trim()}'` : "";
|
|
8110
|
+
const spelling = `\`unique: ${typeof idx.unique === "string" ? `'${idx.unique}'` : idx.unique}\``;
|
|
8111
|
+
const rest = cols.filter((c) => c !== tenantColumn);
|
|
8112
|
+
issues.push({
|
|
8113
|
+
severity: "warning",
|
|
8114
|
+
rule: UNIQUE_LEGACY_ORGANIZATION_COMPOSITE,
|
|
8115
|
+
message: `"${obj.name}" declares index${indexLabel} [${cols.join(", ")}] with ${spelling} and lists the organization column '${tenantColumn}' itself \u2014 the hand-written per-organization composite that predates the scope vocabulary (ADR-0120 S6). It reads as "unique per organization" but materializes as a plain composite, and SQL UNIQUE is NULL-distinct: on every row whose '${tenantColumn}' is NULL it enforces nothing (#5030) \u2014 which on a single-organization deployment is every row.`,
|
|
8116
|
+
path: `objects[${i}].indexes[${j}]`,
|
|
8117
|
+
fix: `State the scope instead: \`unique: 'organization'\` on this index (keep \`fields\` exactly as they are \u2014 the driver makes the listed '${tenantColumn}' NULL-safe in place rather than prepending a second organization key part). ${rest.length > 0 ? `The constraint then really is "one ${rest.join(" + ")} per organization". ` : ""}Opting in is a physical tightening: it surfaces as a \`recreate_index\` drift op gated by the duplicate pre-flight probe (ADR-0120 D4), so pre-existing duplicate NULL-organization rows block it with a report rather than failing a boot. Leaving it as-is stays valid indefinitely and forces no drift.`
|
|
7173
8118
|
});
|
|
7174
8119
|
}
|
|
7175
8120
|
}
|
|
@@ -7318,6 +8263,42 @@ var AUTHORING_RULES = [
|
|
|
7318
8263
|
runtimeTypes: ["flow"],
|
|
7319
8264
|
run: (stack) => validateReferenceIntegrity(stack)
|
|
7320
8265
|
},
|
|
8266
|
+
// ADR-0078 / #5068 — the SDUI component-props gate. `PageComponent.properties`
|
|
8267
|
+
// is `z.record(z.string(), z.unknown())` and ADR-0089 D3a strictness does not
|
|
8268
|
+
// recurse into it, so until this entry existed the 31 typed prop schemas in
|
|
8269
|
+
// `ComponentPropsMap` were parsed by NOTHING (#4001 批 17's `no gate`
|
|
8270
|
+
// verdict): an undeclared or wrongly-typed prop parsed clean, was retained,
|
|
8271
|
+
// and reached objectui's renderer to be ignored there. This dispatches on
|
|
8272
|
+
// `type` and judges the bag; unregistered types are skipped, which is a
|
|
8273
|
+
// required semantic (`type` is an open union — the example corpus authors 87
|
|
8274
|
+
// nodes of 10 types this map does not carry).
|
|
8275
|
+
//
|
|
8276
|
+
// `normalized` for a reason worth stating, since the props bag survives the
|
|
8277
|
+
// Zod parse UNCHANGED and both tiers would otherwise carry the same data: the
|
|
8278
|
+
// ADR-0087 conversion layer runs inside `normalizeStackInput`, so a converted
|
|
8279
|
+
// alias (`page-header-subtitle-alias` rewrites `properties.description` →
|
|
8280
|
+
// `subtitle`) is already canonical here and is never reported as undeclared —
|
|
8281
|
+
// while a schema error elsewhere in the stack cannot take these findings down
|
|
8282
|
+
// with it.
|
|
8283
|
+
//
|
|
8284
|
+
// Advisory, deliberately, and this is the whole shape of #5068's first step:
|
|
8285
|
+
// wiring the parse is the precondition for enforcement, not the enforcement
|
|
8286
|
+
// (#5020, one surface over). The live corpus violates the declarations in two
|
|
8287
|
+
// places that are open contract questions — inline i18n label maps on three
|
|
8288
|
+
// published platform pages (#5728) and the record picker's declared-but-unread
|
|
8289
|
+
// `displayField` (#5775) — so gating today would fail the platform's own pages
|
|
8290
|
+
// to enforce declarations the platform does not keep. The error upgrade is a
|
|
8291
|
+
// separate step, once the warning-period inventory is empty.
|
|
8292
|
+
{
|
|
8293
|
+
name: "validateComponentProps",
|
|
8294
|
+
tier: "advisory",
|
|
8295
|
+
input: "normalized",
|
|
8296
|
+
commands: ALL,
|
|
8297
|
+
source: "packages/lint/src/validate-component-props.ts",
|
|
8298
|
+
surfaces: CLI_ONLY,
|
|
8299
|
+
surfaceReason: RUNTIME_NEEDS_FULL_SNAPSHOT,
|
|
8300
|
+
run: (stack) => validateComponentProps(stack)
|
|
8301
|
+
},
|
|
7321
8302
|
// ADR-0065 — a styled node's responsiveStyles must be scopable (needs an
|
|
7322
8303
|
// `id`), name real CSS properties + design tokens, and carry a `large` base.
|
|
7323
8304
|
{
|
|
@@ -7381,18 +8362,32 @@ var AUTHORING_RULES = [
|
|
|
7381
8362
|
surfaceReason: 'P2 (#4463): the ONE rule the runtime universe makes strictly stronger \u2014 the advisory hedge ("another installed package may provide it") is decidable against the live capability registry, so it graduates from advisory to gating there rather than merely being ported. That promotion is a severity change on a published rule id and belongs in its own PR, not riding a wiring change.',
|
|
7382
8363
|
run: (stack) => validateCapabilityReferences(stack)
|
|
7383
8364
|
},
|
|
7384
|
-
// A
|
|
7385
|
-
//
|
|
7386
|
-
//
|
|
8365
|
+
// A flow that LOOKS armed and never launches — silently. Reads the pre-parse
|
|
8366
|
+
// tier so an author sees what they wrote.
|
|
8367
|
+
//
|
|
8368
|
+
// `gating` since #5762, which reviewed the file's rules as one family and
|
|
8369
|
+
// split them on a single question: is THIS STACK enough to know the flow is
|
|
8370
|
+
// dead? Three rules answer yes and now emit `error` — a `config.timeRelative`
|
|
8371
|
+
// the spec's own `TimeRelativeTriggerSchema` refuses, one the engine's routing
|
|
8372
|
+
// predicate cannot route at all, and a `record-*` triggerType outside the
|
|
8373
|
+
// closed token grammar `triggerTypeToHookEvents` maps. None of those verdicts
|
|
8374
|
+
// can be changed by installing a package, so there is no reading under which
|
|
8375
|
+
// the flow fires. `flow-trigger-unknown-object` deliberately stayed `warning`
|
|
8376
|
+
// (the object may come from another installed package — a hedge this rule
|
|
8377
|
+
// cannot decide), as did `flow-draft-status-ambiguous` (draft flows DO fire;
|
|
8378
|
+
// that one is ambiguity of intent, not a dead flow).
|
|
7387
8379
|
{
|
|
7388
8380
|
name: "validateFlowTriggerReadiness",
|
|
7389
|
-
tier: "
|
|
8381
|
+
tier: "gating",
|
|
7390
8382
|
input: "normalized",
|
|
7391
8383
|
commands: ALL,
|
|
7392
8384
|
source: "packages/lint/src/validate-flow-trigger-readiness.ts",
|
|
7393
|
-
// Runtime publish gate (#4463): the FLOW family.
|
|
7394
|
-
//
|
|
7395
|
-
//
|
|
8385
|
+
// Runtime publish gate (#4463): the FLOW family. Its `error` findings now
|
|
8386
|
+
// REFUSE a `state: 'active'` write (P1 gates on `error` only); the rules that
|
|
8387
|
+
// stayed `warning` keep being logged as advisories. The gate judges a
|
|
8388
|
+
// snapshot whose `flows` holds only the written item and subtracts the
|
|
8389
|
+
// baseline's findings, so this refuses the dead flow's own publish — never
|
|
8390
|
+
// another flow's save on account of a stored one.
|
|
7396
8391
|
surfaces: CLI_AND_RUNTIME,
|
|
7397
8392
|
runtimeTypes: ["flow"],
|
|
7398
8393
|
run: (stack) => validateFlowTriggerReadiness(stack)
|
|
@@ -7603,9 +8598,31 @@ var AUTHORING_RULES = [
|
|
|
7603
8598
|
hint: f.hint
|
|
7604
8599
|
}))
|
|
7605
8600
|
},
|
|
7606
|
-
//
|
|
7607
|
-
//
|
|
7608
|
-
//
|
|
8601
|
+
// ADR-0120 D5a — a declared index with bare `unique: true` states no scope
|
|
8602
|
+
// at all (`unique/unscoped-declared-index` — the #4986 trap). Fires on the
|
|
8603
|
+
// spelling alone, no tenancy inference; 17.x warns, protocol 18 rejects the
|
|
8604
|
+
// spelling (#5082).
|
|
8605
|
+
{
|
|
8606
|
+
name: "lintUnscopedDeclaredIndexes",
|
|
8607
|
+
tier: "advisory",
|
|
8608
|
+
input: "parsed",
|
|
8609
|
+
commands: ["validate", "build"],
|
|
8610
|
+
source: "packages/lint/src/data-model-rules.ts",
|
|
8611
|
+
surfaces: CLI_ONLY,
|
|
8612
|
+
surfaceReason: RUNTIME_OBJECT_WRITES_P2,
|
|
8613
|
+
scopeReason: "`os lint` already reports this rule through `lintDataModel`, which calls it directly ahead of R10 in its best-practice sweep \u2014 registering it for `lint` as well would report every finding twice. This is coverage recorded, not coverage missing: all three commands report the rule.",
|
|
8614
|
+
run: (stack) => lintUnscopedDeclaredIndexes(Array.isArray(stack.objects) ? stack.objects : []).map((f) => ({
|
|
8615
|
+
severity: f.severity === "suggestion" ? "info" : f.severity,
|
|
8616
|
+
rule: f.rule,
|
|
8617
|
+
where: f.path,
|
|
8618
|
+
path: f.path,
|
|
8619
|
+
message: f.message,
|
|
8620
|
+
hint: f.fix ?? ""
|
|
8621
|
+
}))
|
|
8622
|
+
},
|
|
8623
|
+
// #3991 / ADR-0120 D5b — a column carrying BOTH a field-level `unique` and a
|
|
8624
|
+
// single-column declared unique index states two scopes of which at most one
|
|
8625
|
+
// takes effect (`unique/double-declaration`, the four-quadrant matrix).
|
|
7609
8626
|
{
|
|
7610
8627
|
name: "lintUniqueDeclarations",
|
|
7611
8628
|
tier: "advisory",
|
|
@@ -7624,6 +8641,28 @@ var AUTHORING_RULES = [
|
|
|
7624
8641
|
hint: f.fix ?? ""
|
|
7625
8642
|
}))
|
|
7626
8643
|
},
|
|
8644
|
+
// ADR-0120 D5c — a declared unique listing the organization column IS the
|
|
8645
|
+
// hand-written per-organization composite (S6). Advisory nudge toward the
|
|
8646
|
+
// `'organization'` respelling, which is also what closes its NULL hole
|
|
8647
|
+
// (#5030). Never auto-fixed: opting in is a real D4 tightening.
|
|
8648
|
+
{
|
|
8649
|
+
name: "lintLegacyOrganizationComposites",
|
|
8650
|
+
tier: "advisory",
|
|
8651
|
+
input: "parsed",
|
|
8652
|
+
commands: ["validate", "build"],
|
|
8653
|
+
source: "packages/lint/src/data-model-rules.ts",
|
|
8654
|
+
surfaces: CLI_ONLY,
|
|
8655
|
+
surfaceReason: RUNTIME_OBJECT_WRITES_P2,
|
|
8656
|
+
scopeReason: "`os lint` already reports this rule through `lintDataModel`, which calls it directly alongside R10/R11 in its best-practice sweep \u2014 registering it for `lint` as well would report every finding twice. This is coverage recorded, not coverage missing: all three commands report the rule.",
|
|
8657
|
+
run: (stack) => lintLegacyOrganizationComposites(Array.isArray(stack.objects) ? stack.objects : []).map((f) => ({
|
|
8658
|
+
severity: f.severity === "suggestion" ? "info" : f.severity,
|
|
8659
|
+
rule: f.rule,
|
|
8660
|
+
where: f.path,
|
|
8661
|
+
path: f.path,
|
|
8662
|
+
message: f.message,
|
|
8663
|
+
hint: f.fix ?? ""
|
|
8664
|
+
}))
|
|
8665
|
+
},
|
|
7627
8666
|
// ADR-0090 D7 — the security-domain publish linter. Every `error` rule mirrors
|
|
7628
8667
|
// a runtime enforcement point (fail-closed OWD default, canonical enum, anchor
|
|
7629
8668
|
// binding gate, vocabulary freeze), moving the failure from a runtime deny to
|
|
@@ -7651,6 +8690,88 @@ var AUTHORING_RULES = [
|
|
|
7651
8690
|
surfaces: CLI_ONLY,
|
|
7652
8691
|
surfaceReason: RUNTIME_NEEDS_FULL_SNAPSHOT,
|
|
7653
8692
|
run: (stack) => validateOrgAxisRedLines(stack)
|
|
8693
|
+
},
|
|
8694
|
+
// #4698 — the "declared but never read" gate, for the one surface where the
|
|
8695
|
+
// predicate is EXACT rather than inferred. A sharing rule's `condition` has a
|
|
8696
|
+
// single runtime consumer (`bootstrapDeclaredSharingRules`) whose only use of
|
|
8697
|
+
// the key is `compileCelToFilter(condition, { variables: {} })`; a condition
|
|
8698
|
+
// that does not lower means the rule is SKIPPED at boot, so the grant is
|
|
8699
|
+
// declared and does not exist. The lint calls that same compiler, from the
|
|
8700
|
+
// same package, with the same options — the verdict cannot drift from the
|
|
8701
|
+
// consumer's. Gating for the ADR-0078 reason `SharingRuleSchema`'s own
|
|
8702
|
+
// docblock states: the whole authorable surface is enforced, and this was the
|
|
8703
|
+
// one field where that sentence was not yet true.
|
|
8704
|
+
{
|
|
8705
|
+
name: "validateSharingRuleEnforceability",
|
|
8706
|
+
tier: "gating",
|
|
8707
|
+
input: "parsed",
|
|
8708
|
+
commands: ALL,
|
|
8709
|
+
source: "packages/lint/src/validate-sharing-rule-enforceability.ts",
|
|
8710
|
+
surfaces: CLI_ONLY,
|
|
8711
|
+
surfaceReason: "P2 (#4463): a sharing rule is not a `flow`, and P1 gates `flow` alone. The rule itself is snapshot-safe \u2014 it reads ONLY `stack.sharingRules[].condition` and needs no other collection \u2014 so widening it here is a `runtimeTypes: ['sharing_rule']` edit once the gate accepts that type, not new wiring. Recorded as pending rather than done, because a rule that has never run at a door should not claim it.",
|
|
8712
|
+
run: (stack) => validateSharingRuleEnforceability(stack)
|
|
8713
|
+
},
|
|
8714
|
+
// #4983 — the sibling surface of the rule above, and ADR-0056 D4's gate,
|
|
8715
|
+
// which had never been wired to anything: `isSupportedRlsExpression` existed
|
|
8716
|
+
// solely so an authoring command could reject a predicate the runtime drops,
|
|
8717
|
+
// and no authoring command called it. An unlowerable
|
|
8718
|
+
// `rowLevelSecurity[].using` is DROPPED by `RLSCompiler` and — when it is the
|
|
8719
|
+
// only applicable policy — replaced by `RLS_DENY_FILTER`, so the policy reads
|
|
8720
|
+
// as an authorization and behaves as a blanket refusal. Same construction as
|
|
8721
|
+
// the sharing-rule entry: the verdict is the runtime's own function, reached
|
|
8722
|
+
// through `@objectstack/formula` (where #4983 hoisted it), never a model of it.
|
|
8723
|
+
{
|
|
8724
|
+
name: "validateRlsPredicateEnforceability",
|
|
8725
|
+
tier: "gating",
|
|
8726
|
+
input: "parsed",
|
|
8727
|
+
commands: ALL,
|
|
8728
|
+
source: "packages/lint/src/validate-rls-predicate-enforceability.ts",
|
|
8729
|
+
surfaces: CLI_ONLY,
|
|
8730
|
+
surfaceReason: "P2 (#4463): the rule reads `stack.permissions[]`, a stack-wide collection the per-write snapshot does not carry, and P1 gates `flow` alone. It is otherwise snapshot-ready \u2014 it needs no other collection \u2014 so widening it is a `runtimeTypes: ['permission_set']` edit once the gate builds that snapshot, not new wiring. Recorded as pending rather than done, because a rule that has never run at a door should not claim it.",
|
|
8731
|
+
run: (stack) => validateRlsPredicateEnforceability(stack)
|
|
8732
|
+
},
|
|
8733
|
+
// #4762 — the same "declared but enforces nothing" question, for the two
|
|
8734
|
+
// STATIC artifacts an object validation rule carries. A `format` rule's
|
|
8735
|
+
// `regex` that `new RegExp(...)` throws on, and a `json_schema` rule's schema
|
|
8736
|
+
// ajv cannot compile, are both logged and SKIPPED on the write path
|
|
8737
|
+
// (`rule-validator.ts`), so the rule ships, lists, and protects nothing.
|
|
8738
|
+
// Neither needs a record to judge, so the authoring door is the right one:
|
|
8739
|
+
// rejecting a broken regex at RUNTIME instead would reject every write
|
|
8740
|
+
// touching that field for as long as the metadata is deployed (#4762's own
|
|
8741
|
+
// analysis — the runtime-backstop question stays open for the maintainer).
|
|
8742
|
+
// Gating for the `lint-flow-patterns.ts` bar: no reading of the metadata
|
|
8743
|
+
// behaves as written, because the rule does not run at all.
|
|
8744
|
+
{
|
|
8745
|
+
name: "validateRuleCompilability",
|
|
8746
|
+
tier: "gating",
|
|
8747
|
+
input: "parsed",
|
|
8748
|
+
commands: ALL,
|
|
8749
|
+
source: "packages/lint/src/validate-rule-compilability.ts",
|
|
8750
|
+
surfaces: CLI_ONLY,
|
|
8751
|
+
surfaceReason: RUNTIME_OBJECT_WRITES_P2,
|
|
8752
|
+
run: (stack) => validateRuleCompilability(stack)
|
|
8753
|
+
},
|
|
8754
|
+
// #5178 — the residual half of #5029, which registering `ajv-formats` does
|
|
8755
|
+
// NOT close: under `strict: false` a MISSPELLED format name (`emial`) is
|
|
8756
|
+
// logged once and DROPPED, so the rule compiles, ships, runs on every write
|
|
8757
|
+
// and enforces nothing for the keyword its author wrote — and the record is
|
|
8758
|
+
// accepted, which is the silent direction. Deliberately its own entry rather
|
|
8759
|
+
// than a third finding inside the rule above: that one's whole contract is
|
|
8760
|
+
// compiling in the runtime's exact environment, and a typo'd format compiles
|
|
8761
|
+
// there. This judges the format NAME against the registered set (enumerated
|
|
8762
|
+
// from the same ajv instance, never a hardcoded list) and compiles nothing,
|
|
8763
|
+
// so the #4762/#5029 compile parity is untouched — a judgement beside the
|
|
8764
|
+
// compile, not a divergent compile. Gating for the `lint-flow-patterns.ts`
|
|
8765
|
+
// bar: no reading of the metadata behaves as written.
|
|
8766
|
+
{
|
|
8767
|
+
name: "validateRuleSchemaFormats",
|
|
8768
|
+
tier: "gating",
|
|
8769
|
+
input: "parsed",
|
|
8770
|
+
commands: ALL,
|
|
8771
|
+
source: "packages/lint/src/validate-rule-schema-formats.ts",
|
|
8772
|
+
surfaces: CLI_ONLY,
|
|
8773
|
+
surfaceReason: RUNTIME_OBJECT_WRITES_P2,
|
|
8774
|
+
run: (stack) => validateRuleSchemaFormats(stack)
|
|
7654
8775
|
}
|
|
7655
8776
|
];
|
|
7656
8777
|
|