@objectstack/lint 16.0.0-rc.1 → 16.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -14,8 +14,11 @@ It never depends on a runtime and is never bundled into a frontend.
14
14
  ## API
15
15
 
16
16
  - `validateWidgetBindings(stack)` — dashboard widget → dataset → measure/dimension
17
- reference integrity, chart-config bindings, and measure-aggregation coherence
18
- (e.g. SUM of a `percent` field is meaningless).
17
+ reference integrity, chart-config bindings, measure-aggregation coherence
18
+ (e.g. SUM of a `percent` field is meaningless), and dashboard-level filter
19
+ field existence — every `dateRange` / `globalFilters[]` field (after any
20
+ `filterBindings` re-target) must exist on each bound widget's object, else the
21
+ broadcast filter emits invalid SQL and the widget crashes at render (#3365).
19
22
  - `validateStackExpressions(stack)` — CEL/predicate validity for field
20
23
  conditionals, sharing rules, action `visible`/`disabled`, lifecycle hooks
21
24
  (ADR-0032).
package/dist/index.cjs CHANGED
@@ -27,6 +27,9 @@ __export(index_exports, {
27
27
  CAPABILITY_REFERENCE_UNKNOWN: () => CAPABILITY_REFERENCE_UNKNOWN,
28
28
  CHART_CONFIG_MISSING: () => CHART_CONFIG_MISSING,
29
29
  CHART_FIELD_UNKNOWN: () => CHART_FIELD_UNKNOWN,
30
+ DASHBOARD_ACTION_ROUTE_UNRESOLVED: () => DASHBOARD_ACTION_ROUTE_UNRESOLVED,
31
+ DASHBOARD_ACTION_TARGET_UNDEFINED: () => DASHBOARD_ACTION_TARGET_UNDEFINED,
32
+ DASHBOARD_FILTER_FIELD_UNKNOWN: () => DASHBOARD_FILTER_FIELD_UNKNOWN,
30
33
  FIELD_GROUP_EMPTY: () => FIELD_GROUP_EMPTY,
31
34
  FIELD_GROUP_UNDECLARED: () => FIELD_GROUP_UNDECLARED,
32
35
  FLOW_DRAFT_STATUS_AMBIGUOUS: () => FLOW_DRAFT_STATUS_AMBIGUOUS,
@@ -66,6 +69,7 @@ __export(index_exports, {
66
69
  diffAccessMatrix: () => diffAccessMatrix,
67
70
  validateApprovalApprovers: () => validateApprovalApprovers,
68
71
  validateCapabilityReferences: () => validateCapabilityReferences,
72
+ validateDashboardActionRefs: () => validateDashboardActionRefs,
69
73
  validateFlowTriggerReadiness: () => validateFlowTriggerReadiness,
70
74
  validateFormLayout: () => validateFormLayout,
71
75
  validateJsxPages: () => validateJsxPages,
@@ -95,6 +99,7 @@ var TABLE_COUNT_ONLY = "table-count-only";
95
99
  var MEASURE_AGGREGATE_INCOHERENT = "measure-aggregate-incoherent";
96
100
  var WIDGET_LEGACY_ANALYTICS_SHAPE = "widget-legacy-analytics-shape";
97
101
  var WIDGET_LEGACY_ANALYTICS_UNRENDERABLE = "widget-legacy-analytics-unrenderable";
102
+ var DASHBOARD_FILTER_FIELD_UNKNOWN = "dashboard-filter-field-unknown";
98
103
  var LEGACY_ANALYTICS_KEYS = [
99
104
  "categoryField",
100
105
  "valueField",
@@ -172,6 +177,47 @@ function list(names) {
172
177
  const arr = [...names];
173
178
  return arr.length > 0 ? arr.join(", ") : "(none)";
174
179
  }
180
+ var DATE_RANGE_FILTER_NAME = "dateRange";
181
+ var DATE_RANGE_DEFAULT_FIELD = "created_at";
182
+ var SYSTEM_FIELDS = /* @__PURE__ */ new Set([
183
+ "id",
184
+ "created_at",
185
+ "created_by",
186
+ "updated_at",
187
+ "updated_by",
188
+ "owner_id",
189
+ "organization_id",
190
+ "tenant_id",
191
+ "user_id",
192
+ "deleted_at"
193
+ ]);
194
+ function dashboardFilterDefs(dash) {
195
+ const byName = /* @__PURE__ */ new Map();
196
+ const dateRange = dash.dateRange;
197
+ if (dateRange && typeof dateRange === "object") {
198
+ const declared = dateRange.field;
199
+ const field = typeof declared === "string" && declared ? declared : DATE_RANGE_DEFAULT_FIELD;
200
+ byName.set(DATE_RANGE_FILTER_NAME, { name: DATE_RANGE_FILTER_NAME, field });
201
+ }
202
+ for (const f of asArray(dash.globalFilters)) {
203
+ if (typeof f.field !== "string" || !f.field) continue;
204
+ const name = typeof f.name === "string" && f.name ? f.name : f.field;
205
+ const targetWidgets = Array.isArray(f.targetWidgets) ? f.targetWidgets.filter((w) => typeof w === "string") : void 0;
206
+ byName.set(name, { name, field: f.field, targetWidgets });
207
+ }
208
+ return [...byName.values()];
209
+ }
210
+ function effectiveFilterField(widget, def) {
211
+ const bindings = widget.filterBindings;
212
+ const binding = bindings && typeof bindings === "object" ? bindings[def.name] : void 0;
213
+ if (binding === false) return void 0;
214
+ if (typeof binding === "string" && binding) return { field: binding, explicit: true };
215
+ if (def.targetWidgets && def.targetWidgets.length > 0) {
216
+ const id = typeof widget.id === "string" ? widget.id : void 0;
217
+ if (!id || !def.targetWidgets.includes(id)) return void 0;
218
+ }
219
+ return { field: def.field, explicit: false };
220
+ }
175
221
  function validateWidgetBindings(stack) {
176
222
  const findings = [];
177
223
  const datasets = /* @__PURE__ */ new Map();
@@ -216,6 +262,7 @@ function validateWidgetBindings(stack) {
216
262
  const dash = dashboards[i];
217
263
  const dashName = typeof dash.name === "string" ? dash.name : `(dashboard ${i})`;
218
264
  const widgets = Array.isArray(dash.widgets) ? dash.widgets : [];
265
+ const dashFilterDefs = dashboardFilterDefs(dash);
219
266
  for (let j = 0; j < widgets.length; j++) {
220
267
  const w = widgets[j];
221
268
  const widgetId = typeof w.id === "string" ? w.id : `(widget ${j})`;
@@ -260,6 +307,25 @@ function validateWidgetBindings(stack) {
260
307
  });
261
308
  }
262
309
  if (!dataset) continue;
310
+ if (dashFilterDefs.length > 0) {
311
+ const datasetObject = typeof dataset.object === "string" ? dataset.object : void 0;
312
+ const objectFields = datasetObject ? objectFieldTypes.get(datasetObject) : void 0;
313
+ if (objectFields) {
314
+ for (const def of dashFilterDefs) {
315
+ const eff = effectiveFilterField(w, def);
316
+ if (!eff) continue;
317
+ const field = eff.field;
318
+ if (field.includes(".")) continue;
319
+ if (objectFields.has(field) || SYSTEM_FIELDS.has(field)) continue;
320
+ push({
321
+ severity: "error",
322
+ rule: DASHBOARD_FILTER_FIELD_UNKNOWN,
323
+ message: eff.explicit ? `binds dashboard filter \`${def.name}\` to field \`${field}\` (via filterBindings), but object \`${datasetObject}\` (dataset "${dsName}") has no field \`${field}\`.` : `inherits dashboard filter \`${def.name}(${field})\`, but object \`${datasetObject}\` (dataset "${dsName}") has no field \`${field}\`.`,
324
+ hint: eff.explicit ? `Point filterBindings: { ${def.name}: '<field>' } at a field that exists on \`${datasetObject}\`, or opt out with filterBindings: { ${def.name}: false }.${suggest(field, objectFields.keys())} Object fields: ${list(objectFields.keys())}.` : `Set filterBindings: { ${def.name}: false } on this widget to opt out, or re-target to an existing field with filterBindings: { ${def.name}: '<field>' }.${suggest(field, objectFields.keys())} Object fields: ${list(objectFields.keys())}.`
325
+ });
326
+ }
327
+ }
328
+ }
263
329
  const dimensionNames = /* @__PURE__ */ new Set();
264
330
  for (const d of asArray(dataset.dimensions)) {
265
331
  if (typeof d.name === "string") dimensionNames.add(d.name);
@@ -2115,7 +2181,9 @@ function validateSecurityPosture(stack, opts) {
2115
2181
  return findings;
2116
2182
  }
2117
2183
 
2118
- // src/build-access-matrix.ts
2184
+ // src/validate-dashboard-action-refs.ts
2185
+ var DASHBOARD_ACTION_TARGET_UNDEFINED = "dashboard-action-target-undefined";
2186
+ var DASHBOARD_ACTION_ROUTE_UNRESOLVED = "dashboard-action-route-unresolved";
2119
2187
  function asArray17(v) {
2120
2188
  if (Array.isArray(v)) return v;
2121
2189
  if (v && typeof v === "object") {
@@ -2123,17 +2191,166 @@ function asArray17(v) {
2123
2191
  }
2124
2192
  return [];
2125
2193
  }
2194
+ function strName(v) {
2195
+ return typeof v === "string" && v.length > 0 ? v : void 0;
2196
+ }
2197
+ var MODAL_VERB_RE = /^(?:create|new|add|edit|update)_(.+)$/;
2198
+ var URL_COLLECTION_TO_STACK_KEY = {
2199
+ object: "objects",
2200
+ objects: "objects",
2201
+ report: "reports",
2202
+ reports: "reports",
2203
+ dashboard: "dashboards",
2204
+ dashboards: "dashboards",
2205
+ page: "pages",
2206
+ pages: "pages",
2207
+ view: "views",
2208
+ views: "views"
2209
+ };
2210
+ function viewContainerName(item) {
2211
+ return strName(item.name) ?? strName(item.id) ?? strName(item.object) ?? strName(item.list?.data && item.list.data.object) ?? strName(item.form?.data && item.form.data.object);
2212
+ }
2213
+ function collectKnownTargets(stack) {
2214
+ const actions = /* @__PURE__ */ new Set();
2215
+ const objects = /* @__PURE__ */ new Set();
2216
+ const reports = /* @__PURE__ */ new Set();
2217
+ const dashboards = /* @__PURE__ */ new Set();
2218
+ const pages = /* @__PURE__ */ new Set();
2219
+ const views = /* @__PURE__ */ new Set();
2220
+ const collectNames = (v, into, name) => {
2221
+ for (const item of asArray17(v)) {
2222
+ if (!item || typeof item !== "object") continue;
2223
+ const n = name(item);
2224
+ if (n) into.add(n);
2225
+ }
2226
+ };
2227
+ collectNames(stack.actions, actions, (a) => strName(a.name));
2228
+ for (const obj of asArray17(stack.objects)) {
2229
+ if (!obj || typeof obj !== "object") continue;
2230
+ const n = strName(obj.name);
2231
+ if (n) objects.add(n);
2232
+ collectNames(obj.actions, actions, (a) => strName(a.name));
2233
+ }
2234
+ collectNames(stack.reports, reports, (r) => strName(r.name));
2235
+ collectNames(stack.dashboards, dashboards, (d) => strName(d.name));
2236
+ collectNames(stack.pages, pages, (p) => strName(p.name));
2237
+ collectNames(stack.views, views, viewContainerName);
2238
+ for (const o of objects) views.add(o);
2239
+ return { actions, objects, reports, dashboards, pages, views };
2240
+ }
2241
+ function resolveActionTarget(actionType, target, known) {
2242
+ if (known.actions.has(target)) return true;
2243
+ if (actionType === "modal") {
2244
+ if (known.objects.has(target)) return true;
2245
+ const m = MODAL_VERB_RE.exec(target);
2246
+ if (m && known.objects.has(m[1])) return true;
2247
+ }
2248
+ return false;
2249
+ }
2250
+ function resolveUrlRoute(target, known) {
2251
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(target) || target.startsWith("//")) return null;
2252
+ if (target.includes("${")) return null;
2253
+ if (!target.startsWith("/")) return null;
2254
+ const pathPart = target.split(/[?#]/, 1)[0];
2255
+ const segments = pathPart.split("/").filter(Boolean);
2256
+ for (let i = 0; i < segments.length - 1; i++) {
2257
+ const stackKey = URL_COLLECTION_TO_STACK_KEY[segments[i]];
2258
+ if (!stackKey) continue;
2259
+ const name = segments[i + 1];
2260
+ if (known[stackKey].has(name)) return void 0;
2261
+ return { collection: segments[i], name };
2262
+ }
2263
+ return null;
2264
+ }
2265
+ function validateDashboardActionRefs(stack) {
2266
+ const findings = [];
2267
+ if (!stack || typeof stack !== "object") return findings;
2268
+ const dashboards = asArray17(stack.dashboards);
2269
+ if (dashboards.length === 0) return findings;
2270
+ const known = collectKnownTargets(stack);
2271
+ const checkOne = (action, where, path) => {
2272
+ const target = strName(action.actionUrl);
2273
+ if (!target) return;
2274
+ if (target.includes("${")) return;
2275
+ const actionType = strName(action.actionType) ?? "url";
2276
+ if (actionType === "script" || actionType === "modal") {
2277
+ if (resolveActionTarget(actionType, target, known)) return;
2278
+ const kindWord = actionType === "script" ? "script" : "modal";
2279
+ findings.push({
2280
+ severity: "error",
2281
+ rule: DASHBOARD_ACTION_TARGET_UNDEFINED,
2282
+ where,
2283
+ path,
2284
+ message: `${kindWord} action target "${target}" resolves to no defined action` + (actionType === "modal" ? " or object" : "") + `. The button renders but does nothing when clicked \u2014 a dangling reference the runtime cannot dispatch (ADR-0049: a declared reference must resolve).`,
2285
+ hint: actionType === "modal" ? `Define an action named "${target}" (stack.actions or the object's actions), use the "<verb>_<object>" convention against a real object (e.g. "create_<object>"), point actionUrl at an existing object, or remove the button.` : `Define a script action named "${target}" (stack.actions or the object's actions) with an inline body or a registered handler, or remove the button.`
2286
+ });
2287
+ return;
2288
+ }
2289
+ if (actionType === "url") {
2290
+ const route = resolveUrlRoute(target, known);
2291
+ if (!route) return;
2292
+ findings.push({
2293
+ severity: "warning",
2294
+ rule: DASHBOARD_ACTION_ROUTE_UNRESOLVED,
2295
+ where,
2296
+ path,
2297
+ message: `url action target "${target}" points at ${route.collection}/${route.name}, but no ${route.collection.replace(/s$/, "")} named "${route.name}" is registered in this stack \u2014 the button likely navigates to a dead route.`,
2298
+ hint: `Check the path for a typo, define the referenced ${route.collection.replace(/s$/, "")}, or ignore this if the route is served by another installed package or a host/console route.`
2299
+ });
2300
+ return;
2301
+ }
2302
+ };
2303
+ for (let di = 0; di < dashboards.length; di++) {
2304
+ const dash = dashboards[di];
2305
+ if (!dash || typeof dash !== "object") continue;
2306
+ const dashName = strName(dash.name) ?? `(dashboard ${di})`;
2307
+ const dashPath = `dashboards[${di}]`;
2308
+ const headerActions = asArray17(dash.header?.actions);
2309
+ for (let ai = 0; ai < headerActions.length; ai++) {
2310
+ const action = headerActions[ai];
2311
+ if (!action || typeof action !== "object") continue;
2312
+ const label = strName(action.label) ?? strName(action.actionUrl) ?? `#${ai}`;
2313
+ checkOne(
2314
+ action,
2315
+ `dashboard "${dashName}" \xB7 header action "${label}"`,
2316
+ `${dashPath}.header.actions[${ai}].actionUrl`
2317
+ );
2318
+ }
2319
+ const widgets = asArray17(dash.widgets);
2320
+ for (let wi = 0; wi < widgets.length; wi++) {
2321
+ const widget = widgets[wi];
2322
+ if (!widget || typeof widget !== "object") continue;
2323
+ if (!strName(widget.actionUrl)) continue;
2324
+ const widgetId = strName(widget.id) ?? `#${wi}`;
2325
+ checkOne(
2326
+ { actionType: widget.actionType, actionUrl: widget.actionUrl },
2327
+ `dashboard "${dashName}" \xB7 widget "${widgetId}" action`,
2328
+ `${dashPath}.widgets[${wi}].actionUrl`
2329
+ );
2330
+ }
2331
+ }
2332
+ return findings;
2333
+ }
2334
+
2335
+ // src/build-access-matrix.ts
2336
+ function asArray18(v) {
2337
+ if (Array.isArray(v)) return v;
2338
+ if (v && typeof v === "object") {
2339
+ return Object.entries(v).map(([name, def]) => ({ name, ...def }));
2340
+ }
2341
+ return [];
2342
+ }
2126
2343
  function buildAccessMatrix(stack) {
2127
2344
  const entries = [];
2128
2345
  if (!stack || typeof stack !== "object") return { version: 1, entries };
2129
2346
  const owdByObject = /* @__PURE__ */ new Map();
2130
- for (const obj of asArray17(stack.objects)) {
2347
+ for (const obj of asArray18(stack.objects)) {
2131
2348
  const name = typeof obj.name === "string" ? obj.name : "";
2132
2349
  if (!name) continue;
2133
2350
  const owd = obj.sharingModel ?? obj.security?.sharingModel;
2134
2351
  if (typeof owd === "string") owdByObject.set(name, owd);
2135
2352
  }
2136
- for (const ps of asArray17(stack.permissions)) {
2353
+ for (const ps of asArray18(stack.permissions)) {
2137
2354
  const psName = typeof ps.name === "string" ? ps.name : "";
2138
2355
  if (!psName) continue;
2139
2356
  const objects = ps.objects && typeof ps.objects === "object" ? ps.objects : {};
@@ -2212,6 +2429,9 @@ function diffAccessMatrix(before, after) {
2212
2429
  CAPABILITY_REFERENCE_UNKNOWN,
2213
2430
  CHART_CONFIG_MISSING,
2214
2431
  CHART_FIELD_UNKNOWN,
2432
+ DASHBOARD_ACTION_ROUTE_UNRESOLVED,
2433
+ DASHBOARD_ACTION_TARGET_UNDEFINED,
2434
+ DASHBOARD_FILTER_FIELD_UNKNOWN,
2215
2435
  FIELD_GROUP_EMPTY,
2216
2436
  FIELD_GROUP_UNDECLARED,
2217
2437
  FLOW_DRAFT_STATUS_AMBIGUOUS,
@@ -2251,6 +2471,7 @@ function diffAccessMatrix(before, after) {
2251
2471
  diffAccessMatrix,
2252
2472
  validateApprovalApprovers,
2253
2473
  validateCapabilityReferences,
2474
+ validateDashboardActionRefs,
2254
2475
  validateFlowTriggerReadiness,
2255
2476
  validateFormLayout,
2256
2477
  validateJsxPages,