Skip to content

Commit 4b2d904

Browse files
fix(lint)!: object-field-ref-unknown judges a field's relatedListColumns, lookupColumns, lookupFilters and dependsOn, and indexes[].fields (#20479)
Part of #20432 Clause-②: no ## What this does This is step 1 of the triage direction on the card (`5871296642`). The object-level field-reference rule `object-field-ref-unknown` (`packages/lint/src/validate-object-field-refs.ts`, `error`, already on `os validate` / `os build` / `os lint` and the runtime publish door for object writes) now judges five more field-name lists. Before, it judged `highlightFields` and `publicSharing.redactFields` only: - field level: `relatedListColumns`, `lookupColumns` (both arms), `lookupFilters[].field`, `dependsOn` (both arms); - object level: `indexes[].fields` (the fold `5870812245`). A name that is not a field of the object the list addresses is refused at the exact path. Examples: `objects[i].fields.FIELD.lookupColumns[j].field` and `objects[i].indexes[j].fields[k]`. The message has the family's shape: the string that was written, the object it was judged against, a "Did you mean" when a name is close, then the consequence. The hint ends with that object's field list (`Fields on "X": a, b, c.`). The rule id, severity and suite wiring are unchanged. No new rule file and no new gate. Step 2 is not in this PR: `SqlDriver.syncDeclaredIndexes` logging a skipped declared index at `error` through `logDurabilityFailure`, and showing it in drift. That is the `domain:engine` seat's second PR on this card, and #20432 remains open for it. ## Which object a name is judged against (measured on each key's reader) The PM's mechanism hypothesis 3 asked for this per key. The readers were read at the objectui pin `.objectui-sha` `dd3f7e1b`: | Position | Judged against | Reader | |:---|:---|:---| | `relatedListColumns[i]` | the object that owns the field (the child) | `app-shell/src/utils/deriveRelatedLists.ts:291`, where the related list is `childObject: child.name` | | `lookupColumns[i]` / `.field` | the referenced object | `fields/src/widgets/LookupField.tsx:337,484`, picker columns over `refObjectSchema` of `referenceTo` | | `lookupFilters[i].field` | the referenced object | `LookupField.tsx:339,661` `lookupFiltersToRecord`, which feeds the query on `referenceTo`. `validate-preset-comparands.ts` already binds the same key this way. | | `dependsOn[i]` / `.field` | the object that owns the field | `LookupField.tsx:451-458`, where the gate reads the host record by this key | | `dependsOn[i].param`, or the bare name on a picker | the referenced object | `LookupField.tsx:371-380,647`: `param` defaults to `field`, and `dependentFilter[param]` | | `indexes[i].fields[j]` | the object itself, plus injected columns | `SqlDriver.syncDeclaredIndexes` reads names verbatim against `physicalColumns` | The referenced-object positions are judged only on types that render the picker. `lookup` and `master_detail` render `LookupField` (`FieldEditWidget.tsx:105-106`). `user` renders `UserField`, which delegates to `LookupField` with `sys_user` fixed (`UserField.tsx:48`). The target is the graph's `referenceTargetOf` answer. On any other type those keys have no reader and are not judged. `lookupColumns`, `dependsOn` and index columns are read verbatim by their readers, so a dotted name there is judged as one name. `relatedListColumns` and `lookupFilters[].field` keep the family's path resolution. On `dependsOn`, a name that misses on the owner is reported once, not a second time against the referenced object. The field form rows that landed with #19332 G1b (`field.form.ts:232-245`) describe the same addresses, and none of them claims a refusal, so no published text changes. ## Severity `error`, the family's existing tier. `os validate` and `os build` turn author-time `error` findings into exit 1 (`validate.ts` and `compile.ts`, `splitBySeverity` then `this.exit(1)`). This was measured on the real built CLI over a two-object fixture (lookup `invoice.account` to `account`): - `os validate`: clean variant exit 0; misspelt variant exit 1 with 5 findings, one per position (index, relatedListColumns, lookupColumns `.field`, lookupFilters `.field`, dependsOn). - `os build`: clean variant exit 0; misspelt variant exit 1 with the same 5 findings. - The runtime publish door on an object write refuses too. This is pinned in the test file, with `objects.proj_task.indexes[0].fields[1]` and a lookup resolved against a context object. ## Producers census Measured before landing, with the rule run from source (`tsx`) over every exported object: | Corpus | Objects | Entries judged (idx / rLC / lC / lF / dO) | Findings | |:---|---:|:---|---:| | platform, `packages/**/*.object.ts` at `af444b4cd1` | 84 | 352 / 0 / 0 / 0 / 3 | 0 | | `examples/app-showcase` (+ platform: 108) | 24 | 0 / 14 / 4 / 1 / 2 | 0 | | `examples/app-crm` (+ platform: 90) | 6 | 0 / 0 / 0 / 0 / 0 | 0 | | `examples/app-todo` (+ platform: 85) | 1 | 5 / 0 / 0 / 0 / 0 | 0 | | hotcrm `src/**/*.object.ts` at `5bec6eb0` | 18 | 68 / 13 / 0 / 0 / 6 | 0 | | hotcrm + platform | 102 | 420 / 13 / 0 / 0 / 9 | 0 | The hotcrm carriers match the card's reading exactly: `relatedListColumns` in 3 files, `dependsOn` in 4. There is no real misspelling in any producer, so nothing is fixed at a producer and nothing is handed to the hotcrm lane. The controls are lit: - showcase: 5 planted misspellings (one per field-level position, both `lookupColumns` arms) gave 5 findings; - platform: 1 planted index misspelling (`sys_metadata_audit`) gave 1 finding; - hotcrm: 12 planted misspellings gave 12 new findings. `examples/app-multi-package` declares 2 inline objects that carry none of these keys (population 0). ## Tests (all at `af444b4cd1`) - `@objectstack/lint`: `vitest run`, 115 files, 5355 passed / 5 skipped. `typecheck`, including `check:test-typecheck` over the test layer, exit 0. The rule file went from 31 to 60 tests: per list, a misspelling is refused with its code and path, a correct name passes, and a name from the OTHER object is still refused. Both object arms are covered, plus `user` resolving to `sys_user`, the three skips, verbatim dotted names, and junk inertness. - `@objectstack/cli` `--project unit`: the first run gave 231 files passed, 3306 tests passed and 29 skipped. The other 2 files refused with the prerequisite "packages/cli is not built", which is not a red gate. After `pnpm --filter @objectstack/cli build` those 2 files passed, 29/29 tests. Integration tier is declared to CI. - `examples/app-showcase`: 29 files / 385 tests passed. - Runtime-door consumers: `metadata-protocol` `protocol-publish-drafts-object-field-refs` and `build-probes-rule-failure` (12 tests), plus `platform-objects` `sys-email.highlight-fields-resolve` (2 tests), all passed. - Filter direction: only the package itself (`@objectstack/lint`) and named consumers were run. No `...@objectstack/lint` sweep was run, because the public surface (exports, types) is byte-unchanged; the behaviour narrowing is what the consumer runs above cover. ## Ablation (one-shot, restored, not kept) The changes were committed first. Each leg was applied through `scripts/ablation-replace.mjs`, whose anchor must hit, with the landing proven by anchor count 1 to 0 and a blob change. A shell trap restored `HEAD` on exit. The test file imports the rule's source, so no build was involved. - A, field-level slots emptied: 15 failed. - B, index walk emptied: 3 failed. - C, reference address collapsed onto the owner: 13 failed. Restore was proven by blob equal to HEAD blob `ac79635f` and an empty `git diff HEAD`. ## Gates `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` at `af444b4cd1` gave 60 commands, reconciled with `--ran`: 59 exited 0 and 1 is NOT MEASURED. The unmeasured one is `pnpm check:dual-build-cjs-loads`, which exited 3 with PREREQUISITE NOT MET because it reads every package's dist and 9 unrelated packages are unbuilt in this worktree. The four artifact-roster gates flagged under this diff's directories also exited 0: `check-changeset-fixed`, `check:authz-resolver`, `check:error-code-casing` and `check:filter-alias-parity`. ESLint, as a proven narrowing, ran on the 4 changed TS files (`--format json`: 4 files, 0 errors, 0 warnings). The config enables no type-aware linting (the printed config has `parserOptions` equal to `ecmaVersion` and `sourceType` only), so this diff cannot move a verdict on an untouched file. The repo-wide `pnpm lint` is CI's. ## Acceptance notes - `carrier: #20432`: step 2, the sync half, belongs to the `domain:engine` seat's second PR on this card. `SqlDriver.syncDeclaredIndexes` logs a skipped declared index (`unique` above all) at `warn`. Its duplicate-row sibling in the same function logs at `error` through `logDurabilityFailure`. `expectedIndexes` drops the index from drift. The Studio save door (`ObjectSchema.safeParse`) still admits a misspelt name, so that half still matters after this PR. - Boundary: this rule judges existence only. An index column that names a real but virtual field (a `formula`) passes here and is still skipped at sync. Materialization stays the driver's question. It is written into the rule's docblock, which replaces the old "`indexes[].fields[]` is a storage question" paragraph (reason: that exclusion left a misspelling with no door at all). - Boundary: the build-probes receipt plane (`metadata-protocol/src/build-probes.ts`) runs this rule over a one-object universe. The referenced-object positions are therefore unknowable, and silent, there. Its owner-addressed positions are judged. The door-time gate judges both. - Boundary, pre-existing in the shared object graph: fields added by an `objectExtensions` entry are not merged into the graph. A list naming only such a field would be refused as a false finding. The census gave 0 findings in every producer measured, so no such case arose. Recorded here, not filed. - File surface beyond the named file, same package, comments only: `reference-integrity-suite.ts`'s member note said the rule resolves names against the object's OWN field map only, which is now false for the picker keys, so it was rewritten. The `index.ts` export comment lists the new positions. - The changeset carries `Clause-②: no (narrowing)` with a BREAKING banner and `minor`, following the ADR-0087 gate (`not-required (no-migration-prescription)`) and the precedent of the `security-role-word` field-groups changeset. This body carries the claim's `Clause-②: no` verbatim. - `origin/main` moved during the run (`dc0ab6a2ed`). This branch was not re-merged. CI validates the merge ref. The gate derivation flagged one stale family file (`scripts/cross-package-test-inputs.mjs`), whose gate ran green on this tree. --- _Generated by [Claude Code](https://claude.ai/code/session_014EJ1ED8X4MMrT18BhVx4tx)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent b810ddb commit 4b2d904

5 files changed

Lines changed: 784 additions & 29 deletions

File tree

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
---
2+
'@objectstack/lint': minor
3+
---
4+
5+
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`
6+
7+
<!-- adr-0087: not-required (no-migration-prescription) an authoring-time lint rule judges names on five more declaration positions; no key, symbol, enum member or stored value moves, so a stored metadata row is structurally identical before and after and `objectstack migrate meta` has nothing to rewrite -->
8+
9+
**BREAKING** in the accept-set sense — a declaration that passes today can fail tomorrow.
10+
Landing in the launch window as `minor` (the lockstep convention: `major` is refused by
11+
`check-changeset-no-major`, and breaking-ness is carried by this banner plus the ADR-0087
12+
disposition above).
13+
14+
**Clause-②: no (narrowing)** — the rule refuses more than it did; no key is added to any
15+
published payload and no public surface grows. Narrowing is still a semantic-surface change,
16+
which is why it is declared here rather than shipped silently.
17+
18+
Each of these five lists holds bare field names that the schema cannot judge, and until now no
19+
authoring door read them for existence, so a misspelling surfaced only when a user opened the
20+
view or the picker — or never:
21+
22+
- a misspelt `relatedListColumns` entry asked the child object for a column it does not have,
23+
when the parent's detail page opened;
24+
- a misspelt `lookupColumns` entry rendered an empty picker column;
25+
- a misspelt `lookupFilters[].field` filtered the picker's query by a field the referenced
26+
object lacks;
27+
- a misspelt `dependsOn` name kept its field gated for good;
28+
- a misspelt `indexes[].fields` column made the SQL driver skip the WHOLE index at sync, with a
29+
warning, and drift dropped it too — so a `unique` index was silently unenforced while
30+
everything looked normal.
31+
32+
`os validate`, `os build` and `os lint` now refuse each of them at `error` (exit 1), under the
33+
existing rule id `object-field-ref-unknown`, and so does the runtime publish door on an object
34+
write (`422`), exactly as they already did for `highlightFields` and
35+
`publicSharing.redactFields`. The finding sits at the exact path —
36+
`objects[i].fields.<field>.lookupColumns[j].field`, `objects[i].indexes[j].fields[k]`, and so
37+
on — names the string that was written and the object it was judged against, offers the
38+
nearest name when one is close, and lists that object's fields.
39+
40+
**Which object a name is judged against** — read off each key's runtime reader, not assumed:
41+
42+
| Position | Judged against |
43+
|:---|:---|
44+
| `relatedListColumns[]` | the object that owns the field — the related list shows that (child) object's rows |
45+
| `lookupColumns[]`, both arms | the referenced object — the picker lists its records |
46+
| `lookupFilters[].field` | the referenced object — the picker's query runs on it |
47+
| `dependsOn[]` name, or `{ field }` | the object that owns the field — the form gate reads this record |
48+
| `dependsOn[]` `param` (or the bare name, on a picker) | the referenced object — the picker filters its candidates by that key |
49+
| `indexes[].fields[]` | the object itself, including the columns the platform injects (`created_at`, `organization_id`, …) |
50+
51+
The referenced-object positions are judged on `lookup`, `master_detail` and `user` fields (a
52+
`user` field references `sys_user`), and only when the referenced object is in the stack being
53+
checked. `lookupColumns`, `dependsOn` and index columns are read verbatim by their readers, so a
54+
dotted name there is refused as a name that is not a field. The family's three skips hold
55+
unchanged: an object outside the stack, an object with no readable field map (ADR-0015
56+
`external`), and a registry-injected column resolved per object.
57+
58+
**What an author does.** Nothing is renamed or rewritten for you. Fix the name the finding
59+
points at, or drop the entry. On a lookup whose `dependsOn` field is spelled differently on the
60+
two records, write the entry with its `param` naming the referenced object's field. An existing
61+
object carrying one of these misspellings is refused when it is next republished through the
62+
publish door, and `os validate` reports it on the next run.
63+
64+
Unchanged: the object schema's own parse still admits these names, so a draft save does not
65+
judge them. An index column that resolves to a real but virtual field (a `formula`) passes this
66+
rule; whether the column is materialized stays the SQL driver's question at sync.

‎packages/lint/src/index.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -497,7 +497,9 @@ export type {
497497

498498
// [#15254] The object-level half of the same sweep: the field-name LISTS an
499499
// object carries about its own fields (`highlightFields`,
500-
// `publicSharing.redactFields`). `error`, and on the runtime publish door as
500+
// `publicSharing.redactFields`, and since #20432 `indexes[].fields` and the
501+
// field-level lists `relatedListColumns` / `lookupColumns` /
502+
// `lookupFilters[].field` / `dependsOn`). `error`, and on the runtime publish door as
501503
// well as the three commands — Studio's app builder mints no `view` items, so
502504
// the list-view members above have nothing to inspect on the only artifacts
503505
// the click path authors, and a dangling `highlightFields` reference produced

‎packages/lint/src/reference-integrity-suite.ts‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -326,9 +326,12 @@ export const REFERENCE_INTEGRITY_RULES: readonly ReferenceIntegrityRule[] = [
326326
// authors is the OBJECT. The crossing carries the #9313 property that makes
327327
// it safe — this member resolves only against `stack.objects`, the
328328
// collection the per-write snapshot does carry, so it has no
329-
// missing-collection false-positive channel; and it resolves each name
330-
// against the object's OWN field map, so a one-object snapshot is not
331-
// merely sufficient, it is the whole universe the question has.
329+
// missing-collection false-positive channel. [#20432] Most of its names
330+
// resolve against the object's OWN field map, but the picker keys a lookup
331+
// field carries (`lookupColumns`, `lookupFilters[].field`, the filter half
332+
// of `dependsOn`) address the REFERENCED object: that object is judged when
333+
// the snapshot's `objects` carries it, and an object it does not carry is
334+
// `unknowable` — never a miss — so the channel stays closed.
332335
//
333336
// It names `flow` because EVERY member of this suite does — the #4463 P1
334337
// surface is the floor the member axis was never meant to narrow, and

0 commit comments

Comments
 (0)