From 0f214dce9c2e10f2b732b13311b74f0900a72ef6 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 11:04:41 +0000 Subject: [PATCH 1/4] feat(lint): warn when a filter/binding field resolves to an unprovisioned injected anchor (#8340) --- packages/lint/src/index.ts | 9 +- packages/lint/src/system-fields.ts | 87 +++++++++++++++++++ .../lint/src/validate-flow-template-paths.ts | 56 +++++++++++- .../lint/src/validate-page-field-bindings.ts | 61 ++++++++++++- .../lint/src/validate-react-page-props.ts | 60 +++++++++++-- packages/lint/src/validate-widget-bindings.ts | 55 +++++++++++- 6 files changed, 312 insertions(+), 16 deletions(-) diff --git a/packages/lint/src/index.ts b/packages/lint/src/index.ts index f39ebc168f..c42cfb5215 100644 --- a/packages/lint/src/index.ts +++ b/packages/lint/src/index.ts @@ -23,6 +23,7 @@ export { WIDGET_LEGACY_ANALYTICS_SHAPE, WIDGET_LEGACY_ANALYTICS_UNRENDERABLE, DASHBOARD_FILTER_FIELD_UNKNOWN, + DASHBOARD_FILTER_FIELD_UNPROVISIONED, } from './validate-widget-bindings.js'; export type { WidgetBindingFinding, WidgetBindingSeverity } from './validate-widget-bindings.js'; @@ -105,6 +106,7 @@ export { validateFlowTemplatePaths, FLOW_TEMPLATE_UNKNOWN_FIELD, FLOW_TEMPLATE_LOOKUP_TRAVERSAL, + FLOW_TEMPLATE_FIELD_UNPROVISIONED, } from './validate-flow-template-paths.js'; export type { FlowTemplatePathFinding, @@ -140,6 +142,7 @@ export type { ReactPageFinding, ReactPageSeverity } from './validate-react-pages export { validateReactPageProps, REACT_CHART_FIELD_UNKNOWN, + REACT_CHART_FIELD_UNPROVISIONED, REACT_CHART_AGGREGATE_INVALID, REACT_CHART_AXIS_UNKNOWN, REACT_CHART_DRILLDOWN_INVALID, @@ -348,7 +351,11 @@ export type { ActionNameRefFinding, ActionNameRefSeverity } from './validate-act export { validateActionLocations, ACTION_NO_PLACEMENT } from './validate-action-locations.js'; export type { ActionLocationsFinding, ActionLocationsSeverity } from './validate-action-locations.js'; -export { validatePageFieldBindings, PAGE_FIELD_UNKNOWN } from './validate-page-field-bindings.js'; +export { + validatePageFieldBindings, + PAGE_FIELD_UNKNOWN, + PAGE_FIELD_UNPROVISIONED, +} from './validate-page-field-bindings.js'; export type { PageFieldFinding, PageFieldSeverity } from './validate-page-field-bindings.js'; export { diff --git a/packages/lint/src/system-fields.ts b/packages/lint/src/system-fields.ts index 3b95fc58ea..692f041eb7 100644 --- a/packages/lint/src/system-fields.ts +++ b/packages/lint/src/system-fields.ts @@ -100,3 +100,90 @@ export function injectedColumnsFor(objectDef: unknown): ReadonlySet { export function unprovisionedInjectedColumnsFor(objectDef: unknown): ReadonlySet { return new Set(unprovisionedInjectedColumns(objectDef)); } + +/** Coerce an array-or-name-keyed-map collection to an array (name injected). */ +function objectDefsOf(stack: unknown): Record[] { + if (!stack || typeof stack !== 'object') return []; + const objects = (stack as { objects?: unknown }).objects; + if (Array.isArray(objects)) return objects.filter((o): o is Record => !!o && typeof o === 'object'); + if (objects && typeof objects === 'object') { + return Object.entries(objects as Record) + .filter(([, def]) => !!def && typeof def === 'object') + .map(([name, def]) => ({ name, ...(def as Record) })); + } + return []; +} + +/** + * `objectName -> its unprovisioned injected anchors`, over a whole stack + * (#8340) — the shape a rule that resolves references PER STACK consumes. + * + * Only non-empty entries are stored, so `get(name)` is `undefined` for every + * ordinary (platform-provisioned) object and the lookup doubles as the + * "nothing to say here" fast path — the same shape `validate-expressions.ts` + * built inline for #8116. + * + * ⛔ Not a replacement for {@link SYSTEM_FIELDS} at the call sites that consume + * it. The blanket union answers "could this name be a system column anywhere", + * which is the right question for a rule deciding whether to FLAG a name; this + * index answers "does this object's registered anchor have storage", which is a + * question about a name the first one already decided NOT to flag. The four + * filter/binding rules ask both: membership still governs the existence + * error, and this governs an additional warning on the path where the existence + * check stays silent. + */ +export function indexUnprovisionedAnchors(stack: unknown): ReadonlyMap> { + const index = new Map>(); + for (const obj of objectDefsOf(stack)) { + const name = typeof obj.name === 'string' && obj.name.length > 0 ? obj.name : undefined; + if (!name) continue; + const anchors = unprovisionedInjectedColumnsFor(obj); + if (anchors.size > 0) index.set(name, anchors); + } + return index; +} + +/** + * The CAUSE clause every unprovisioned-anchor diagnostic in this package + * states — one sentence, one wording, across the four filter/binding rules + * #8340 wired (`validate-widget-bindings`, `validate-react-page-props`, + * `validate-page-field-bindings`, `validate-flow-template-paths`). + * + * Shared rather than re-typed because the sentence is the finding's whole + * evidentiary content: it names the column, the object, WHY the platform + * registered an anchor it did not provision (ADR-0015 federation), and it is + * the part an author checks against their remote schema. A rule that re-words + * it drifts from the others and, worse, from the runtime guards + * (#7833 / #7859 / #7858) whose verdict it reports. Each call site supplies its + * own POSITION prefix and its own CONSEQUENCE clause — those genuinely differ + * per surface (a filter degrades to constant-false, a display binding renders + * blank, an interpolated flow token drops the condition outright). + * + * #8116's two originals (`warnUnprovisionedAnchors` in `validate-expressions.ts` + * and `unprovisionedPointer` in `validate-semantic-roles.ts`) still carry their + * own copies of this sentence; they are the convergence target when either is + * next touched, and were left alone here because #8340's file surface stops at + * the filter/binding rules. + */ +export function unprovisionedAnchorCause(objectName: string, field: string): string { + return ( + `'${field}' is an injected system column with NO storage behind it: '${objectName}' is an ` + + `external object (ADR-0015), so the remote database owns its schema and the platform ` + + `registers this anchor without provisioning a column` + ); +} + +/** + * The FIX clause paired with {@link unprovisionedAnchorCause} — the two ways + * out, in the order an author should consider them: vouch for the remote column + * by declaring it, or stop referencing an anchor this object does not have. + */ +export function unprovisionedAnchorHint(objectName: string, field: string): string { + return ( + `If the remote table really carries '${field}', declare it in ${objectName}'s own fields ` + + `(mapped through the external binding's columnMap) so the reference resolves to a column ` + + `you vouch for; otherwise drop the reference, or opt the object out of the injection ` + + `(\`ownership: 'none'\` for the ownership anchors, \`systemFields: { audit: false }\` for ` + + `the audit family).` + ); +} diff --git a/packages/lint/src/validate-flow-template-paths.ts b/packages/lint/src/validate-flow-template-paths.ts index 0f32c45bb0..6bed8ab2fe 100644 --- a/packages/lint/src/validate-flow-template-paths.ts +++ b/packages/lint/src/validate-flow-template-paths.ts @@ -19,6 +19,18 @@ // note and #1872). So `record.account.name` walks `.name` on a string id // and yields '' silently. Not resolved today; tracked on #3426. // +// 3. `{record.}` on an ADR-0015 `external` trigger object +// (#8340) — the head RESOLVES (it is a registry-injected system column, so +// case 1 rightly stays silent), but the remote database owns the table and +// the platform provisions no storage behind the anchor. The value is empty +// on every run, so the token renders '' with the same silence — reaching +// case 1's failure by a route case 1 structurally cannot see, because it +// judges the name against the object-independent `SYSTEM_FIELDS` union. +// Reported as a WARNING in both positions, filter included: the existence +// question has a closed oracle (the field is absent or it is not) and the +// provenance one does not — this pass knows the platform stores nothing, +// not what the deployment's remote schema holds (#8116's reasoning). +// // A pure `(stack) => Finding[]` rule (ADR-0019), run from `os validate` and // reusable by AI authoring. // @@ -55,7 +67,12 @@ // - Structured scalar heads (`json` / `composite` / `repeater` / `record`) may // carry legitimate sub-paths — their `.` access is left alone. -import { SYSTEM_FIELDS } from './system-fields.js'; +import { + SYSTEM_FIELDS, + unprovisionedInjectedColumnsFor, + unprovisionedAnchorCause, + unprovisionedAnchorHint, +} from './system-fields.js'; import { walkFlowNodes } from './flow-walk.js'; export type FlowTemplatePathSeverity = 'error' | 'warning'; @@ -74,6 +91,7 @@ export interface FlowTemplatePathFinding { // Rule ids (registry entries). export const FLOW_TEMPLATE_UNKNOWN_FIELD = 'flow-template-unknown-field'; export const FLOW_TEMPLATE_LOOKUP_TRAVERSAL = 'flow-template-lookup-traversal'; +export const FLOW_TEMPLATE_FIELD_UNPROVISIONED = 'flow-template-field-unprovisioned'; type AnyRec = Record; @@ -301,6 +319,11 @@ export function validateFlowTemplatePaths(stack: AnyRec): FlowTemplatePathFindin if (!obj) return; const fieldTypes = fieldTypesOf(obj); + // [#8340] The injected anchors THIS trigger object registers with no + // storage behind them. Read off the object def already resolved above — + // there is no second lookup and no stack-level index, because this rule + // judges every token of a flow against ONE object (the trigger's). + const unprovisionedAnchors = unprovisionedInjectedColumnsFor(obj); const expandSet = declaredExpandOf(flow); // Every node, INCLUDING those nested in try_catch / loop / parallel regions @@ -335,6 +358,7 @@ export function validateFlowTemplatePaths(stack: AnyRec): FlowTemplatePathFindin // Dedupe references so one repeated typo yields one finding per node. const seenUnknown = new Set(); const seenTraversal = new Set(); + const seenUnprovisioned = new Set(); for (const leaf of leaves) { const inFilter = leaf.inFilter; @@ -346,6 +370,36 @@ export function validateFlowTemplatePaths(stack: AnyRec): FlowTemplatePathFindin const isKnown = fieldTypes.has(head) || IMPLICIT_HEADS.has(head); + // [#8340] The head RESOLVES — `IMPLICIT_HEADS` keeps owning that + // decision, exactly as before — but on an ADR-0015 `external` trigger + // object the platform registers this anchor and stores nothing in it, + // so the interpolator reads an empty value from the flow record. In a + // filter position that is #3810's own failure reached by a second + // route: the token erases the authored condition and `resolveNodeFilter` + // refuses the node at run time. Warning, not error, on both positions: + // unlike a typo (a closed oracle — the field is simply absent) this + // pass cannot see whether the remote schema resolves the column. + if (unprovisionedAnchors.has(head)) { + if (!seenUnprovisioned.has(head)) { + seenUnprovisioned.add(head); + findings.push({ + severity: 'warning', + rule: FLOW_TEMPLATE_FIELD_UNPROVISIONED, + where, + path: nodePath, + message: + (inFilter ? `${nodeType} filter references ` : 'template references ') + + `'{record.${rest.join('.')}}', and ${unprovisionedAnchorCause(objectName, head)} — ` + + (inFilter + ? `the token resolves to nothing on every run, which DROPS the condition from ` + + `the query instead of narrowing it; the node then refuses to run at execution ` + + `time (#3810).` + : `the token resolves to an empty string on every run (silently).`), + hint: unprovisionedAnchorHint(objectName, head), + }); + } + } + if (!isKnown) { if (seenUnknown.has(head)) continue; seenUnknown.add(head); diff --git a/packages/lint/src/validate-page-field-bindings.ts b/packages/lint/src/validate-page-field-bindings.ts index 72a9704f0d..f828d87bfb 100644 --- a/packages/lint/src/validate-page-field-bindings.ts +++ b/packages/lint/src/validate-page-field-bindings.ts @@ -55,6 +55,17 @@ */ export const PAGE_FIELD_UNKNOWN = 'page-field-unknown'; +/** + * [#8340] The reference RESOLVES to a registry-injected system column — so + * {@link PAGE_FIELD_UNKNOWN} is right to stay silent — but the bound object is + * ADR-0015 `external`, where the platform registers the anchor and provisions + * no storage behind it. Always `warning`, on both consequences and including + * the `queried` one that gates for a genuinely missing column: the existence + * question has a closed oracle here and the provenance question does not (this + * pass cannot see the remote schema, only that the platform stores nothing) — + * #8116's severity reasoning, unchanged. + */ +export const PAGE_FIELD_UNPROVISIONED = 'page-field-unprovisioned'; export type PageFieldSeverity = 'error' | 'warning'; @@ -82,7 +93,12 @@ import { walkPageComponents, type AnyRec } from './page-walk.js'; // Real pages DO reference registry-injected columns — e.g. `sys_user.page.ts` // lists `created_at` in a related-list's columns — so the shared set is load- // bearing here, not merely defensive. -import { SYSTEM_FIELDS } from './system-fields.js'; +import { + SYSTEM_FIELDS, + indexUnprovisionedAnchors, + unprovisionedAnchorCause, + unprovisionedAnchorHint, +} from './system-fields.js'; function asArray(v: unknown): AnyRec[] { if (Array.isArray(v)) return v as AnyRec[]; @@ -331,16 +347,50 @@ export function checkFieldRefs( objectFields: ReadonlyMap>, where: string, consequence: FieldRefConsequence = 'skipped', + // [#8340] `objectName -> its unprovisioned injected anchors` + // ({@link indexUnprovisionedAnchors}). OPTIONAL, and its absence means + // exactly one thing: this caller did not build the index, so the provenance + // question goes unasked and only the existence one is answered — the + // pre-#8340 behaviour, preserved for out-of-repo callers of this exported + // core (cloud graph-lint, the AI authoring path). Every in-repo caller passes + // it; `check-cross-package-test-inputs` is what would notice if one stopped. + unprovisionedAnchors?: ReadonlyMap>, ): PageFieldFinding[] { const findings: PageFieldFinding[] = []; if (!objectName) return findings; // nothing to resolve against const known = objectFields.get(objectName); if (!known) return findings; // cross-package object — unknowable here + const anchors = unprovisionedAnchors?.get(objectName); for (const ref of refs) { // A relationship path (`account.name`) is resolved by the query engine, // not a base column, so it cannot be judged here. if (ref.name.includes('.')) continue; - if (known.has(ref.name) || SYSTEM_FIELDS.has(ref.name)) continue; + if (known.has(ref.name) || SYSTEM_FIELDS.has(ref.name)) { + // [#8340] Existence answered "yes"; provenance is a second question, and + // the membership test above keeps owning the first one. An injected + // anchor on a federated object is addressable and empty: in a QUERY the + // predicate degrades to constant-false (an empty result that reads as + // "there is no data"), in a display binding the column renders blank on + // every record. + if (anchors?.has(ref.name)) { + findings.push({ + severity: 'warning', + rule: PAGE_FIELD_UNPROVISIONED, + where, + path: ref.path, + message: + `field "${ref.name}" resolves on object "${objectName}", but ` + + `${unprovisionedAnchorCause(objectName, ref.name)}` + + (consequence === 'queried' + ? ' — it is used in a QUERY, so the predicate can never match a real value: on ' + + 'SQLite it silently degrades to constant-false and the surface renders an empty ' + + 'result that looks exactly like "there is no data".' + : ' — the component renders it, blank, on every record.'), + hint: unprovisionedAnchorHint(objectName, ref.name), + }); + } + continue; + } findings.push({ severity: consequence === 'queried' ? 'error' : 'warning', rule: PAGE_FIELD_UNKNOWN, @@ -368,6 +418,9 @@ export function validatePageFieldBindings(stack: AnyRec): PageFieldFinding[] { // object name → its declared field names. Built with `asArray` so BOTH // `fields` shapes (array of `{name}` and name-keyed map) resolve. const objectFields = indexObjectFields(stack); + // [#8340] The provenance index alongside the existence one — same keying, + // asked on the path where the existence check stays silent. + const unprovisionedAnchors = indexUnprovisionedAnchors(stack); const pages = asArray(stack.pages); for (let pi = 0; pi < pages.length; pi++) { @@ -376,7 +429,9 @@ export function validatePageFieldBindings(stack: AnyRec): PageFieldFinding[] { const pageName = strName(page.name) ?? `#${pi}`; const checkRefs = (refs: readonly FieldRef[], objectName: string | undefined, where: string) => { - findings.push(...checkFieldRefs(refs, objectName, objectFields, where)); + findings.push( + ...checkFieldRefs(refs, objectName, objectFields, where, 'skipped', unprovisionedAnchors), + ); }; for (const { component, path, objectName } of walkPageComponents(page, `pages[${pi}]`)) { diff --git a/packages/lint/src/validate-react-page-props.ts b/packages/lint/src/validate-react-page-props.ts index 35d575adf7..0daafc7389 100644 --- a/packages/lint/src/validate-react-page-props.ts +++ b/packages/lint/src/validate-react-page-props.ts @@ -63,7 +63,12 @@ import { // since #5068 — see `zod-issue-format.ts` for why one copy matters here. import { describeIssue } from './zod-issue-format.js'; -import { SYSTEM_FIELDS } from './system-fields.js'; +import { + SYSTEM_FIELDS, + indexUnprovisionedAnchors, + unprovisionedAnchorCause, + unprovisionedAnchorHint, +} from './system-fields.js'; // The TypeScript compiler must NOT be imported at module top level: it is // ~9 MB of CJS (~70 ms+ to parse, worse on container cold starts), and @@ -257,6 +262,7 @@ function filterAttrValue(tsc: typeof ts, sf: ts.SourceFile, attr: ts.JsxAttribut // way to write the same binding. export const REACT_CHART_FIELD_UNKNOWN = 'react-chart-field-unknown'; +export const REACT_CHART_FIELD_UNPROVISIONED = 'react-chart-field-unprovisioned'; export const REACT_CHART_AGGREGATE_INVALID = 'react-chart-aggregate-invalid'; export const REACT_CHART_AXIS_UNKNOWN = 'react-chart-axis-unknown'; export const REACT_CHART_DRILLDOWN_INVALID = 'react-chart-drilldown-invalid'; @@ -420,6 +426,8 @@ function checkObjectChart( attrs: ChartAttrs, objectFields: Map>, findings: ReactPropFinding[], + // [#8340] `objectName -> its unprovisioned injected anchors`. + unprovisionedAnchors: ReadonlyMap> = new Map(), ): void { const { values, where, path } = attrs; const push = (severity: ReactPropSeverity, rule: string, message: string, hint: string) => @@ -469,11 +477,30 @@ function checkObjectChart( // No object name, or an object declared in another package: unknowable here // — the same skip the widget/flow/page rules take. if (objectName && known) { + const anchors = unprovisionedAnchors.get(objectName); const fieldRef = (name: string | undefined, prop: string) => { if (!name) return; // A relationship path (`account.name`) is resolved by the query engine. if (name.includes('.')) return; - if (known.has(name) || SYSTEM_FIELDS.has(name)) return; + if (known.has(name) || SYSTEM_FIELDS.has(name)) { + // [#8340] The name resolves — the existence error stays silent and + // `SYSTEM_FIELDS` keeps owning that decision — but an injected anchor + // on an ADR-0015 `external` object has no column behind it. Aggregating + // or grouping by one returns a single empty bucket rather than an + // error, so the chart renders and says nothing true. + if (anchors?.has(name)) { + push( + 'warning', + REACT_CHART_FIELD_UNPROVISIONED, + `aggregate.${prop} "${name}" resolves on object "${objectName}", but ` + + `${unprovisionedAnchorCause(objectName, name)} — the aggregate query reads a column ` + + `that is empty on every row, so the chart ${prop === 'groupBy' ? 'groups everything into one empty bucket' : 'aggregates nothing'} ` + + `instead of failing.`, + unprovisionedAnchorHint(objectName, name), + ); + } + return; + } push( 'error', REACT_CHART_FIELD_UNKNOWN, @@ -804,6 +831,10 @@ function checkBlockFieldProps( objectFields: ReadonlyMap>, where: string, path: string, + // [#8340] Threaded straight through to the shared `checkFieldRefs` core — + // this surface asks the same two questions of the same refs as the metadata + // one, so it must hand over the same index rather than answer differently. + unprovisionedAnchors?: ReadonlyMap>, ): ReactPropFinding[] { const objectName = strOf(values.get('objectName')); const out: PageFieldFinding[] = []; @@ -811,18 +842,26 @@ function checkBlockFieldProps( const spec = REACT_FIELD_SPECS[tag]; if (spec) { const { own, queried } = reactFieldRefs(spec, values, path); - out.push(...checkFieldRefs(own, objectName, objectFields, where)); - out.push(...checkFieldRefs(queried, objectName, objectFields, where, 'queried')); + out.push( + ...checkFieldRefs(own, objectName, objectFields, where, 'skipped', unprovisionedAnchors), + ); + out.push( + ...checkFieldRefs(queried, objectName, objectFields, where, 'queried', unprovisionedAnchors), + ); } if (tag === 'ObjectForm') { const raw = values.get('subforms'); const subs = subformFieldRefs(raw === NOT_STATIC ? undefined : raw, `${path}${PATH_SEP}subforms`); for (const sub of subs.child) { - out.push(...checkFieldRefs(sub.refs, sub.objectName, objectFields, where)); + out.push( + ...checkFieldRefs(sub.refs, sub.objectName, objectFields, where, 'skipped', unprovisionedAnchors), + ); } // `totalField` names the FORM object's field the child sum rolls up into. - out.push(...checkFieldRefs(subs.parent, objectName, objectFields, where)); + out.push( + ...checkFieldRefs(subs.parent, objectName, objectFields, where, 'skipped', unprovisionedAnchors), + ); } // `` renders the registered component the author @@ -836,6 +875,8 @@ function checkBlockFieldProps( objectName, objectFields, where, + 'skipped', + unprovisionedAnchors, ), ); } @@ -921,6 +962,9 @@ function localComponentNames(tsc: typeof ts, sf: ts.SourceFile): Set { export function validateReactPageProps(stack: AnyRec): ReactPropFinding[] { const findings: ReactPropFinding[] = []; const objectFields = indexObjectFields(stack); + // [#8340] The provenance index alongside the existence one — same keying, + // asked on the path where the existence check stays silent. + const unprovisionedAnchors = indexUnprovisionedAnchors(stack); // A separate index for the searchableFields check, built by the metadata // rule's own indexer: it keeps `null` for an object with no authored field // map (external / datasource-introspected), a distinction `indexObjectFields` @@ -1020,7 +1064,7 @@ export function validateReactPageProps(stack: AnyRec): ReactPropFinding[] { // A spread can supply any of the bindings below, so the values we // can see are an incomplete picture — skip rather than guess. if (tag === 'ObjectChart' && !hasSpread) { - checkObjectChart({ values, where, path }, objectFields, findings); + checkObjectChart({ values, where, path }, objectFields, findings, unprovisionedAnchors); } // names fields on the bound object — the // react-surface twin of `searchable-field-unknown` (#4329) and, as a @@ -1048,7 +1092,7 @@ export function validateReactPageProps(stack: AnyRec): ReactPropFinding[] { // non-static value is unresolvable rather than wrong. if (!hasSpread) { findings.push( - ...checkBlockFieldProps(tag, values, objectFields, where, path), + ...checkBlockFieldProps(tag, values, objectFields, where, path, unprovisionedAnchors), ); } } diff --git a/packages/lint/src/validate-widget-bindings.ts b/packages/lint/src/validate-widget-bindings.ts index f8c1b1b7a4..e73ea7d48c 100644 --- a/packages/lint/src/validate-widget-bindings.ts +++ b/packages/lint/src/validate-widget-bindings.ts @@ -3,7 +3,12 @@ import { isIncoherentAggregate } from '@objectstack/spec/data'; import { ChartTypeSchema } from '@objectstack/spec/ui'; -import { SYSTEM_FIELDS } from './system-fields.js'; +import { + SYSTEM_FIELDS, + indexUnprovisionedAnchors, + unprovisionedAnchorCause, + unprovisionedAnchorHint, +} from './system-fields.js'; /** * Build-time dashboard widget binding diagnostics (issues #1719, #1721). @@ -70,6 +75,14 @@ import { SYSTEM_FIELDS } from './system-fields.js'; * single-form cutover removed. The dashboard renderer routes dataset-bound * widgets through `DatasetWidget` and never reads these, so they are a * silent no-op. Steers the author onto `dataset`+`dimensions`+`values`. + * - `dashboard-filter-field-unprovisioned` (#8340) — the filter's effective + * field RESOLVES (it is a registry-injected system column, so + * `dashboard-filter-field-unknown` above rightly stays silent) but the bound + * object is ADR-0015 `external`, where the platform registers the anchor and + * provisions no storage for it. The filter is still ANDed into the query, so + * the widget renders empty instead of crashing — the silent-degradation half + * of the same invariant, warned rather than errored because this pass cannot + * see the remote schema (#8116's severity reasoning). * * Warnings can be deliberately suppressed per widget via * `suppressWarnings: ['']`; errors cannot — they describe a @@ -86,6 +99,7 @@ export const MEASURE_AGGREGATE_INCOHERENT = 'measure-aggregate-incoherent'; export const WIDGET_LEGACY_ANALYTICS_SHAPE = 'widget-legacy-analytics-shape'; export const WIDGET_LEGACY_ANALYTICS_UNRENDERABLE = 'widget-legacy-analytics-unrenderable'; export const DASHBOARD_FILTER_FIELD_UNKNOWN = 'dashboard-filter-field-unknown'; +export const DASHBOARD_FILTER_FIELD_UNPROVISIONED = 'dashboard-filter-field-unprovisioned'; /** * Pre-ADR-0021 inline-analytics keys. The single-form cutover replaced them @@ -319,6 +333,10 @@ export function validateWidgetBindings(stack: AnyRec): WidgetBindingFinding[] { } objectFieldTypes.set(o.name, fm); } + // [#8340] object name → the injected anchors it registers with NO storage + // behind them. Built once for the whole stack; empty for every object that is + // not ADR-0015 `external`, so the filter check below pays one map lookup. + const unprovisionedAnchors = indexUnprovisionedAnchors(stack); const datasetList = asArray(stack.datasets); for (let i = 0; i < datasetList.length; i++) { const ds = datasetList[i]; @@ -468,7 +486,9 @@ export function validateWidgetBindings(stack: AnyRec): WidgetBindingFinding[] { // object from another installed package is unknowable here — skip rather // than false-positive (mirrors the measure-aggregate check above). const objectFields = datasetObject ? objectFieldTypes.get(datasetObject) : undefined; - if (objectFields) { + // [#8340] The provenance half of the same question, for THIS object. + const anchors = datasetObject ? unprovisionedAnchors.get(datasetObject) : undefined; + if (objectFields && datasetObject) { for (const def of dashFilterDefs) { const eff = effectiveFilterField(w, def); if (!eff) continue; // opted out / not targeted → filter never applies @@ -476,7 +496,36 @@ export function validateWidgetBindings(stack: AnyRec): WidgetBindingFinding[] { // A relationship path (`account.region`) is resolved by the query // engine, not a base column, so it can't be checked here — skip it. if (field.includes('.')) continue; - if (objectFields.has(field) || SYSTEM_FIELDS.has(field)) continue; + if (objectFields.has(field) || SYSTEM_FIELDS.has(field)) { + // The name RESOLVES — the existence error above is right to stay + // silent, and `SYSTEM_FIELDS` keeps that decision (#8340 adds a + // question, it does not replace the membership test). But a + // resolvable anchor on a federated object has no column behind + // it, so ANDing it into the widget's analytics query silently + // narrows the result to nothing instead of crashing. Warning, + // not error, on #4330's cost asymmetry: this pass cannot see the + // remote table, only that the PLATFORM provisions no storage. + if (anchors?.has(field)) { + push({ + severity: 'warning', + rule: DASHBOARD_FILTER_FIELD_UNPROVISIONED, + message: + (eff.explicit + ? `binds dashboard filter \`${def.name}\` to field \`${field}\` (via filterBindings), but ` + : `inherits dashboard filter \`${def.name}(${field})\`, but `) + + `${unprovisionedAnchorCause(datasetObject, field)}. The filter is ANDed ` + + `into this widget's analytics query (#2501), so it can never match a real value — ` + + `on SQLite it silently degrades to constant-false and the widget renders empty ` + + `(HTTP 200, zero rows, no error).`, + hint: + `${unprovisionedAnchorHint(datasetObject, field)} A widget can also opt out ` + + `with filterBindings: { ${def.name}: false }. Suppress with ` + + `suppressWarnings: ['${DASHBOARD_FILTER_FIELD_UNPROVISIONED}'] if the remote schema ` + + `resolves it some other way.`, + }); + } + continue; + } push({ severity: 'error', rule: DASHBOARD_FILTER_FIELD_UNKNOWN, From 14e1aed9a2ed2ba7cc0c2f74a70e733b427a8273 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 11:07:42 +0000 Subject: [PATCH 2/4] test(lint): cover the four filter/binding unprovisioned-anchor warnings (#8340) --- .../src/validate-flow-template-paths.test.ts | 86 +++++++++++++ .../src/validate-page-field-bindings.test.ts | 116 ++++++++++++++++- .../src/validate-react-page-props.test.ts | 81 +++++++++++- .../lint/src/validate-widget-bindings.test.ts | 118 ++++++++++++++++++ 4 files changed, 399 insertions(+), 2 deletions(-) diff --git a/packages/lint/src/validate-flow-template-paths.test.ts b/packages/lint/src/validate-flow-template-paths.test.ts index 3161027b12..b4539fb89b 100644 --- a/packages/lint/src/validate-flow-template-paths.test.ts +++ b/packages/lint/src/validate-flow-template-paths.test.ts @@ -5,6 +5,7 @@ import { validateFlowTemplatePaths, FLOW_TEMPLATE_UNKNOWN_FIELD, FLOW_TEMPLATE_LOOKUP_TRAVERSAL, + FLOW_TEMPLATE_FIELD_UNPROVISIONED, } from './validate-flow-template-paths.js'; type AnyRec = Record; @@ -419,3 +420,88 @@ describe('validateFlowTemplatePaths', () => { }); }); }); + +describe('validateFlowTemplatePaths — unprovisioned injected anchors (#8340)', () => { + /** The #8116 fixture shape: an ADR-0015 `external` trigger object. */ + const EXT_OBJECT = (extra: AnyRec = {}): AnyRec => ({ + name: 'ext_customer', + external: { remoteName: 'customers' }, + fields: { email: { name: 'email', type: 'text' } }, + ...extra, + }); + + /** A record-change flow on the external object, with one node of `type`. */ + function extFlow(type: string, block: AnyRec, object: AnyRec = EXT_OBJECT()): AnyRec { + return { + objects: [object], + flows: [ + { + name: 'ext_flow', + type: 'record_change', + nodes: [ + { id: 'start', type: 'start', config: { objectName: 'ext_customer', triggerType: 'record-after-create' } }, + { id: 'n1', type, config: block }, + ], + }, + ], + }; + } + const only = (findings: { rule: string }[]) => + findings.filter((f) => f.rule === FLOW_TEMPLATE_FIELD_UNPROVISIONED); + + it('warns on a filter token over an unprovisioned anchor — the existence rule stays silent', () => { + const findings = validateFlowTemplatePaths( + extFlow('get_record', { objectName: 'ext_customer', filter: { email: '{record.owner_id}' } }), + ); + expect(findings.filter((f) => f.rule === FLOW_TEMPLATE_UNKNOWN_FIELD)).toHaveLength(0); + const warned = only(findings); + expect(warned).toHaveLength(1); + // WARNING even in the filter position, where a typo would be an ERROR: + // the provenance question has no closed oracle here (#8116). + expect(warned[0].severity).toBe('warning'); + expect(warned[0].message).toContain('owner_id'); + expect(warned[0].message).toContain('external object (ADR-0015)'); + expect(warned[0].message).toContain('refuses to run'); + expect(warned[0].hint).toContain('columnMap'); + }); + + it('warns outside a filter too, naming the blank-string consequence', () => { + const findings = validateFlowTemplatePaths( + extFlow('notify', { title: 'Owned by {record.owner_id}' }), + ); + const warned = only(findings); + expect(warned).toHaveLength(1); + expect(warned[0].message).toContain('empty string on every run'); + }); + + it('is silent on the local twin — platform storage is real (mutation: drop `external`)', () => { + const findings = validateFlowTemplatePaths( + extFlow('notify', { title: '{record.owner_id}' }, EXT_OBJECT({ external: undefined })), + ); + expect(findings).toEqual([]); + }); + + it('is silent when the author DECLARES the column (#7859)', () => { + const findings = validateFlowTemplatePaths( + extFlow('notify', { title: '{record.owner_id}' }, EXT_OBJECT({ + fields: { email: { name: 'email', type: 'text' }, owner_id: { name: 'owner_id', type: 'text' } }, + })), + ); + expect(only(findings)).toHaveLength(0); + }); + + it('is silent on a declared field of the same external object', () => { + expect(validateFlowTemplatePaths(extFlow('notify', { title: '{record.email}' }))).toEqual([]); + }); + + it('reports one finding per node for a token repeated in two positions', () => { + const findings = validateFlowTemplatePaths( + extFlow('update_record', { + objectName: 'ext_customer', + filter: { email: '{record.owner_id}' }, + fields: { email: 'echo {record.owner_id}' }, + }), + ); + expect(only(findings)).toHaveLength(1); + }); +}); diff --git a/packages/lint/src/validate-page-field-bindings.test.ts b/packages/lint/src/validate-page-field-bindings.test.ts index 53f19ce8b0..7a27595e8f 100644 --- a/packages/lint/src/validate-page-field-bindings.test.ts +++ b/packages/lint/src/validate-page-field-bindings.test.ts @@ -1,7 +1,14 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. import { describe, it, expect } from 'vitest'; -import { validatePageFieldBindings, PAGE_FIELD_UNKNOWN } from './validate-page-field-bindings.js'; +import { + validatePageFieldBindings, + checkFieldRefs, + indexObjectFields, + PAGE_FIELD_UNKNOWN, + PAGE_FIELD_UNPROVISIONED, +} from './validate-page-field-bindings.js'; +import { indexUnprovisionedAnchors } from './system-fields.js'; const baseStack = () => ({ objects: [ @@ -345,3 +352,110 @@ describe('validatePageFieldBindings — legacy bare-string sort (#4340)', () => expect(bad[0].message).not.toContain('ghost_col desc'); }); }); + +describe('validatePageFieldBindings — unprovisioned injected anchors (#8340)', () => { + /** + * The #8340 repro: a page binding over an ADR-0015 `external` object naming + * `owner_id` — a registry-injected system column, so `page-field-unknown` + * rightly stays silent, but nothing is stored behind it on a federated + * object. `objectExtra` breaks each half of the derivation independently. + */ + const externalStack = (objectExtra: Record = {}) => ({ + objects: [ + { + name: 'ext_customer', + external: { remoteName: 'customers' }, + fields: { email: { type: 'text' }, tier: { type: 'select' } }, + ...objectExtra, + }, + ], + }); + const extPage = (components: unknown[]) => ({ + name: 'customer_detail', + object: 'ext_customer', + regions: [{ name: 'main', components }], + }); + const only = (findings: { rule: string }[]) => + findings.filter((f) => f.rule === PAGE_FIELD_UNPROVISIONED); + + it('warns on a highlights binding over an unprovisioned anchor, and the existence rule stays silent', () => { + const findings = validatePageFieldBindings({ + ...externalStack(), + pages: [extPage([ + { type: 'record:highlights', properties: { fields: ['tier', 'owner_id'] } }, + ])], + }); + expect(findings.filter((f) => f.rule === PAGE_FIELD_UNKNOWN)).toHaveLength(0); + const warned = only(findings); + expect(warned).toHaveLength(1); + expect(warned[0].severity).toBe('warning'); + expect(warned[0].path).toBe('pages[0].regions[0].components[0].properties.fields[1]'); + expect(warned[0].message).toContain('owner_id'); + expect(warned[0].message).toContain('external object (ADR-0015)'); + expect(warned[0].message).toContain('renders it, blank, on every record'); + expect(warned[0].hint).toContain('columnMap'); + }); + + it('is silent on the local twin — platform storage is real (mutation: drop `external`)', () => { + const findings = validatePageFieldBindings({ + ...externalStack({ external: undefined }), + pages: [extPage([ + { type: 'record:highlights', properties: { fields: ['owner_id'] } }, + ])], + }); + expect(findings).toEqual([]); + }); + + it('is silent when the author DECLARES the column (#7859 — it maps a remote column they vouch for)', () => { + const findings = validatePageFieldBindings({ + ...externalStack({ fields: { email: { type: 'text' }, owner_id: { type: 'text' } } }), + pages: [extPage([ + { type: 'record:highlights', properties: { fields: ['owner_id'] } }, + ])], + }); + expect(only(findings)).toHaveLength(0); + }); + + it('is silent on a declared field of the same external object', () => { + const findings = validatePageFieldBindings({ + ...externalStack(), + pages: [extPage([ + { type: 'record:highlights', properties: { fields: ['tier'] } }, + ])], + }); + expect(findings).toEqual([]); + }); + + it('names the QUERY consequence when the ref reached a predicate, not a renderer', () => { + // The `queried` consequence is reachable only through the shared core (the + // react surface's own call); assert it there rather than inventing a page + // shape that does not exist. + const stack = externalStack(); + const findings = checkFieldRefs( + [{ name: 'owner_id', path: 'p' }], + 'ext_customer', + indexObjectFields(stack), + 'where', + 'queried', + indexUnprovisionedAnchors(stack), + ); + expect(findings).toHaveLength(1); + expect(findings[0].rule).toBe(PAGE_FIELD_UNPROVISIONED); + // WARNING even in the gating position: unlike a missing column, the remote + // schema is not visible to this pass (#8116's severity reasoning). + expect(findings[0].severity).toBe('warning'); + expect(findings[0].message).toContain('constant-false'); + }); + + it('asks nothing when the caller passes no anchor index — the pre-#8340 behaviour', () => { + const stack = externalStack(); + const findings = checkFieldRefs( + [{ name: 'owner_id', path: 'p' }], + 'ext_customer', + indexObjectFields(stack), + 'where', + 'queried', + ); + expect(findings).toEqual([]); + }); +}); diff --git a/packages/lint/src/validate-react-page-props.test.ts b/packages/lint/src/validate-react-page-props.test.ts index 886dbfcb73..913078461b 100644 --- a/packages/lint/src/validate-react-page-props.test.ts +++ b/packages/lint/src/validate-react-page-props.test.ts @@ -3,6 +3,7 @@ import { describe, it, expect } from 'vitest'; import { validateReactPageProps, REACT_CHART_FIELD_UNKNOWN, + REACT_CHART_FIELD_UNPROVISIONED, REACT_CHART_AGGREGATE_INVALID, REACT_CHART_AXIS_UNKNOWN, REACT_CHART_DRILLDOWN_INVALID, @@ -13,7 +14,7 @@ import { SEARCHABLE_FIELD_UNKNOWN, SEARCHABLE_FIELD_UNSEARCHABLE, } from './validate-searchable-fields.js'; -import { PAGE_FIELD_UNKNOWN } from './validate-page-field-bindings.js'; +import { PAGE_FIELD_UNKNOWN, PAGE_FIELD_UNPROVISIONED } from './validate-page-field-bindings.js'; // The gate PARSES `ChartAggregateSchema` since #5020, so the function // vocabulary lives in the schema and nowhere in the rule. Imported here to pin // the test table against it — see the `aggregate` block near the bottom. @@ -1137,3 +1138,81 @@ describe('validateReactPageProps — PARSED (#5020)', () expect(f.filter((x) => x.rule === REACT_CHART_AGGREGATE_INVALID)).toEqual([]); }); }); + +// ───────────────────────────────────────────────────────────────────────── +// Unprovisioned injected anchors on the react surface (#8340) +// ───────────────────────────────────────────────────────────────────────── + +describe('validateReactPageProps — unprovisioned injected anchors (#8340)', () => { + /** + * An ADR-0015 `external` object: the remote database owns the table, so the + * platform's injected anchors (`owner_id`, the audit family, …) are + * registered and never provisioned. `extra` breaks each half of the + * derivation independently. + */ + const extCustomer = (extra: Record = {}) => ({ + name: 'ext_customer', + external: { remoteName: 'customers' }, + fields: [{ name: 'email' }, { name: 'tier' }], + ...extra, + }); + const extPage = (source: string, objects: unknown[] = [extCustomer()]) => ({ + objects, + pages: [{ name: 'p', kind: 'react', source }], + }); + + it('warns on an aggregate.groupBy over an anchor, and the existence rule stays silent', () => { + const f = validateReactPageProps( + extPage(chart(`objectName="ext_customer" aggregate={{ field: 'email', function: 'count', groupBy: 'owner_id' }}`)), + ); + expect(f.filter((x) => x.rule === REACT_CHART_FIELD_UNKNOWN)).toHaveLength(0); + const warned = f.filter((x) => x.rule === REACT_CHART_FIELD_UNPROVISIONED); + expect(warned).toHaveLength(1); + expect(warned[0].severity).toBe('warning'); + expect(warned[0].message).toContain('aggregate.groupBy "owner_id"'); + expect(warned[0].message).toContain('external object (ADR-0015)'); + expect(warned[0].message).toContain('one empty bucket'); + expect(warned[0].hint).toContain('columnMap'); + }); + + it('is silent on the local twin — platform storage is real (mutation: drop `external`)', () => { + const f = validateReactPageProps( + extPage( + chart(`objectName="ext_customer" aggregate={{ field: 'email', function: 'count', groupBy: 'owner_id' }}`), + [extCustomer({ external: undefined })], + ), + ); + expect(f.filter((x) => x.rule === REACT_CHART_FIELD_UNPROVISIONED)).toHaveLength(0); + }); + + it('is silent when the author DECLARES the column (#7859)', () => { + const f = validateReactPageProps( + extPage( + chart(`objectName="ext_customer" aggregate={{ field: 'email', function: 'count', groupBy: 'owner_id' }}`), + [extCustomer({ fields: [{ name: 'email' }, { name: 'owner_id' }] })], + ), + ); + expect(f.filter((x) => x.rule === REACT_CHART_FIELD_UNPROVISIONED)).toHaveLength(0); + }); + + it('reaches the FILTER position through the shared core — the #8340 headline case', () => { + // `` is the react surface's filter position: the ref goes + // to a QUERY, so the message names the silent-zero degradation. + const f = validateReactPageProps( + extPage(`function Page(){ return ; }`), + ); + expect(f.filter((x) => x.rule === PAGE_FIELD_UNKNOWN)).toHaveLength(0); + const warned = f.filter((x) => x.rule === PAGE_FIELD_UNPROVISIONED); + expect(warned).toHaveLength(1); + expect(warned[0].severity).toBe('warning'); + expect(warned[0].message).toContain('constant-false'); + expect(warned[0].path).toBe('pages[0].source › filters[0]'); + }); + + it('is silent on a declared field in the same filter position', () => { + const f = validateReactPageProps( + extPage(`function Page(){ return ; }`), + ); + expect(f).toEqual([]); + }); +}); diff --git a/packages/lint/src/validate-widget-bindings.test.ts b/packages/lint/src/validate-widget-bindings.test.ts index 9786e33445..2539ff5f2c 100644 --- a/packages/lint/src/validate-widget-bindings.test.ts +++ b/packages/lint/src/validate-widget-bindings.test.ts @@ -11,6 +11,7 @@ import { WIDGET_LEGACY_ANALYTICS_SHAPE, WIDGET_LEGACY_ANALYTICS_UNRENDERABLE, DASHBOARD_FILTER_FIELD_UNKNOWN, + DASHBOARD_FILTER_FIELD_UNPROVISIONED, } from './validate-widget-bindings.js'; /** The downstream repro from issue #1719 — dataset with a count AND a sum @@ -644,3 +645,120 @@ describe('validateWidgetBindings (dashboard-filter-field-unknown, issue #3365)', expect(only(validateWidgetBindings(stack({ dateRange: undefined })))).toHaveLength(0); }); }); + +describe('validateWidgetBindings (dashboard-filter-field-unprovisioned, issue #8340)', () => { + const only = (findings: ReturnType) => + findings.filter((f) => f.rule === DASHBOARD_FILTER_FIELD_UNPROVISIONED); + const unknownOnly = (findings: ReturnType) => + findings.filter((f) => f.rule === DASHBOARD_FILTER_FIELD_UNKNOWN); + + /** + * The #8340 repro: a dashboard filter on `owner_id` — a registry-injected + * system column, so #3365's existence error rightly stays silent — over a + * dataset bound to an ADR-0015 `external` object, where the platform + * registers the anchor and provisions no storage for it. + * + * `objectExtra` mutates the object (drop `external`, declare the column) so + * each half of the derivation can be broken independently. + */ + function stack( + dash: Record = {}, + widget: Record = {}, + objectExtra: Record = {}, + ) { + return { + objects: [ + { + name: 'ext_customer', + external: { remoteName: 'customers' }, + fields: [{ name: 'email', type: 'text' }, { name: 'signed_up_on', type: 'date' }], + ...objectExtra, + }, + ], + datasets: [ + { name: 'customer_metrics', object: 'ext_customer', + dimensions: [{ name: 'email', field: 'email' }], + measures: [{ name: 'customer_count', aggregate: 'count' }] }, + ], + dashboards: [{ + name: 'federation_dashboard', + label: 'Federation', + globalFilters: [{ field: 'owner_id', type: 'select' }], + widgets: [{ + id: 'total_customers', type: 'metric', + dataset: 'customer_metrics', values: ['customer_count'], + ...widget, + }], + ...dash, + }], + }; + } + + it('warns on the repro: an inherited filter over an unprovisioned injected anchor', () => { + const findings = only(validateWidgetBindings(stack())); + expect(findings).toHaveLength(1); + const f = findings[0]; + expect(f.severity).toBe('warning'); + expect(f.where).toContain('federation_dashboard'); + expect(f.where).toContain('total_customers'); + expect(f.message).toContain('owner_id'); + expect(f.message).toContain('ext_customer'); + expect(f.message).toContain('external object (ADR-0015)'); + expect(f.message).toContain('constant-false'); + expect(f.hint).toContain("columnMap"); + expect(f.path).toBe('dashboards[0].widgets[0]'); + // The existence rule is UNCHANGED — the name still resolves. + expect(unknownOnly(validateWidgetBindings(stack()))).toHaveLength(0); + }); + + it('is silent on the local twin — platform storage is real (mutation: drop `external`)', () => { + // Break the ADR-0015 half of the derivation: the same filter over a + // platform-stored object has a real `owner_id` column behind it. + const local = stack({}, {}, { external: undefined }); + expect(only(validateWidgetBindings(local))).toHaveLength(0); + expect(unknownOnly(validateWidgetBindings(local))).toHaveLength(0); + }); + + it('is silent when the author DECLARES the column — it maps a remote column they vouch for', () => { + // #7859's security direction, and the second half of the derivation. + const declared = stack({}, {}, { + fields: [{ name: 'email', type: 'text' }, { name: 'owner_id', type: 'text' }], + }); + expect(only(validateWidgetBindings(declared))).toHaveLength(0); + }); + + it('is silent on an ordinary declared field of the same external object', () => { + expect(only(validateWidgetBindings(stack({ + globalFilters: [{ field: 'signed_up_on', type: 'date' }], + })))).toHaveLength(0); + }); + + it('warns with the explicit wording when filterBindings re-targets onto an anchor', () => { + const findings = only(validateWidgetBindings(stack( + { globalFilters: [{ name: 'owner', field: 'signed_up_on', type: 'date' }] }, + { filterBindings: { owner: 'created_by' } }, + ))); + expect(findings).toHaveLength(1); + expect(findings[0].message).toContain('via filterBindings'); + expect(findings[0].message).toContain('created_by'); + }); + + it('is silent when the widget opts the filter out entirely', () => { + expect(only(validateWidgetBindings(stack( + { globalFilters: [{ name: 'owner_id', field: 'owner_id', type: 'select' }] }, + { filterBindings: { owner_id: false } }, + )))).toHaveLength(0); + }); + + it('IS suppressible — it is advice, not a broken query', () => { + expect(only(validateWidgetBindings(stack({}, { + suppressWarnings: [DASHBOARD_FILTER_FIELD_UNPROVISIONED], + })))).toHaveLength(0); + }); + + it('cannot judge — and never false-positives — when the object is not in the stack', () => { + const s = stack(); + delete (s as { objects?: unknown }).objects; + expect(only(validateWidgetBindings(s))).toHaveLength(0); + }); +}); From f43a0a6db0d87ef5a7864d57ffa3d157b3412796 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 11:13:42 +0000 Subject: [PATCH 3/4] chore(changeset): filter-surface unprovisioned-anchor warnings (#8340) --- .../filter-surface-unprovisioned-anchor.md | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 .changeset/filter-surface-unprovisioned-anchor.md diff --git a/.changeset/filter-surface-unprovisioned-anchor.md b/.changeset/filter-surface-unprovisioned-anchor.md new file mode 100644 index 0000000000..e0b06489e7 --- /dev/null +++ b/.changeset/filter-surface-unprovisioned-anchor.md @@ -0,0 +1,35 @@ +--- +'@objectstack/lint': minor +--- + +feat(lint): the unprovisioned-anchor warning reaches filter and page-binding surfaces (#8340) + +#8116 taught the two rules that resolve fields **per object** (`validate-expressions`, +`validate-semantic-roles`) to warn when a reference resolves to an injected system column +that an ADR-0015 `external` object registers with no storage behind it. The +filter-position and page-binding checks could not reach that class at all: they judge a +field name against the object-independent `SYSTEM_FIELDS` union, which by design answers +"could this name be a system column anywhere" and therefore never flags a system name. A +`filter: [['owner_id', '=', '…']]` on a view, widget, page or flow bound to a federated +object linted clean while the runtime degraded exactly as #8116 describes (on SQLite: +constant-false, HTTP 200, zero rows, no error). + +Four rules now ask the provenance question on the path where the existence check stays +silent, each with its own surface-specific consequence wording, all advisory +(`warning`, never gating) on #8116's severity reasoning — this pass knows the platform +provisions no storage, not what the deployment's remote schema holds: + +- `dashboard-filter-field-unprovisioned` — a dashboard filter (`dateRange` / + `globalFilters[]`, after any `filterBindings` re-target) is ANDed into a widget's + analytics query, so the widget renders empty instead of crashing. Suppressible per + widget via `suppressWarnings`. +- `page-field-unprovisioned` — a page/react component field binding. Names the QUERY + degradation in filter positions and the blank-column one in display positions. +- `react-chart-field-unprovisioned` — ``'s `field` / `groupBy`. +- `flow-template-field-unprovisioned` — a `{record.}` token in a record-change + flow whose trigger object is external; inside a filter-guarded CRUD node's `filter` + the token erases the authored condition and the node refuses to run (framework#3810). + +`SYSTEM_FIELDS` keeps owning every existing pass/fail decision — no existence finding +changes severity or wording, and an author-declared column of the same name remains the +author's (#7859) and is never warned. From 24a714e156d4bc413893f998b14964d5a8a20d5c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 12:14:02 +0000 Subject: [PATCH 4/4] test(lint): type the two #8340 finding filters against their validator's return (#8340) The two new-suite helpers were annotated `{ rule: string }[]`, so every assertion past `.rule` was a TS2339 the package's own `typecheck` cannot see (its tsconfig excludes `**/*.test.ts`) but check:type-check-debt counts: 12 raw errors on top of @objectstack/lint's frozen TEST_DEBT of 20. --- packages/lint/src/validate-flow-template-paths.test.ts | 2 +- packages/lint/src/validate-page-field-bindings.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/lint/src/validate-flow-template-paths.test.ts b/packages/lint/src/validate-flow-template-paths.test.ts index b4539fb89b..7427be466c 100644 --- a/packages/lint/src/validate-flow-template-paths.test.ts +++ b/packages/lint/src/validate-flow-template-paths.test.ts @@ -446,7 +446,7 @@ describe('validateFlowTemplatePaths — unprovisioned injected anchors (#8340)', ], }; } - const only = (findings: { rule: string }[]) => + const only = (findings: ReturnType) => findings.filter((f) => f.rule === FLOW_TEMPLATE_FIELD_UNPROVISIONED); it('warns on a filter token over an unprovisioned anchor — the existence rule stays silent', () => { diff --git a/packages/lint/src/validate-page-field-bindings.test.ts b/packages/lint/src/validate-page-field-bindings.test.ts index 7a27595e8f..21d3f82f6c 100644 --- a/packages/lint/src/validate-page-field-bindings.test.ts +++ b/packages/lint/src/validate-page-field-bindings.test.ts @@ -375,7 +375,7 @@ describe('validatePageFieldBindings — unprovisioned injected anchors (#8340)', object: 'ext_customer', regions: [{ name: 'main', components }], }); - const only = (findings: { rule: string }[]) => + const only = (findings: ReturnType) => findings.filter((f) => f.rule === PAGE_FIELD_UNPROVISIONED); it('warns on a highlights binding over an unprovisioned anchor, and the existence rule stays silent', () => {