Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions .changeset/filter-surface-unprovisioned-anchor.md
Original file line numberDiff line numberDiff line change
@@ -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` — `<ObjectChart aggregate>`'s `field` / `groupBy`.
- `flow-template-field-unprovisioned` — a `{record.<anchor>}` 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.
9 changes: 8 additions & 1 deletion packages/lint/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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';

Expand DownExpand Up@@ -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,
Expand DownExpand Up@@ -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,
Expand DownExpand Up@@ -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 {
Expand Down
87 changes: 87 additions & 0 deletions packages/lint/src/system-fields.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -100,3 +100,90 @@ export function injectedColumnsFor(objectDef: unknown): ReadonlySet<string> {
export function unprovisionedInjectedColumnsFor(objectDef: unknown): ReadonlySet<string> {
return new Set(unprovisionedInjectedColumns(objectDef));
}

/** Coerce an array-or-name-keyed-map collection to an array (name injected). */
function objectDefsOf(stack: unknown): Record<string, unknown>[] {
if (!stack || typeof stack !== 'object') return [];
const objects = (stack as { objects?: unknown }).objects;
if (Array.isArray(objects)) return objects.filter((o): o is Record<string, unknown> => !!o && typeof o === 'object');
if (objects && typeof objects === 'object') {
return Object.entries(objects as Record<string, unknown>)
.filter(([, def]) => !!def && typeof def === 'object')
.map(([name, def]) => ({ name, ...(def as Record<string, unknown>) }));
}
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<string, ReadonlySet<string>> {
const index = new Map<string, ReadonlySet<string>>();
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).`
);
}
86 changes: 86 additions & 0 deletions packages/lint/src/validate-flow-template-paths.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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<string, unknown>;
Expand DownExpand Up@@ -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: ReturnType<typeof validateFlowTemplatePaths>) =>
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);
});
});
56 changes: 55 additions & 1 deletion packages/lint/src/validate-flow-template-paths.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.<injected anchor>}` 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.
//
Expand DownExpand Up@@ -55,7 +67,12 @@
// - Structured scalar heads (`json` / `composite` / `repeater` / `record`) may
// carry legitimate sub-paths — their `.<sub>` 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';
Expand All@@ -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<string, unknown>;

Expand DownExpand Up@@ -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
Expand DownExpand Up@@ -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<string>();
const seenTraversal = new Set<string>();
const seenUnprovisioned = new Set<string>();

for (const leaf of leaves) {
const inFilter = leaf.inFilter;
Expand All@@ -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);
Expand Down
Loading
Loading