From 38cdd30b2a161a311471ea14cebd685b0853bb90 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 14:43:02 +0000 Subject: [PATCH 1/3] wip(lint): object-field-ref family judges the field-level name lists and indexes[].fields Claude-Session: https://claude.ai/code/session_014EJ1ED8X4MMrT18BhVx4tx Co-authored-by: Claude --- .../lint/src/validate-object-field-refs.ts | 392 ++++++++++++++++-- 1 file changed, 367 insertions(+), 25 deletions(-) diff --git a/packages/lint/src/validate-object-field-refs.ts b/packages/lint/src/validate-object-field-refs.ts index 140acef4dde..ac79635fce8 100644 --- a/packages/lint/src/validate-object-field-refs.ts +++ b/packages/lint/src/validate-object-field-refs.ts @@ -2,8 +2,11 @@ /** * [#15254 — object-level field-name list reference integrity] Every field name - * an OBJECT names in one of its own field-name LISTS — `highlightFields` - * today — must name a field that object actually has. + * an OBJECT names in one of its own field-name LISTS — `highlightFields`, + * `publicSharing.redactFields`, `indexes[].fields` — and every field name one + * of its FIELDS names in a field-level list (`relatedListColumns`, + * `lookupColumns`, `lookupFilters[].field`, `dependsOn`, #20432) must name a + * field the object that list addresses actually has. * * ## The state this rule ends, measured on `origin/main` (f01adfa5c) * @@ -59,6 +62,13 @@ * Warning is the tier that produced the measured state above; it is not * enough for this judgement, so both surfaces gate. * + * The positions #20432 added take the same tier on the same argument. Some of + * their misses can reach a refusal downstream — the data route judges a filter + * key against the object's field map — but only when a USER opens the view or + * the picker, which is a refusal the author never sees; a misspelt `dependsOn` + * gates its field for good, and the index miss is the silent kind outright. The bar they fall short of is the one the #19332 + * ruling states for this family: a misspelling is refused loudly at authoring. + * * ## What this rule owns, and what it deliberately does NOT * * It owns the object-level keys whose value is a LIST of names of fields on @@ -72,6 +82,66 @@ * fails OPEN, which the schema's own `history` note says in as many words: * a redaction the author wrote and mis-spelled does not redact, and the * field is served to whoever holds the link. Same shape, worse consequence. + * - **`indexes[].fields[]`** (`IndexSchema`, `object.zod.ts`) — the columns + * of a declared index. A misspelt column makes + * `SqlDriver.syncDeclaredIndexes` skip the WHOLE index at `warn`, and + * `expectedIndexes` drops it from drift, so `os migrate plan` never shows + * it; for a `unique` index the declared constraint is silently unenforced + * while the system looks normal — the AGENTS.md durability-degradation + * shape (#20432). + * + * …and, since #20432, the FIELD-level lists — the keys on one field whose + * value names other fields. Each is a bare `z.string()` in `FieldSchema` + * (`packages/spec/src/data/field.zod.ts`), so the parse cannot judge one, and + * no other authoring door read them for existence: a misspelling surfaced only + * when a user opened the view or the picker, or never. + * + * - **`relatedListColumns[]`** — the columns of the related list the parent's + * detail page derives from this relationship. + * - **`lookupColumns[]`** (both arms: a name, or `{ field }`) — the columns + * of this field's record picker. + * - **`lookupFilters[].field`** — the picker's base filter. + * - **`dependsOn[]`** (both arms: a name, or `{ field, param }`) — the fields + * whose values gate this field and, on a picker, scope its candidates. + * + * ## Which object a field-level name addresses — measured on each reader + * + * Not always the object that owns the field, and a rule that assumed so would + * refuse correct metadata and pass broken metadata in equal measure. Read at + * the objectui pin (`.objectui-sha` `dd3f7e1b`): + * + * ``` + * relatedListColumns[i] OWNER deriveRelatedLists.ts — the related list + * lists the CHILD's rows, and the child is + * the object that owns the FK field + * lookupColumns[i] / .field REFERENCE LookupField.tsx — picker columns over + * the referenced object's records + * lookupFilters[i].field REFERENCE LookupField.tsx `lookupFiltersToRecord` + * → the query on `referenceTo` + * (`validate-preset-comparands.ts` binds + * the same key the same way, #19791) + * dependsOn[i] / .field OWNER LookupField.tsx — the gate reads the + * host's record by this key + * dependsOn[i] / .param REFERENCE LookupField.tsx `dependentFilter` — the + * candidate filter key; a bare name is + * BOTH (`param` defaults to `field`) + * ``` + * + * REFERENCE is the field's target as `referenceTargetOf` answers it (the + * graph's `GraphField.reference`), judged only on the three types that render + * that picker — `lookup` and `master_detail` (`FieldEditWidget.tsx`) and + * `user`, whose `UserField` delegates to it with `sys_user` fixed. On any + * other type the REFERENCE positions have no reader and stay unjudged. + * + * `lookupColumns`, `dependsOn` and `indexes[].fields` are read VERBATIM by + * their readers — a picker column key, a record key, a physical column — so + * a dotted name there addresses nothing and is judged as one name, never + * walked as a relationship path. `relatedListColumns` and + * `lookupFilters[].field` keep the family's path resolution. + * + * On `dependsOn`, a bare name that misses on the owner is reported once, + * there; the same name is not reported a second time against the referenced + * object, since one fix answers both. * * NOT owned, each with the reason its schema gives: * @@ -88,10 +158,17 @@ * the title pair is `validateRecordTitle`'s axis. Promoting a scalar role * pointer to `error` is the same judgement one key over, but it is a * separate decision with its own blast radius and it is left to one. - * - **`indexes[].fields[]`** — a list of names, but the question there is a - * STORAGE one (does the driver create the index?), owned by the index - * registration path, and it is answered against the physical column set - * rather than the authored field map. + * - **Whether an index column is MATERIALIZED** — a name that resolves to a + * virtual field (a `formula`) is judged here as existing, and the driver + * still skips the index at sync. `indexes[].fields[]` used to sit in this + * list whole, as a storage question for the registration path answered + * against the physical column set. That left a MISSPELLING with no door at + * all: the sync's skip is a `warn` and drift drops the index, so nothing + * anywhere refused a name that is not a field. Existence is therefore + * judged here, against the authored field map plus the injected columns — + * exactly the physical set a correct name can land in — and only the + * materialization question stays with the sync (#20432 step 2, the + * driver's half). * - **`tenancy.tenantField` / `tenancy.organizationField` / * `lifecycle.ttl.field` / `activityMilestones[].field`** — scalars, and * the first three habitually name REGISTRY-INJECTED columns @@ -131,11 +208,16 @@ import { describeFieldPathVerdict, indexObjectGraph, isUnjudgeable, + recordsOf, resolveFieldPath, + type FieldPathVerdict, type ObjectGraph, } from './object-graph.js'; -/** An object-level field-name list entry that resolves to no field on the object. */ +/** + * A field-name list entry — object-level, or on one of the object's fields — + * that resolves to no field on the object the list addresses. + */ export const OBJECT_FIELD_REF_UNKNOWN = 'object-field-ref-unknown'; export type ObjectFieldRefSeverity = 'error' | 'warning'; @@ -145,9 +227,16 @@ export interface ObjectFieldRefFinding { severity: ObjectFieldRefSeverity; /** Diagnostic rule id. */ rule: string; - /** Human-readable location, e.g. `object "proj_task" › highlightFields`. */ + /** + * Human-readable location, e.g. `object "proj_task" › highlightFields` or + * `object "invoice" › fields.account.lookupColumns`. + */ where: string; - /** Config path, e.g. `objects[0].highlightFields[1]`. */ + /** + * Config path, e.g. `objects[0].highlightFields[1]`, + * `objects[0].indexes[0].fields[1]` or + * `objects[0].fields.account.lookupColumns[1].field`. + */ path: string; /** What is wrong. */ message: string; @@ -225,10 +314,178 @@ const LIST_POSITIONS: readonly ListPosition[] = [ ]; /** - * Validate every object's own field-name lists against the object graph. - * Returns findings (empty = clean). Pure `(stack) => Finding[]`; no I/O, and - * safe on both the schema-parsed stack and the raw config the `lint` path - * carries. + * `indexes[i].fields[j]` — a position of its own rather than a + * {@link ListPosition} row: the list sits one collection deeper (each entry of + * `indexes` carries one), and its reader takes every name VERBATIM as a + * physical column. See the module note for what is judged here and what the + * sync keeps. + */ +const INDEX_POSITION = { + consequence: + 'The SQL driver skips the WHOLE index at sync with only a warning, and drift drops it too, ' + + 'so `os migrate plan` never reports it: a `unique` index is then silently unenforced ' + + 'while everything looks normal.', + prescription: + 'Fix the column name. An index column is a field of this object, or a column the platform ' + + 'injects on it (`created_at`, `organization_id`, …), spelled exactly — never a dotted path.', +} as const; + +/** + * Which object's field map judges a field-level name: the object that OWNS + * the field, or the object the field REFERENCES. Decided per key on the key's + * runtime reader — the module note's table is the measurement. + */ +type NameAddress = 'owner' | 'reference'; + +/** + * The field types whose editor is the record picker that reads the + * REFERENCE-addressed keys (`lookupColumns`, `lookupFilters`, the filter half + * of `dependsOn`): `lookup` and `master_detail` render `LookupField`, and + * `user` renders `UserField`, which delegates to it with `sys_user` fixed. On + * any other type those keys have no reader, so they are not judged. + */ +const PICKER_FIELD_TYPES: ReadonlySet = new Set(['lookup', 'master_detail', 'user']); + +/** One name read out of one list entry, and where it sits inside the entry. */ +interface SlotRead { + name: string; + /** Path suffix after `key[i]` — `''` for a bare-name entry, `.field` / `.param` for a member. */ + suffix: string; +} + +/** + * One field-level NAME SLOT: a key on a field, the object its names address, + * and how a name is read out of each entry. Declarative for the reason + * {@link LIST_POSITIONS} is — the table can be read against `FieldSchema` key + * by key, and against each key's reader row by row. + */ +interface FieldNameSlot { + /** The field key whose value is the list. */ + key: string; + /** Which object's field map judges the name. */ + address: NameAddress; + /** + * `true` when the reader takes the name VERBATIM — a picker column key, a + * record key — so a dotted name is one name that names no field, never a + * relationship path. `false` keeps the family's path resolution. + */ + verbatim: boolean; + /** Read this slot's name out of one entry, or `undefined` when it holds none. */ + read: (entry: unknown) => SlotRead | undefined; + /** What the platform does with a name that resolves to nothing. */ + consequence: string; + /** The prescription half of the hint. */ + prescription: string; +} + +function nameOf(v: unknown): string | undefined { + return typeof v === 'string' && v.length > 0 ? v : undefined; +} + +/** A bare-name entry (`'status'`). */ +function readString(entry: unknown): SlotRead | undefined { + const name = nameOf(entry); + return name ? { name, suffix: '' } : undefined; +} + +/** A named member of an object entry (`{ field: 'status' }`). */ +function readMember(entry: unknown, member: string): SlotRead | undefined { + const name = isRec(entry) ? nameOf(entry[member]) : undefined; + return name ? { name, suffix: `.${member}` } : undefined; +} + +const FIELD_NAME_SLOTS: readonly FieldNameSlot[] = [ + { + key: 'relatedListColumns', + address: 'owner', + verbatim: false, + read: readString, + consequence: + 'The related list the parent\'s detail page derives from this relationship asks the child ' + + 'object for a column it does not have, and the miss surfaces only when that page opens.', + prescription: + 'Fix the column name — a related-list column is a field of the object that declares this ' + + 'relationship (the child whose rows the list shows) — or drop the entry and let the ' + + 'columns derive.', + }, + { + key: 'lookupColumns', + address: 'reference', + verbatim: true, + read: (entry) => readString(entry) ?? readMember(entry, 'field'), + consequence: + 'The record picker renders that column empty for every candidate: a picker column is read ' + + 'off each record of the referenced object, and nothing reports the miss.', + prescription: + 'Fix the column name — a picker column is a field of the referenced object, not of the ' + + 'object that owns this field — or drop the entry and let the columns derive.', + }, + { + key: 'lookupFilters', + address: 'reference', + verbatim: false, + read: (entry) => readMember(entry, 'field'), + consequence: + 'The picker applies this filter to its query on the referenced object, which names a field ' + + 'that object does not have; the miss surfaces only when a user opens the picker.', + prescription: + 'Fix `field` — a picker filter runs on the referenced object, so it names a field of that ' + + 'object, not of the object that owns this field.', + }, + { + key: 'dependsOn', + address: 'owner', + verbatim: true, + read: (entry) => readString(entry) ?? readMember(entry, 'field'), + consequence: + 'The form gates this field until that field has a value, and a field the record does not ' + + 'have never gets one: this field stays gated for good.', + prescription: + 'Fix the name — `dependsOn` names fields on the same record, i.e. of the object that owns ' + + 'this field — or drop the entry.', + }, + { + // The filter half of the same entry. A bare name is the key on BOTH sides; + // `{ field, param }` names the referenced object's key in `param`, and + // without one `param` defaults to `field` (LookupField's own mapping). + key: 'dependsOn', + address: 'reference', + verbatim: true, + read: (entry) => readString(entry) ?? readMember(entry, 'param') ?? readMember(entry, 'field'), + consequence: + 'The picker scopes its candidates by this key on the referenced object — a bare name is ' + + 'the key on both sides — and that object has no such field; the miss surfaces only when ' + + 'a user opens the picker.', + prescription: + 'When the two sides are spelled differently, write the entry as ' + + '`{ field: \'this_record_field\', param: \'referenced_object_field\' }`; otherwise fix ' + + 'the name.', + }, +]; + +/** + * {@link resolveFieldPath}, or — at a position whose reader takes the name + * verbatim — the one-name verdict: a dotted name there is not a path to walk, + * it is a name no field carries. + */ +function resolveName( + graph: ObjectGraph, + objectName: string, + name: string, + verbatim: boolean, +): FieldPathVerdict | undefined { + if (!verbatim || !name.includes('.')) return resolveFieldPath(graph, objectName, name); + if (!graph.has(objectName)) return { kind: 'unknowable', reason: 'object-not-in-stack', object: objectName }; + const obj = graph.get(objectName); + if (!obj) return { kind: 'unknowable', reason: 'no-field-map', object: objectName }; + return { kind: 'field-unknown', object: objectName, field: name, candidates: obj.names }; +} + +/** + * Validate every object's own field-name lists, and its fields' field-level + * lists, against the object graph. Returns findings (empty = clean). Pure + * `(stack) => Finding[]`; no I/O, and safe on both the schema-parsed stack and + * the raw config the `lint` path carries. */ export function validateObjectFieldRefs(stack: AnyRec): ObjectFieldRefFinding[] { const findings: ObjectFieldRefFinding[] = []; @@ -237,6 +494,37 @@ export function validateObjectFieldRefs(stack: AnyRec): ObjectFieldRefFinding[] const graph: ObjectGraph = indexObjectGraph(stack); if (graph.size === 0) return findings; + /** + * Resolve one name and push the finding when it misses. `true` = reported. + * Every position funnels through here, so the message shape — the verdict's + * account, then the position's consequence; the prescription, then the + * addressed object's field list — is one shape across the family. + */ + const judge = (at: { + against: string; + name: string; + verbatim: boolean; + subject: string; + where: string; + path: string; + consequence: string; + prescription: string; + }): boolean => { + const verdict = resolveName(graph, at.against, at.name, at.verbatim); + if (isUnjudgeable(verdict) || !verdict) return false; + const account = describeFieldPathVerdict(verdict, at.name, at.subject); + if (!account) return false; // the name resolves — nothing to say + findings.push({ + severity: 'error', + rule: OBJECT_FIELD_REF_UNKNOWN, + where: at.where, + path: at.path, + message: `${account.message} ${at.consequence}`, + hint: `${at.prescription} ${account.detail}`, + }); + return true; + }; + const objects = asArray(stack.objects); for (let oi = 0; oi < objects.length; oi++) { const obj = objects[oi]; @@ -248,7 +536,8 @@ export function validateObjectFieldRefs(stack: AnyRec): ObjectFieldRefFinding[] // An object with no entry, or a null entry (no readable field map), is // `resolveFieldPath`'s `unknowable` — asking per entry would report the // same non-answer once per list member. - if (!graph.has(objName) || !graph.get(objName)) continue; + const owner = graph.get(objName); + if (!owner) continue; const label = `object "${objName}"`; const objPath = `objects[${oi}]`; @@ -269,21 +558,74 @@ export function validateObjectFieldRefs(stack: AnyRec): ObjectFieldRefFinding[] list.forEach((entry, i) => { if (typeof entry !== 'string' || entry.length === 0) return; - const verdict = resolveFieldPath(graph, objName, entry); - if (isUnjudgeable(verdict) || !verdict) return; - const subject = `${written}[${i}]`; - const account = describeFieldPathVerdict(verdict, entry, subject); - if (!account) return; // the name resolves — nothing to say - - findings.push({ - severity: 'error', - rule: OBJECT_FIELD_REF_UNKNOWN, + judge({ + against: objName, + name: entry, + verbatim: false, + subject: `${written}[${i}]`, where: `${label} › ${written}`, path: `${hostPath}.${written}[${i}]`, - message: `${account.message} ${position.consequence}`, - hint: `${position.prescription} ${account.detail}`, + consequence: position.consequence, + prescription: position.prescription, + }); + }); + } + + // ── indexes[i].fields[j] — verbatim physical column names ── + const indexes = Array.isArray(obj.indexes) ? obj.indexes : []; + indexes.forEach((index, xi) => { + if (!isRec(index) || !Array.isArray(index.fields)) return; + index.fields.forEach((entry, j) => { + const name = nameOf(entry); + if (!name) return; + judge({ + against: objName, + name, + verbatim: true, + subject: `indexes[${xi}].fields[${j}]`, + where: `${label} › indexes[${xi}].fields`, + path: `${objPath}.indexes[${xi}].fields[${j}]`, + consequence: INDEX_POSITION.consequence, + prescription: INDEX_POSITION.prescription, }); }); + }); + + // ── fields..[i] — the field-level lists ── + for (const field of recordsOf(obj.fields)) { + const fieldName = nameOf(field.name); + if (!fieldName) continue; + const meta = owner.fields.get(fieldName); + const target = meta?.type && PICKER_FIELD_TYPES.has(meta.type) ? meta.reference : undefined; + + // A name already reported at an entry is not reported a second time + // against the other object (the `dependsOn` pair): one fix answers both. + const reported = new Set(); + + for (const slot of FIELD_NAME_SLOTS) { + const list = field[slot.key]; + if (!Array.isArray(list)) continue; + const against = slot.address === 'owner' ? objName : target; + if (!against) continue; + + list.forEach((entry, i) => { + const read = slot.read(entry); + if (!read) return; + const at = `${slot.key}[${i}]`; + if (reported.has(`${at}=${read.name}`)) return; + const hit = judge({ + against, + name: read.name, + verbatim: slot.verbatim, + subject: `fields.${fieldName}.${at}${read.suffix}`, + where: `${label} › fields.${fieldName}.${slot.key}`, + path: `${objPath}.fields.${fieldName}.${at}${read.suffix}`, + consequence: slot.consequence, + prescription: slot.prescription, + }); + if (hit) reported.add(`${at}=${read.name}`); + }); + } } } From 9c40ba07d7300ccaf6cf48ab840cc0a3409ac4be Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 15:07:29 +0000 Subject: [PATCH 2/3] test(lint): pin the five field-name lists at both addresses, and the runtime door Claude-Session: https://claude.ai/code/session_014EJ1ED8X4MMrT18BhVx4tx Co-authored-by: Claude --- packages/lint/src/index.ts | 4 +- .../lint/src/reference-integrity-suite.ts | 9 +- .../src/validate-object-field-refs.test.ts | 342 ++++++++++++++++++ 3 files changed, 351 insertions(+), 4 deletions(-) diff --git a/packages/lint/src/index.ts b/packages/lint/src/index.ts index c32eda832bf..5dc98ebc286 100644 --- a/packages/lint/src/index.ts +++ b/packages/lint/src/index.ts @@ -497,7 +497,9 @@ export type { // [#15254] The object-level half of the same sweep: the field-name LISTS an // object carries about its own fields (`highlightFields`, -// `publicSharing.redactFields`). `error`, and on the runtime publish door as +// `publicSharing.redactFields`, and since #20432 `indexes[].fields` and the +// field-level lists `relatedListColumns` / `lookupColumns` / +// `lookupFilters[].field` / `dependsOn`). `error`, and on the runtime publish door as // well as the three commands — Studio's app builder mints no `view` items, so // the list-view members above have nothing to inspect on the only artifacts // the click path authors, and a dangling `highlightFields` reference produced diff --git a/packages/lint/src/reference-integrity-suite.ts b/packages/lint/src/reference-integrity-suite.ts index 58eb48c662e..faabe2675d5 100644 --- a/packages/lint/src/reference-integrity-suite.ts +++ b/packages/lint/src/reference-integrity-suite.ts @@ -326,9 +326,12 @@ export const REFERENCE_INTEGRITY_RULES: readonly ReferenceIntegrityRule[] = [ // authors is the OBJECT. The crossing carries the #9313 property that makes // it safe — this member resolves only against `stack.objects`, the // collection the per-write snapshot does carry, so it has no - // missing-collection false-positive channel; and it resolves each name - // against the object's OWN field map, so a one-object snapshot is not - // merely sufficient, it is the whole universe the question has. + // missing-collection false-positive channel. [#20432] Most of its names + // resolve against the object's OWN field map, but the picker keys a lookup + // field carries (`lookupColumns`, `lookupFilters[].field`, the filter half + // of `dependsOn`) address the REFERENCED object: that object is judged when + // the snapshot's `objects` carries it, and an object it does not carry is + // `unknowable` — never a miss — so the channel stays closed. // // It names `flow` because EVERY member of this suite does — the #4463 P1 // surface is the floor the member axis was never meant to narrow, and diff --git a/packages/lint/src/validate-object-field-refs.test.ts b/packages/lint/src/validate-object-field-refs.test.ts index b3d084a9d4f..b4f1c8d13f4 100644 --- a/packages/lint/src/validate-object-field-refs.test.ts +++ b/packages/lint/src/validate-object-field-refs.test.ts @@ -324,3 +324,345 @@ describe('registry wiring', () => { expect(entry.runtimeTypes).toContain('object'); }); }); + +// --------------------------------------------------------------------------- +// [#20432] The field-level name lists and `indexes[].fields`. +// +// A two-object stack in the showcase's own shape: an invoice whose `account` +// lookup points at an account. The names each list may use are decided per +// key by its runtime READER (see the module note's table), so every block +// below pins BOTH directions of the address: a name of the object the list +// addresses passes, and a name that exists only on the OTHER object is still +// refused. +// --------------------------------------------------------------------------- +const account = (over: Record = {}) => ({ + name: 'crm_account', + fields: { + name: { type: 'text' }, + industry: { type: 'select' }, + status: { type: 'select' }, + region: { type: 'text' }, + }, + ...over, +}); + +const invoice = (fields: Record, over: Record = {}) => ({ + name: 'crm_invoice', + fields: { + name: { type: 'text' }, + total: { type: 'currency' }, + region: { type: 'text' }, + // Owner-only field — the referenced account does not have it. + issued_on: { type: 'date' }, + ...fields, + }, + ...over, +}); + +const lookup = (over: Record = {}) => ({ + type: 'lookup', + reference: 'crm_account', + ...over, +}); + +const twoObjects = (fields: Record, over: Record = {}) => ({ + objects: [invoice(fields, over), account()], +}); + +describe('validateObjectFieldRefs — relatedListColumns (addresses the OWNING object)', () => { + it('REFUSES a misspelt column at the exact path, naming the owning object and its fields', () => { + const findings = validateObjectFieldRefs(twoObjects({ + account: lookup({ relatedListColumns: ['name', 'totl'] }), + })); + expect(findings).toHaveLength(1); + expect(findings[0]).toMatchObject({ + severity: 'error', + rule: OBJECT_FIELD_REF_UNKNOWN, + path: 'objects[0].fields.account.relatedListColumns[1]', + where: 'object "crm_invoice" › fields.account.relatedListColumns', + }); + expect(findings[0]!.message).toContain('"totl" is not a field on object "crm_invoice"'); + expect(findings[0]!.message).toContain('Did you mean "total"?'); + // The prescription: the addressed object's field list. + expect(findings[0]!.hint).toContain('Fields on "crm_invoice": account, issued_on, name, region, total.'); + }); + + it('passes columns that are fields of the owning object (the child whose rows the list shows)', () => { + expect(validateObjectFieldRefs(twoObjects({ + account: lookup({ relatedListColumns: ['name', 'total', 'issued_on'] }), + }))).toEqual([]); + }); + + it('REFUSES a column that exists only on the REFERENCED object — the list shows the child\'s rows', () => { + const findings = validateObjectFieldRefs(twoObjects({ + account: lookup({ relatedListColumns: ['industry'] }), + })); + expect(findings.map((f) => f.path)).toEqual(['objects[0].fields.account.relatedListColumns[0]']); + expect(findings[0]!.message).toContain('object "crm_invoice"'); + }); + + it('keeps the family\'s path resolution: a dotted column through a real lookup resolves', () => { + expect(validateObjectFieldRefs(twoObjects({ + account: lookup({ relatedListColumns: ['account.industry'] }), + }))).toEqual([]); + }); +}); + +describe('validateObjectFieldRefs — lookupColumns (addresses the REFERENCED object)', () => { + it('REFUSES a misspelt name in the string arm, against the referenced object', () => { + const findings = validateObjectFieldRefs(twoObjects({ + account: lookup({ lookupColumns: ['name', 'industy'] }), + })); + expect(findings).toHaveLength(1); + expect(findings[0]).toMatchObject({ + severity: 'error', + rule: OBJECT_FIELD_REF_UNKNOWN, + path: 'objects[0].fields.account.lookupColumns[1]', + }); + expect(findings[0]!.message).toContain('"industy" is not a field on object "crm_account"'); + expect(findings[0]!.message).toContain('Did you mean "industry"?'); + expect(findings[0]!.hint).toContain('Fields on "crm_account": industry, name, region, status.'); + }); + + it('REFUSES a misspelt `field` in the object arm, at `.field`', () => { + const findings = validateObjectFieldRefs(twoObjects({ + account: lookup({ lookupColumns: [{ field: 'stauts', label: 'Lifecycle' }] }), + })); + expect(findings.map((f) => f.path)).toEqual(['objects[0].fields.account.lookupColumns[0].field']); + expect(findings[0]!.rule).toBe(OBJECT_FIELD_REF_UNKNOWN); + }); + + it('passes both arms when every name is a field of the referenced object', () => { + expect(validateObjectFieldRefs(twoObjects({ + account: lookup({ lookupColumns: ['name', { field: 'industry', label: 'Industry' }] }), + }))).toEqual([]); + }); + + it('REFUSES a name that exists only on the OWNING object — the picker lists the referenced records', () => { + const findings = validateObjectFieldRefs(twoObjects({ + account: lookup({ lookupColumns: ['issued_on'] }), + })); + expect(findings.map((f) => f.path)).toEqual(['objects[0].fields.account.lookupColumns[0]']); + expect(findings[0]!.message).toContain('object "crm_account"'); + }); + + it('judges a dotted name as ONE name — the picker reads its columns verbatim', () => { + // `region` is a real field on the account; `account.region` would resolve + // as a PATH from the invoice, but the picker never walks one. + const findings = validateObjectFieldRefs(twoObjects({ + account: lookup({ lookupColumns: ['crm_account.region'] }), + })); + expect(findings.map((f) => f.path)).toEqual(['objects[0].fields.account.lookupColumns[0]']); + }); + + it('judges a `user` field against `sys_user`, the target its type fixes', () => { + const stack = { + objects: [ + invoice({ approver: { type: 'user', lookupColumns: ['email', 'emial'] } }), + { name: 'sys_user', fields: { name: { type: 'text' }, email: { type: 'email' } } }, + ], + }; + const findings = validateObjectFieldRefs(stack); + expect(findings.map((f) => f.path)).toEqual(['objects[0].fields.approver.lookupColumns[1]']); + expect(findings[0]!.message).toContain('object "sys_user"'); + }); + + it('stays silent when the referenced object is not in this stack (skip 1)', () => { + expect(validateObjectFieldRefs({ + objects: [invoice({ owner_account: lookup({ reference: 'elsewhere', lookupColumns: ['anything'] }) })], + })).toEqual([]); + }); + + it('stays silent on a type with no picker: nothing reads the key there', () => { + expect(validateObjectFieldRefs(twoObjects({ + notes: { type: 'text', lookupColumns: ['nope'] }, + }))).toEqual([]); + }); +}); + +describe('validateObjectFieldRefs — lookupFilters[].field (addresses the REFERENCED object)', () => { + it('REFUSES a misspelt filter field against the referenced object, at `.field`', () => { + const findings = validateObjectFieldRefs(twoObjects({ + account: lookup({ lookupFilters: [{ field: 'statsu', operator: 'ne', value: 'churned' }] }), + })); + expect(findings).toHaveLength(1); + expect(findings[0]).toMatchObject({ + severity: 'error', + rule: OBJECT_FIELD_REF_UNKNOWN, + path: 'objects[0].fields.account.lookupFilters[0].field', + }); + expect(findings[0]!.message).toContain('"statsu" is not a field on object "crm_account"'); + expect(findings[0]!.hint).toContain('Fields on "crm_account":'); + }); + + it('passes a filter over a field of the referenced object', () => { + expect(validateObjectFieldRefs(twoObjects({ + account: lookup({ lookupFilters: [{ field: 'status', operator: 'ne', value: 'churned' }] }), + }))).toEqual([]); + }); + + it('REFUSES a filter over a field only the OWNING object has', () => { + const findings = validateObjectFieldRefs(twoObjects({ + account: lookup({ lookupFilters: [{ field: 'total', operator: 'gt', value: 0 }] }), + })); + expect(findings.map((f) => f.path)).toEqual(['objects[0].fields.account.lookupFilters[0].field']); + }); +}); + +describe('validateObjectFieldRefs — dependsOn (the gate on the OWNER, the filter on the REFERENCE)', () => { + it('REFUSES a misspelt name in the string arm against the owning object — once, not twice', () => { + const findings = validateObjectFieldRefs(twoObjects({ + contact: lookup({ dependsOn: ['regoin'] }), + })); + // One typo, one finding: the same name is not reported again against the + // referenced object, since one fix answers both. + expect(findings).toHaveLength(1); + expect(findings[0]).toMatchObject({ + severity: 'error', + rule: OBJECT_FIELD_REF_UNKNOWN, + path: 'objects[0].fields.contact.dependsOn[0]', + }); + expect(findings[0]!.message).toContain('"regoin" is not a field on object "crm_invoice"'); + expect(findings[0]!.message).toContain('stays gated for good'); + }); + + it('passes a bare name that is a field on BOTH sides (the shorthand)', () => { + expect(validateObjectFieldRefs(twoObjects({ + contact: lookup({ dependsOn: ['region'] }), + }))).toEqual([]); + }); + + it('REFUSES a bare name the owner has but the referenced object lacks — it is the filter key too', () => { + const findings = validateObjectFieldRefs(twoObjects({ + contact: lookup({ dependsOn: ['issued_on'] }), + })); + expect(findings.map((f) => f.path)).toEqual(['objects[0].fields.contact.dependsOn[0]']); + expect(findings[0]!.message).toContain('object "crm_account"'); + expect(findings[0]!.hint).toContain('param'); + }); + + it('object arm: judges `field` on the owner and `param` on the referenced object', () => { + expect(validateObjectFieldRefs(twoObjects({ + contact: lookup({ dependsOn: [{ field: 'issued_on', param: 'region' }] }), + }))).toEqual([]); + + const findings = validateObjectFieldRefs(twoObjects({ + contact: lookup({ dependsOn: [{ field: 'isued_on', param: 'regon' }] }), + })); + expect(findings.map((f) => f.path)).toEqual([ + 'objects[0].fields.contact.dependsOn[0].field', + 'objects[0].fields.contact.dependsOn[0].param', + ]); + expect(findings[0]!.message).toContain('object "crm_invoice"'); + expect(findings[1]!.message).toContain('object "crm_account"'); + }); + + it('object arm without `param`: `field` is the filter key as well', () => { + const findings = validateObjectFieldRefs(twoObjects({ + contact: lookup({ dependsOn: [{ field: 'total' }] }), + })); + expect(findings.map((f) => f.path)).toEqual(['objects[0].fields.contact.dependsOn[0].field']); + expect(findings[0]!.message).toContain('object "crm_account"'); + }); + + it('on a type with no picker, only the gate is judged — against the owning object', () => { + // The cascading select: `province` gates on `country`, and its per-option + // `visibleWhen` is the rule. No referenced object exists to judge against. + const ok = { objects: [invoice({ + country: { type: 'select' }, + province: { type: 'select', dependsOn: ['country'] }, + })] }; + expect(validateObjectFieldRefs(ok)).toEqual([]); + + const findings = validateObjectFieldRefs({ objects: [invoice({ + country: { type: 'select' }, + province: { type: 'select', dependsOn: ['contry'] }, + })] }); + expect(findings.map((f) => f.path)).toEqual(['objects[0].fields.province.dependsOn[0]']); + expect(findings[0]!.message).toContain('Did you mean "country"?'); + }); + + it('a registry-injected column on the owner is a live gate (skip 3)', () => { + expect(validateObjectFieldRefs({ objects: [invoice({ + note: { type: 'text', dependsOn: ['owner_id'] }, + }, { ownership: 'user' })] })).toEqual([]); + }); +}); + +describe('validateObjectFieldRefs — indexes[].fields (verbatim physical columns)', () => { + it('REFUSES a misspelt index column at the exact path, with the owning object\'s field list', () => { + const findings = validateObjectFieldRefs({ objects: [invoice({}, { + indexes: [{ fields: ['name'] }, { fields: ['region', 'totl'], unique: true }], + })] }); + expect(findings).toHaveLength(1); + expect(findings[0]).toMatchObject({ + severity: 'error', + rule: OBJECT_FIELD_REF_UNKNOWN, + path: 'objects[0].indexes[1].fields[1]', + where: 'object "crm_invoice" › indexes[1].fields', + }); + expect(findings[0]!.message).toContain('"totl" is not a field on object "crm_invoice"'); + expect(findings[0]!.message).toContain('Did you mean "total"?'); + expect(findings[0]!.message).toContain('`unique` index is then silently unenforced'); + expect(findings[0]!.hint).toContain('Fields on "crm_invoice":'); + }); + + it('passes authored columns and the columns the platform injects', () => { + expect(validateObjectFieldRefs({ objects: [invoice({}, { + indexes: [ + { fields: ['region', 'total'], unique: true }, + { fields: ['organization_id', 'created_at'] }, + { fields: ['id'] }, + ], + })] })).toEqual([]); + }); + + it('REFUSES a dotted column — an index names physical columns, never a path', () => { + const findings = validateObjectFieldRefs({ objects: [invoice({ account: lookup() }, { + indexes: [{ fields: ['account.name'] }], + }), account()] }); + expect(findings.map((f) => f.path)).toEqual(['objects[0].indexes[0].fields[0]']); + }); + + it('judges EXISTENCE only: a virtual formula column resolves here, and materialization stays with the sync', () => { + expect(validateObjectFieldRefs({ objects: [invoice({ margin: { type: 'formula' } }, { + indexes: [{ fields: ['margin'] }], + })] })).toEqual([]); + }); + + it('is inert on junk index entries', () => { + expect(validateObjectFieldRefs({ objects: [invoice({}, { + indexes: [null, 'x', { fields: 'name' }, { fields: [null, 3, ''] }, {}], + })] })).toEqual([]); + }); +}); + +describe('validateObjectFieldRefs — the runtime publish door judges the new positions too', () => { + it('refuses an object write whose index names a column it does not have', () => { + const result = runRuntimeAuthoringRules({ + type: 'object', + item: obj({ indexes: [{ fields: ['name', 'helth_score'], unique: true }] }), + context: { objects: [] }, + }); + const refusal = result.errors.find((f) => f.rule === OBJECT_FIELD_REF_UNKNOWN); + expect(refusal, JSON.stringify(result.errors)).toBeDefined(); + expect(refusal!.path).toBe('objects.proj_task.indexes[0].fields[1]'); + expect(refusal!.severity).toBe('error'); + }); + + it('resolves a lookup\'s picker columns against the referenced object the context carries', () => { + const write = (lookupColumns: unknown[]) => runRuntimeAuthoringRules({ + type: 'object', + // A publishable object in every other respect, so the only rule that + // can speak is the one this block is about. + item: invoice({ account: lookup({ lookupColumns }) }, { + label: 'Invoice', sharingModel: 'private', nameField: 'name', + }), + context: { objects: [account()] }, + }); + expect(write(['name', 'industry']).errors, 'clean').toEqual([]); + const refusal = write(['name', 'industy']).errors.find((f) => f.rule === OBJECT_FIELD_REF_UNKNOWN); + expect(refusal).toBeDefined(); + expect(refusal!.path).toBe('objects.crm_invoice.fields.account.lookupColumns[1]'); + }); +}); From af444b4cd12f2bb8252ce319d94a9b676382310b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 15:08:05 +0000 Subject: [PATCH 3/3] =?UTF-8?q?chore(changeset):=20lint=20minor=20?= =?UTF-8?q?=E2=80=94=20five=20more=20field-name=20positions=20refused=20at?= =?UTF-8?q?=20authoring?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_014EJ1ED8X4MMrT18BhVx4tx Co-authored-by: Claude --- .changeset/20432-field-name-list-refs.md | 66 ++++++++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 .changeset/20432-field-name-list-refs.md diff --git a/.changeset/20432-field-name-list-refs.md b/.changeset/20432-field-name-list-refs.md new file mode 100644 index 00000000000..4008b29c1a4 --- /dev/null +++ b/.changeset/20432-field-name-list-refs.md @@ -0,0 +1,66 @@ +--- +'@objectstack/lint': minor +--- + +fix(lint)!: `object-field-ref-unknown` judges the field-name lists on a field — `relatedListColumns`, `lookupColumns`, `lookupFilters[].field`, `dependsOn` — and an object's `indexes[].fields` + + + +**BREAKING** in the accept-set sense — a declaration that passes today can fail tomorrow. +Landing in the launch window as `minor` (the lockstep convention: `major` is refused by +`check-changeset-no-major`, and breaking-ness is carried by this banner plus the ADR-0087 +disposition above). + +**Clause-②: no (narrowing)** — the rule refuses more than it did; no key is added to any +published payload and no public surface grows. Narrowing is still a semantic-surface change, +which is why it is declared here rather than shipped silently. + +Each of these five lists holds bare field names that the schema cannot judge, and until now no +authoring door read them for existence, so a misspelling surfaced only when a user opened the +view or the picker — or never: + +- a misspelt `relatedListColumns` entry asked the child object for a column it does not have, + when the parent's detail page opened; +- a misspelt `lookupColumns` entry rendered an empty picker column; +- a misspelt `lookupFilters[].field` filtered the picker's query by a field the referenced + object lacks; +- a misspelt `dependsOn` name kept its field gated for good; +- a misspelt `indexes[].fields` column made the SQL driver skip the WHOLE index at sync, with a + warning, and drift dropped it too — so a `unique` index was silently unenforced while + everything looked normal. + +`os validate`, `os build` and `os lint` now refuse each of them at `error` (exit 1), under the +existing rule id `object-field-ref-unknown`, and so does the runtime publish door on an object +write (`422`), exactly as they already did for `highlightFields` and +`publicSharing.redactFields`. The finding sits at the exact path — +`objects[i].fields..lookupColumns[j].field`, `objects[i].indexes[j].fields[k]`, and so +on — names the string that was written and the object it was judged against, offers the +nearest name when one is close, and lists that object's fields. + +**Which object a name is judged against** — read off each key's runtime reader, not assumed: + +| Position | Judged against | +|:---|:---| +| `relatedListColumns[]` | the object that owns the field — the related list shows that (child) object's rows | +| `lookupColumns[]`, both arms | the referenced object — the picker lists its records | +| `lookupFilters[].field` | the referenced object — the picker's query runs on it | +| `dependsOn[]` name, or `{ field }` | the object that owns the field — the form gate reads this record | +| `dependsOn[]` `param` (or the bare name, on a picker) | the referenced object — the picker filters its candidates by that key | +| `indexes[].fields[]` | the object itself, including the columns the platform injects (`created_at`, `organization_id`, …) | + +The referenced-object positions are judged on `lookup`, `master_detail` and `user` fields (a +`user` field references `sys_user`), and only when the referenced object is in the stack being +checked. `lookupColumns`, `dependsOn` and index columns are read verbatim by their readers, so a +dotted name there is refused as a name that is not a field. The family's three skips hold +unchanged: an object outside the stack, an object with no readable field map (ADR-0015 +`external`), and a registry-injected column resolved per object. + +**What an author does.** Nothing is renamed or rewritten for you. Fix the name the finding +points at, or drop the entry. On a lookup whose `dependsOn` field is spelled differently on the +two records, write the entry with its `param` naming the referenced object's field. An existing +object carrying one of these misspellings is refused when it is next republished through the +publish door, and `os validate` reports it on the next run. + +Unchanged: the object schema's own parse still admits these names, so a draft save does not +judge them. An index column that resolves to a real but virtual field (a `formula`) passes this +rule; whether the column is materialized stays the SQL driver's question at sync.