@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.cjs
CHANGED
|
@@ -28,21 +28,25 @@ __export(runtime_exports, {
|
|
|
28
28
|
module.exports = __toCommonJS(runtime_exports);
|
|
29
29
|
|
|
30
30
|
// src/validate-expressions.ts
|
|
31
|
-
var
|
|
31
|
+
var import_formula2 = require("@objectstack/formula");
|
|
32
32
|
var import_automation = require("@objectstack/spec/automation");
|
|
33
33
|
|
|
34
|
+
// src/system-fields.ts
|
|
35
|
+
var import_data = require("@objectstack/spec/data");
|
|
36
|
+
var import_system = require("@objectstack/spec/system");
|
|
37
|
+
var SYSTEM_FIELDS = /* @__PURE__ */ new Set([
|
|
38
|
+
...import_data.FIELD_GROUP_SYSTEM_FIELDS,
|
|
39
|
+
...Object.values(import_system.SystemFieldName)
|
|
40
|
+
]);
|
|
41
|
+
function injectedColumnsFor(objectDef) {
|
|
42
|
+
return (0, import_data.resolveInjectedSystemColumns)(objectDef).names;
|
|
43
|
+
}
|
|
44
|
+
|
|
34
45
|
// src/validate-null-guards.ts
|
|
35
|
-
var
|
|
46
|
+
var import_formula = require("@objectstack/formula");
|
|
36
47
|
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.`;
|
|
37
48
|
var FAULTING_BINARY_OPS = /* @__PURE__ */ new Set(["<", "<=", ">", ">=", "+", "-", "*", "/", "%"]);
|
|
38
49
|
var DEFAULT_RECORD_ROOTS = ["record", "previous"];
|
|
39
|
-
var parseEnv;
|
|
40
|
-
function getParseEnv() {
|
|
41
|
-
if (!parseEnv) {
|
|
42
|
-
parseEnv = new import_cel_js.Environment({ unlistedVariablesAreDyn: true, enableOptionalTypes: true });
|
|
43
|
-
}
|
|
44
|
-
return parseEnv;
|
|
45
|
-
}
|
|
46
50
|
function isNode(v) {
|
|
47
51
|
return !!v && typeof v === "object" && typeof v.op === "string";
|
|
48
52
|
}
|
|
@@ -175,12 +179,8 @@ function findUnguardedNullableOperands(source, opts) {
|
|
|
175
179
|
if (typeof source !== "string" || !source.trim()) return [];
|
|
176
180
|
if (opts.nullableFields.size === 0) return [];
|
|
177
181
|
const roots = opts.roots ?? DEFAULT_RECORD_ROOTS;
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
ast = getParseEnv().parse(source).ast;
|
|
181
|
-
} catch {
|
|
182
|
-
return [];
|
|
183
|
-
}
|
|
182
|
+
const ast = (0, import_formula.parseCelToAst)(source);
|
|
183
|
+
if (!ast) return [];
|
|
184
184
|
const hasOperands = /* @__PURE__ */ new Set();
|
|
185
185
|
collectHasOperands(ast, roots, hasOperands);
|
|
186
186
|
const findings = [];
|
|
@@ -243,10 +243,14 @@ function findUnguardedNullableOperands(source, opts) {
|
|
|
243
243
|
visit(ast, /* @__PURE__ */ new Set());
|
|
244
244
|
return findings;
|
|
245
245
|
}
|
|
246
|
-
|
|
246
|
+
var OUTCOME_CLAUSE = {
|
|
247
|
+
"fail-closed": "so the rule enforces nothing and the write is rejected fail-closed (#4649/#4763)",
|
|
248
|
+
"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)"
|
|
249
|
+
};
|
|
250
|
+
function nullGuardMessage(subject, objectName, finding, outcome = "fail-closed") {
|
|
247
251
|
const owner = objectName ? `'${objectName}'` : "this object";
|
|
248
252
|
const hasNote = finding.hasOnlyGuard ? ` \`has(${finding.operand})\` does not guard it.` : "";
|
|
249
|
-
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
|
|
253
|
+
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}`;
|
|
250
254
|
}
|
|
251
255
|
|
|
252
256
|
// src/validate-expressions.ts
|
|
@@ -266,7 +270,7 @@ function buildFieldIndex(objects) {
|
|
|
266
270
|
let names = [];
|
|
267
271
|
if (Array.isArray(fields)) names = fields.map((f) => f.name).filter((n) => typeof n === "string");
|
|
268
272
|
else if (fields && typeof fields === "object") names = Object.keys(fields);
|
|
269
|
-
idx.set(name, names);
|
|
273
|
+
idx.set(name, [.../* @__PURE__ */ new Set([...names, ...injectedColumnsFor(obj)])]);
|
|
270
274
|
}
|
|
271
275
|
return idx;
|
|
272
276
|
}
|
|
@@ -326,6 +330,19 @@ function buildNullableFieldIndex(objects) {
|
|
|
326
330
|
}
|
|
327
331
|
return idx;
|
|
328
332
|
}
|
|
333
|
+
function readsParentRoot(source) {
|
|
334
|
+
const roots = (0, import_formula2.collectCelRootIdentifiers)(source);
|
|
335
|
+
return roots.ok && roots.roots.includes("parent");
|
|
336
|
+
}
|
|
337
|
+
function masterDetailCount(obj) {
|
|
338
|
+
let n = 0;
|
|
339
|
+
for (const [, def] of fieldEntries(obj)) {
|
|
340
|
+
if (def.type !== "master_detail") continue;
|
|
341
|
+
const ref = def.reference;
|
|
342
|
+
if (typeof ref === "string" && ref.trim() !== "") n += 1;
|
|
343
|
+
}
|
|
344
|
+
return n;
|
|
345
|
+
}
|
|
329
346
|
function celSourceOf(raw) {
|
|
330
347
|
if (typeof raw === "string") return raw;
|
|
331
348
|
if (raw && typeof raw === "object") {
|
|
@@ -339,7 +356,7 @@ function rulePredicates(rule, path) {
|
|
|
339
356
|
const out = [];
|
|
340
357
|
const name = typeof rule.name === "string" ? rule.name : "?";
|
|
341
358
|
const here = path ? `${path} \u2192 '${name}'` : `'${name}'`;
|
|
342
|
-
const main = rule.
|
|
359
|
+
const main = rule.condition;
|
|
343
360
|
if (main != null) out.push({ label: `validation rule ${here}`, raw: main });
|
|
344
361
|
if (rule.when != null) out.push({ label: `validation rule ${here} when-predicate`, raw: rule.when });
|
|
345
362
|
for (const branch of ["then", "otherwise"]) {
|
|
@@ -356,7 +373,7 @@ function validateStackExpressions(stack) {
|
|
|
356
373
|
const fieldIndex = buildFieldIndex(objects);
|
|
357
374
|
const fieldTypeIndex = buildFieldTypeIndex(objects);
|
|
358
375
|
const nullableIndex = buildNullableFieldIndex(objects);
|
|
359
|
-
const checkNullGuards = (where, subject, raw, objectName) => {
|
|
376
|
+
const checkNullGuards = (where, subject, raw, objectName, outcome = "fail-closed") => {
|
|
360
377
|
if (!objectName) return;
|
|
361
378
|
const nullableFields = nullableIndex.get(objectName);
|
|
362
379
|
if (!nullableFields || nullableFields.size === 0) return;
|
|
@@ -365,7 +382,7 @@ function validateStackExpressions(stack) {
|
|
|
365
382
|
for (const finding of findUnguardedNullableOperands(source, { nullableFields })) {
|
|
366
383
|
issues.push({
|
|
367
384
|
where,
|
|
368
|
-
message: nullGuardMessage(subject, objectName, finding),
|
|
385
|
+
message: nullGuardMessage(subject, objectName, finding, outcome),
|
|
369
386
|
source,
|
|
370
387
|
severity: "error"
|
|
371
388
|
});
|
|
@@ -375,7 +392,7 @@ function validateStackExpressions(stack) {
|
|
|
375
392
|
if (raw == null) return;
|
|
376
393
|
const fields = objectName ? fieldIndex.get(objectName) : void 0;
|
|
377
394
|
const fieldTypes = objectName ? fieldTypeIndex.get(objectName) : void 0;
|
|
378
|
-
const res = (0,
|
|
395
|
+
const res = (0, import_formula2.validateExpression)(
|
|
379
396
|
"predicate",
|
|
380
397
|
raw,
|
|
381
398
|
objectName ? { objectName, fields, fieldTypes, scope } : { scope }
|
|
@@ -385,7 +402,7 @@ function validateStackExpressions(stack) {
|
|
|
385
402
|
};
|
|
386
403
|
const checkDeclaredPredicate = (where, raw) => {
|
|
387
404
|
if (raw == null) return;
|
|
388
|
-
const res = (0,
|
|
405
|
+
const res = (0, import_formula2.validateExpression)("predicate", raw);
|
|
389
406
|
for (const e of res.errors) issues.push({ where, message: e.message, source: e.source, severity: "error" });
|
|
390
407
|
for (const w of res.warnings) issues.push({ where, message: w.message, source: w.source, severity: "warning" });
|
|
391
408
|
};
|
|
@@ -434,31 +451,45 @@ function validateStackExpressions(stack) {
|
|
|
434
451
|
}
|
|
435
452
|
for (const obj of objects) {
|
|
436
453
|
const objectName = typeof obj.name === "string" ? obj.name : void 0;
|
|
437
|
-
const validations = obj.validations
|
|
454
|
+
const validations = obj.validations;
|
|
438
455
|
for (const rule of asArray(validations)) {
|
|
439
456
|
const where = `object '${objectName}' \xB7 validation '${rule.name ?? "?"}'`;
|
|
440
|
-
check(where, rule.
|
|
457
|
+
check(where, rule.condition, objectName, "record");
|
|
441
458
|
check(`${where} when`, rule.when, objectName, "record");
|
|
442
459
|
for (const p of rulePredicates(rule, "")) {
|
|
443
460
|
checkNullGuards(`object '${objectName}' \xB7 ${p.label}`, p.label, p.raw, objectName);
|
|
444
461
|
}
|
|
445
462
|
}
|
|
446
463
|
const fields = obj.fields;
|
|
447
|
-
const fieldList = Array.isArray(fields) ? fields : fields && typeof fields === "object" ? Object.
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
464
|
+
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]) : [];
|
|
465
|
+
const masters = masterDetailCount(obj);
|
|
466
|
+
for (const [fname, f] of fieldList) {
|
|
467
|
+
for (const key of ["requiredWhen", "readonlyWhen", "conditionalRequired", "visibleWhen"]) {
|
|
468
|
+
check(`object '${objectName}' \xB7 field '${fname}' ${key}`, f[key], objectName, "record");
|
|
469
|
+
}
|
|
470
|
+
const roWhenSource = celSourceOf(f.readonlyWhen);
|
|
471
|
+
if (masters !== 1 && roWhenSource && readsParentRoot(roWhenSource)) {
|
|
472
|
+
issues.push({
|
|
473
|
+
where: `object '${objectName}' \xB7 field '${fname}' readonlyWhen`,
|
|
474
|
+
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\`.`),
|
|
475
|
+
source: roWhenSource,
|
|
476
|
+
severity: "error"
|
|
477
|
+
});
|
|
454
478
|
}
|
|
455
|
-
|
|
456
|
-
|
|
479
|
+
checkNullGuards(
|
|
480
|
+
`object '${objectName}' \xB7 field '${fname}' requiredWhen`,
|
|
481
|
+
`field '${fname}' requiredWhen`,
|
|
482
|
+
f.requiredWhen,
|
|
483
|
+
objectName,
|
|
484
|
+
"fail-open"
|
|
485
|
+
);
|
|
486
|
+
if (f.expression) {
|
|
487
|
+
const res = (0, import_formula2.validateExpression)(
|
|
457
488
|
"value",
|
|
458
|
-
f.
|
|
489
|
+
f.expression,
|
|
459
490
|
objectName ? { objectName, fields: fieldIndex.get(objectName), fieldTypes: fieldTypeIndex.get(objectName), scope: "record" } : { scope: "record" }
|
|
460
491
|
);
|
|
461
|
-
const fieldWhere = `object '${objectName}' \xB7 field '${
|
|
492
|
+
const fieldWhere = `object '${objectName}' \xB7 field '${fname}' expression`;
|
|
462
493
|
for (const e of res.errors) issues.push({ where: fieldWhere, message: e.message, source: e.source, severity: "error" });
|
|
463
494
|
for (const w of res.warnings) issues.push({ where: fieldWhere, message: w.message, source: w.source, severity: "warning" });
|
|
464
495
|
}
|
|
@@ -466,7 +497,7 @@ function validateStackExpressions(stack) {
|
|
|
466
497
|
}
|
|
467
498
|
const seenActions = /* @__PURE__ */ new Set();
|
|
468
499
|
const checkAction = (where, action, objectName) => {
|
|
469
|
-
const obj = objectName ?? (typeof action.objectName === "string" ? action.objectName : void 0)
|
|
500
|
+
const obj = objectName ?? (typeof action.objectName === "string" ? action.objectName : void 0);
|
|
470
501
|
const name = typeof action.name === "string" ? action.name : "?";
|
|
471
502
|
const key = `${obj ?? ""}:${name}`;
|
|
472
503
|
if (seenActions.has(key)) return;
|
|
@@ -485,10 +516,10 @@ function validateStackExpressions(stack) {
|
|
|
485
516
|
checkAction(`object '${objectName}'`, action, objectName);
|
|
486
517
|
}
|
|
487
518
|
}
|
|
488
|
-
for (const
|
|
489
|
-
const ruleObj = typeof
|
|
490
|
-
const where = `sharingRule '${
|
|
491
|
-
check(where,
|
|
519
|
+
for (const sharingRule of asArray(stack.sharingRules)) {
|
|
520
|
+
const ruleObj = typeof sharingRule.object === "string" ? sharingRule.object : void 0;
|
|
521
|
+
const where = `sharingRule '${sharingRule.name ?? "?"}'${ruleObj ? ` (${ruleObj})` : ""} condition`;
|
|
522
|
+
check(where, sharingRule.condition, ruleObj, "record");
|
|
492
523
|
}
|
|
493
524
|
for (const hook of asArray(stack.hooks)) {
|
|
494
525
|
const hookName = hook.name ?? "?";
|
|
@@ -706,16 +737,6 @@ function validateViewContainers(stack) {
|
|
|
706
737
|
// src/validate-widget-bindings.ts
|
|
707
738
|
var import_data2 = require("@objectstack/spec/data");
|
|
708
739
|
var import_ui = require("@objectstack/spec/ui");
|
|
709
|
-
|
|
710
|
-
// src/system-fields.ts
|
|
711
|
-
var import_data = require("@objectstack/spec/data");
|
|
712
|
-
var import_system = require("@objectstack/spec/system");
|
|
713
|
-
var SYSTEM_FIELDS = /* @__PURE__ */ new Set([
|
|
714
|
-
...import_data.FIELD_GROUP_SYSTEM_FIELDS,
|
|
715
|
-
...Object.values(import_system.SystemFieldName)
|
|
716
|
-
]);
|
|
717
|
-
|
|
718
|
-
// src/validate-widget-bindings.ts
|
|
719
740
|
var WIDGET_DATASET_UNKNOWN = "widget-dataset-unknown";
|
|
720
741
|
var WIDGET_DIMENSION_UNKNOWN = "widget-dimension-unknown";
|
|
721
742
|
var WIDGET_MEASURE_UNKNOWN = "widget-measure-unknown";
|
|
@@ -1169,18 +1190,6 @@ function validateDashboardActionRefs(stack) {
|
|
|
1169
1190
|
`${dashPath}.header.actions[${ai}].actionUrl`
|
|
1170
1191
|
);
|
|
1171
1192
|
}
|
|
1172
|
-
const widgets = asArray4(dash.widgets);
|
|
1173
|
-
for (let wi = 0; wi < widgets.length; wi++) {
|
|
1174
|
-
const widget = widgets[wi];
|
|
1175
|
-
if (!widget || typeof widget !== "object") continue;
|
|
1176
|
-
if (!strName(widget.actionUrl)) continue;
|
|
1177
|
-
const widgetId = strName(widget.id) ?? `#${wi}`;
|
|
1178
|
-
checkOne(
|
|
1179
|
-
{ actionType: widget.actionType, actionUrl: widget.actionUrl },
|
|
1180
|
-
`dashboard "${dashName}" \xB7 widget "${widgetId}" action`,
|
|
1181
|
-
`${dashPath}.widgets[${wi}].actionUrl`
|
|
1182
|
-
);
|
|
1183
|
-
}
|
|
1184
1193
|
}
|
|
1185
1194
|
return findings;
|
|
1186
1195
|
}
|
|
@@ -1670,13 +1679,13 @@ function validateSearchableFields(stack) {
|
|
|
1670
1679
|
for (let vi = 0; vi < views.length; vi++) {
|
|
1671
1680
|
const view = views[vi];
|
|
1672
1681
|
if (!isRec2(view)) continue;
|
|
1673
|
-
const
|
|
1682
|
+
const viewLabel2 = strName3(view.name) ?? strName3(view.objectName) ?? `#${vi}`;
|
|
1674
1683
|
const viewObject = strName3(view.objectName) ?? strName3(view.object);
|
|
1675
1684
|
if (isRec2(view.list)) {
|
|
1676
1685
|
check(
|
|
1677
1686
|
view.list.searchableFields,
|
|
1678
1687
|
listViewObject(view.list) ?? viewObject,
|
|
1679
|
-
`view "${
|
|
1688
|
+
`view "${viewLabel2}" \u203A list`,
|
|
1680
1689
|
`views[${vi}].list.searchableFields`,
|
|
1681
1690
|
"list-view searchableFields",
|
|
1682
1691
|
"narrowing"
|
|
@@ -1688,7 +1697,7 @@ function validateSearchableFields(stack) {
|
|
|
1688
1697
|
check(
|
|
1689
1698
|
lv.searchableFields,
|
|
1690
1699
|
listViewObject(lv) ?? viewObject,
|
|
1691
|
-
`view "${
|
|
1700
|
+
`view "${viewLabel2}" \u203A listViews.${key}`,
|
|
1692
1701
|
`views[${vi}].listViews.${key}.searchableFields`,
|
|
1693
1702
|
"list-view searchableFields",
|
|
1694
1703
|
"narrowing"
|
|
@@ -1996,8 +2005,11 @@ function sortFieldRefs(value, basePath) {
|
|
|
1996
2005
|
}
|
|
1997
2006
|
var COMPONENT_FIELD_SPECS = {
|
|
1998
2007
|
"record:highlights": { props: ["fields"] },
|
|
1999
|
-
// `sections
|
|
2000
|
-
//
|
|
2008
|
+
// `sections` (object form) and `hideFields` are what every real page authors,
|
|
2009
|
+
// and since #5611 they are what `RecordDetailsProps` declares — this model and
|
|
2010
|
+
// the spec agree. (Before that, `sections` was declared as an ID `string[]`
|
|
2011
|
+
// and `hideFields` not at all; both survived only because `properties` is
|
|
2012
|
+
// unvalidated.)
|
|
2001
2013
|
"record:details": { props: ["fields", "hideFields"], nestedSections: ["sections"] },
|
|
2002
2014
|
"record:path": { props: ["statusField"] },
|
|
2003
2015
|
"element:number": { props: ["field"] },
|
|
@@ -2060,7 +2072,7 @@ function indexObjectFields(stack) {
|
|
|
2060
2072
|
}
|
|
2061
2073
|
return objectFields;
|
|
2062
2074
|
}
|
|
2063
|
-
function checkFieldRefs(refs, objectName, objectFields, where,
|
|
2075
|
+
function checkFieldRefs(refs, objectName, objectFields, where, consequence2 = "skipped") {
|
|
2064
2076
|
const findings = [];
|
|
2065
2077
|
if (!objectName) return findings;
|
|
2066
2078
|
const known = objectFields.get(objectName);
|
|
@@ -2069,11 +2081,11 @@ function checkFieldRefs(refs, objectName, objectFields, where, consequence = "sk
|
|
|
2069
2081
|
if (ref.name.includes(".")) continue;
|
|
2070
2082
|
if (known.has(ref.name) || SYSTEM_FIELDS.has(ref.name)) continue;
|
|
2071
2083
|
findings.push({
|
|
2072
|
-
severity:
|
|
2084
|
+
severity: consequence2 === "queried" ? "error" : "warning",
|
|
2073
2085
|
rule: PAGE_FIELD_UNKNOWN,
|
|
2074
2086
|
where,
|
|
2075
2087
|
path: ref.path,
|
|
2076
|
-
message: `field "${ref.name}" is not a field on object "${objectName}" \u2014 ` + (
|
|
2088
|
+
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."),
|
|
2077
2089
|
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(", ")}.` : "")
|
|
2078
2090
|
});
|
|
2079
2091
|
}
|
|
@@ -2642,6 +2654,13 @@ function collectViewRecord(view, factsFor) {
|
|
|
2642
2654
|
const addView = (objectName, name) => {
|
|
2643
2655
|
if (objectName && name) factsFor(objectName).views.add(name);
|
|
2644
2656
|
};
|
|
2657
|
+
const addSections = (container, binding) => {
|
|
2658
|
+
if (!binding) return;
|
|
2659
|
+
for (const section of asArray14(container.sections)) {
|
|
2660
|
+
const sectionName = strName10(section.name);
|
|
2661
|
+
if (sectionName) factsFor(binding).sections.add(sectionName);
|
|
2662
|
+
}
|
|
2663
|
+
};
|
|
2645
2664
|
const listBinding = isRec7(view.list) ? bindingOf(view.list) : void 0;
|
|
2646
2665
|
if (isRec7(view.list)) addView(listBinding, strName10(view.list.name));
|
|
2647
2666
|
addView(recordObject ?? listBinding, strName10(view.name));
|
|
@@ -2653,21 +2672,11 @@ function collectViewRecord(view, factsFor) {
|
|
|
2653
2672
|
const binding = bindingOf(sub) ?? listBinding;
|
|
2654
2673
|
addView(binding, subKey);
|
|
2655
2674
|
addView(binding, strName10(sub.name));
|
|
2656
|
-
|
|
2657
|
-
for (const section of asArray14(sub.sections)) {
|
|
2658
|
-
const sectionName = strName10(section.name);
|
|
2659
|
-
if (sectionName) factsFor(binding).sections.add(sectionName);
|
|
2660
|
-
}
|
|
2661
|
-
}
|
|
2662
|
-
}
|
|
2663
|
-
}
|
|
2664
|
-
const sectionBinding = recordObject ?? listBinding;
|
|
2665
|
-
if (sectionBinding) {
|
|
2666
|
-
for (const section of asArray14(view.sections)) {
|
|
2667
|
-
const sectionName = strName10(section.name);
|
|
2668
|
-
if (sectionName) factsFor(sectionBinding).sections.add(sectionName);
|
|
2675
|
+
addSections(sub, binding);
|
|
2669
2676
|
}
|
|
2670
2677
|
}
|
|
2678
|
+
if (isRec7(view.form)) addSections(view.form, bindingOf(view.form) ?? listBinding);
|
|
2679
|
+
addSections(view, recordObject ?? listBinding);
|
|
2671
2680
|
}
|
|
2672
2681
|
function viewObjectName(view) {
|
|
2673
2682
|
return strName10(view.objectName) ?? strName10(view.object) ?? (isRec7(view.data) ? strName10(view.data.object) : void 0);
|
|
@@ -3032,24 +3041,169 @@ function checkActionParams(findings, ctx) {
|
|
|
3032
3041
|
}
|
|
3033
3042
|
}
|
|
3034
3043
|
|
|
3035
|
-
// src/
|
|
3036
|
-
var
|
|
3044
|
+
// src/validate-translatable-sections.ts
|
|
3045
|
+
var TRANSLATION_SECTION_NAME_MISSING = "translation-section-name-missing";
|
|
3037
3046
|
function isRec8(v) {
|
|
3038
3047
|
return !!v && typeof v === "object" && !Array.isArray(v);
|
|
3039
3048
|
}
|
|
3040
3049
|
function strName11(v) {
|
|
3041
3050
|
return typeof v === "string" && v.length > 0 ? v : void 0;
|
|
3042
3051
|
}
|
|
3052
|
+
function viewObjectName2(view) {
|
|
3053
|
+
return strName11(view.objectName) ?? strName11(view.object) ?? (isRec8(view.data) ? strName11(view.data.object) : void 0);
|
|
3054
|
+
}
|
|
3055
|
+
function collectionEntries(v, base) {
|
|
3056
|
+
if (Array.isArray(v)) {
|
|
3057
|
+
const out = [];
|
|
3058
|
+
for (let i = 0; i < v.length; i++) {
|
|
3059
|
+
if (isRec8(v[i])) out.push({ rec: v[i], path: `${base}[${i}]` });
|
|
3060
|
+
}
|
|
3061
|
+
return out;
|
|
3062
|
+
}
|
|
3063
|
+
if (isRec8(v)) {
|
|
3064
|
+
return Object.entries(v).filter(([, def]) => isRec8(def)).map(([name, def]) => ({ rec: { name, ...def }, path: `${base}.${name}` }));
|
|
3065
|
+
}
|
|
3066
|
+
return [];
|
|
3067
|
+
}
|
|
3068
|
+
function viewLabel(view) {
|
|
3069
|
+
const name = strName11(view.name);
|
|
3070
|
+
return name ? `view "${name}"` : "";
|
|
3071
|
+
}
|
|
3072
|
+
function joinWhere(...parts) {
|
|
3073
|
+
return parts.filter((p) => p.length > 0).join(" \xB7 ");
|
|
3074
|
+
}
|
|
3075
|
+
function collectViewSites(view, basePath, label2, sites) {
|
|
3076
|
+
const recordObject = viewObjectName2(view);
|
|
3077
|
+
const listBinding = isRec8(view.list) ? viewObjectName2(view.list) ?? recordObject : void 0;
|
|
3078
|
+
const bindingOf = (container) => viewObjectName2(container) ?? recordObject;
|
|
3079
|
+
sites.push({
|
|
3080
|
+
path: `${basePath}.sections`,
|
|
3081
|
+
surface: label2,
|
|
3082
|
+
objectName: recordObject ?? listBinding,
|
|
3083
|
+
sections: view.sections
|
|
3084
|
+
});
|
|
3085
|
+
if (isRec8(view.form)) {
|
|
3086
|
+
sites.push({
|
|
3087
|
+
path: `${basePath}.form.sections`,
|
|
3088
|
+
surface: joinWhere(label2, "form"),
|
|
3089
|
+
objectName: bindingOf(view.form) ?? listBinding,
|
|
3090
|
+
sections: view.form.sections
|
|
3091
|
+
});
|
|
3092
|
+
}
|
|
3093
|
+
for (const key of ["listViews", "formViews"]) {
|
|
3094
|
+
const container = view[key];
|
|
3095
|
+
if (!isRec8(container)) continue;
|
|
3096
|
+
for (const [subKey, sub] of Object.entries(container)) {
|
|
3097
|
+
if (!isRec8(sub)) continue;
|
|
3098
|
+
sites.push({
|
|
3099
|
+
path: `${basePath}.${key}.${subKey}.sections`,
|
|
3100
|
+
surface: joinWhere(label2, `${key}.${subKey}`),
|
|
3101
|
+
objectName: bindingOf(sub) ?? listBinding,
|
|
3102
|
+
sections: sub.sections
|
|
3103
|
+
});
|
|
3104
|
+
}
|
|
3105
|
+
}
|
|
3106
|
+
}
|
|
3107
|
+
function translatedObjectNames(stack) {
|
|
3108
|
+
const out = /* @__PURE__ */ new Set();
|
|
3109
|
+
const bundles = Array.isArray(stack.translations) ? stack.translations : [];
|
|
3110
|
+
for (const bundle of bundles) {
|
|
3111
|
+
if (!isRec8(bundle)) continue;
|
|
3112
|
+
for (const data of Object.values(bundle)) {
|
|
3113
|
+
if (!isRec8(data) || !isRec8(data.objects)) continue;
|
|
3114
|
+
for (const [objectName, node] of Object.entries(data.objects)) {
|
|
3115
|
+
if (isRec8(node)) out.add(objectName);
|
|
3116
|
+
}
|
|
3117
|
+
}
|
|
3118
|
+
}
|
|
3119
|
+
return out;
|
|
3120
|
+
}
|
|
3121
|
+
function suggestedName(label2) {
|
|
3122
|
+
const slug = label2.toLowerCase().replace(/&/g, " and ").replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "");
|
|
3123
|
+
return slug.length > 0 ? slug : void 0;
|
|
3124
|
+
}
|
|
3125
|
+
function validateTranslatableSections(stack) {
|
|
3126
|
+
const findings = [];
|
|
3127
|
+
if (!isRec8(stack)) return findings;
|
|
3128
|
+
const translated = translatedObjectNames(stack);
|
|
3129
|
+
if (translated.size === 0) return findings;
|
|
3130
|
+
const sites = [];
|
|
3131
|
+
for (const { rec: obj, path: objPath } of collectionEntries(stack.objects, "objects")) {
|
|
3132
|
+
const objectName = strName11(obj.name);
|
|
3133
|
+
if (!objectName) continue;
|
|
3134
|
+
for (const { rec: view, path } of collectionEntries(obj.views, `${objPath}.views`)) {
|
|
3135
|
+
collectViewSites(
|
|
3136
|
+
{ ...view, object: strName11(view.object) ?? objectName },
|
|
3137
|
+
path,
|
|
3138
|
+
viewLabel(view),
|
|
3139
|
+
sites
|
|
3140
|
+
);
|
|
3141
|
+
}
|
|
3142
|
+
if (isRec8(obj.listViews)) {
|
|
3143
|
+
collectViewSites({ object: objectName, listViews: obj.listViews }, objPath, "", sites);
|
|
3144
|
+
}
|
|
3145
|
+
}
|
|
3146
|
+
for (const { rec: view, path } of collectionEntries(stack.views, "views")) {
|
|
3147
|
+
collectViewSites(view, path, viewLabel(view), sites);
|
|
3148
|
+
}
|
|
3149
|
+
for (const { rec: page, path: pagePath } of collectionEntries(stack.pages, "pages")) {
|
|
3150
|
+
const pageName = strName11(page.name);
|
|
3151
|
+
const pageLabel = pageName ? `page "${pageName}"` : "";
|
|
3152
|
+
for (const walked of walkPageComponents(page, pagePath)) {
|
|
3153
|
+
if (!walked.objectName) continue;
|
|
3154
|
+
const props = isRec8(walked.component.properties) ? walked.component.properties : void 0;
|
|
3155
|
+
if (!props) continue;
|
|
3156
|
+
const type = strName11(walked.component.type) ?? "component";
|
|
3157
|
+
sites.push({
|
|
3158
|
+
path: `${walked.path}.properties.sections`,
|
|
3159
|
+
surface: joinWhere(pageLabel, type),
|
|
3160
|
+
objectName: walked.objectName,
|
|
3161
|
+
sections: props.sections
|
|
3162
|
+
});
|
|
3163
|
+
}
|
|
3164
|
+
}
|
|
3165
|
+
for (const site of sites) {
|
|
3166
|
+
const objectName = site.objectName;
|
|
3167
|
+
if (!objectName || !translated.has(objectName)) continue;
|
|
3168
|
+
if (!Array.isArray(site.sections)) continue;
|
|
3169
|
+
for (let i = 0; i < site.sections.length; i++) {
|
|
3170
|
+
const section = site.sections[i];
|
|
3171
|
+
if (!isRec8(section)) continue;
|
|
3172
|
+
if (strName11(section.name)) continue;
|
|
3173
|
+
const heading = strName11(section.label) ?? strName11(section.title);
|
|
3174
|
+
if (!heading) continue;
|
|
3175
|
+
const slug = suggestedName(heading);
|
|
3176
|
+
findings.push({
|
|
3177
|
+
severity: "warning",
|
|
3178
|
+
rule: TRANSLATION_SECTION_NAME_MISSING,
|
|
3179
|
+
where: joinWhere(`object "${objectName}"`, site.surface, `section "${heading}"`),
|
|
3180
|
+
path: `${site.path}[${i}]`,
|
|
3181
|
+
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.`,
|
|
3182
|
+
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.`
|
|
3183
|
+
});
|
|
3184
|
+
}
|
|
3185
|
+
}
|
|
3186
|
+
return findings;
|
|
3187
|
+
}
|
|
3188
|
+
|
|
3189
|
+
// src/flow-walk.ts
|
|
3190
|
+
var import_automation2 = require("@objectstack/spec/automation");
|
|
3191
|
+
function isRec9(v) {
|
|
3192
|
+
return !!v && typeof v === "object" && !Array.isArray(v);
|
|
3193
|
+
}
|
|
3194
|
+
function strName12(v) {
|
|
3195
|
+
return typeof v === "string" && v.length > 0 ? v : void 0;
|
|
3196
|
+
}
|
|
3043
3197
|
var REGION_SLOTS = new Map(
|
|
3044
3198
|
[...import_automation2.FLOW_REGION_SLOTS_BY_TYPE].map(([type, slots]) => [type, slots.map((s) => s.key)])
|
|
3045
3199
|
);
|
|
3046
3200
|
var REGION_CONFIG_KEYS = import_automation2.FLOW_REGION_CONFIG_KEYS;
|
|
3047
3201
|
var MAX_REGION_DEPTH = 16;
|
|
3048
3202
|
function flowNodeLabel(node, index) {
|
|
3049
|
-
return
|
|
3203
|
+
return strName12(node.label) ?? strName12(node.id) ?? `#${index}`;
|
|
3050
3204
|
}
|
|
3051
3205
|
function stripRegions(config) {
|
|
3052
|
-
if (!
|
|
3206
|
+
if (!isRec9(config)) return void 0;
|
|
3053
3207
|
let out;
|
|
3054
3208
|
for (const key of Object.keys(config)) {
|
|
3055
3209
|
if (!REGION_CONFIG_KEYS.has(key)) continue;
|
|
@@ -3060,11 +3214,11 @@ function stripRegions(config) {
|
|
|
3060
3214
|
}
|
|
3061
3215
|
function walkFlowNodes(flow, flowPath) {
|
|
3062
3216
|
const out = [];
|
|
3063
|
-
if (!
|
|
3217
|
+
if (!isRec9(flow)) return out;
|
|
3064
3218
|
const visitList = (nodes, basePath, trail, depth) => {
|
|
3065
3219
|
if (!Array.isArray(nodes) || depth > MAX_REGION_DEPTH) return;
|
|
3066
3220
|
nodes.forEach((raw, index) => {
|
|
3067
|
-
if (!
|
|
3221
|
+
if (!isRec9(raw)) return;
|
|
3068
3222
|
const path = `${basePath}[${index}]`;
|
|
3069
3223
|
out.push({
|
|
3070
3224
|
node: raw,
|
|
@@ -3073,9 +3227,9 @@ function walkFlowNodes(flow, flowPath) {
|
|
|
3073
3227
|
regionTrail: trail,
|
|
3074
3228
|
depth
|
|
3075
3229
|
});
|
|
3076
|
-
const type =
|
|
3230
|
+
const type = strName12(raw.type);
|
|
3077
3231
|
const slots = type ? REGION_SLOTS.get(type) : void 0;
|
|
3078
|
-
if (!slots || !
|
|
3232
|
+
if (!slots || !isRec9(raw.config)) return;
|
|
3079
3233
|
const config = raw.config;
|
|
3080
3234
|
const here = `${type} "${flowNodeLabel(raw, index)}"`;
|
|
3081
3235
|
for (const slot of slots) {
|
|
@@ -3083,8 +3237,8 @@ function walkFlowNodes(flow, flowPath) {
|
|
|
3083
3237
|
if (slot === "branches") {
|
|
3084
3238
|
if (!Array.isArray(value)) continue;
|
|
3085
3239
|
value.forEach((branch, b) => {
|
|
3086
|
-
if (!
|
|
3087
|
-
const branchName =
|
|
3240
|
+
if (!isRec9(branch)) return;
|
|
3241
|
+
const branchName = strName12(branch.name) ?? `#${b}`;
|
|
3088
3242
|
visitList(
|
|
3089
3243
|
branch.nodes,
|
|
3090
3244
|
`${path}.config.branches[${b}].nodes`,
|
|
@@ -3094,7 +3248,7 @@ function walkFlowNodes(flow, flowPath) {
|
|
|
3094
3248
|
});
|
|
3095
3249
|
continue;
|
|
3096
3250
|
}
|
|
3097
|
-
if (!
|
|
3251
|
+
if (!isRec9(value)) continue;
|
|
3098
3252
|
visitList(
|
|
3099
3253
|
value.nodes,
|
|
3100
3254
|
`${path}.config.${slot}.nodes`,
|
|
@@ -3319,7 +3473,7 @@ function asArray16(v) {
|
|
|
3319
3473
|
}
|
|
3320
3474
|
return [];
|
|
3321
3475
|
}
|
|
3322
|
-
function
|
|
3476
|
+
function strName13(v) {
|
|
3323
3477
|
return typeof v === "string" && v.length > 0 ? v : void 0;
|
|
3324
3478
|
}
|
|
3325
3479
|
function surfaceOf(v) {
|
|
@@ -3330,17 +3484,17 @@ function validateAiSurfaceAffinity(stack) {
|
|
|
3330
3484
|
if (!stack || typeof stack !== "object") return findings;
|
|
3331
3485
|
const skillsByName = /* @__PURE__ */ new Map();
|
|
3332
3486
|
for (const skill of asArray16(stack.skills)) {
|
|
3333
|
-
const n =
|
|
3487
|
+
const n = strName13(skill.name);
|
|
3334
3488
|
if (n) skillsByName.set(n, skill);
|
|
3335
3489
|
}
|
|
3336
3490
|
const agents = asArray16(stack.agents);
|
|
3337
3491
|
for (let ai = 0; ai < agents.length; ai++) {
|
|
3338
3492
|
const agent = agents[ai];
|
|
3339
|
-
const agentName =
|
|
3493
|
+
const agentName = strName13(agent.name) ?? `#${ai}`;
|
|
3340
3494
|
const agentSurface = surfaceOf(agent.surface);
|
|
3341
3495
|
const skillRefs = Array.isArray(agent.skills) ? agent.skills : [];
|
|
3342
3496
|
for (let si = 0; si < skillRefs.length; si++) {
|
|
3343
|
-
const ref =
|
|
3497
|
+
const ref = strName13(skillRefs[si]);
|
|
3344
3498
|
if (!ref) continue;
|
|
3345
3499
|
const skill = skillsByName.get(ref);
|
|
3346
3500
|
if (!skill) continue;
|
|
@@ -3369,7 +3523,7 @@ function asArray17(v) {
|
|
|
3369
3523
|
}
|
|
3370
3524
|
return [];
|
|
3371
3525
|
}
|
|
3372
|
-
function
|
|
3526
|
+
function strName14(v) {
|
|
3373
3527
|
return typeof v === "string" && v.length > 0 ? v : void 0;
|
|
3374
3528
|
}
|
|
3375
3529
|
function distance6(a, b) {
|
|
@@ -3410,8 +3564,8 @@ function materialisesAsTool(action) {
|
|
|
3410
3564
|
if (!ai || typeof ai !== "object") return false;
|
|
3411
3565
|
const aiRec = ai;
|
|
3412
3566
|
if (aiRec.exposed !== true) return false;
|
|
3413
|
-
if (!
|
|
3414
|
-
const type =
|
|
3567
|
+
if (!strName14(aiRec.description)) return false;
|
|
3568
|
+
const type = strName14(action.type);
|
|
3415
3569
|
if (!type || !HEADLESS_ACTION_TYPES.has(type)) return false;
|
|
3416
3570
|
if (type === "script") return Boolean(action.target || action.body);
|
|
3417
3571
|
return Boolean(action.target);
|
|
@@ -3419,12 +3573,12 @@ function materialisesAsTool(action) {
|
|
|
3419
3573
|
function collectToolUniverse(stack) {
|
|
3420
3574
|
const universe = new Set(import_system5.PLATFORM_PROVIDED_TOOL_NAMES);
|
|
3421
3575
|
for (const tool of asArray17(stack.tools)) {
|
|
3422
|
-
const n =
|
|
3576
|
+
const n = strName14(tool.name);
|
|
3423
3577
|
if (n) universe.add(n);
|
|
3424
3578
|
}
|
|
3425
3579
|
const addActionFamily = (actions) => {
|
|
3426
3580
|
for (const action of asArray17(actions)) {
|
|
3427
|
-
const n =
|
|
3581
|
+
const n = strName14(action.name);
|
|
3428
3582
|
if (n && materialisesAsTool(action)) universe.add(`action_${n}`);
|
|
3429
3583
|
}
|
|
3430
3584
|
};
|
|
@@ -3438,7 +3592,7 @@ function collectUnexposedActionNames(stack) {
|
|
|
3438
3592
|
const names = /* @__PURE__ */ new Set();
|
|
3439
3593
|
const scan = (actions) => {
|
|
3440
3594
|
for (const action of asArray17(actions)) {
|
|
3441
|
-
const n =
|
|
3595
|
+
const n = strName14(action.name);
|
|
3442
3596
|
if (n && !materialisesAsTool(action)) names.add(n);
|
|
3443
3597
|
}
|
|
3444
3598
|
};
|
|
@@ -3464,10 +3618,10 @@ function validateAiToolReferences(stack) {
|
|
|
3464
3618
|
const skills = asArray17(stack.skills);
|
|
3465
3619
|
for (let si = 0; si < skills.length; si++) {
|
|
3466
3620
|
const skill = skills[si];
|
|
3467
|
-
const skillName =
|
|
3621
|
+
const skillName = strName14(skill.name) ?? `#${si}`;
|
|
3468
3622
|
const refs = Array.isArray(skill.tools) ? skill.tools : [];
|
|
3469
3623
|
for (let ti = 0; ti < refs.length; ti++) {
|
|
3470
|
-
const ref =
|
|
3624
|
+
const ref = strName14(refs[ti]);
|
|
3471
3625
|
if (!ref || resolves(ref)) continue;
|
|
3472
3626
|
const isPattern = ref.endsWith("*");
|
|
3473
3627
|
const unexposed = !isPattern && ref.startsWith("action_") && unexposedActions.has(ref.slice("action_".length)) ? ref.slice("action_".length) : void 0;
|
|
@@ -3493,7 +3647,7 @@ function asArray18(v) {
|
|
|
3493
3647
|
}
|
|
3494
3648
|
return [];
|
|
3495
3649
|
}
|
|
3496
|
-
function
|
|
3650
|
+
function strName15(v) {
|
|
3497
3651
|
return typeof v === "string" && v.length > 0 ? v : void 0;
|
|
3498
3652
|
}
|
|
3499
3653
|
var PLATFORM_AGENT_NAMES = /* @__PURE__ */ new Set(["ask", "build", "data_chat", "metadata_assistant"]);
|
|
@@ -3503,7 +3657,7 @@ function validateAiAgentAuthoring(stack) {
|
|
|
3503
3657
|
const agents = asArray18(stack.agents);
|
|
3504
3658
|
for (let ai = 0; ai < agents.length; ai++) {
|
|
3505
3659
|
const agent = agents[ai];
|
|
3506
|
-
const name =
|
|
3660
|
+
const name = strName15(agent.name) ?? `#${ai}`;
|
|
3507
3661
|
const isPlatformName = PLATFORM_AGENT_NAMES.has(name);
|
|
3508
3662
|
const skillCount = Array.isArray(agent.skills) ? agent.skills.length : 0;
|
|
3509
3663
|
findings.push({
|
|
@@ -3602,13 +3756,13 @@ var IMPLICIT_FIELDS2 = /* @__PURE__ */ new Set([
|
|
|
3602
3756
|
"owner",
|
|
3603
3757
|
"record_type"
|
|
3604
3758
|
]);
|
|
3605
|
-
var
|
|
3759
|
+
var isRec10 = (v) => !!v && typeof v === "object" && !Array.isArray(v);
|
|
3606
3760
|
function asArray19(v) {
|
|
3607
|
-
if (Array.isArray(v)) return v.filter((x) =>
|
|
3608
|
-
if (
|
|
3761
|
+
if (Array.isArray(v)) return v.filter((x) => isRec10(x));
|
|
3762
|
+
if (isRec10(v)) {
|
|
3609
3763
|
return Object.entries(v).map(([name, def]) => ({
|
|
3610
3764
|
name,
|
|
3611
|
-
...
|
|
3765
|
+
...isRec10(def) ? def : {}
|
|
3612
3766
|
}));
|
|
3613
3767
|
}
|
|
3614
3768
|
return [];
|
|
@@ -3754,7 +3908,7 @@ function validateHookBodyWrites(stack) {
|
|
|
3754
3908
|
let objectFields = null;
|
|
3755
3909
|
hooks.forEach((hook, hookIndex) => {
|
|
3756
3910
|
const body = hook.body;
|
|
3757
|
-
if (!
|
|
3911
|
+
if (!isRec10(body) || body.language !== "js") return;
|
|
3758
3912
|
const source = body.source;
|
|
3759
3913
|
if (typeof source !== "string" || source.trim() === "") return;
|
|
3760
3914
|
const writes = extractHookBodyWrites(source).filter((w) => HOOK_APPLICABLE_IDS.has(w.patternId));
|
|
@@ -3825,13 +3979,13 @@ var ACTION_BODY_WRITE_PATTERNS = HOOK_BODY_WRITE_PATTERNS.filter((p) => ACTION_B
|
|
|
3825
3979
|
var ACTION_RECORD_WRITE_PATTERNS = HOOK_BODY_WRITE_PATTERNS.filter((p) => ACTION_RECORD_WRITE_PATTERN_IDS.includes(p.id));
|
|
3826
3980
|
var APPLICABLE_IDS = new Set(ACTION_BODY_WRITE_PATTERN_IDS);
|
|
3827
3981
|
var RECORD_WRITE_IDS = new Set(ACTION_RECORD_WRITE_PATTERN_IDS);
|
|
3828
|
-
var
|
|
3982
|
+
var isRec11 = (v) => !!v && typeof v === "object" && !Array.isArray(v);
|
|
3829
3983
|
function asArray20(v) {
|
|
3830
|
-
if (Array.isArray(v)) return v.filter((x) =>
|
|
3831
|
-
if (
|
|
3984
|
+
if (Array.isArray(v)) return v.filter((x) => isRec11(x));
|
|
3985
|
+
if (isRec11(v)) {
|
|
3832
3986
|
return Object.entries(v).map(([name, def]) => ({
|
|
3833
3987
|
name,
|
|
3834
|
-
...
|
|
3988
|
+
...isRec11(def) ? def : {}
|
|
3835
3989
|
}));
|
|
3836
3990
|
}
|
|
3837
3991
|
return [];
|
|
@@ -3849,7 +4003,7 @@ function collectActionBodies(stack) {
|
|
|
3849
4003
|
const type = typeof action.type === "string" ? action.type : "script";
|
|
3850
4004
|
if (type !== "script") return;
|
|
3851
4005
|
const body = action.body;
|
|
3852
|
-
if (!
|
|
4006
|
+
if (!isRec11(body) || body.language !== "js") return;
|
|
3853
4007
|
const source = body.source;
|
|
3854
4008
|
if (typeof source !== "string" || source.trim() === "") return;
|
|
3855
4009
|
const name = typeof action.name === "string" && action.name ? action.name : `#${index}`;
|
|
@@ -3868,7 +4022,7 @@ function collectActionBodies(stack) {
|
|
|
3868
4022
|
}
|
|
3869
4023
|
function validateActionBodyWrites(stack) {
|
|
3870
4024
|
const findings = [];
|
|
3871
|
-
if (!
|
|
4025
|
+
if (!isRec11(stack)) return findings;
|
|
3872
4026
|
const sites = collectActionBodies(stack);
|
|
3873
4027
|
if (sites.length === 0) return findings;
|
|
3874
4028
|
let objectFields = null;
|
|
@@ -3926,13 +4080,13 @@ function fixHint2(field, declared) {
|
|
|
3926
4080
|
var import_shared3 = require("@objectstack/spec/shared");
|
|
3927
4081
|
var FLOW_NODE_WRITE_UNKNOWN_FIELD = "flow-node-write-unknown-field";
|
|
3928
4082
|
var FLOW_WRITE_NODE_TYPES = ["update_record", "create_record"];
|
|
3929
|
-
var
|
|
4083
|
+
var isRec12 = (v) => !!v && typeof v === "object" && !Array.isArray(v);
|
|
3930
4084
|
function asArray21(v) {
|
|
3931
|
-
if (Array.isArray(v)) return v.filter((x) =>
|
|
3932
|
-
if (
|
|
4085
|
+
if (Array.isArray(v)) return v.filter((x) => isRec12(x));
|
|
4086
|
+
if (isRec12(v)) {
|
|
3933
4087
|
return Object.entries(v).map(([name, def]) => ({
|
|
3934
4088
|
name,
|
|
3935
|
-
...
|
|
4089
|
+
...isRec12(def) ? def : {}
|
|
3936
4090
|
}));
|
|
3937
4091
|
}
|
|
3938
4092
|
return [];
|
|
@@ -3945,7 +4099,7 @@ function readLiteralObjectName(config) {
|
|
|
3945
4099
|
var COVERED_TYPES = new Set(FLOW_WRITE_NODE_TYPES);
|
|
3946
4100
|
function validateFlowNodeWrites(stack) {
|
|
3947
4101
|
const findings = [];
|
|
3948
|
-
if (!
|
|
4102
|
+
if (!isRec12(stack)) return findings;
|
|
3949
4103
|
const flows = asArray21(stack.flows);
|
|
3950
4104
|
if (flows.length === 0) return findings;
|
|
3951
4105
|
let objectFields = null;
|
|
@@ -3954,10 +4108,10 @@ function validateFlowNodeWrites(stack) {
|
|
|
3954
4108
|
const walked = walkFlowNodes(flow, `flows[${flowIndex}]`);
|
|
3955
4109
|
walked.forEach(({ node, path: nodePath, regionTrail }, walkIndex) => {
|
|
3956
4110
|
if (typeof node.type !== "string" || !COVERED_TYPES.has(node.type)) return;
|
|
3957
|
-
const config =
|
|
4111
|
+
const config = isRec12(node.config) ? node.config : void 0;
|
|
3958
4112
|
if (!config) return;
|
|
3959
4113
|
const fields = config.fields;
|
|
3960
|
-
if (!
|
|
4114
|
+
if (!isRec12(fields)) return;
|
|
3961
4115
|
const written = Object.keys(fields);
|
|
3962
4116
|
if (written.length === 0) return;
|
|
3963
4117
|
const objectName = readLiteralObjectName(config);
|
|
@@ -4083,6 +4237,44 @@ function validateReadonlyFlowWrites(stack) {
|
|
|
4083
4237
|
var import_node_module2 = require("module");
|
|
4084
4238
|
var import_ui2 = require("@objectstack/spec/ui");
|
|
4085
4239
|
var import_data5 = require("@objectstack/spec/data");
|
|
4240
|
+
|
|
4241
|
+
// src/zod-issue-format.ts
|
|
4242
|
+
var isRec13 = (v) => !!v && typeof v === "object" && !Array.isArray(v);
|
|
4243
|
+
var valueAtPath = (root, path) => {
|
|
4244
|
+
let cur = root;
|
|
4245
|
+
for (const key of path) {
|
|
4246
|
+
if (!isRec13(cur) && !Array.isArray(cur)) return void 0;
|
|
4247
|
+
cur = cur[key];
|
|
4248
|
+
}
|
|
4249
|
+
return cur;
|
|
4250
|
+
};
|
|
4251
|
+
var preview = (value) => {
|
|
4252
|
+
let text;
|
|
4253
|
+
try {
|
|
4254
|
+
text = JSON.stringify(value) ?? String(value);
|
|
4255
|
+
} catch {
|
|
4256
|
+
text = String(value);
|
|
4257
|
+
}
|
|
4258
|
+
return text.length > 80 ? `${text.slice(0, 77)}\u2026` : text;
|
|
4259
|
+
};
|
|
4260
|
+
function describeIssue(issue, root, depth = 0) {
|
|
4261
|
+
const value = depth === 0 ? valueAtPath(root, issue.path) : void 0;
|
|
4262
|
+
const seen = depth > 0 || issue.code === "custom" || issue.message.includes("received ") ? "" : value === void 0 ? " (nothing is set there)" : ` (received ${preview(value)})`;
|
|
4263
|
+
const armIssues = issue.code === "invalid_union" ? issue.errors : void 0;
|
|
4264
|
+
if (!armIssues || armIssues.length === 0) {
|
|
4265
|
+
return `${issue.message}${seen}`;
|
|
4266
|
+
}
|
|
4267
|
+
const arms = armIssues.map(
|
|
4268
|
+
(arm) => arm.map((inner) => {
|
|
4269
|
+
const where = inner.path.length ? `${inner.path.join(".")} \u2014 ` : "";
|
|
4270
|
+
return `${where}${describeIssue(inner, root, depth + 1)}`;
|
|
4271
|
+
}).join("; ")
|
|
4272
|
+
).filter((text) => text.length > 0);
|
|
4273
|
+
if (arms.length === 0) return `${issue.message}${seen}`;
|
|
4274
|
+
return `${issue.message}${seen} \u2014 no accepted form matched: ` + arms.map((text, i) => `(${i + 1}) ${text}`).join(" ");
|
|
4275
|
+
}
|
|
4276
|
+
|
|
4277
|
+
// src/validate-react-page-props.ts
|
|
4086
4278
|
var import_meta2 = {};
|
|
4087
4279
|
var cachedTs2 = null;
|
|
4088
4280
|
function loadTypeScript2() {
|
|
@@ -4187,35 +4379,78 @@ function filterAttrValue(tsc, sf, attr) {
|
|
|
4187
4379
|
var REACT_CHART_FIELD_UNKNOWN = "react-chart-field-unknown";
|
|
4188
4380
|
var REACT_CHART_AGGREGATE_INVALID = "react-chart-aggregate-invalid";
|
|
4189
4381
|
var REACT_CHART_AXIS_UNKNOWN = "react-chart-axis-unknown";
|
|
4190
|
-
var
|
|
4191
|
-
|
|
4382
|
+
var REACT_CHART_DRILLDOWN_INVALID = "react-chart-drilldown-invalid";
|
|
4383
|
+
function checkChartDrillDown(raw, push2) {
|
|
4384
|
+
if (raw === void 0 || raw === NOT_STATIC) return;
|
|
4385
|
+
if (!isRec14(raw)) {
|
|
4386
|
+
push2(
|
|
4387
|
+
"error",
|
|
4388
|
+
REACT_CHART_DRILLDOWN_INVALID,
|
|
4389
|
+
`drillDown must be a configuration object, not ${Array.isArray(raw) ? "an array" : typeof raw}.`,
|
|
4390
|
+
"Write drillDown={{ \u2026 }} \u2014 or, to turn the drill on with all defaults, drillDown={{}}. Omit the prop entirely to leave drill off."
|
|
4391
|
+
);
|
|
4392
|
+
return;
|
|
4393
|
+
}
|
|
4394
|
+
const parsed = import_ui2.ChartDrillDownSchema.safeParse(raw);
|
|
4395
|
+
if (parsed.success) return;
|
|
4396
|
+
for (const issue of parsed.error.issues) {
|
|
4397
|
+
const at = issue.path.length ? `drillDown.${issue.path.join(".")}` : "drillDown";
|
|
4398
|
+
push2(
|
|
4399
|
+
"error",
|
|
4400
|
+
REACT_CHART_DRILLDOWN_INVALID,
|
|
4401
|
+
`${at}: ${issue.message}`,
|
|
4402
|
+
"The drill config is declared by ChartDrillDownSchema (@objectstack/spec/ui) \u2014 the rejection above carries the fix."
|
|
4403
|
+
);
|
|
4404
|
+
}
|
|
4405
|
+
}
|
|
4406
|
+
function checkChartAggregate(raw, push2) {
|
|
4407
|
+
if (raw === void 0 || raw === NOT_STATIC) return;
|
|
4408
|
+
if (!isRec14(raw)) {
|
|
4409
|
+
push2(
|
|
4410
|
+
"error",
|
|
4411
|
+
REACT_CHART_AGGREGATE_INVALID,
|
|
4412
|
+
`aggregate must be a configuration object, not ${Array.isArray(raw) ? "an array" : typeof raw}.`,
|
|
4413
|
+
'Write aggregate={{ function: "count", groupBy: "<field>" }} \u2014 or bind data={\u2026} instead to chart precomputed rows.'
|
|
4414
|
+
);
|
|
4415
|
+
return;
|
|
4416
|
+
}
|
|
4417
|
+
const groupByAbsent = raw.groupBy === void 0;
|
|
4418
|
+
if (groupByAbsent) {
|
|
4419
|
+
push2(
|
|
4420
|
+
"warning",
|
|
4421
|
+
REACT_CHART_AGGREGATE_INVALID,
|
|
4422
|
+
"aggregate.groupBy is not set, so the aggregate returns ONE ungrouped row and the chart plots a single point.",
|
|
4423
|
+
"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."
|
|
4424
|
+
);
|
|
4425
|
+
}
|
|
4426
|
+
const parsed = import_ui2.ChartAggregateSchema.safeParse(raw);
|
|
4427
|
+
if (parsed.success) return;
|
|
4428
|
+
for (const issue of parsed.error.issues) {
|
|
4429
|
+
if (groupByAbsent && issue.path[0] === "groupBy") continue;
|
|
4430
|
+
const at = issue.path.length ? `aggregate.${issue.path.join(".")}` : "aggregate";
|
|
4431
|
+
push2(
|
|
4432
|
+
"error",
|
|
4433
|
+
REACT_CHART_AGGREGATE_INVALID,
|
|
4434
|
+
`${at}: ${describeIssue(issue, raw)}`,
|
|
4435
|
+
"The aggregate is declared by ChartAggregateSchema (@objectstack/spec/ui) \u2014 the rejection above carries the fix."
|
|
4436
|
+
);
|
|
4437
|
+
}
|
|
4438
|
+
}
|
|
4439
|
+
var isRec14 = (v) => !!v && typeof v === "object" && !Array.isArray(v);
|
|
4192
4440
|
var strOf = (v) => typeof v === "string" && v.length > 0 ? v : void 0;
|
|
4193
4441
|
function checkObjectChart(attrs, objectFields, findings) {
|
|
4194
4442
|
const { values, where, path } = attrs;
|
|
4195
4443
|
const push2 = (severity, rule, message, hint) => findings.push({ severity, rule, where, path, message, hint });
|
|
4444
|
+
checkChartDrillDown(values.get("drillDown"), push2);
|
|
4196
4445
|
if (values.has("data")) return;
|
|
4197
4446
|
const aggregate = values.get("aggregate");
|
|
4447
|
+
checkChartAggregate(aggregate, push2);
|
|
4198
4448
|
if (aggregate === void 0 || aggregate === NOT_STATIC) return;
|
|
4199
|
-
if (!
|
|
4449
|
+
if (!isRec14(aggregate)) return;
|
|
4200
4450
|
const fn = strOf(aggregate.function);
|
|
4201
4451
|
const field = strOf(aggregate.field);
|
|
4202
4452
|
const groupBy = aggregate.groupBy;
|
|
4203
|
-
const groupByField = strOf(groupBy) ?? (
|
|
4204
|
-
if (fn && !CHART_FUNCTIONS.includes(fn)) {
|
|
4205
|
-
push2(
|
|
4206
|
-
"error",
|
|
4207
|
-
REACT_CHART_AGGREGATE_INVALID,
|
|
4208
|
-
`aggregate.function "${fn}" is not an aggregation this chart can run.`,
|
|
4209
|
-
`Use one of: ${CHART_FUNCTIONS.join(", ")}.`
|
|
4210
|
-
);
|
|
4211
|
-
} else if (fn && fn !== "count" && !field) {
|
|
4212
|
-
push2(
|
|
4213
|
-
"error",
|
|
4214
|
-
REACT_CHART_AGGREGATE_INVALID,
|
|
4215
|
-
`aggregate.function "${fn}" has no "field" to aggregate.`,
|
|
4216
|
-
'Add aggregate.field, or use function "count" (the only one that may omit it).'
|
|
4217
|
-
);
|
|
4218
|
-
}
|
|
4453
|
+
const groupByField = strOf(groupBy) ?? (isRec14(groupBy) ? strOf(groupBy.field) : void 0);
|
|
4219
4454
|
const objectName = strOf(values.get("objectName"));
|
|
4220
4455
|
const known = objectName ? objectFields.get(objectName) : void 0;
|
|
4221
4456
|
if (objectName && known) {
|
|
@@ -4248,18 +4483,18 @@ function checkObjectChart(attrs, objectFields, findings) {
|
|
|
4248
4483
|
);
|
|
4249
4484
|
};
|
|
4250
4485
|
const xAxisRaw = values.get("xAxis");
|
|
4251
|
-
const categoryAxis = strOf(values.get("xAxisKey")) ?? strOf(xAxisRaw) ?? (
|
|
4486
|
+
const categoryAxis = strOf(values.get("xAxisKey")) ?? strOf(xAxisRaw) ?? (isRec14(xAxisRaw) ? strOf(xAxisRaw.field) : void 0);
|
|
4252
4487
|
const categoryProp = values.has("xAxisKey") ? "xAxisKey" : "xAxis.field";
|
|
4253
4488
|
axisRef(categoryAxis, categoryProp);
|
|
4254
4489
|
const yAxisRaw = values.get("yAxis");
|
|
4255
4490
|
const yAxisList = Array.isArray(yAxisRaw) ? yAxisRaw : yAxisRaw !== void 0 ? [yAxisRaw] : [];
|
|
4256
4491
|
for (const a of yAxisList) {
|
|
4257
|
-
axisRef(strOf(a) ?? (
|
|
4492
|
+
axisRef(strOf(a) ?? (isRec14(a) ? strOf(a.field) : void 0), "yAxis[].field");
|
|
4258
4493
|
}
|
|
4259
4494
|
const series = values.get("series");
|
|
4260
4495
|
if (Array.isArray(series)) {
|
|
4261
4496
|
for (const s of series) {
|
|
4262
|
-
if (!
|
|
4497
|
+
if (!isRec14(s)) continue;
|
|
4263
4498
|
const dataKey = strOf(s.dataKey);
|
|
4264
4499
|
axisRef(dataKey ?? strOf(s.name), dataKey ? "series[].dataKey" : "series[].name");
|
|
4265
4500
|
}
|
|
@@ -4315,7 +4550,7 @@ function subformFieldRefs(value, basePath) {
|
|
|
4315
4550
|
if (!Array.isArray(value)) return { child, parent };
|
|
4316
4551
|
for (let i = 0; i < value.length; i++) {
|
|
4317
4552
|
const sub = value[i];
|
|
4318
|
-
if (!
|
|
4553
|
+
if (!isRec14(sub)) continue;
|
|
4319
4554
|
const at = (key) => `${basePath}[${i}].${key}`;
|
|
4320
4555
|
child.push({
|
|
4321
4556
|
objectName: strOf(sub.childObject),
|
|
@@ -4360,20 +4595,20 @@ function reactFieldRefs(spec, values, basePath) {
|
|
|
4360
4595
|
}
|
|
4361
4596
|
for (const key of spec.nestedFields ?? []) {
|
|
4362
4597
|
const v = readable(key);
|
|
4363
|
-
if (
|
|
4598
|
+
if (isRec14(v)) own.push(...fieldRefsFrom(v.fields, at(`${key}.fields`)));
|
|
4364
4599
|
}
|
|
4365
4600
|
for (const key of spec.sections ?? []) {
|
|
4366
4601
|
const v = readable(key);
|
|
4367
4602
|
if (!Array.isArray(v)) continue;
|
|
4368
4603
|
for (let i = 0; i < v.length; i++) {
|
|
4369
4604
|
const section = v[i];
|
|
4370
|
-
if (!
|
|
4605
|
+
if (!isRec14(section)) continue;
|
|
4371
4606
|
own.push(...fieldRefsFrom(section.fields, at(`${key}[${i}].fields`)));
|
|
4372
4607
|
}
|
|
4373
4608
|
}
|
|
4374
4609
|
for (const key of spec.keyedByField ?? []) {
|
|
4375
4610
|
const v = readable(key);
|
|
4376
|
-
if (!
|
|
4611
|
+
if (!isRec14(v)) continue;
|
|
4377
4612
|
for (const k of Object.keys(v)) own.push({ name: k, path: at(`${key}.${k}`) });
|
|
4378
4613
|
}
|
|
4379
4614
|
for (const key of spec.filterArrays ?? []) {
|
|
@@ -4563,6 +4798,14 @@ var REFERENCE_INTEGRITY_RULES = [
|
|
|
4563
4798
|
// `component` (an unregistered ref renders a named diagnostic, not silence).
|
|
4564
4799
|
{ name: "validateNavTargetRefs", run: validateNavTargetRefs },
|
|
4565
4800
|
{ name: "validateTranslationReferences", run: validateTranslationReferences },
|
|
4801
|
+
// The same family from the other end (#5417). Its sibling above asks "does
|
|
4802
|
+
// this bundle key resolve?"; this one asks "is there a key at all?" — a form
|
|
4803
|
+
// section authored with a `label` and no `name` renders a heading that
|
|
4804
|
+
// `_sections` (keyed by name) can never address, so neither the orphan check
|
|
4805
|
+
// nor the coverage walk can see it. A reference that cannot be written is
|
|
4806
|
+
// still a reference question, and warning-only for the same reason its
|
|
4807
|
+
// sibling is: one heading stays in the source locale, nothing breaks.
|
|
4808
|
+
{ name: "validateTranslatableSections", run: validateTranslatableSections },
|
|
4566
4809
|
{ name: "validateFlowTemplatePaths", run: validateFlowTemplatePaths },
|
|
4567
4810
|
{ name: "validateAiSurfaceAffinity", run: validateAiSurfaceAffinity },
|
|
4568
4811
|
{ name: "validateAiToolReferences", run: validateAiToolReferences },
|
|
@@ -4647,6 +4890,90 @@ function validateReferenceIntegrity(stack) {
|
|
|
4647
4890
|
return findings;
|
|
4648
4891
|
}
|
|
4649
4892
|
|
|
4893
|
+
// src/validate-component-props.ts
|
|
4894
|
+
var import_ui3 = require("@objectstack/spec/ui");
|
|
4895
|
+
var import_spec = require("@objectstack/spec");
|
|
4896
|
+
var COMPONENT_PROPS_UNKNOWN_KEY = "component-props-unknown-key";
|
|
4897
|
+
var COMPONENT_PROPS_INVALID = "component-props-invalid";
|
|
4898
|
+
function isRec15(v) {
|
|
4899
|
+
return !!v && typeof v === "object" && !Array.isArray(v);
|
|
4900
|
+
}
|
|
4901
|
+
function strName16(v) {
|
|
4902
|
+
return typeof v === "string" && v.length > 0 ? v : void 0;
|
|
4903
|
+
}
|
|
4904
|
+
function asArray24(v) {
|
|
4905
|
+
if (Array.isArray(v)) return v;
|
|
4906
|
+
if (v && typeof v === "object") {
|
|
4907
|
+
return Object.entries(v).map(([name, def]) => ({ name, ...def }));
|
|
4908
|
+
}
|
|
4909
|
+
return [];
|
|
4910
|
+
}
|
|
4911
|
+
var PROPS_SCHEMAS = import_ui3.ComponentPropsMap;
|
|
4912
|
+
var DATASOURCE_SUPPLIED_PROP = "object";
|
|
4913
|
+
function suppliedByDataSource(issue, component) {
|
|
4914
|
+
if (issue.path.length !== 1 || issue.path[0] !== DATASOURCE_SUPPLIED_PROP) return false;
|
|
4915
|
+
const dataSource = isRec15(component.dataSource) ? component.dataSource : void 0;
|
|
4916
|
+
return strName16(dataSource?.object) !== void 0;
|
|
4917
|
+
}
|
|
4918
|
+
function validateComponentProps(stack) {
|
|
4919
|
+
const findings = [];
|
|
4920
|
+
if (!isRec15(stack)) return findings;
|
|
4921
|
+
const pages = asArray24(stack.pages);
|
|
4922
|
+
for (let pi = 0; pi < pages.length; pi++) {
|
|
4923
|
+
const page = pages[pi];
|
|
4924
|
+
if (!isRec15(page)) continue;
|
|
4925
|
+
const pageName = strName16(page.name) ?? `#${pi}`;
|
|
4926
|
+
for (const { component, path } of walkPageComponents(page, `pages[${pi}]`)) {
|
|
4927
|
+
const type = strName16(component.type);
|
|
4928
|
+
if (!type) continue;
|
|
4929
|
+
const schema = PROPS_SCHEMAS[type];
|
|
4930
|
+
if (!schema) continue;
|
|
4931
|
+
const props = isRec15(component.properties) ? component.properties : void 0;
|
|
4932
|
+
if (!props) continue;
|
|
4933
|
+
const where = `page "${pageName}" \xB7 ${type}`;
|
|
4934
|
+
const base = `${path}.properties`;
|
|
4935
|
+
for (const f of (0, import_spec.lintUnknownKeysAgainstSchema)(schema, props, type, base)) {
|
|
4936
|
+
findings.push({
|
|
4937
|
+
severity: "warning",
|
|
4938
|
+
rule: COMPONENT_PROPS_UNKNOWN_KEY,
|
|
4939
|
+
where,
|
|
4940
|
+
path: f.path,
|
|
4941
|
+
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}\`?` : ""),
|
|
4942
|
+
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.`)
|
|
4943
|
+
});
|
|
4944
|
+
}
|
|
4945
|
+
const parsed = schema.safeParse(props);
|
|
4946
|
+
if (parsed.success) continue;
|
|
4947
|
+
for (const issue of parsed.error?.issues ?? []) {
|
|
4948
|
+
if (suppliedByDataSource(issue, component)) continue;
|
|
4949
|
+
const at = issue.path.length ? `${base}.${issue.path.join(".")}` : base;
|
|
4950
|
+
if (issue.code === "unrecognized_keys") {
|
|
4951
|
+
for (const key of issue.keys ?? []) {
|
|
4952
|
+
findings.push({
|
|
4953
|
+
severity: "warning",
|
|
4954
|
+
rule: COMPONENT_PROPS_UNKNOWN_KEY,
|
|
4955
|
+
where,
|
|
4956
|
+
path: `${at}.${key}`,
|
|
4957
|
+
message: `\`${key}\` is not a prop \`${type}\` declares (ComponentPropsMap, @objectstack/spec/ui): ${issue.message}`,
|
|
4958
|
+
hint: `Remove \`${key}\`, or declare it on \`${type}\`'s props schema if the component honours it.`
|
|
4959
|
+
});
|
|
4960
|
+
}
|
|
4961
|
+
continue;
|
|
4962
|
+
}
|
|
4963
|
+
findings.push({
|
|
4964
|
+
severity: "warning",
|
|
4965
|
+
rule: COMPONENT_PROPS_INVALID,
|
|
4966
|
+
where,
|
|
4967
|
+
path: at,
|
|
4968
|
+
message: `${at.slice(base.length + 1) || "properties"}: ${describeIssue(issue, props)}`,
|
|
4969
|
+
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).`
|
|
4970
|
+
});
|
|
4971
|
+
}
|
|
4972
|
+
}
|
|
4973
|
+
}
|
|
4974
|
+
return findings;
|
|
4975
|
+
}
|
|
4976
|
+
|
|
4650
4977
|
// src/validate-responsive-styles.ts
|
|
4651
4978
|
var STYLE_NODE_MISSING_ID = "style-node-missing-id";
|
|
4652
4979
|
var STYLE_CLASSNAME_TAILWIND = "style-classname-tailwind";
|
|
@@ -4850,7 +5177,7 @@ function looksLikeTailwind(className) {
|
|
|
4850
5177
|
return false;
|
|
4851
5178
|
});
|
|
4852
5179
|
}
|
|
4853
|
-
function
|
|
5180
|
+
function asArray25(v) {
|
|
4854
5181
|
if (Array.isArray(v)) return v;
|
|
4855
5182
|
if (v && typeof v === "object") {
|
|
4856
5183
|
return Object.entries(v).map(([name, def]) => ({ name, ...def }));
|
|
@@ -4943,13 +5270,13 @@ function checkNode(node, pageName, path, findings) {
|
|
|
4943
5270
|
}
|
|
4944
5271
|
function validateResponsiveStyles(stack) {
|
|
4945
5272
|
const findings = [];
|
|
4946
|
-
const pages =
|
|
5273
|
+
const pages = asArray25(stack.pages);
|
|
4947
5274
|
for (let p = 0; p < pages.length; p++) {
|
|
4948
5275
|
const page = pages[p];
|
|
4949
5276
|
const pageName = typeof page.name === "string" ? page.name : `pages[${p}]`;
|
|
4950
|
-
const regions =
|
|
5277
|
+
const regions = asArray25(page.regions);
|
|
4951
5278
|
for (let r = 0; r < regions.length; r++) {
|
|
4952
|
-
const components =
|
|
5279
|
+
const components = asArray25(regions[r].components);
|
|
4953
5280
|
for (let c = 0; c < components.length; c++) {
|
|
4954
5281
|
checkNode(components[c], pageName, `pages[${p}].regions[${r}].components[${c}]`, findings);
|
|
4955
5282
|
}
|
|
@@ -4960,10 +5287,10 @@ function validateResponsiveStyles(stack) {
|
|
|
4960
5287
|
|
|
4961
5288
|
// src/validate-jsx-pages.ts
|
|
4962
5289
|
var import_sdui_parser = require("@objectstack/sdui-parser");
|
|
4963
|
-
var
|
|
5290
|
+
var asArray26 = (v) => Array.isArray(v) ? v : [];
|
|
4964
5291
|
function validateJsxPages(stack, opts = {}) {
|
|
4965
5292
|
const findings = [];
|
|
4966
|
-
const pages =
|
|
5293
|
+
const pages = asArray26(stack.pages);
|
|
4967
5294
|
for (let p = 0; p < pages.length; p++) {
|
|
4968
5295
|
const page = pages[p];
|
|
4969
5296
|
if (!page || page.kind !== "html" && page.kind !== "jsx") continue;
|
|
@@ -5011,10 +5338,10 @@ function loadSucraseTransform() {
|
|
|
5011
5338
|
}
|
|
5012
5339
|
return cachedTransform;
|
|
5013
5340
|
}
|
|
5014
|
-
var
|
|
5341
|
+
var asArray27 = (v) => Array.isArray(v) ? v : [];
|
|
5015
5342
|
function validateReactPages(stack) {
|
|
5016
5343
|
const findings = [];
|
|
5017
|
-
const pages =
|
|
5344
|
+
const pages = asArray27(stack.pages);
|
|
5018
5345
|
for (let p = 0; p < pages.length; p++) {
|
|
5019
5346
|
const page = pages[p];
|
|
5020
5347
|
if (!page || page.kind !== "react") continue;
|
|
@@ -5051,11 +5378,11 @@ function validateReactPages(stack) {
|
|
|
5051
5378
|
|
|
5052
5379
|
// src/validate-page-source-styling.ts
|
|
5053
5380
|
var PAGE_SOURCE_CLASSNAME = "page-source-className-tailwind";
|
|
5054
|
-
var
|
|
5381
|
+
var asArray28 = (v) => Array.isArray(v) ? v : [];
|
|
5055
5382
|
var CLASSNAME_ATTR = /\bclassName\s*=\s*["'{]/g;
|
|
5056
5383
|
function validatePageSourceStyling(stack) {
|
|
5057
5384
|
const findings = [];
|
|
5058
|
-
const pages =
|
|
5385
|
+
const pages = asArray28(stack.pages);
|
|
5059
5386
|
for (let p = 0; p < pages.length; p++) {
|
|
5060
5387
|
const page = pages[p];
|
|
5061
5388
|
if (!page) continue;
|
|
@@ -5083,7 +5410,7 @@ function validatePageSourceStyling(stack) {
|
|
|
5083
5410
|
// src/validate-capability-references.ts
|
|
5084
5411
|
var import_security = require("@objectstack/spec/security");
|
|
5085
5412
|
var CAPABILITY_REFERENCE_UNKNOWN = "capability-reference-unknown";
|
|
5086
|
-
function
|
|
5413
|
+
function asArray29(v) {
|
|
5087
5414
|
if (Array.isArray(v)) return v;
|
|
5088
5415
|
if (v && typeof v === "object") {
|
|
5089
5416
|
return Object.entries(v).map(([name, def]) => ({ name, ...def }));
|
|
@@ -5108,13 +5435,13 @@ function validateCapabilityReferences(stack) {
|
|
|
5108
5435
|
const findings = [];
|
|
5109
5436
|
if (!stack || typeof stack !== "object") return findings;
|
|
5110
5437
|
const known = new Set(import_security.PLATFORM_CAPABILITY_NAMES);
|
|
5111
|
-
for (const cap of
|
|
5438
|
+
for (const cap of asArray29(stack.capabilities)) {
|
|
5112
5439
|
if (typeof cap.name === "string" && cap.name.length > 0) known.add(cap.name);
|
|
5113
5440
|
}
|
|
5114
|
-
for (const ps of
|
|
5441
|
+
for (const ps of asArray29(stack.permissions)) {
|
|
5115
5442
|
for (const cap of asCapArray(ps.systemPermissions)) known.add(cap);
|
|
5116
5443
|
}
|
|
5117
|
-
for (const seed of
|
|
5444
|
+
for (const seed of asArray29(stack.data)) {
|
|
5118
5445
|
if (seed.object !== "sys_capability") continue;
|
|
5119
5446
|
for (const rec of Array.isArray(seed.records) ? seed.records : []) {
|
|
5120
5447
|
const name = rec?.name;
|
|
@@ -5133,7 +5460,7 @@ function validateCapabilityReferences(stack) {
|
|
|
5133
5460
|
hint
|
|
5134
5461
|
});
|
|
5135
5462
|
};
|
|
5136
|
-
const objects =
|
|
5463
|
+
const objects = asArray29(stack.objects);
|
|
5137
5464
|
for (let i = 0; i < objects.length; i++) {
|
|
5138
5465
|
const obj = objects[i];
|
|
5139
5466
|
if (!obj || typeof obj !== "object") continue;
|
|
@@ -5142,27 +5469,27 @@ function validateCapabilityReferences(stack) {
|
|
|
5142
5469
|
for (const { cap, key } of flattenObjectRequired(obj.requiredPermissions)) {
|
|
5143
5470
|
flag(cap, `object "${objName}"`, `${objPath}.requiredPermissions${key ? `.${key}` : ""}`);
|
|
5144
5471
|
}
|
|
5145
|
-
const fields =
|
|
5472
|
+
const fields = asArray29(obj.fields);
|
|
5146
5473
|
for (const f of fields) {
|
|
5147
5474
|
const fname = typeof f.name === "string" ? f.name : "(field)";
|
|
5148
5475
|
for (const cap of asCapArray(f.requiredPermissions)) {
|
|
5149
5476
|
flag(cap, `field "${objName}.${fname}"`, `${objPath}.fields.${fname}.requiredPermissions`);
|
|
5150
5477
|
}
|
|
5151
5478
|
}
|
|
5152
|
-
for (const [ai, action] of
|
|
5479
|
+
for (const [ai, action] of asArray29(obj.actions).entries()) {
|
|
5153
5480
|
const aName = typeof action.name === "string" ? action.name : `(action ${ai})`;
|
|
5154
5481
|
for (const cap of asCapArray(action.requiredPermissions)) {
|
|
5155
5482
|
flag(cap, `action "${objName}.${aName}"`, `${objPath}.actions[${ai}].requiredPermissions`);
|
|
5156
5483
|
}
|
|
5157
5484
|
}
|
|
5158
5485
|
}
|
|
5159
|
-
for (const [i, action] of
|
|
5486
|
+
for (const [i, action] of asArray29(stack.actions).entries()) {
|
|
5160
5487
|
const aName = typeof action.name === "string" ? action.name : `(action ${i})`;
|
|
5161
5488
|
for (const cap of asCapArray(action.requiredPermissions)) {
|
|
5162
5489
|
flag(cap, `action "${aName}"`, `actions[${i}].requiredPermissions`);
|
|
5163
5490
|
}
|
|
5164
5491
|
}
|
|
5165
|
-
const apps =
|
|
5492
|
+
const apps = asArray29(stack.apps);
|
|
5166
5493
|
for (let i = 0; i < apps.length; i++) {
|
|
5167
5494
|
const app = apps[i];
|
|
5168
5495
|
if (!app || typeof app !== "object") continue;
|
|
@@ -5189,11 +5516,14 @@ function validateCapabilityReferences(stack) {
|
|
|
5189
5516
|
}
|
|
5190
5517
|
|
|
5191
5518
|
// src/validate-flow-trigger-readiness.ts
|
|
5519
|
+
var import_automation3 = require("@objectstack/spec/automation");
|
|
5192
5520
|
var FLOW_TRIGGER_UNKNOWN_OBJECT = "flow-trigger-unknown-object";
|
|
5193
5521
|
var FLOW_DRAFT_STATUS_AMBIGUOUS = "flow-draft-status-ambiguous";
|
|
5194
5522
|
var FLOW_TRIGGER_UNKNOWN_EVENT = "flow-trigger-unknown-event";
|
|
5523
|
+
var FLOW_TIME_RELATIVE_DESCRIPTOR_INVALID = "flow-time-relative-descriptor-invalid";
|
|
5524
|
+
var FLOW_TIME_RELATIVE_DESCRIPTOR_UNROUTABLE = "flow-time-relative-descriptor-unroutable";
|
|
5195
5525
|
var VALID_RECORD_TRIGGER = /^record-(?:before|after)-(?:create|insert|update|delete|write)$/;
|
|
5196
|
-
function
|
|
5526
|
+
function asArray30(v) {
|
|
5197
5527
|
if (Array.isArray(v)) return v;
|
|
5198
5528
|
if (v && typeof v === "object") {
|
|
5199
5529
|
return Object.entries(v).map(([name, def]) => ({
|
|
@@ -5203,6 +5533,12 @@ function asArray29(v) {
|
|
|
5203
5533
|
}
|
|
5204
5534
|
return [];
|
|
5205
5535
|
}
|
|
5536
|
+
function renderNonObject(v) {
|
|
5537
|
+
const t = typeof v;
|
|
5538
|
+
if (t === "string" || t === "number" || t === "boolean") return `${JSON.stringify(v)} (a ${t})`;
|
|
5539
|
+
if (t === "bigint") return `${String(v)}n (a bigint)`;
|
|
5540
|
+
return `a ${t}`;
|
|
5541
|
+
}
|
|
5206
5542
|
function startNodeOf(flow) {
|
|
5207
5543
|
const nodes = Array.isArray(flow.nodes) ? flow.nodes : [];
|
|
5208
5544
|
const index = nodes.findIndex((n) => n?.type === "start");
|
|
@@ -5210,10 +5546,10 @@ function startNodeOf(flow) {
|
|
|
5210
5546
|
}
|
|
5211
5547
|
function validateFlowTriggerReadiness(stack) {
|
|
5212
5548
|
const findings = [];
|
|
5213
|
-
const flows =
|
|
5549
|
+
const flows = asArray30(stack.flows);
|
|
5214
5550
|
if (flows.length === 0) return findings;
|
|
5215
5551
|
const objectNames = new Set(
|
|
5216
|
-
|
|
5552
|
+
asArray30(stack.objects).map((o) => typeof o.name === "string" ? o.name : void 0).filter((n) => !!n)
|
|
5217
5553
|
);
|
|
5218
5554
|
flows.forEach((flow, flowIndex) => {
|
|
5219
5555
|
const flowName = typeof flow.name === "string" ? flow.name : `#${flowIndex}`;
|
|
@@ -5250,10 +5586,35 @@ function validateFlowTriggerReadiness(stack) {
|
|
|
5250
5586
|
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.`
|
|
5251
5587
|
});
|
|
5252
5588
|
}
|
|
5589
|
+
const parsed = import_automation3.TimeRelativeTriggerSchema.safeParse(tr);
|
|
5590
|
+
if (!parsed.success) {
|
|
5591
|
+
const problems = parsed.error.issues.map((i) => `${i.path.join(".") || "(root)"}: ${i.message.replace(/\s+/g, " ").trim()}`).join("; ");
|
|
5592
|
+
findings.push({
|
|
5593
|
+
// `error` (#5762): the verdict is `TimeRelativeTriggerSchema`'s, and it
|
|
5594
|
+
// is the same schema the trigger safeParses at bind time. A descriptor
|
|
5595
|
+
// it refuses is refused at bind too — the sweep is never installed, on
|
|
5596
|
+
// every deployment, with no installed package able to change the
|
|
5597
|
+
// answer. Nothing is left for the author to weigh.
|
|
5598
|
+
severity: "error",
|
|
5599
|
+
rule: FLOW_TIME_RELATIVE_DESCRIPTOR_INVALID,
|
|
5600
|
+
where: `flow "${flowName}" \u203A start node`,
|
|
5601
|
+
path: `flows[${flowIndex}].nodes[${start.index}].config.timeRelative`,
|
|
5602
|
+
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}`,
|
|
5603
|
+
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.`
|
|
5604
|
+
});
|
|
5605
|
+
}
|
|
5253
5606
|
}
|
|
5254
5607
|
if (start && isRecordTriggered2 && !VALID_RECORD_TRIGGER.test((triggerType ?? "").trim())) {
|
|
5255
5608
|
findings.push({
|
|
5256
|
-
|
|
5609
|
+
// `error` (#5762). The token grammar is CLOSED and local: the engine
|
|
5610
|
+
// routes any `record-`-prefixed string to the record-change trigger by a
|
|
5611
|
+
// hardcoded prefix test (no registry lookup, so installing a package
|
|
5612
|
+
// cannot claim a new `record-*` token), and that trigger maps the token
|
|
5613
|
+
// with `triggerTypeToHookEvents` — the same regex this file's
|
|
5614
|
+
// `VALID_RECORD_TRIGGER` mirrors. Off-grammar means zero hook events,
|
|
5615
|
+
// which means bound-to-nothing on every deployment. Unlike an object
|
|
5616
|
+
// name, there is no other-package reading that rescues it.
|
|
5617
|
+
severity: "error",
|
|
5257
5618
|
rule: FLOW_TRIGGER_UNKNOWN_EVENT,
|
|
5258
5619
|
where: `flow "${flowName}" \u203A start node`,
|
|
5259
5620
|
path: `flows[${flowIndex}].nodes[${start.index}].config.triggerType`,
|
|
@@ -5263,7 +5624,14 @@ function validateFlowTriggerReadiness(stack) {
|
|
|
5263
5624
|
}
|
|
5264
5625
|
if (start && isArrayRecordTriggered) {
|
|
5265
5626
|
findings.push({
|
|
5266
|
-
|
|
5627
|
+
// `error` (#5762), same id and same reason as 1c: an array maps to no
|
|
5628
|
+
// hook event either. The engine routes it to the record-change trigger
|
|
5629
|
+
// for the express purpose of making it loud, and its own comment names
|
|
5630
|
+
// THIS rule as the primary catch — a primary catch that only warns is
|
|
5631
|
+
// the "declared ≠ enforced" shape the registry's tier exists to close.
|
|
5632
|
+
// Multi-event arrays are deferred, not unsupported-by-accident (#3457),
|
|
5633
|
+
// so if they land the grammar widens here in the same commit.
|
|
5634
|
+
severity: "error",
|
|
5267
5635
|
rule: FLOW_TRIGGER_UNKNOWN_EVENT,
|
|
5268
5636
|
where: `flow "${flowName}" \u203A start node`,
|
|
5269
5637
|
path: `flows[${flowIndex}].nodes[${start.index}].config.triggerType`,
|
|
@@ -5271,6 +5639,25 @@ function validateFlowTriggerReadiness(stack) {
|
|
|
5271
5639
|
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).`
|
|
5272
5640
|
});
|
|
5273
5641
|
}
|
|
5642
|
+
if (start && config.timeRelative != null && typeof config.timeRelative !== "object") {
|
|
5643
|
+
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;
|
|
5644
|
+
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.`;
|
|
5645
|
+
findings.push({
|
|
5646
|
+
// `error` (#5762). The criterion IS the engine's routing predicate, so a
|
|
5647
|
+
// value that fails it is not routed to the time-relative trigger by any
|
|
5648
|
+
// deployment — the strongest verdict in this file, and the one case with
|
|
5649
|
+
// no runtime channel to fall back on (not even the bind-time warn 1b-ii
|
|
5650
|
+
// moves earlier). Note the two consequences below are both defects: one
|
|
5651
|
+
// never fires, the other silently drops the descriptor. Neither is a
|
|
5652
|
+
// shape the author can have meant, so both gate.
|
|
5653
|
+
severity: "error",
|
|
5654
|
+
rule: FLOW_TIME_RELATIVE_DESCRIPTOR_UNROUTABLE,
|
|
5655
|
+
where: `flow "${flowName}" \u203A start node`,
|
|
5656
|
+
path: `flows[${flowIndex}].nodes[${start.index}].config.timeRelative`,
|
|
5657
|
+
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}`,
|
|
5658
|
+
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.`
|
|
5659
|
+
});
|
|
5660
|
+
}
|
|
5274
5661
|
if (isAutoTriggered && (flow.status == null || flow.status === "draft")) {
|
|
5275
5662
|
findings.push({
|
|
5276
5663
|
severity: "warning",
|
|
@@ -5286,9 +5673,9 @@ function validateFlowTriggerReadiness(stack) {
|
|
|
5286
5673
|
}
|
|
5287
5674
|
|
|
5288
5675
|
// src/validate-approval-approvers.ts
|
|
5289
|
-
var
|
|
5290
|
-
var
|
|
5291
|
-
var
|
|
5676
|
+
var import_automation4 = require("@objectstack/spec/automation");
|
|
5677
|
+
var import_spec2 = require("@objectstack/spec");
|
|
5678
|
+
var import_formula3 = require("@objectstack/formula");
|
|
5292
5679
|
var APPROVAL_APPROVER_NOT_MEMBERSHIP_TIER = "approval-approver-not-membership-tier";
|
|
5293
5680
|
var APPROVAL_APPROVER_TYPE_DEPRECATED = "approval-approver-type-deprecated";
|
|
5294
5681
|
var APPROVAL_APPROVER_TYPE_UNKNOWN = "approval-approver-type-unknown";
|
|
@@ -5302,13 +5689,13 @@ var APPROVAL_APPROVER_CROSS_ORG_UNSUPPORTED = "approval-approver-cross-org-unsup
|
|
|
5302
5689
|
var EXPRESSION_ROOTS = /* @__PURE__ */ new Set(["current", "trigger", "vars"]);
|
|
5303
5690
|
var RESERVED_OUTPUT_KEYS = /* @__PURE__ */ new Set(["decision", "requestId"]);
|
|
5304
5691
|
var GROUP_ROUTED_TYPES = /* @__PURE__ */ new Set(["position", "team", "department"]);
|
|
5305
|
-
var MEMBERSHIP_TIERS = new Set(
|
|
5306
|
-
var MEMBERSHIP_TIER_LIST =
|
|
5692
|
+
var MEMBERSHIP_TIERS = new Set(import_spec2.BUILTIN_MEMBERSHIP_ROLES);
|
|
5693
|
+
var MEMBERSHIP_TIER_LIST = import_spec2.BUILTIN_MEMBERSHIP_ROLES.join("/");
|
|
5307
5694
|
var TYPE_FIX = {
|
|
5308
5695
|
business_unit: "department",
|
|
5309
5696
|
bu: "department"
|
|
5310
5697
|
};
|
|
5311
|
-
function
|
|
5698
|
+
function asArray31(v) {
|
|
5312
5699
|
if (Array.isArray(v)) return v;
|
|
5313
5700
|
if (v && typeof v === "object") {
|
|
5314
5701
|
return Object.entries(v).map(([name, def]) => ({ name, ...def }));
|
|
@@ -5318,8 +5705,8 @@ function asArray30(v) {
|
|
|
5318
5705
|
function validateApprovalApprovers(stack) {
|
|
5319
5706
|
const findings = [];
|
|
5320
5707
|
if (!stack || typeof stack !== "object") return findings;
|
|
5321
|
-
const flows =
|
|
5322
|
-
const validTypes = new Set(
|
|
5708
|
+
const flows = asArray31(stack.flows);
|
|
5709
|
+
const validTypes = new Set(import_automation4.ApproverType.options);
|
|
5323
5710
|
for (let fi = 0; fi < flows.length; fi++) {
|
|
5324
5711
|
const flow = flows[fi];
|
|
5325
5712
|
if (!flow || typeof flow !== "object") continue;
|
|
@@ -5327,7 +5714,7 @@ function validateApprovalApprovers(stack) {
|
|
|
5327
5714
|
const walked = walkFlowNodes(flow, `flows[${fi}]`);
|
|
5328
5715
|
for (let ni = 0; ni < walked.length; ni++) {
|
|
5329
5716
|
const { node, path: nodePath } = walked[ni];
|
|
5330
|
-
if (!node || node.type !==
|
|
5717
|
+
if (!node || node.type !== import_automation4.APPROVAL_NODE_TYPE) continue;
|
|
5331
5718
|
const nodeId = typeof node.id === "string" ? node.id : `(node ${ni})`;
|
|
5332
5719
|
const cfg = node.config ?? {};
|
|
5333
5720
|
const approvers = Array.isArray(cfg.approvers) ? cfg.approvers : [];
|
|
@@ -5345,12 +5732,12 @@ function validateApprovalApprovers(stack) {
|
|
|
5345
5732
|
rule: APPROVAL_APPROVER_TYPE_UNKNOWN,
|
|
5346
5733
|
where,
|
|
5347
5734
|
path: `${path}.type`,
|
|
5348
|
-
message: `approver type '${type}' is not an ApproverType (${
|
|
5735
|
+
message: `approver type '${type}' is not an ApproverType (${import_automation4.ApproverType.options.join(" | ")}).`,
|
|
5349
5736
|
hint: fix ? `Use the spec value: { type: '${fix}', value: '${value}' }.` : `Pick one of the spec values; unmapped types degrade to an inert '${type}:${value}' literal at runtime.`
|
|
5350
5737
|
});
|
|
5351
5738
|
continue;
|
|
5352
5739
|
}
|
|
5353
|
-
const canonical = (0,
|
|
5740
|
+
const canonical = (0, import_automation4.canonicalApproverType)(type);
|
|
5354
5741
|
if (canonical === "expression") {
|
|
5355
5742
|
const source = value.trim();
|
|
5356
5743
|
if (!source) {
|
|
@@ -5363,7 +5750,7 @@ function validateApprovalApprovers(stack) {
|
|
|
5363
5750
|
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.`
|
|
5364
5751
|
});
|
|
5365
5752
|
} else {
|
|
5366
|
-
const parsed = (0,
|
|
5753
|
+
const parsed = (0, import_formula3.collectCelRootIdentifiers)(source);
|
|
5367
5754
|
if (!parsed.ok) {
|
|
5368
5755
|
findings.push({
|
|
5369
5756
|
severity: "error",
|
|
@@ -5407,8 +5794,8 @@ function validateApprovalApprovers(stack) {
|
|
|
5407
5794
|
message: `approver { type: '${type}', value: '${value}' } resolves against the better-auth org-membership tier (sys_member.role: ${MEMBERSHIP_TIER_LIST}) \u2014 '${value}' is not a membership tier, so this approver matches nobody and the request stalls.`,
|
|
5408
5795
|
hint: `If '${value}' is an org position, author { type: 'position', value: '${value}' } (resolved via sys_user_position, ADR-0090 D3). Keep type 'org_membership_level' only for membership tiers (${MEMBERSHIP_TIER_LIST}) \u2014 the vocabulary is closed (ADR-0108), so a business role is always a position.`
|
|
5409
5796
|
});
|
|
5410
|
-
} else if (type in
|
|
5411
|
-
const fix = (0,
|
|
5797
|
+
} else if (type in import_automation4.DEPRECATED_APPROVER_TYPES) {
|
|
5798
|
+
const fix = (0, import_automation4.canonicalApproverType)(type);
|
|
5412
5799
|
findings.push({
|
|
5413
5800
|
severity: "warning",
|
|
5414
5801
|
rule: APPROVAL_APPROVER_TYPE_DEPRECATED,
|
|
@@ -5417,7 +5804,7 @@ function validateApprovalApprovers(stack) {
|
|
|
5417
5804
|
message: `approver type '${type}' is the deprecated spelling of '${fix}' (ADR-0090 D3) and is removed in the next major.`,
|
|
5418
5805
|
hint: `Author { type: '${fix}', value: '${value}' }. It resolves identically today.`
|
|
5419
5806
|
});
|
|
5420
|
-
} else if (
|
|
5807
|
+
} else if (import_automation4.APPROVER_VALUE_BINDINGS[canonical]?.source === "unsupported") {
|
|
5421
5808
|
findings.push({
|
|
5422
5809
|
severity: "warning",
|
|
5423
5810
|
rule: APPROVAL_APPROVER_TYPE_UNSUPPORTED,
|
|
@@ -5428,7 +5815,7 @@ function validateApprovalApprovers(stack) {
|
|
|
5428
5815
|
});
|
|
5429
5816
|
}
|
|
5430
5817
|
const declaredOrg = a.organization;
|
|
5431
|
-
if (typeof declaredOrg === "string" && declaredOrg.trim() !== "" &&
|
|
5818
|
+
if (typeof declaredOrg === "string" && declaredOrg.trim() !== "" && import_automation4.ApproverType.options.includes(canonical) && !(0, import_automation4.approverTypeIsOrgScoped)(canonical)) {
|
|
5432
5819
|
findings.push({
|
|
5433
5820
|
severity: "error",
|
|
5434
5821
|
rule: APPROVAL_APPROVER_CROSS_ORG_UNSUPPORTED,
|
|
@@ -5442,7 +5829,7 @@ function validateApprovalApprovers(stack) {
|
|
|
5442
5829
|
const routable = approvers.filter(
|
|
5443
5830
|
(a) => a && typeof a === "object" && typeof a.type === "string"
|
|
5444
5831
|
);
|
|
5445
|
-
if (routable.length > 0 && routable.every((a) => GROUP_ROUTED_TYPES.has((0,
|
|
5832
|
+
if (routable.length > 0 && routable.every((a) => GROUP_ROUTED_TYPES.has((0, import_automation4.canonicalApproverType)(String(a.type))))) {
|
|
5446
5833
|
const locks = cfg.lockRecord !== false;
|
|
5447
5834
|
findings.push({
|
|
5448
5835
|
severity: "info",
|
|
@@ -5454,7 +5841,7 @@ function validateApprovalApprovers(stack) {
|
|
|
5454
5841
|
});
|
|
5455
5842
|
}
|
|
5456
5843
|
const hasExpression = approvers.some(
|
|
5457
|
-
(a) => a && typeof a === "object" && (0,
|
|
5844
|
+
(a) => a && typeof a === "object" && (0, import_automation4.canonicalApproverType)(String(a.type ?? "")) === "expression"
|
|
5458
5845
|
);
|
|
5459
5846
|
if (hasExpression && cfg.onEmptyApprovers == null) {
|
|
5460
5847
|
findings.push({
|
|
@@ -5466,7 +5853,7 @@ function validateApprovalApprovers(stack) {
|
|
|
5466
5853
|
hint: `Declare the empty-slate policy explicitly: onEmptyApprovers: 'admin_rescue' (hold for admin takeover), 'fail' (fail the node \u2014 config bug), or 'auto_approve' (wave through, output.autoApproved = true).`
|
|
5467
5854
|
});
|
|
5468
5855
|
}
|
|
5469
|
-
const declaredOutputs = (0,
|
|
5856
|
+
const declaredOutputs = (0, import_automation4.normalizeDecisionOutputs)(cfg.decisionOutputs).map((d) => d.key);
|
|
5470
5857
|
const reserved = declaredOutputs.filter((k) => RESERVED_OUTPUT_KEYS.has(k));
|
|
5471
5858
|
if (reserved.length) {
|
|
5472
5859
|
findings.push({
|
|
@@ -5501,7 +5888,7 @@ function validateApprovalApprovers(stack) {
|
|
|
5501
5888
|
var import_data6 = require("@objectstack/spec/data");
|
|
5502
5889
|
var TITLE_FORMAT_RETIRED = "title-format-retired";
|
|
5503
5890
|
var TITLE_UNRESOLVABLE = "title-unresolvable";
|
|
5504
|
-
function
|
|
5891
|
+
function asArray32(v) {
|
|
5505
5892
|
if (Array.isArray(v)) return v;
|
|
5506
5893
|
if (v && typeof v === "object") {
|
|
5507
5894
|
return Object.entries(v).map(([name, def]) => ({ name, ...def }));
|
|
@@ -5510,7 +5897,7 @@ function asArray31(v) {
|
|
|
5510
5897
|
}
|
|
5511
5898
|
function validateRecordTitle(stack) {
|
|
5512
5899
|
const findings = [];
|
|
5513
|
-
const objects =
|
|
5900
|
+
const objects = asArray32(stack.objects);
|
|
5514
5901
|
for (let i = 0; i < objects.length; i++) {
|
|
5515
5902
|
const obj = objects[i];
|
|
5516
5903
|
const objName = typeof obj.name === "string" ? obj.name : `(object ${i})`;
|
|
@@ -5546,7 +5933,7 @@ var FIELD_GROUP_UNDECLARED = "field-group-undeclared";
|
|
|
5546
5933
|
var FIELD_GROUP_EMPTY = "field-group-empty";
|
|
5547
5934
|
var FIELD_GROUP_SHADOWED = "field-group-shadowed";
|
|
5548
5935
|
var SEMANTIC_ROLE_FIELD_UNKNOWN = "semantic-role-field-unknown";
|
|
5549
|
-
function
|
|
5936
|
+
function asArray33(v) {
|
|
5550
5937
|
if (Array.isArray(v)) return v;
|
|
5551
5938
|
if (v && typeof v === "object") {
|
|
5552
5939
|
return Object.entries(v).map(([name, def]) => ({ name, ...def }));
|
|
@@ -5555,7 +5942,7 @@ function asArray32(v) {
|
|
|
5555
5942
|
}
|
|
5556
5943
|
function validateSemanticRoles(stack) {
|
|
5557
5944
|
const findings = [];
|
|
5558
|
-
const objects =
|
|
5945
|
+
const objects = asArray33(stack.objects);
|
|
5559
5946
|
for (let i = 0; i < objects.length; i++) {
|
|
5560
5947
|
const obj = objects[i];
|
|
5561
5948
|
if (!obj || typeof obj !== "object") continue;
|
|
@@ -5563,7 +5950,7 @@ function validateSemanticRoles(stack) {
|
|
|
5563
5950
|
const where = `object "${objName}"`;
|
|
5564
5951
|
const path = `objects[${i}]`;
|
|
5565
5952
|
const fields = obj.fields && typeof obj.fields === "object" && !Array.isArray(obj.fields) ? obj.fields : {};
|
|
5566
|
-
const fieldNames = new Set(Object.keys(fields));
|
|
5953
|
+
const fieldNames = /* @__PURE__ */ new Set([...Object.keys(fields), ...injectedColumnsFor(obj)]);
|
|
5567
5954
|
const declaredGroups = new Set(
|
|
5568
5955
|
(Array.isArray(obj.fieldGroups) ? obj.fieldGroups : []).filter((g) => !!g && typeof g === "object").map((g) => g.key).filter((k) => typeof k === "string" && k.length > 0)
|
|
5569
5956
|
);
|
|
@@ -5650,7 +6037,7 @@ function validateSemanticRoles(stack) {
|
|
|
5650
6037
|
// src/validate-form-layout.ts
|
|
5651
6038
|
var FORM_FIELD_UNKNOWN = "form-field-unknown";
|
|
5652
6039
|
var FORM_COLSPAN_ABSOLUTE = "absolute-colspan-discouraged";
|
|
5653
|
-
function
|
|
6040
|
+
function asArray34(v) {
|
|
5654
6041
|
if (Array.isArray(v)) return v;
|
|
5655
6042
|
if (v && typeof v === "object") {
|
|
5656
6043
|
return Object.entries(v).map(([name, def]) => ({ name, ...def }));
|
|
@@ -5675,13 +6062,13 @@ function boundObject(view) {
|
|
|
5675
6062
|
function validateFormLayout(stack) {
|
|
5676
6063
|
const findings = [];
|
|
5677
6064
|
const objectFields = /* @__PURE__ */ new Map();
|
|
5678
|
-
for (const obj of
|
|
6065
|
+
for (const obj of asArray34(stack.objects)) {
|
|
5679
6066
|
const name = typeof obj.name === "string" ? obj.name : void 0;
|
|
5680
6067
|
if (!name) continue;
|
|
5681
6068
|
const fields = obj.fields && typeof obj.fields === "object" && !Array.isArray(obj.fields) ? Object.keys(obj.fields) : [];
|
|
5682
6069
|
objectFields.set(name, new Set(fields));
|
|
5683
6070
|
}
|
|
5684
|
-
const views =
|
|
6071
|
+
const views = asArray34(stack.views);
|
|
5685
6072
|
for (let i = 0; i < views.length; i++) {
|
|
5686
6073
|
const view = views[i];
|
|
5687
6074
|
if (!view || typeof view !== "object") continue;
|
|
@@ -5821,7 +6208,7 @@ var VISIBILITY_ALIAS_DEPRECATED = "visibility-alias-deprecated";
|
|
|
5821
6208
|
var VISIBILITY_ROOT_MISLAYERED = "visibility-root-mislayered";
|
|
5822
6209
|
var CANONICAL = "visibleWhen";
|
|
5823
6210
|
var ALIASES = ["visibleOn", "visibility"];
|
|
5824
|
-
function
|
|
6211
|
+
function asArray35(v) {
|
|
5825
6212
|
if (Array.isArray(v)) return v;
|
|
5826
6213
|
if (v && typeof v === "object") {
|
|
5827
6214
|
return Object.entries(v).map(([name, def]) => ({ name, ...def }));
|
|
@@ -5883,7 +6270,7 @@ function isFieldObject(entry) {
|
|
|
5883
6270
|
function validateVisibilityPredicates(stack, opts = {}) {
|
|
5884
6271
|
const layer = opts.layer ?? "runtime";
|
|
5885
6272
|
const findings = [];
|
|
5886
|
-
const views =
|
|
6273
|
+
const views = asArray35(stack.views);
|
|
5887
6274
|
for (let i = 0; i < views.length; i++) {
|
|
5888
6275
|
const view = views[i];
|
|
5889
6276
|
if (!view || typeof view !== "object") continue;
|
|
@@ -5906,7 +6293,7 @@ function validateVisibilityPredicates(stack, opts = {}) {
|
|
|
5906
6293
|
}
|
|
5907
6294
|
}
|
|
5908
6295
|
}
|
|
5909
|
-
const pages =
|
|
6296
|
+
const pages = asArray35(stack.pages);
|
|
5910
6297
|
for (let i = 0; i < pages.length; i++) {
|
|
5911
6298
|
const page = pages[i];
|
|
5912
6299
|
if (!page || typeof page !== "object") continue;
|
|
@@ -5953,7 +6340,7 @@ var OWD_WIDTH = {
|
|
|
5953
6340
|
public_read: 1,
|
|
5954
6341
|
public_read_write: 2
|
|
5955
6342
|
};
|
|
5956
|
-
function
|
|
6343
|
+
function asArray36(v) {
|
|
5957
6344
|
if (Array.isArray(v)) return v;
|
|
5958
6345
|
if (v && typeof v === "object") {
|
|
5959
6346
|
return Object.entries(v).map(([name, def]) => ({ name, ...def }));
|
|
@@ -5961,7 +6348,7 @@ function asArray35(v) {
|
|
|
5961
6348
|
return [];
|
|
5962
6349
|
}
|
|
5963
6350
|
function owdOf(obj) {
|
|
5964
|
-
return obj.sharingModel
|
|
6351
|
+
return obj.sharingModel;
|
|
5965
6352
|
}
|
|
5966
6353
|
function isSystemObject(obj) {
|
|
5967
6354
|
return obj.isSystem === true || String(obj.name ?? "").startsWith("sys_");
|
|
@@ -5975,11 +6362,11 @@ function labelHasRoleWord(label2) {
|
|
|
5975
6362
|
return /\brole(s)?\b/i.test(label2);
|
|
5976
6363
|
}
|
|
5977
6364
|
function refOf(def) {
|
|
5978
|
-
const r = def.reference
|
|
6365
|
+
const r = def.reference;
|
|
5979
6366
|
return typeof r === "string" && r ? r : void 0;
|
|
5980
6367
|
}
|
|
5981
6368
|
function firstMasterDetailField(obj) {
|
|
5982
|
-
for (const f of
|
|
6369
|
+
for (const f of asArray36(obj.fields)) {
|
|
5983
6370
|
if (f.type === "master_detail") {
|
|
5984
6371
|
return { name: String(f.name ?? "?"), parent: refOf(f) };
|
|
5985
6372
|
}
|
|
@@ -5992,8 +6379,8 @@ function grantsObjectAccess(p) {
|
|
|
5992
6379
|
function validateSecurityPosture(stack, opts) {
|
|
5993
6380
|
const findings = [];
|
|
5994
6381
|
if (!stack || typeof stack !== "object") return findings;
|
|
5995
|
-
const objects =
|
|
5996
|
-
const permissionSets =
|
|
6382
|
+
const objects = asArray36(stack.objects);
|
|
6383
|
+
const permissionSets = asArray36(stack.permissions);
|
|
5997
6384
|
for (let i = 0; i < objects.length; i++) {
|
|
5998
6385
|
const obj = objects[i];
|
|
5999
6386
|
if (!obj || typeof obj !== "object") continue;
|
|
@@ -6122,10 +6509,10 @@ function validateSecurityPosture(stack, opts) {
|
|
|
6122
6509
|
if (!obj || typeof obj !== "object" || isSystemObject(obj)) continue;
|
|
6123
6510
|
const objName = typeof obj.name === "string" ? obj.name : `(object ${i})`;
|
|
6124
6511
|
flagRole("object", obj.name, obj.label, `object "${objName}"`, `objects[${i}].name`);
|
|
6125
|
-
for (const f of
|
|
6512
|
+
for (const f of asArray36(obj.fields)) {
|
|
6126
6513
|
flagRole("field", f.name, f.label, `field "${objName}.${String(f.name ?? "?")}"`, `objects[${i}].fields.${String(f.name ?? "?")}.name`);
|
|
6127
6514
|
}
|
|
6128
|
-
for (const [ai, action] of
|
|
6515
|
+
for (const [ai, action] of asArray36(obj.actions).entries()) {
|
|
6129
6516
|
flagRole("action", action.name, action.label, `action "${objName}.${String(action.name ?? "?")}"`, `objects[${i}].actions[${ai}].name`);
|
|
6130
6517
|
}
|
|
6131
6518
|
}
|
|
@@ -6134,19 +6521,19 @@ function validateSecurityPosture(stack, opts) {
|
|
|
6134
6521
|
if (!ps || typeof ps !== "object") continue;
|
|
6135
6522
|
flagRole("permission set", ps.name, ps.label, `permission set "${String(ps.name ?? i)}"`, `permissions[${i}].name`);
|
|
6136
6523
|
}
|
|
6137
|
-
for (const [i, pos] of
|
|
6524
|
+
for (const [i, pos] of asArray36(stack.positions).entries()) {
|
|
6138
6525
|
flagRole("position", pos.name, pos.label, `position "${String(pos.name ?? i)}"`, `positions[${i}].name`);
|
|
6139
6526
|
}
|
|
6140
|
-
for (const [i, app] of
|
|
6527
|
+
for (const [i, app] of asArray36(stack.apps).entries()) {
|
|
6141
6528
|
flagRole("app", app.name, app.label, `app "${String(app.name ?? i)}"`, `apps[${i}].name`);
|
|
6142
6529
|
}
|
|
6143
|
-
for (const [i, book] of
|
|
6530
|
+
for (const [i, book] of asArray36(stack.books).entries()) {
|
|
6144
6531
|
flagRole("book", book.name, book.label, `book "${String(book.name ?? i)}"`, `books[${i}].name`);
|
|
6145
6532
|
}
|
|
6146
6533
|
const stackSetNames = new Set(
|
|
6147
6534
|
permissionSets.map((ps) => typeof ps.name === "string" ? ps.name : void 0).filter((n) => !!n)
|
|
6148
6535
|
);
|
|
6149
|
-
for (const [i, book] of
|
|
6536
|
+
for (const [i, book] of asArray36(stack.books).entries()) {
|
|
6150
6537
|
const audience = book.audience;
|
|
6151
6538
|
if (!audience || typeof audience !== "object") continue;
|
|
6152
6539
|
const setName = audience.permissionSet;
|
|
@@ -6224,7 +6611,7 @@ function validateSecurityPosture(stack, opts) {
|
|
|
6224
6611
|
}
|
|
6225
6612
|
const GRANT_SEED_OBJECTS = /* @__PURE__ */ new Set(["sys_user_position", "sys_user_permission_set"]);
|
|
6226
6613
|
const nowMs = opts?.nowMs ?? Date.now();
|
|
6227
|
-
for (const [i, seed] of
|
|
6614
|
+
for (const [i, seed] of asArray36(stack.data).entries()) {
|
|
6228
6615
|
const seedObject = typeof seed.object === "string" ? seed.object : "";
|
|
6229
6616
|
if (!GRANT_SEED_OBJECTS.has(seedObject)) continue;
|
|
6230
6617
|
const records = Array.isArray(seed.records) ? seed.records : [];
|
|
@@ -6268,7 +6655,8 @@ function validateSecurityPosture(stack, opts) {
|
|
|
6268
6655
|
var ORG_AXIS_PERMISSION_INHERITANCE = "org-axis-permission-inheritance";
|
|
6269
6656
|
var ORG_AXIS_CROSS_ORG_BU_GRANT = "org-axis-cross-org-bu-grant";
|
|
6270
6657
|
var ORG_PARENT_FIELD = "parent_organization_id";
|
|
6271
|
-
|
|
6658
|
+
var BU_TREE_RECIPIENT_TYPES = /* @__PURE__ */ new Set(["business_unit", "unit_and_subordinates"]);
|
|
6659
|
+
function asArray37(v) {
|
|
6272
6660
|
if (Array.isArray(v)) return v;
|
|
6273
6661
|
if (v && typeof v === "object") {
|
|
6274
6662
|
return Object.entries(v).map(([name, def]) => ({ name, ...def }));
|
|
@@ -6278,6 +6666,15 @@ function asArray36(v) {
|
|
|
6278
6666
|
function str(v) {
|
|
6279
6667
|
return typeof v === "string" ? v : "";
|
|
6280
6668
|
}
|
|
6669
|
+
function expressionText(v) {
|
|
6670
|
+
if (typeof v === "string") return v;
|
|
6671
|
+
if (v && typeof v === "object") {
|
|
6672
|
+
const rec = v;
|
|
6673
|
+
if (typeof rec.source === "string") return rec.source;
|
|
6674
|
+
if (rec.ast !== void 0) return JSON.stringify(rec.ast) ?? "";
|
|
6675
|
+
}
|
|
6676
|
+
return "";
|
|
6677
|
+
}
|
|
6281
6678
|
function isTenancyDisabled(object) {
|
|
6282
6679
|
const tenancy = object.tenancy;
|
|
6283
6680
|
if (tenancy && typeof tenancy === "object" && tenancy.enabled === false) return true;
|
|
@@ -6289,9 +6686,9 @@ var INHERITANCE_HINT = `Remove the ${ORG_PARENT_FIELD} reference. Cross-organiza
|
|
|
6289
6686
|
function validateOrgAxisRedLines(stack) {
|
|
6290
6687
|
const findings = [];
|
|
6291
6688
|
const cfg = stack ?? {};
|
|
6292
|
-
const permissionSets =
|
|
6689
|
+
const permissionSets = asArray37(cfg.permissions);
|
|
6293
6690
|
permissionSets.forEach((ps, psIndex) => {
|
|
6294
|
-
|
|
6691
|
+
asArray37(ps.rowLevelSecurity).forEach((policy, pIndex) => {
|
|
6295
6692
|
for (const clause of ["using", "check"]) {
|
|
6296
6693
|
if (!str(policy[clause]).includes(ORG_PARENT_FIELD)) continue;
|
|
6297
6694
|
findings.push({
|
|
@@ -6305,68 +6702,435 @@ function validateOrgAxisRedLines(stack) {
|
|
|
6305
6702
|
}
|
|
6306
6703
|
});
|
|
6307
6704
|
});
|
|
6308
|
-
|
|
6309
|
-
|
|
6310
|
-
|
|
6311
|
-
|
|
6312
|
-
|
|
6313
|
-
|
|
6314
|
-
|
|
6315
|
-
severity: "error",
|
|
6316
|
-
rule: ORG_AXIS_PERMISSION_INHERITANCE,
|
|
6317
|
-
where: `object "${objectName}" policy "${str(policy.name) || pIndex}"`,
|
|
6318
|
-
path: `objects[${oIndex}].rowLevelSecurity[${pIndex}].${clause}`,
|
|
6319
|
-
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.`,
|
|
6320
|
-
hint: INHERITANCE_HINT
|
|
6321
|
-
});
|
|
6322
|
-
}
|
|
6323
|
-
});
|
|
6324
|
-
});
|
|
6325
|
-
asArray36(cfg.sharingRules ?? cfg.sharing).forEach((rule, rIndex) => {
|
|
6326
|
-
const criteria = JSON.stringify(rule.criteria ?? rule.filter ?? "");
|
|
6327
|
-
const sharedTo = JSON.stringify(rule.sharedTo ?? rule.recipient ?? "");
|
|
6328
|
-
if (criteria.includes(ORG_PARENT_FIELD) || sharedTo.includes(ORG_PARENT_FIELD)) {
|
|
6705
|
+
asArray37(cfg.sharingRules).forEach((rule, rIndex) => {
|
|
6706
|
+
const slots = [
|
|
6707
|
+
{ key: "condition", text: expressionText(rule.condition) },
|
|
6708
|
+
{ key: "sharedWith", text: JSON.stringify(rule.sharedWith ?? "") ?? "" }
|
|
6709
|
+
];
|
|
6710
|
+
for (const slot of slots) {
|
|
6711
|
+
if (!slot.text.includes(ORG_PARENT_FIELD)) continue;
|
|
6329
6712
|
findings.push({
|
|
6330
6713
|
severity: "error",
|
|
6331
6714
|
rule: ORG_AXIS_PERMISSION_INHERITANCE,
|
|
6332
6715
|
where: `sharing rule "${str(rule.name) || rIndex}"`,
|
|
6333
|
-
path: `sharingRules[${rIndex}]`,
|
|
6334
|
-
message: `Sharing rule reads \`${ORG_PARENT_FIELD}\`, granting access by walking the organization tree. ADR-0105 D6 forbids permission inheritance along the org axis.`,
|
|
6716
|
+
path: `sharingRules[${rIndex}].${slot.key}`,
|
|
6717
|
+
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.`,
|
|
6335
6718
|
hint: INHERITANCE_HINT
|
|
6336
6719
|
});
|
|
6337
6720
|
}
|
|
6338
6721
|
});
|
|
6339
6722
|
const tenancyDisabledObjects = new Set(
|
|
6340
|
-
objects.filter((o) => isTenancyDisabled(o)).map((o) => str(o.name)).filter(Boolean)
|
|
6723
|
+
asArray37(cfg.objects).filter((o) => isTenancyDisabled(o)).map((o) => str(o.name)).filter(Boolean)
|
|
6341
6724
|
);
|
|
6342
|
-
|
|
6343
|
-
const target = str(rule.object
|
|
6725
|
+
asArray37(cfg.sharingRules).forEach((rule, rIndex) => {
|
|
6726
|
+
const target = str(rule.object);
|
|
6344
6727
|
if (!target || !tenancyDisabledObjects.has(target)) return;
|
|
6345
|
-
const
|
|
6346
|
-
const recipientType = str(
|
|
6347
|
-
if (recipientType
|
|
6728
|
+
const sharedWith = rule.sharedWith;
|
|
6729
|
+
const recipientType = str(sharedWith?.type);
|
|
6730
|
+
if (!BU_TREE_RECIPIENT_TYPES.has(recipientType)) return;
|
|
6731
|
+
const reach = recipientType === "unit_and_subordinates" ? "a business unit AND every descendant unit" : "a business unit";
|
|
6348
6732
|
findings.push({
|
|
6349
6733
|
severity: "error",
|
|
6350
6734
|
rule: ORG_AXIS_CROSS_ORG_BU_GRANT,
|
|
6351
6735
|
where: `sharing rule "${str(rule.name) || rIndex}" on object "${target}"`,
|
|
6352
|
-
path: `sharingRules[${rIndex}].
|
|
6353
|
-
message: `
|
|
6354
|
-
hint: `Either scope the object to organizations (drop \`tenancy.enabled: false\` so Layer 0 walls it), or share it to a
|
|
6736
|
+
path: `sharingRules[${rIndex}].sharedWith`,
|
|
6737
|
+
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).`,
|
|
6738
|
+
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.`
|
|
6355
6739
|
});
|
|
6356
6740
|
});
|
|
6357
6741
|
return findings;
|
|
6358
6742
|
}
|
|
6359
6743
|
|
|
6744
|
+
// src/validate-sharing-rule-enforceability.ts
|
|
6745
|
+
var import_formula4 = require("@objectstack/formula");
|
|
6746
|
+
var SHARING_RULE_UNLOWERABLE_CONDITION = "sharing-rule-unlowerable-condition";
|
|
6747
|
+
var SHARING_RULE_RUNTIME_VARIABLE_CONDITION = "sharing-rule-runtime-variable-condition";
|
|
6748
|
+
function asArray38(v) {
|
|
6749
|
+
if (Array.isArray(v)) return v;
|
|
6750
|
+
if (v && typeof v === "object") {
|
|
6751
|
+
return Object.entries(v).map(([name, def]) => ({ name, ...def }));
|
|
6752
|
+
}
|
|
6753
|
+
return [];
|
|
6754
|
+
}
|
|
6755
|
+
function str2(v) {
|
|
6756
|
+
return typeof v === "string" ? v : "";
|
|
6757
|
+
}
|
|
6758
|
+
function toCompilerInput(condition) {
|
|
6759
|
+
if (typeof condition === "string") return condition.trim() ? condition : null;
|
|
6760
|
+
if (condition && typeof condition === "object") {
|
|
6761
|
+
const source = condition.source;
|
|
6762
|
+
if (typeof source === "string" && source.trim()) return { source };
|
|
6763
|
+
}
|
|
6764
|
+
return null;
|
|
6765
|
+
}
|
|
6766
|
+
function sourceOf(condition) {
|
|
6767
|
+
const input = toCompilerInput(condition);
|
|
6768
|
+
if (typeof input === "string") return input;
|
|
6769
|
+
return str2(input?.source);
|
|
6770
|
+
}
|
|
6771
|
+
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).";
|
|
6772
|
+
function validateSharingRuleEnforceability(stack) {
|
|
6773
|
+
const findings = [];
|
|
6774
|
+
const cfg = stack ?? {};
|
|
6775
|
+
asArray38(cfg.sharingRules).forEach((rule, index) => {
|
|
6776
|
+
const input = toCompilerInput(rule.condition);
|
|
6777
|
+
if (input === null) return;
|
|
6778
|
+
const result = (0, import_formula4.compileCelToFilter)(input, { variables: {} });
|
|
6779
|
+
if (result.ok) return;
|
|
6780
|
+
if (result.reason === "parse-error") return;
|
|
6781
|
+
const name = str2(rule.name) || String(index);
|
|
6782
|
+
const object = str2(rule.object);
|
|
6783
|
+
const where = `sharing rule "${name}"${object ? ` on object "${object}"` : ""}`;
|
|
6784
|
+
const path = `sharingRules[${index}].condition`;
|
|
6785
|
+
const source = sourceOf(rule.condition);
|
|
6786
|
+
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).";
|
|
6787
|
+
if (result.reason === "unresolved-variable") {
|
|
6788
|
+
findings.push({
|
|
6789
|
+
severity: "error",
|
|
6790
|
+
rule: SHARING_RULE_RUNTIME_VARIABLE_CONDITION,
|
|
6791
|
+
where,
|
|
6792
|
+
path,
|
|
6793
|
+
message: `Sharing-rule condition \`${source}\` reads a runtime variable (${result.detail}), ` + skipped,
|
|
6794
|
+
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`."
|
|
6795
|
+
});
|
|
6796
|
+
return;
|
|
6797
|
+
}
|
|
6798
|
+
findings.push({
|
|
6799
|
+
severity: "error",
|
|
6800
|
+
rule: SHARING_RULE_UNLOWERABLE_CONDITION,
|
|
6801
|
+
where,
|
|
6802
|
+
path,
|
|
6803
|
+
message: `Sharing-rule condition \`${source}\` is outside the pushdown subset the runtime can compile (${result.detail}), ` + skipped,
|
|
6804
|
+
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."
|
|
6805
|
+
});
|
|
6806
|
+
});
|
|
6807
|
+
return findings;
|
|
6808
|
+
}
|
|
6809
|
+
|
|
6810
|
+
// src/validate-rls-predicate-enforceability.ts
|
|
6811
|
+
var import_formula5 = require("@objectstack/formula");
|
|
6812
|
+
var RLS_PREDICATE_UNENFORCEABLE = "rls-predicate-unenforceable";
|
|
6813
|
+
var RLS_PREDICATE_UNPARSEABLE = "rls-predicate-unparseable";
|
|
6814
|
+
function asArray39(v) {
|
|
6815
|
+
if (Array.isArray(v)) return v;
|
|
6816
|
+
if (v && typeof v === "object") {
|
|
6817
|
+
return Object.entries(v).map(([name, def]) => ({ name, ...def }));
|
|
6818
|
+
}
|
|
6819
|
+
return [];
|
|
6820
|
+
}
|
|
6821
|
+
function str3(v) {
|
|
6822
|
+
return typeof v === "string" ? v : "";
|
|
6823
|
+
}
|
|
6824
|
+
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.";
|
|
6825
|
+
function consequence(clause) {
|
|
6826
|
+
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). ';
|
|
6827
|
+
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.";
|
|
6828
|
+
}
|
|
6829
|
+
function validateRlsPredicateEnforceability(stack) {
|
|
6830
|
+
const findings = [];
|
|
6831
|
+
const cfg = stack ?? {};
|
|
6832
|
+
asArray39(cfg.permissions).forEach((ps, psIndex) => {
|
|
6833
|
+
asArray39(ps.rowLevelSecurity).forEach((policy, pIndex) => {
|
|
6834
|
+
for (const clause of ["using", "check"]) {
|
|
6835
|
+
const source = str3(policy[clause]);
|
|
6836
|
+
if (!source.trim()) continue;
|
|
6837
|
+
if ((0, import_formula5.isSupportedRlsExpression)(source)) continue;
|
|
6838
|
+
const why = (0, import_formula5.isPushdownableCel)((0, import_formula5.sqlPredicateToCel)(source));
|
|
6839
|
+
const detail = why.ok ? "" : why.detail;
|
|
6840
|
+
const parseError = !why.ok && why.reason === "parse-error";
|
|
6841
|
+
const psName = str3(ps.name) || String(psIndex);
|
|
6842
|
+
const policyName = str3(policy.name) || String(pIndex);
|
|
6843
|
+
const object = str3(policy.object);
|
|
6844
|
+
const where = `permission set "${psName}" policy "${policyName}"` + (object ? ` on object "${object}"` : "");
|
|
6845
|
+
const path = `permissions[${psIndex}].rowLevelSecurity[${pIndex}].${clause}`;
|
|
6846
|
+
if (parseError) {
|
|
6847
|
+
findings.push({
|
|
6848
|
+
severity: "error",
|
|
6849
|
+
rule: RLS_PREDICATE_UNPARSEABLE,
|
|
6850
|
+
where,
|
|
6851
|
+
path,
|
|
6852
|
+
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),
|
|
6853
|
+
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."
|
|
6854
|
+
});
|
|
6855
|
+
continue;
|
|
6856
|
+
}
|
|
6857
|
+
findings.push({
|
|
6858
|
+
severity: "error",
|
|
6859
|
+
rule: RLS_PREDICATE_UNENFORCEABLE,
|
|
6860
|
+
where,
|
|
6861
|
+
path,
|
|
6862
|
+
message: `RLS ${clause} \`${source}\` is outside the pushdown subset the runtime can compile (${detail}), ` + consequence(clause),
|
|
6863
|
+
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."
|
|
6864
|
+
});
|
|
6865
|
+
}
|
|
6866
|
+
});
|
|
6867
|
+
});
|
|
6868
|
+
return findings;
|
|
6869
|
+
}
|
|
6870
|
+
|
|
6871
|
+
// src/validate-rule-compilability.ts
|
|
6872
|
+
var import_node_module4 = require("module");
|
|
6873
|
+
var import_meta4 = {};
|
|
6874
|
+
var VALIDATION_RULE_REGEX_UNCOMPILABLE = "validation-rule-regex-uncompilable";
|
|
6875
|
+
var VALIDATION_RULE_SCHEMA_UNCOMPILABLE = "validation-rule-json-schema-uncompilable";
|
|
6876
|
+
var RUNTIME_AJV_OPTIONS = { allErrors: true, strict: false };
|
|
6877
|
+
var isRec16 = (v) => !!v && typeof v === "object" && !Array.isArray(v);
|
|
6878
|
+
function asArray40(v) {
|
|
6879
|
+
if (Array.isArray(v)) return v.filter(isRec16);
|
|
6880
|
+
if (isRec16(v)) {
|
|
6881
|
+
return Object.entries(v).filter(([, def]) => isRec16(def)).map(([name, def]) => ({ name, ...def }));
|
|
6882
|
+
}
|
|
6883
|
+
return [];
|
|
6884
|
+
}
|
|
6885
|
+
var cachedAjv = null;
|
|
6886
|
+
var cachedAddFormats = null;
|
|
6887
|
+
function loadAjv() {
|
|
6888
|
+
if (cachedAjv) return cachedAjv;
|
|
6889
|
+
const anchor = typeof import_meta4 !== "undefined" && import_meta4.url ? import_meta4.url : typeof __filename !== "undefined" ? __filename : process.cwd() + "/";
|
|
6890
|
+
let mod;
|
|
6891
|
+
try {
|
|
6892
|
+
mod = (0, import_node_module4.createRequire)(anchor)("ajv");
|
|
6893
|
+
} catch (err) {
|
|
6894
|
+
throw new Error(
|
|
6895
|
+
`@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.`
|
|
6896
|
+
);
|
|
6897
|
+
}
|
|
6898
|
+
const ctor = isRec16(mod) && "default" in mod ? mod.default : mod;
|
|
6899
|
+
cachedAjv = ctor;
|
|
6900
|
+
return ctor;
|
|
6901
|
+
}
|
|
6902
|
+
function loadAddFormats() {
|
|
6903
|
+
if (cachedAddFormats) return cachedAddFormats;
|
|
6904
|
+
const anchor = typeof import_meta4 !== "undefined" && import_meta4.url ? import_meta4.url : typeof __filename !== "undefined" ? __filename : process.cwd() + "/";
|
|
6905
|
+
let mod;
|
|
6906
|
+
try {
|
|
6907
|
+
mod = (0, import_node_module4.createRequire)(anchor)("ajv-formats");
|
|
6908
|
+
} catch (err) {
|
|
6909
|
+
throw new Error(
|
|
6910
|
+
`@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.`
|
|
6911
|
+
);
|
|
6912
|
+
}
|
|
6913
|
+
const plugin = isRec16(mod) && "default" in mod ? mod.default : mod;
|
|
6914
|
+
cachedAddFormats = plugin;
|
|
6915
|
+
return plugin;
|
|
6916
|
+
}
|
|
6917
|
+
function createRuntimeAjv() {
|
|
6918
|
+
const instance = new (loadAjv())(RUNTIME_AJV_OPTIONS);
|
|
6919
|
+
loadAddFormats()(instance);
|
|
6920
|
+
return instance;
|
|
6921
|
+
}
|
|
6922
|
+
function registeredFormatNames() {
|
|
6923
|
+
const names = Object.keys(createRuntimeAjv().formats).sort();
|
|
6924
|
+
if (names.length === 0) {
|
|
6925
|
+
throw new Error(
|
|
6926
|
+
`@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.`
|
|
6927
|
+
);
|
|
6928
|
+
}
|
|
6929
|
+
return names;
|
|
6930
|
+
}
|
|
6931
|
+
function errorText(err) {
|
|
6932
|
+
return err instanceof Error ? err.message : String(err);
|
|
6933
|
+
}
|
|
6934
|
+
var MAX_RULE_NESTING_DEPTH = 16;
|
|
6935
|
+
function flattenRules(rule, labelTrail, pathTrail, depth = 0) {
|
|
6936
|
+
const name = typeof rule.name === "string" && rule.name ? rule.name : "?";
|
|
6937
|
+
const label2 = labelTrail ? `${labelTrail} \u2192 '${name}'` : `'${name}'`;
|
|
6938
|
+
const path = pathTrail ? `${pathTrail}.${name}` : name;
|
|
6939
|
+
const out = [{ rule, label: label2, path }];
|
|
6940
|
+
if (depth >= MAX_RULE_NESTING_DEPTH) return out;
|
|
6941
|
+
for (const branch of ["then", "otherwise"]) {
|
|
6942
|
+
const nested = rule[branch];
|
|
6943
|
+
if (isRec16(nested)) out.push(...flattenRules(nested, label2, `${path}.${branch}`, depth + 1));
|
|
6944
|
+
}
|
|
6945
|
+
return out;
|
|
6946
|
+
}
|
|
6947
|
+
function walkObjectValidationRules(stack) {
|
|
6948
|
+
const walked = [];
|
|
6949
|
+
if (!isRec16(stack)) return walked;
|
|
6950
|
+
for (const obj of asArray40(stack.objects)) {
|
|
6951
|
+
const objectName = typeof obj.name === "string" ? obj.name : "(unnamed object)";
|
|
6952
|
+
const validations = obj.validations;
|
|
6953
|
+
for (const authored of asArray40(validations)) {
|
|
6954
|
+
for (const { rule, label: label2, path } of flattenRules(authored, "", "")) {
|
|
6955
|
+
walked.push({
|
|
6956
|
+
rule,
|
|
6957
|
+
objectName,
|
|
6958
|
+
label: label2,
|
|
6959
|
+
where: `object '${objectName}' \xB7 validation ${label2}`,
|
|
6960
|
+
basePath: `objects.${objectName}.validations.${path}`
|
|
6961
|
+
});
|
|
6962
|
+
}
|
|
6963
|
+
}
|
|
6964
|
+
}
|
|
6965
|
+
return walked;
|
|
6966
|
+
}
|
|
6967
|
+
function validateRuleCompilability(stack) {
|
|
6968
|
+
const findings = [];
|
|
6969
|
+
for (const { rule, objectName, label: label2, where, basePath } of walkObjectValidationRules(stack)) {
|
|
6970
|
+
if (rule.type === "format" && typeof rule.regex === "string" && rule.regex !== "") {
|
|
6971
|
+
try {
|
|
6972
|
+
new RegExp(rule.regex);
|
|
6973
|
+
} catch (err) {
|
|
6974
|
+
findings.push({
|
|
6975
|
+
severity: "error",
|
|
6976
|
+
rule: VALIDATION_RULE_REGEX_UNCOMPILABLE,
|
|
6977
|
+
where,
|
|
6978
|
+
path: `${basePath}.regex`,
|
|
6979
|
+
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.`,
|
|
6980
|
+
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').`
|
|
6981
|
+
});
|
|
6982
|
+
}
|
|
6983
|
+
}
|
|
6984
|
+
if (rule.type === "json_schema" && isRec16(rule.schema)) {
|
|
6985
|
+
try {
|
|
6986
|
+
createRuntimeAjv().compile(rule.schema);
|
|
6987
|
+
} catch (err) {
|
|
6988
|
+
findings.push({
|
|
6989
|
+
severity: "error",
|
|
6990
|
+
rule: VALIDATION_RULE_SCHEMA_UNCOMPILABLE,
|
|
6991
|
+
where,
|
|
6992
|
+
path: `${basePath}.schema`,
|
|
6993
|
+
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.`,
|
|
6994
|
+
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.`
|
|
6995
|
+
});
|
|
6996
|
+
}
|
|
6997
|
+
}
|
|
6998
|
+
}
|
|
6999
|
+
return findings;
|
|
7000
|
+
}
|
|
7001
|
+
|
|
7002
|
+
// src/validate-rule-schema-formats.ts
|
|
7003
|
+
var VALIDATION_RULE_SCHEMA_UNKNOWN_FORMAT = "validation-rule-json-schema-unknown-format";
|
|
7004
|
+
var isRec17 = (v) => !!v && typeof v === "object" && !Array.isArray(v);
|
|
7005
|
+
var SUBSCHEMA_KEYS = [
|
|
7006
|
+
"additionalItems",
|
|
7007
|
+
"additionalProperties",
|
|
7008
|
+
"contains",
|
|
7009
|
+
"propertyNames",
|
|
7010
|
+
"if",
|
|
7011
|
+
"then",
|
|
7012
|
+
"else",
|
|
7013
|
+
"not",
|
|
7014
|
+
"unevaluatedItems",
|
|
7015
|
+
"unevaluatedProperties"
|
|
7016
|
+
];
|
|
7017
|
+
var SUBSCHEMA_LIST_KEYS = ["allOf", "anyOf", "oneOf", "prefixItems"];
|
|
7018
|
+
var SUBSCHEMA_MAP_KEYS = [
|
|
7019
|
+
"properties",
|
|
7020
|
+
"patternProperties",
|
|
7021
|
+
"$defs",
|
|
7022
|
+
"definitions",
|
|
7023
|
+
"dependentSchemas"
|
|
7024
|
+
];
|
|
7025
|
+
var MAX_SCHEMA_WALK_DEPTH = 32;
|
|
7026
|
+
var escapePointerSegment = (segment) => segment.replace(/~/g, "~0").replace(/\//g, "~1");
|
|
7027
|
+
function collectFormatUses(schema, pointer, out, depth) {
|
|
7028
|
+
if (!isRec17(schema)) return;
|
|
7029
|
+
if (typeof schema.format === "string") {
|
|
7030
|
+
out.push({ pointer: `${pointer}/format`, name: schema.format });
|
|
7031
|
+
}
|
|
7032
|
+
if (depth >= MAX_SCHEMA_WALK_DEPTH) return;
|
|
7033
|
+
for (const key of SUBSCHEMA_KEYS) {
|
|
7034
|
+
if (key in schema) {
|
|
7035
|
+
collectFormatUses(schema[key], `${pointer}/${escapePointerSegment(key)}`, out, depth + 1);
|
|
7036
|
+
}
|
|
7037
|
+
}
|
|
7038
|
+
for (const key of SUBSCHEMA_LIST_KEYS) {
|
|
7039
|
+
const value = schema[key];
|
|
7040
|
+
if (!Array.isArray(value)) continue;
|
|
7041
|
+
value.forEach((entry, index) => {
|
|
7042
|
+
collectFormatUses(entry, `${pointer}/${escapePointerSegment(key)}/${index}`, out, depth + 1);
|
|
7043
|
+
});
|
|
7044
|
+
}
|
|
7045
|
+
for (const key of SUBSCHEMA_MAP_KEYS) {
|
|
7046
|
+
const value = schema[key];
|
|
7047
|
+
if (!isRec17(value)) continue;
|
|
7048
|
+
for (const [name, entry] of Object.entries(value)) {
|
|
7049
|
+
collectFormatUses(entry, `${pointer}/${escapePointerSegment(key)}/${escapePointerSegment(name)}`, out, depth + 1);
|
|
7050
|
+
}
|
|
7051
|
+
}
|
|
7052
|
+
const items = schema.items;
|
|
7053
|
+
if (Array.isArray(items)) {
|
|
7054
|
+
items.forEach((entry, index) => collectFormatUses(entry, `${pointer}/items/${index}`, out, depth + 1));
|
|
7055
|
+
} else if (isRec17(items)) {
|
|
7056
|
+
collectFormatUses(items, `${pointer}/items`, out, depth + 1);
|
|
7057
|
+
}
|
|
7058
|
+
const dependencies = schema.dependencies;
|
|
7059
|
+
if (isRec17(dependencies)) {
|
|
7060
|
+
for (const [name, entry] of Object.entries(dependencies)) {
|
|
7061
|
+
if (!isRec17(entry)) continue;
|
|
7062
|
+
collectFormatUses(entry, `${pointer}/dependencies/${escapePointerSegment(name)}`, out, depth + 1);
|
|
7063
|
+
}
|
|
7064
|
+
}
|
|
7065
|
+
}
|
|
7066
|
+
function editDistance2(a, b) {
|
|
7067
|
+
let previous = Array.from({ length: b.length + 1 }, (_, j) => j);
|
|
7068
|
+
for (let i = 1; i <= a.length; i++) {
|
|
7069
|
+
const current = [i];
|
|
7070
|
+
for (let j = 1; j <= b.length; j++) {
|
|
7071
|
+
current[j] = Math.min(
|
|
7072
|
+
previous[j] + 1,
|
|
7073
|
+
current[j - 1] + 1,
|
|
7074
|
+
previous[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1)
|
|
7075
|
+
);
|
|
7076
|
+
}
|
|
7077
|
+
previous = current;
|
|
7078
|
+
}
|
|
7079
|
+
return previous[b.length];
|
|
7080
|
+
}
|
|
7081
|
+
function nearestRegisteredFormat(name, registered) {
|
|
7082
|
+
const budget = Math.min(3, Math.floor(name.length / 2));
|
|
7083
|
+
if (budget < 1) return null;
|
|
7084
|
+
const authored = name.toLowerCase();
|
|
7085
|
+
let best = null;
|
|
7086
|
+
let bestDistance = Number.POSITIVE_INFINITY;
|
|
7087
|
+
for (const candidate of [...registered].sort()) {
|
|
7088
|
+
const distance7 = editDistance2(authored, candidate);
|
|
7089
|
+
if (distance7 < bestDistance) {
|
|
7090
|
+
bestDistance = distance7;
|
|
7091
|
+
best = candidate;
|
|
7092
|
+
}
|
|
7093
|
+
}
|
|
7094
|
+
return bestDistance <= budget ? best : null;
|
|
7095
|
+
}
|
|
7096
|
+
function validateRuleSchemaFormats(stack) {
|
|
7097
|
+
const findings = [];
|
|
7098
|
+
const pending = [];
|
|
7099
|
+
for (const { rule, objectName, label: label2, where, basePath } of walkObjectValidationRules(stack)) {
|
|
7100
|
+
if (rule.type !== "json_schema" || !isRec17(rule.schema)) continue;
|
|
7101
|
+
const uses = [];
|
|
7102
|
+
collectFormatUses(rule.schema, "", uses, 0);
|
|
7103
|
+
for (const use of uses) pending.push({ use, where, label: label2, objectName, basePath });
|
|
7104
|
+
}
|
|
7105
|
+
if (pending.length === 0) return findings;
|
|
7106
|
+
const registered = registeredFormatNames();
|
|
7107
|
+
const known = new Set(registered);
|
|
7108
|
+
for (const { use, where, label: label2, objectName, basePath } of pending) {
|
|
7109
|
+
if (known.has(use.name)) continue;
|
|
7110
|
+
const suggestion = nearestRegisteredFormat(use.name, registered);
|
|
7111
|
+
const pointer = `#${use.pointer}`;
|
|
7112
|
+
findings.push({
|
|
7113
|
+
severity: "error",
|
|
7114
|
+
rule: VALIDATION_RULE_SCHEMA_UNKNOWN_FORMAT,
|
|
7115
|
+
where,
|
|
7116
|
+
path: `${basePath}.schema${pointer}`,
|
|
7117
|
+
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.`,
|
|
7118
|
+
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.`
|
|
7119
|
+
});
|
|
7120
|
+
}
|
|
7121
|
+
return findings;
|
|
7122
|
+
}
|
|
7123
|
+
|
|
6360
7124
|
// src/validate-action-locations.ts
|
|
6361
7125
|
var ACTION_NO_PLACEMENT = "action-no-placement";
|
|
6362
|
-
function
|
|
7126
|
+
function asArray41(v) {
|
|
6363
7127
|
if (Array.isArray(v)) return v;
|
|
6364
7128
|
if (v && typeof v === "object") {
|
|
6365
7129
|
return Object.entries(v).map(([name, def]) => ({ name, ...def }));
|
|
6366
7130
|
}
|
|
6367
7131
|
return [];
|
|
6368
7132
|
}
|
|
6369
|
-
function
|
|
7133
|
+
function strName17(v) {
|
|
6370
7134
|
return typeof v === "string" && v.length > 0 ? v : void 0;
|
|
6371
7135
|
}
|
|
6372
7136
|
function strList3(v) {
|
|
@@ -6380,8 +7144,8 @@ function collectNamePlacedActions(stack) {
|
|
|
6380
7144
|
for (const key of ["rowActions", "bulkActions"]) {
|
|
6381
7145
|
for (const n of strList3(list3[key])) placed.add(n);
|
|
6382
7146
|
}
|
|
6383
|
-
for (const def of
|
|
6384
|
-
const n =
|
|
7147
|
+
for (const def of asArray41(list3.bulkActionDefs)) {
|
|
7148
|
+
const n = strName17(def?.name);
|
|
6385
7149
|
if (n) placed.add(n);
|
|
6386
7150
|
}
|
|
6387
7151
|
};
|
|
@@ -6389,12 +7153,12 @@ function collectNamePlacedActions(stack) {
|
|
|
6389
7153
|
if (!listViews || typeof listViews !== "object" || Array.isArray(listViews)) return;
|
|
6390
7154
|
for (const lv of Object.values(listViews)) harvest(lv);
|
|
6391
7155
|
};
|
|
6392
|
-
for (const view of
|
|
7156
|
+
for (const view of asArray41(stack.views)) {
|
|
6393
7157
|
if (!view || typeof view !== "object") continue;
|
|
6394
7158
|
harvest(view.list);
|
|
6395
7159
|
harvestListViews(view.listViews);
|
|
6396
7160
|
}
|
|
6397
|
-
for (const obj of
|
|
7161
|
+
for (const obj of asArray41(stack.objects)) {
|
|
6398
7162
|
if (!obj || typeof obj !== "object") continue;
|
|
6399
7163
|
harvestListViews(obj.listViews);
|
|
6400
7164
|
}
|
|
@@ -6407,7 +7171,7 @@ function validateActionLocations(stack) {
|
|
|
6407
7171
|
const check = (action, path) => {
|
|
6408
7172
|
if (!action || typeof action !== "object") return;
|
|
6409
7173
|
if ("locations" in action) return;
|
|
6410
|
-
const name =
|
|
7174
|
+
const name = strName17(action.name);
|
|
6411
7175
|
if (!name) return;
|
|
6412
7176
|
if (namePlaced.has(name)) return;
|
|
6413
7177
|
findings.push({
|
|
@@ -6419,20 +7183,21 @@ function validateActionLocations(stack) {
|
|
|
6419
7183
|
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."
|
|
6420
7184
|
});
|
|
6421
7185
|
};
|
|
6422
|
-
const actions =
|
|
7186
|
+
const actions = asArray41(stack.actions);
|
|
6423
7187
|
for (let i = 0; i < actions.length; i++) check(actions[i], `actions[${i}]`);
|
|
6424
|
-
const objects =
|
|
7188
|
+
const objects = asArray41(stack.objects);
|
|
6425
7189
|
for (let oi = 0; oi < objects.length; oi++) {
|
|
6426
7190
|
const obj = objects[oi];
|
|
6427
7191
|
if (!obj || typeof obj !== "object") continue;
|
|
6428
|
-
const own =
|
|
7192
|
+
const own = asArray41(obj.actions);
|
|
6429
7193
|
for (let ai = 0; ai < own.length; ai++) check(own[ai], `objects[${oi}].actions[${ai}]`);
|
|
6430
7194
|
}
|
|
6431
7195
|
return findings;
|
|
6432
7196
|
}
|
|
6433
7197
|
|
|
6434
7198
|
// src/lint-flow-patterns.ts
|
|
6435
|
-
|
|
7199
|
+
var import_automation5 = require("@objectstack/spec/automation");
|
|
7200
|
+
function asArray42(v) {
|
|
6436
7201
|
if (Array.isArray(v)) return v;
|
|
6437
7202
|
if (v && typeof v === "object") return Object.entries(v).map(([name, def]) => ({ name, ...def }));
|
|
6438
7203
|
return [];
|
|
@@ -6455,6 +7220,7 @@ var FLOW_BARE_DOLLAR_REF = "flow-bare-dollar-reference";
|
|
|
6455
7220
|
var FLOW_APPROVAL_REVISE_DEAD_END = "flow-approval-revise-dead-end";
|
|
6456
7221
|
var FLOW_APPROVAL_REVISE_UNMARKED_BACKEDGE = "flow-approval-revise-unmarked-backedge";
|
|
6457
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";
|
|
6458
7224
|
var FLOW_RUNAS_UNSCOPED = "flow-runas-unscoped";
|
|
6459
7225
|
var FLOW_ERROR_LABEL_NOT_FAULT = "flow-error-label-not-fault";
|
|
6460
7226
|
var FLOW_BRANCH_LABEL_UNMATCHED = "flow-branch-label-unmatched";
|
|
@@ -6462,6 +7228,7 @@ var FLOW_DECISION_UNCONDITIONAL_BRANCH = "flow-decision-unconditional-branch";
|
|
|
6462
7228
|
var FLOW_DEFAULT_EDGE_WITH_CONDITION = "flow-default-edge-with-condition";
|
|
6463
7229
|
var FLOW_MULTIPLE_DEFAULT_EDGES = "flow-multiple-default-edges";
|
|
6464
7230
|
var FLOW_INERT_NODE_CONDITION = "flow-inert-node-condition";
|
|
7231
|
+
var FLOW_MULTI_WRITE_UNFILTERED = "flow-multi-write-unfiltered";
|
|
6465
7232
|
var INERT_CONDITION_NODE_TYPES = /* @__PURE__ */ new Set([
|
|
6466
7233
|
"decision",
|
|
6467
7234
|
"assignment",
|
|
@@ -6484,6 +7251,36 @@ var INERT_CONDITION_NODE_TYPES = /* @__PURE__ */ new Set([
|
|
|
6484
7251
|
"end"
|
|
6485
7252
|
]);
|
|
6486
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 (0, import_automation5.collectFlowGraphs)({
|
|
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
|
+
]);
|
|
6487
7284
|
var ERROR_LABELS = /* @__PURE__ */ new Set(["error", "fault", "failure", "failed", "catch", "on_error", "onerror", "on error"]);
|
|
6488
7285
|
var BRANCH_LABEL_NODE_TYPES = /* @__PURE__ */ new Set(["decision", "approval", "screen", "try_catch"]);
|
|
6489
7286
|
function isScheduleTriggered(flow, startCfg) {
|
|
@@ -6568,7 +7365,7 @@ function collectTemplateStrings(value, key, out) {
|
|
|
6568
7365
|
function edgeLabelOf(e) {
|
|
6569
7366
|
return typeof e.label === "string" ? e.label.trim().toLowerCase() : "";
|
|
6570
7367
|
}
|
|
6571
|
-
function scanErrorLabelledEdges(
|
|
7368
|
+
function scanErrorLabelledEdges(at, nodes, edges, findings) {
|
|
6572
7369
|
const typeById = /* @__PURE__ */ new Map();
|
|
6573
7370
|
for (const n of nodes) {
|
|
6574
7371
|
if (typeof n.id === "string") typeById.set(n.id, typeof n.type === "string" ? n.type : "");
|
|
@@ -6581,14 +7378,14 @@ function scanErrorLabelledEdges(flowName, nodes, edges, findings) {
|
|
|
6581
7378
|
const src = typeof e.source === "string" ? e.source : "";
|
|
6582
7379
|
if (BRANCH_LABEL_NODE_TYPES.has(typeById.get(src) ?? "")) continue;
|
|
6583
7380
|
findings.push({
|
|
6584
|
-
where:
|
|
7381
|
+
where: `${at} \xB7 edge '${src}' \u2192 '${String(e.target)}'`,
|
|
6585
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.`,
|
|
6586
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)`,
|
|
6587
7384
|
rule: FLOW_ERROR_LABEL_NOT_FAULT
|
|
6588
7385
|
});
|
|
6589
7386
|
}
|
|
6590
7387
|
}
|
|
6591
|
-
function scanBranchRouting(
|
|
7388
|
+
function scanBranchRouting(at, nodes, edges, findings) {
|
|
6592
7389
|
const outEdgesBySource = /* @__PURE__ */ new Map();
|
|
6593
7390
|
for (const e of edges) {
|
|
6594
7391
|
if (e.type === "fault") continue;
|
|
@@ -6601,7 +7398,7 @@ function scanBranchRouting(flowName, nodes, edges, findings) {
|
|
|
6601
7398
|
for (const e of outs) {
|
|
6602
7399
|
if (e.isDefault === true && e.condition) {
|
|
6603
7400
|
findings.push({
|
|
6604
|
-
where:
|
|
7401
|
+
where: `${at} \xB7 edge '${src}' \u2192 '${String(e.target)}'`,
|
|
6605
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.`,
|
|
6606
7403
|
hint: `Drop one: keep \`condition\` for a guarded branch, or drop it and keep \`isDefault: true\` for the "otherwise" path. (#4414)`,
|
|
6607
7404
|
rule: FLOW_DEFAULT_EDGE_WITH_CONDITION,
|
|
@@ -6614,7 +7411,7 @@ function scanBranchRouting(flowName, nodes, edges, findings) {
|
|
|
6614
7411
|
const defaults = outs.filter((e) => e.isDefault === true && !e.condition);
|
|
6615
7412
|
if (defaults.length > 1) {
|
|
6616
7413
|
findings.push({
|
|
6617
|
-
where:
|
|
7414
|
+
where: `${at} \xB7 node '${src}'`,
|
|
6618
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".`,
|
|
6619
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)`,
|
|
6620
7417
|
rule: FLOW_MULTIPLE_DEFAULT_EDGES
|
|
@@ -6627,7 +7424,7 @@ function scanBranchRouting(flowName, nodes, edges, findings) {
|
|
|
6627
7424
|
const cfg = node.config ?? {};
|
|
6628
7425
|
if (cfg.condition == null || conditionSource(cfg.condition).trim() === "") continue;
|
|
6629
7426
|
findings.push({
|
|
6630
|
-
where:
|
|
7427
|
+
where: `${at} \xB7 node '${String(node.id)}' (${nodeType})`,
|
|
6631
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.)`,
|
|
6632
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)`,
|
|
6633
7430
|
rule: FLOW_INERT_NODE_CONDITION
|
|
@@ -6647,7 +7444,7 @@ function scanBranchRouting(flowName, nodes, edges, findings) {
|
|
|
6647
7444
|
const unclaimed = [...declaredLabels].filter((l) => !edgeLabels.has(l));
|
|
6648
7445
|
if (unclaimed.length > 0) {
|
|
6649
7446
|
findings.push({
|
|
6650
|
-
where:
|
|
7447
|
+
where: `${at} \xB7 decision '${nid}'`,
|
|
6651
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.`,
|
|
6652
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)`,
|
|
6653
7450
|
rule: FLOW_BRANCH_LABEL_UNMATCHED,
|
|
@@ -6663,7 +7460,7 @@ function scanBranchRouting(flowName, nodes, edges, findings) {
|
|
|
6663
7460
|
);
|
|
6664
7461
|
if (ungated.length > 0) {
|
|
6665
7462
|
findings.push({
|
|
6666
|
-
where:
|
|
7463
|
+
where: `${at} \xB7 decision '${nid}'`,
|
|
6667
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\`.`,
|
|
6668
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)`,
|
|
6669
7466
|
rule: FLOW_DECISION_UNCONDITIONAL_BRANCH
|
|
@@ -6671,10 +7468,39 @@ function scanBranchRouting(flowName, nodes, edges, findings) {
|
|
|
6671
7468
|
}
|
|
6672
7469
|
}
|
|
6673
7470
|
}
|
|
6674
|
-
function
|
|
6675
|
-
|
|
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 === import_automation5.APPROVAL_NODE_TYPE);
|
|
6676
7499
|
if (approvals.length === 0) return;
|
|
6677
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
|
+
);
|
|
6678
7504
|
const outEdges = /* @__PURE__ */ new Map();
|
|
6679
7505
|
for (const e of edges) {
|
|
6680
7506
|
const src = typeof e.source === "string" ? e.source : "";
|
|
@@ -6687,7 +7513,18 @@ function scanApprovalReviseLoops(flowName, nodes, edges, findings) {
|
|
|
6687
7513
|
if (!aid) continue;
|
|
6688
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));
|
|
6689
7515
|
if (reviseTargets.length === 0) continue;
|
|
6690
|
-
const where =
|
|
7516
|
+
const where = `${at} \xB7 approval '${aid}'`;
|
|
7517
|
+
for (const target of reviseTargets) {
|
|
7518
|
+
const targetType = nodeTypeById.get(target) ?? "";
|
|
7519
|
+
if (targetType === import_automation5.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 '${import_automation5.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: '${import_automation5.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
|
+
}
|
|
6691
7528
|
const cfg = a.config ?? {};
|
|
6692
7529
|
if (cfg.maxRevisions === 0) {
|
|
6693
7530
|
findings.push({
|
|
@@ -6715,7 +7552,7 @@ function scanApprovalReviseLoops(flowName, nodes, edges, findings) {
|
|
|
6715
7552
|
findings.push({
|
|
6716
7553
|
where,
|
|
6717
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.)`,
|
|
6718
|
-
hint: `Close the loop: the 'revise' edge should reach
|
|
7555
|
+
hint: `Close the loop: the 'revise' edge should reach an '${import_automation5.APPROVAL_REVISE_NODE_TYPE}' node whose resubmit edge returns to '${aid}' marked \`type: 'back'\` (ADR-0044). See examples/app-showcase showcase_budget_approval.`,
|
|
6719
7556
|
rule: FLOW_APPROVAL_REVISE_DEAD_END
|
|
6720
7557
|
});
|
|
6721
7558
|
} else if (!returnEdges.some((e) => e.type === "back")) {
|
|
@@ -6730,7 +7567,7 @@ function scanApprovalReviseLoops(flowName, nodes, edges, findings) {
|
|
|
6730
7567
|
}
|
|
6731
7568
|
function lintFlowPatterns(stack) {
|
|
6732
7569
|
const findings = [];
|
|
6733
|
-
for (const flow of
|
|
7570
|
+
for (const flow of asArray42(stack.flows)) {
|
|
6734
7571
|
const flowName = typeof flow.name === "string" ? flow.name : "(unnamed flow)";
|
|
6735
7572
|
const nodes = Array.isArray(flow.nodes) ? flow.nodes : [];
|
|
6736
7573
|
const edges = Array.isArray(flow.edges) ? flow.edges : [];
|
|
@@ -6751,75 +7588,91 @@ function lintFlowPatterns(stack) {
|
|
|
6751
7588
|
const runAs = typeof flow.runAs === "string" ? flow.runAs : "user";
|
|
6752
7589
|
const userLessKind = userLessTriggerKind(flow, startCfg);
|
|
6753
7590
|
if (userLessKind && runAs !== "system") {
|
|
6754
|
-
const dataNode = nodes
|
|
7591
|
+
const dataNode = findDataNodeAnywhere(nodes, edges);
|
|
6755
7592
|
if (dataNode) {
|
|
6756
|
-
const
|
|
7593
|
+
const at = dataNode.scope ? `, in ${dataNode.scope},` : "";
|
|
6757
7594
|
findings.push({
|
|
6758
7595
|
where: `flow '${flowName}' \xB7 runAs`,
|
|
6759
|
-
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.`,
|
|
6760
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)`,
|
|
6761
7598
|
rule: FLOW_RUNAS_UNSCOPED,
|
|
6762
7599
|
severity: "error"
|
|
6763
7600
|
});
|
|
6764
7601
|
}
|
|
6765
7602
|
}
|
|
6766
|
-
for (const
|
|
6767
|
-
|
|
6768
|
-
|
|
6769
|
-
|
|
6770
|
-
|
|
6771
|
-
|
|
6772
|
-
|
|
6773
|
-
|
|
6774
|
-
|
|
6775
|
-
|
|
6776
|
-
|
|
6777
|
-
|
|
6778
|
-
|
|
6779
|
-
|
|
6780
|
-
const
|
|
6781
|
-
|
|
6782
|
-
|
|
6783
|
-
if (
|
|
6784
|
-
|
|
6785
|
-
|
|
6786
|
-
|
|
6787
|
-
|
|
6788
|
-
|
|
6789
|
-
|
|
7603
|
+
for (const graph of (0, import_automation5.collectFlowGraphs)({
|
|
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
|
+
}
|
|
6790
7630
|
}
|
|
6791
|
-
|
|
6792
|
-
|
|
6793
|
-
|
|
6794
|
-
|
|
6795
|
-
|
|
6796
|
-
|
|
6797
|
-
|
|
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
|
+
}
|
|
6798
7650
|
}
|
|
6799
7651
|
}
|
|
7652
|
+
scanApprovalReviseLoops(at, graphNodes, graphEdges, findings);
|
|
7653
|
+
scanErrorLabelledEdges(at, graphNodes, graphEdges, findings);
|
|
7654
|
+
scanBranchRouting(at, graphNodes, graphEdges, findings);
|
|
7655
|
+
scanUnboundedBulkWrites(at, graphNodes, findings);
|
|
6800
7656
|
}
|
|
6801
|
-
scanApprovalReviseLoops(flowName, nodes, edges, findings);
|
|
6802
|
-
scanErrorLabelledEdges(flowName, nodes, edges, findings);
|
|
6803
|
-
scanBranchRouting(flowName, nodes, edges, findings);
|
|
6804
7657
|
}
|
|
6805
7658
|
return findings;
|
|
6806
7659
|
}
|
|
6807
7660
|
|
|
6808
7661
|
// src/lint-liveness-properties.ts
|
|
6809
|
-
var
|
|
7662
|
+
var import_node_module5 = require("module");
|
|
6810
7663
|
var import_node_path = require("path");
|
|
6811
7664
|
var import_node_fs = require("fs");
|
|
6812
|
-
var
|
|
7665
|
+
var import_meta5 = {};
|
|
6813
7666
|
var LIVENESS_DEAD_PROPERTY = "liveness-dead-property";
|
|
6814
7667
|
var LIVENESS_EXPERIMENTAL_PROPERTY = "liveness-experimental-property";
|
|
6815
|
-
function
|
|
7668
|
+
function asArray43(v) {
|
|
6816
7669
|
if (Array.isArray(v)) return v;
|
|
6817
7670
|
if (v && typeof v === "object") return Object.entries(v).map(([name, def]) => ({ name, ...def }));
|
|
6818
7671
|
return [];
|
|
6819
7672
|
}
|
|
6820
7673
|
function resolveLivenessDir() {
|
|
6821
7674
|
try {
|
|
6822
|
-
const require2 = (0,
|
|
7675
|
+
const require2 = (0, import_node_module5.createRequire)(import_meta5.url);
|
|
6823
7676
|
const pkgJson = require2.resolve("@objectstack/spec/package.json");
|
|
6824
7677
|
const dir = (0, import_node_path.join)((0, import_node_path.dirname)(pkgJson), "liveness");
|
|
6825
7678
|
return (0, import_node_fs.existsSync)(dir) ? dir : null;
|
|
@@ -6925,7 +7778,16 @@ var TYPE_COLLECTIONS = [
|
|
|
6925
7778
|
{ type: "job", key: "jobs" },
|
|
6926
7779
|
{ type: "email_template", key: "emailTemplates" },
|
|
6927
7780
|
{ type: "mapping", key: "mappings" },
|
|
6928
|
-
{ type: "translation", key: "translations" }
|
|
7781
|
+
{ type: "translation", key: "translations" },
|
|
7782
|
+
// #4956 — dashboard joins the list the moment its ledger first warns on
|
|
7783
|
+
// anything, which is exactly the rule the comment above states. Drilling
|
|
7784
|
+
// `widgets` produced five warned keys (`colorVariant`, `actionUrl`,
|
|
7785
|
+
// `actionType`, `actionIcon`, `aria`), all under `widgets[]`; `getNested`
|
|
7786
|
+
// fans a dotted path out over an array level, so `widgets.colorVariant`
|
|
7787
|
+
// checks every widget on the dashboard. Registering it here is not optional
|
|
7788
|
+
// bookkeeping: without it the ledger would be newly correct and newly
|
|
7789
|
+
// silent, which is the shape this lint exists to prevent.
|
|
7790
|
+
{ type: "dashboard", key: "dashboards" }
|
|
6929
7791
|
];
|
|
6930
7792
|
function lintLivenessProperties(stack) {
|
|
6931
7793
|
const dir = resolveLivenessDir();
|
|
@@ -6933,11 +7795,11 @@ function lintLivenessProperties(stack) {
|
|
|
6933
7795
|
const findings = [];
|
|
6934
7796
|
const objectWarn = loadWarnMap(dir, "object");
|
|
6935
7797
|
const fieldWarn = loadWarnMap(dir, "field");
|
|
6936
|
-
for (const obj of
|
|
7798
|
+
for (const obj of asArray43(stack.objects)) {
|
|
6937
7799
|
const objName = typeof obj.name === "string" ? obj.name : "(unnamed object)";
|
|
6938
7800
|
if (objectWarn.size > 0) checkItem("object", obj, `object '${objName}'`, objectWarn, findings);
|
|
6939
7801
|
if (fieldWarn.size > 0) {
|
|
6940
|
-
for (const field of
|
|
7802
|
+
for (const field of asArray43(obj.fields)) {
|
|
6941
7803
|
const fieldName = typeof field.name === "string" ? field.name : "(unnamed field)";
|
|
6942
7804
|
checkItem("field", field, `object '${objName}' \xB7 field '${fieldName}'`, fieldWarn, findings);
|
|
6943
7805
|
}
|
|
@@ -6946,7 +7808,7 @@ function lintLivenessProperties(stack) {
|
|
|
6946
7808
|
for (const { type, key } of TYPE_COLLECTIONS) {
|
|
6947
7809
|
const warnMap = loadWarnMap(dir, type);
|
|
6948
7810
|
if (warnMap.size === 0) continue;
|
|
6949
|
-
for (const item of
|
|
7811
|
+
for (const item of asArray43(stack[key])) {
|
|
6950
7812
|
const name = typeof item.name === "string" ? item.name : typeof item.object === "string" ? item.object : `(unnamed ${type})`;
|
|
6951
7813
|
checkItem(type, item, `${type} '${name}'`, warnMap, findings);
|
|
6952
7814
|
}
|
|
@@ -6960,7 +7822,7 @@ var AUTONUMBER_UNKNOWN_FIELD = "autonumber-references-unknown-field";
|
|
|
6960
7822
|
var AUTONUMBER_OPTIONAL_FIELD = "autonumber-references-optional-field";
|
|
6961
7823
|
var AUTONUMBER_SELF_REFERENCE = "autonumber-references-self";
|
|
6962
7824
|
var AUTONUMBER_LITERAL_TOKEN = "autonumber-unrecognized-token";
|
|
6963
|
-
function
|
|
7825
|
+
function asArray44(v) {
|
|
6964
7826
|
if (Array.isArray(v)) return v;
|
|
6965
7827
|
if (v && typeof v === "object") {
|
|
6966
7828
|
return Object.entries(v).map(([name, def]) => ({ name, ...def }));
|
|
@@ -6969,9 +7831,9 @@ function asArray40(v) {
|
|
|
6969
7831
|
}
|
|
6970
7832
|
function lintAutonumberFormats(stack) {
|
|
6971
7833
|
const findings = [];
|
|
6972
|
-
for (const obj of
|
|
7834
|
+
for (const obj of asArray44(stack.objects)) {
|
|
6973
7835
|
const objectName = typeof obj.name === "string" ? obj.name : "(unnamed object)";
|
|
6974
|
-
const fields =
|
|
7836
|
+
const fields = asArray44(obj.fields);
|
|
6975
7837
|
const fieldMeta = /* @__PURE__ */ new Map();
|
|
6976
7838
|
for (const f of fields) {
|
|
6977
7839
|
if (typeof f.name === "string") fieldMeta.set(f.name, { required: f.required === true });
|
|
@@ -7036,8 +7898,8 @@ function lintAutonumberFormats(stack) {
|
|
|
7036
7898
|
}
|
|
7037
7899
|
|
|
7038
7900
|
// src/lint-view-refs.ts
|
|
7039
|
-
var
|
|
7040
|
-
function
|
|
7901
|
+
var import_spec3 = require("@objectstack/spec");
|
|
7902
|
+
function asArray45(v) {
|
|
7041
7903
|
if (Array.isArray(v)) return v;
|
|
7042
7904
|
if (v && typeof v === "object") return Object.entries(v).map(([name, def]) => ({ name, ...def }));
|
|
7043
7905
|
return [];
|
|
@@ -7065,16 +7927,16 @@ function lintViewRefs(stack) {
|
|
|
7065
7927
|
s.add(kind);
|
|
7066
7928
|
};
|
|
7067
7929
|
const containers = [];
|
|
7068
|
-
for (const v of
|
|
7930
|
+
for (const v of asArray45(stack.views)) {
|
|
7069
7931
|
if (v.viewKind) {
|
|
7070
7932
|
if (typeof v.name === "string") indexKind(v.name, v.viewKind === "form" ? "form" : "list");
|
|
7071
7933
|
continue;
|
|
7072
7934
|
}
|
|
7073
|
-
if (!(0,
|
|
7935
|
+
if (!(0, import_spec3.isAggregatedViewContainer)(v)) continue;
|
|
7074
7936
|
const object = viewContainerObjectName(v);
|
|
7075
7937
|
if (object) containers.push({ object, container: v });
|
|
7076
7938
|
}
|
|
7077
|
-
for (const obj of
|
|
7939
|
+
for (const obj of asArray45(stack.objects)) {
|
|
7078
7940
|
const object = typeof obj.name === "string" ? obj.name : void 0;
|
|
7079
7941
|
if (!object) continue;
|
|
7080
7942
|
if (obj.list || obj.form || obj.listViews || obj.formViews) {
|
|
@@ -7082,7 +7944,7 @@ function lintViewRefs(stack) {
|
|
|
7082
7944
|
}
|
|
7083
7945
|
}
|
|
7084
7946
|
for (const { object, container } of containers) {
|
|
7085
|
-
const { items, collisions } = (0,
|
|
7947
|
+
const { items, collisions } = (0, import_spec3.expandViewContainerWithDiagnostics)(object, container);
|
|
7086
7948
|
for (const it of items) indexKind(it.name, it.viewKind);
|
|
7087
7949
|
for (const col of collisions) {
|
|
7088
7950
|
findings.push({
|
|
@@ -7128,11 +7990,11 @@ function lintViewRefs(stack) {
|
|
|
7128
7990
|
});
|
|
7129
7991
|
}
|
|
7130
7992
|
};
|
|
7131
|
-
for (const obj of
|
|
7993
|
+
for (const obj of asArray45(stack.objects)) {
|
|
7132
7994
|
const object = typeof obj.name === "string" ? obj.name : void 0;
|
|
7133
|
-
for (const action of
|
|
7995
|
+
for (const action of asArray45(obj.actions)) checkAction(action, object);
|
|
7134
7996
|
}
|
|
7135
|
-
for (const action of
|
|
7997
|
+
for (const action of asArray45(stack.actions)) checkAction(action);
|
|
7136
7998
|
return findings;
|
|
7137
7999
|
}
|
|
7138
8000
|
|
|
@@ -7145,8 +8007,43 @@ function fieldEntries2(fields) {
|
|
|
7145
8007
|
return Object.entries(fields).map(([name, def]) => ({ name, def }));
|
|
7146
8008
|
}
|
|
7147
8009
|
var UNIQUE_DOUBLE_DECLARATION = "unique/double-declaration";
|
|
8010
|
+
var UNIQUE_UNSCOPED_DECLARED_INDEX = "unique/unscoped-declared-index";
|
|
8011
|
+
var UNIQUE_LEGACY_ORGANIZATION_COMPOSITE = "unique/legacy-organization-composite";
|
|
8012
|
+
function authoredTenantColumn(obj) {
|
|
8013
|
+
const declared = obj?.tenancy?.tenantField;
|
|
8014
|
+
return typeof declared === "string" && declared.trim() ? declared.trim() : "organization_id";
|
|
8015
|
+
}
|
|
7148
8016
|
function uniqueDeclared(u) {
|
|
7149
|
-
return u === true || u === "global";
|
|
8017
|
+
return u === true || u === "global" || u === "organization";
|
|
8018
|
+
}
|
|
8019
|
+
function fieldUniqueScope(u) {
|
|
8020
|
+
return u === "global" ? "global" : "organization";
|
|
8021
|
+
}
|
|
8022
|
+
function indexUniqueScope(u) {
|
|
8023
|
+
return u === "organization" ? "organization" : "global";
|
|
8024
|
+
}
|
|
8025
|
+
function lintUnscopedDeclaredIndexes(objects) {
|
|
8026
|
+
const issues = [];
|
|
8027
|
+
if (!Array.isArray(objects) || objects.length === 0) return issues;
|
|
8028
|
+
for (let i = 0; i < objects.length; i++) {
|
|
8029
|
+
const obj = objects[i];
|
|
8030
|
+
if (!obj?.name) continue;
|
|
8031
|
+
const declaredIndexes = Array.isArray(obj.indexes) ? obj.indexes : [];
|
|
8032
|
+
for (let j = 0; j < declaredIndexes.length; j++) {
|
|
8033
|
+
const idx = declaredIndexes[j];
|
|
8034
|
+
if (idx?.unique !== true) continue;
|
|
8035
|
+
const cols = Array.isArray(idx?.fields) ? idx.fields.filter((f) => typeof f === "string").join(", ") : "";
|
|
8036
|
+
const indexLabel = typeof idx?.name === "string" && idx.name.trim() ? ` '${idx.name.trim()}'` : "";
|
|
8037
|
+
issues.push({
|
|
8038
|
+
severity: "warning",
|
|
8039
|
+
rule: UNIQUE_UNSCOPED_DECLARED_INDEX,
|
|
8040
|
+
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).`,
|
|
8041
|
+
path: `objects[${i}].indexes[${j}]`,
|
|
8042
|
+
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).`
|
|
8043
|
+
});
|
|
8044
|
+
}
|
|
8045
|
+
}
|
|
8046
|
+
return issues;
|
|
7150
8047
|
}
|
|
7151
8048
|
function lintUniqueDeclarations(objects) {
|
|
7152
8049
|
const issues = [];
|
|
@@ -7166,16 +8063,59 @@ function lintUniqueDeclarations(objects) {
|
|
|
7166
8063
|
if (singleColumnUniqueIndexes.size === 0) continue;
|
|
7167
8064
|
for (const { name, def } of fieldEntries2(obj.fields)) {
|
|
7168
8065
|
if (!uniqueDeclared(def?.unique)) continue;
|
|
7169
|
-
if (def.unique === "global") continue;
|
|
7170
8066
|
const idx = singleColumnUniqueIndexes.get(name);
|
|
7171
8067
|
if (!idx) continue;
|
|
8068
|
+
const fScope = fieldUniqueScope(def.unique);
|
|
8069
|
+
const iScope = indexUniqueScope(idx.unique);
|
|
7172
8070
|
const indexLabel = typeof idx?.name === "string" && idx.name.trim() ? ` '${idx.name.trim()}'` : "";
|
|
8071
|
+
const fieldSpelling = `\`unique: ${typeof def.unique === "string" ? `'${def.unique}'` : def.unique}\``;
|
|
8072
|
+
const indexSpelling = `\`unique: ${typeof idx.unique === "string" ? `'${idx.unique}'` : idx.unique}\``;
|
|
8073
|
+
let message;
|
|
8074
|
+
let fix;
|
|
8075
|
+
if (fScope === iScope) {
|
|
8076
|
+
const boundary = fScope === "global" ? "installation-wide" : "per-organization";
|
|
8077
|
+
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.`;
|
|
8078
|
+
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.`;
|
|
8079
|
+
} else {
|
|
8080
|
+
const globalSide = fScope === "global" ? `field-level ${fieldSpelling}` : `declared index${indexLabel} (${indexSpelling})`;
|
|
8081
|
+
const orgSide = fScope === "global" ? `declared index${indexLabel} (${indexSpelling})` : `field-level ${fieldSpelling}`;
|
|
8082
|
+
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.`;
|
|
8083
|
+
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.`;
|
|
8084
|
+
}
|
|
7173
8085
|
issues.push({
|
|
7174
8086
|
severity: "warning",
|
|
7175
8087
|
rule: UNIQUE_DOUBLE_DECLARATION,
|
|
7176
|
-
message
|
|
8088
|
+
message,
|
|
7177
8089
|
path: `objects[${i}]`,
|
|
7178
|
-
fix
|
|
8090
|
+
fix
|
|
8091
|
+
});
|
|
8092
|
+
}
|
|
8093
|
+
}
|
|
8094
|
+
return issues;
|
|
8095
|
+
}
|
|
8096
|
+
function lintLegacyOrganizationComposites(objects) {
|
|
8097
|
+
const issues = [];
|
|
8098
|
+
if (!Array.isArray(objects) || objects.length === 0) return issues;
|
|
8099
|
+
for (let i = 0; i < objects.length; i++) {
|
|
8100
|
+
const obj = objects[i];
|
|
8101
|
+
if (!obj?.name) continue;
|
|
8102
|
+
const tenantColumn = authoredTenantColumn(obj);
|
|
8103
|
+
const declaredIndexes = Array.isArray(obj.indexes) ? obj.indexes : [];
|
|
8104
|
+
for (let j = 0; j < declaredIndexes.length; j++) {
|
|
8105
|
+
const idx = declaredIndexes[j];
|
|
8106
|
+
if (!uniqueDeclared(idx?.unique) || idx.unique === "organization") continue;
|
|
8107
|
+
const cols = Array.isArray(idx?.fields) ? idx.fields.filter((f) => typeof f === "string") : [];
|
|
8108
|
+
if (cols.length < 2) continue;
|
|
8109
|
+
if (!cols.includes(tenantColumn)) continue;
|
|
8110
|
+
const indexLabel = typeof idx?.name === "string" && idx.name.trim() ? ` '${idx.name.trim()}'` : "";
|
|
8111
|
+
const spelling = `\`unique: ${typeof idx.unique === "string" ? `'${idx.unique}'` : idx.unique}\``;
|
|
8112
|
+
const rest = cols.filter((c) => c !== tenantColumn);
|
|
8113
|
+
issues.push({
|
|
8114
|
+
severity: "warning",
|
|
8115
|
+
rule: UNIQUE_LEGACY_ORGANIZATION_COMPOSITE,
|
|
8116
|
+
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.`,
|
|
8117
|
+
path: `objects[${i}].indexes[${j}]`,
|
|
8118
|
+
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.`
|
|
7179
8119
|
});
|
|
7180
8120
|
}
|
|
7181
8121
|
}
|
|
@@ -7324,6 +8264,42 @@ var AUTHORING_RULES = [
|
|
|
7324
8264
|
runtimeTypes: ["flow"],
|
|
7325
8265
|
run: (stack) => validateReferenceIntegrity(stack)
|
|
7326
8266
|
},
|
|
8267
|
+
// ADR-0078 / #5068 — the SDUI component-props gate. `PageComponent.properties`
|
|
8268
|
+
// is `z.record(z.string(), z.unknown())` and ADR-0089 D3a strictness does not
|
|
8269
|
+
// recurse into it, so until this entry existed the 31 typed prop schemas in
|
|
8270
|
+
// `ComponentPropsMap` were parsed by NOTHING (#4001 批 17's `no gate`
|
|
8271
|
+
// verdict): an undeclared or wrongly-typed prop parsed clean, was retained,
|
|
8272
|
+
// and reached objectui's renderer to be ignored there. This dispatches on
|
|
8273
|
+
// `type` and judges the bag; unregistered types are skipped, which is a
|
|
8274
|
+
// required semantic (`type` is an open union — the example corpus authors 87
|
|
8275
|
+
// nodes of 10 types this map does not carry).
|
|
8276
|
+
//
|
|
8277
|
+
// `normalized` for a reason worth stating, since the props bag survives the
|
|
8278
|
+
// Zod parse UNCHANGED and both tiers would otherwise carry the same data: the
|
|
8279
|
+
// ADR-0087 conversion layer runs inside `normalizeStackInput`, so a converted
|
|
8280
|
+
// alias (`page-header-subtitle-alias` rewrites `properties.description` →
|
|
8281
|
+
// `subtitle`) is already canonical here and is never reported as undeclared —
|
|
8282
|
+
// while a schema error elsewhere in the stack cannot take these findings down
|
|
8283
|
+
// with it.
|
|
8284
|
+
//
|
|
8285
|
+
// Advisory, deliberately, and this is the whole shape of #5068's first step:
|
|
8286
|
+
// wiring the parse is the precondition for enforcement, not the enforcement
|
|
8287
|
+
// (#5020, one surface over). The live corpus violates the declarations in two
|
|
8288
|
+
// places that are open contract questions — inline i18n label maps on three
|
|
8289
|
+
// published platform pages (#5728) and the record picker's declared-but-unread
|
|
8290
|
+
// `displayField` (#5775) — so gating today would fail the platform's own pages
|
|
8291
|
+
// to enforce declarations the platform does not keep. The error upgrade is a
|
|
8292
|
+
// separate step, once the warning-period inventory is empty.
|
|
8293
|
+
{
|
|
8294
|
+
name: "validateComponentProps",
|
|
8295
|
+
tier: "advisory",
|
|
8296
|
+
input: "normalized",
|
|
8297
|
+
commands: ALL,
|
|
8298
|
+
source: "packages/lint/src/validate-component-props.ts",
|
|
8299
|
+
surfaces: CLI_ONLY,
|
|
8300
|
+
surfaceReason: RUNTIME_NEEDS_FULL_SNAPSHOT,
|
|
8301
|
+
run: (stack) => validateComponentProps(stack)
|
|
8302
|
+
},
|
|
7327
8303
|
// ADR-0065 — a styled node's responsiveStyles must be scopable (needs an
|
|
7328
8304
|
// `id`), name real CSS properties + design tokens, and carry a `large` base.
|
|
7329
8305
|
{
|
|
@@ -7387,18 +8363,32 @@ var AUTHORING_RULES = [
|
|
|
7387
8363
|
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.',
|
|
7388
8364
|
run: (stack) => validateCapabilityReferences(stack)
|
|
7389
8365
|
},
|
|
7390
|
-
// A
|
|
7391
|
-
//
|
|
7392
|
-
//
|
|
8366
|
+
// A flow that LOOKS armed and never launches — silently. Reads the pre-parse
|
|
8367
|
+
// tier so an author sees what they wrote.
|
|
8368
|
+
//
|
|
8369
|
+
// `gating` since #5762, which reviewed the file's rules as one family and
|
|
8370
|
+
// split them on a single question: is THIS STACK enough to know the flow is
|
|
8371
|
+
// dead? Three rules answer yes and now emit `error` — a `config.timeRelative`
|
|
8372
|
+
// the spec's own `TimeRelativeTriggerSchema` refuses, one the engine's routing
|
|
8373
|
+
// predicate cannot route at all, and a `record-*` triggerType outside the
|
|
8374
|
+
// closed token grammar `triggerTypeToHookEvents` maps. None of those verdicts
|
|
8375
|
+
// can be changed by installing a package, so there is no reading under which
|
|
8376
|
+
// the flow fires. `flow-trigger-unknown-object` deliberately stayed `warning`
|
|
8377
|
+
// (the object may come from another installed package — a hedge this rule
|
|
8378
|
+
// cannot decide), as did `flow-draft-status-ambiguous` (draft flows DO fire;
|
|
8379
|
+
// that one is ambiguity of intent, not a dead flow).
|
|
7393
8380
|
{
|
|
7394
8381
|
name: "validateFlowTriggerReadiness",
|
|
7395
|
-
tier: "
|
|
8382
|
+
tier: "gating",
|
|
7396
8383
|
input: "normalized",
|
|
7397
8384
|
commands: ALL,
|
|
7398
8385
|
source: "packages/lint/src/validate-flow-trigger-readiness.ts",
|
|
7399
|
-
// Runtime publish gate (#4463): the FLOW family.
|
|
7400
|
-
//
|
|
7401
|
-
//
|
|
8386
|
+
// Runtime publish gate (#4463): the FLOW family. Its `error` findings now
|
|
8387
|
+
// REFUSE a `state: 'active'` write (P1 gates on `error` only); the rules that
|
|
8388
|
+
// stayed `warning` keep being logged as advisories. The gate judges a
|
|
8389
|
+
// snapshot whose `flows` holds only the written item and subtracts the
|
|
8390
|
+
// baseline's findings, so this refuses the dead flow's own publish — never
|
|
8391
|
+
// another flow's save on account of a stored one.
|
|
7402
8392
|
surfaces: CLI_AND_RUNTIME,
|
|
7403
8393
|
runtimeTypes: ["flow"],
|
|
7404
8394
|
run: (stack) => validateFlowTriggerReadiness(stack)
|
|
@@ -7609,9 +8599,31 @@ var AUTHORING_RULES = [
|
|
|
7609
8599
|
hint: f.hint
|
|
7610
8600
|
}))
|
|
7611
8601
|
},
|
|
7612
|
-
//
|
|
7613
|
-
//
|
|
7614
|
-
//
|
|
8602
|
+
// ADR-0120 D5a — a declared index with bare `unique: true` states no scope
|
|
8603
|
+
// at all (`unique/unscoped-declared-index` — the #4986 trap). Fires on the
|
|
8604
|
+
// spelling alone, no tenancy inference; 17.x warns, protocol 18 rejects the
|
|
8605
|
+
// spelling (#5082).
|
|
8606
|
+
{
|
|
8607
|
+
name: "lintUnscopedDeclaredIndexes",
|
|
8608
|
+
tier: "advisory",
|
|
8609
|
+
input: "parsed",
|
|
8610
|
+
commands: ["validate", "build"],
|
|
8611
|
+
source: "packages/lint/src/data-model-rules.ts",
|
|
8612
|
+
surfaces: CLI_ONLY,
|
|
8613
|
+
surfaceReason: RUNTIME_OBJECT_WRITES_P2,
|
|
8614
|
+
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.",
|
|
8615
|
+
run: (stack) => lintUnscopedDeclaredIndexes(Array.isArray(stack.objects) ? stack.objects : []).map((f) => ({
|
|
8616
|
+
severity: f.severity === "suggestion" ? "info" : f.severity,
|
|
8617
|
+
rule: f.rule,
|
|
8618
|
+
where: f.path,
|
|
8619
|
+
path: f.path,
|
|
8620
|
+
message: f.message,
|
|
8621
|
+
hint: f.fix ?? ""
|
|
8622
|
+
}))
|
|
8623
|
+
},
|
|
8624
|
+
// #3991 / ADR-0120 D5b — a column carrying BOTH a field-level `unique` and a
|
|
8625
|
+
// single-column declared unique index states two scopes of which at most one
|
|
8626
|
+
// takes effect (`unique/double-declaration`, the four-quadrant matrix).
|
|
7615
8627
|
{
|
|
7616
8628
|
name: "lintUniqueDeclarations",
|
|
7617
8629
|
tier: "advisory",
|
|
@@ -7630,6 +8642,28 @@ var AUTHORING_RULES = [
|
|
|
7630
8642
|
hint: f.fix ?? ""
|
|
7631
8643
|
}))
|
|
7632
8644
|
},
|
|
8645
|
+
// ADR-0120 D5c — a declared unique listing the organization column IS the
|
|
8646
|
+
// hand-written per-organization composite (S6). Advisory nudge toward the
|
|
8647
|
+
// `'organization'` respelling, which is also what closes its NULL hole
|
|
8648
|
+
// (#5030). Never auto-fixed: opting in is a real D4 tightening.
|
|
8649
|
+
{
|
|
8650
|
+
name: "lintLegacyOrganizationComposites",
|
|
8651
|
+
tier: "advisory",
|
|
8652
|
+
input: "parsed",
|
|
8653
|
+
commands: ["validate", "build"],
|
|
8654
|
+
source: "packages/lint/src/data-model-rules.ts",
|
|
8655
|
+
surfaces: CLI_ONLY,
|
|
8656
|
+
surfaceReason: RUNTIME_OBJECT_WRITES_P2,
|
|
8657
|
+
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.",
|
|
8658
|
+
run: (stack) => lintLegacyOrganizationComposites(Array.isArray(stack.objects) ? stack.objects : []).map((f) => ({
|
|
8659
|
+
severity: f.severity === "suggestion" ? "info" : f.severity,
|
|
8660
|
+
rule: f.rule,
|
|
8661
|
+
where: f.path,
|
|
8662
|
+
path: f.path,
|
|
8663
|
+
message: f.message,
|
|
8664
|
+
hint: f.fix ?? ""
|
|
8665
|
+
}))
|
|
8666
|
+
},
|
|
7633
8667
|
// ADR-0090 D7 — the security-domain publish linter. Every `error` rule mirrors
|
|
7634
8668
|
// a runtime enforcement point (fail-closed OWD default, canonical enum, anchor
|
|
7635
8669
|
// binding gate, vocabulary freeze), moving the failure from a runtime deny to
|
|
@@ -7657,6 +8691,88 @@ var AUTHORING_RULES = [
|
|
|
7657
8691
|
surfaces: CLI_ONLY,
|
|
7658
8692
|
surfaceReason: RUNTIME_NEEDS_FULL_SNAPSHOT,
|
|
7659
8693
|
run: (stack) => validateOrgAxisRedLines(stack)
|
|
8694
|
+
},
|
|
8695
|
+
// #4698 — the "declared but never read" gate, for the one surface where the
|
|
8696
|
+
// predicate is EXACT rather than inferred. A sharing rule's `condition` has a
|
|
8697
|
+
// single runtime consumer (`bootstrapDeclaredSharingRules`) whose only use of
|
|
8698
|
+
// the key is `compileCelToFilter(condition, { variables: {} })`; a condition
|
|
8699
|
+
// that does not lower means the rule is SKIPPED at boot, so the grant is
|
|
8700
|
+
// declared and does not exist. The lint calls that same compiler, from the
|
|
8701
|
+
// same package, with the same options — the verdict cannot drift from the
|
|
8702
|
+
// consumer's. Gating for the ADR-0078 reason `SharingRuleSchema`'s own
|
|
8703
|
+
// docblock states: the whole authorable surface is enforced, and this was the
|
|
8704
|
+
// one field where that sentence was not yet true.
|
|
8705
|
+
{
|
|
8706
|
+
name: "validateSharingRuleEnforceability",
|
|
8707
|
+
tier: "gating",
|
|
8708
|
+
input: "parsed",
|
|
8709
|
+
commands: ALL,
|
|
8710
|
+
source: "packages/lint/src/validate-sharing-rule-enforceability.ts",
|
|
8711
|
+
surfaces: CLI_ONLY,
|
|
8712
|
+
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.",
|
|
8713
|
+
run: (stack) => validateSharingRuleEnforceability(stack)
|
|
8714
|
+
},
|
|
8715
|
+
// #4983 — the sibling surface of the rule above, and ADR-0056 D4's gate,
|
|
8716
|
+
// which had never been wired to anything: `isSupportedRlsExpression` existed
|
|
8717
|
+
// solely so an authoring command could reject a predicate the runtime drops,
|
|
8718
|
+
// and no authoring command called it. An unlowerable
|
|
8719
|
+
// `rowLevelSecurity[].using` is DROPPED by `RLSCompiler` and — when it is the
|
|
8720
|
+
// only applicable policy — replaced by `RLS_DENY_FILTER`, so the policy reads
|
|
8721
|
+
// as an authorization and behaves as a blanket refusal. Same construction as
|
|
8722
|
+
// the sharing-rule entry: the verdict is the runtime's own function, reached
|
|
8723
|
+
// through `@objectstack/formula` (where #4983 hoisted it), never a model of it.
|
|
8724
|
+
{
|
|
8725
|
+
name: "validateRlsPredicateEnforceability",
|
|
8726
|
+
tier: "gating",
|
|
8727
|
+
input: "parsed",
|
|
8728
|
+
commands: ALL,
|
|
8729
|
+
source: "packages/lint/src/validate-rls-predicate-enforceability.ts",
|
|
8730
|
+
surfaces: CLI_ONLY,
|
|
8731
|
+
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.",
|
|
8732
|
+
run: (stack) => validateRlsPredicateEnforceability(stack)
|
|
8733
|
+
},
|
|
8734
|
+
// #4762 — the same "declared but enforces nothing" question, for the two
|
|
8735
|
+
// STATIC artifacts an object validation rule carries. A `format` rule's
|
|
8736
|
+
// `regex` that `new RegExp(...)` throws on, and a `json_schema` rule's schema
|
|
8737
|
+
// ajv cannot compile, are both logged and SKIPPED on the write path
|
|
8738
|
+
// (`rule-validator.ts`), so the rule ships, lists, and protects nothing.
|
|
8739
|
+
// Neither needs a record to judge, so the authoring door is the right one:
|
|
8740
|
+
// rejecting a broken regex at RUNTIME instead would reject every write
|
|
8741
|
+
// touching that field for as long as the metadata is deployed (#4762's own
|
|
8742
|
+
// analysis — the runtime-backstop question stays open for the maintainer).
|
|
8743
|
+
// Gating for the `lint-flow-patterns.ts` bar: no reading of the metadata
|
|
8744
|
+
// behaves as written, because the rule does not run at all.
|
|
8745
|
+
{
|
|
8746
|
+
name: "validateRuleCompilability",
|
|
8747
|
+
tier: "gating",
|
|
8748
|
+
input: "parsed",
|
|
8749
|
+
commands: ALL,
|
|
8750
|
+
source: "packages/lint/src/validate-rule-compilability.ts",
|
|
8751
|
+
surfaces: CLI_ONLY,
|
|
8752
|
+
surfaceReason: RUNTIME_OBJECT_WRITES_P2,
|
|
8753
|
+
run: (stack) => validateRuleCompilability(stack)
|
|
8754
|
+
},
|
|
8755
|
+
// #5178 — the residual half of #5029, which registering `ajv-formats` does
|
|
8756
|
+
// NOT close: under `strict: false` a MISSPELLED format name (`emial`) is
|
|
8757
|
+
// logged once and DROPPED, so the rule compiles, ships, runs on every write
|
|
8758
|
+
// and enforces nothing for the keyword its author wrote — and the record is
|
|
8759
|
+
// accepted, which is the silent direction. Deliberately its own entry rather
|
|
8760
|
+
// than a third finding inside the rule above: that one's whole contract is
|
|
8761
|
+
// compiling in the runtime's exact environment, and a typo'd format compiles
|
|
8762
|
+
// there. This judges the format NAME against the registered set (enumerated
|
|
8763
|
+
// from the same ajv instance, never a hardcoded list) and compiles nothing,
|
|
8764
|
+
// so the #4762/#5029 compile parity is untouched — a judgement beside the
|
|
8765
|
+
// compile, not a divergent compile. Gating for the `lint-flow-patterns.ts`
|
|
8766
|
+
// bar: no reading of the metadata behaves as written.
|
|
8767
|
+
{
|
|
8768
|
+
name: "validateRuleSchemaFormats",
|
|
8769
|
+
tier: "gating",
|
|
8770
|
+
input: "parsed",
|
|
8771
|
+
commands: ALL,
|
|
8772
|
+
source: "packages/lint/src/validate-rule-schema-formats.ts",
|
|
8773
|
+
surfaces: CLI_ONLY,
|
|
8774
|
+
surfaceReason: RUNTIME_OBJECT_WRITES_P2,
|
|
8775
|
+
run: (stack) => validateRuleSchemaFormats(stack)
|
|
7660
8776
|
}
|
|
7661
8777
|
];
|
|
7662
8778
|
|