Skip to content

Commit e9731fc

Browse files
fix(lint)!: the remaining field indexes judge an object as served, and an object that declares no field keeps the list-view skip once extended (#30608) (#30659)
<!-- claude-projects-attribution: {"github_login":"os-sales"} --> _Requested by **Sales** · [project thread](https://claude.ai/code/project/chan_01D5bWFY3rFzgJcytD5gD4Uu?thread=cmsg_01D5bWFY3rFzgJcytD5gD4UuV5cRafodM2YQHxeDkdiWJM)_ domain:spec#2 · os-sales · session_01L6bhgVSM5tJd2UYk3Aa1MM Fixes #30608 Clause-②: yes (narrowing) — `os validate` / `os build` / `os lint` refuse shapes they accepted before, each a field an `objectExtensions` entry of the same artifact adds or re-declares, now judged on its served definition: a notify node's `recipients.emailField` naming an owner `email` field an extension re-declares as `text` is `notify-email-field-not-email` at `error`; a form open to anonymous intake whose section names an `image` / `file` field an extension adds, or an owner field an extension re-declares as one, is `public-form-file-field` at `error`; a `list` page leading `allowedVisualizations` with `gallery` over an owner `image` field an extension re-declares as `text` is `page/visualization-without-binding` at `error`; a `searchableFields` entry naming an owner text field an extension re-declares as `formula` is `searchable-field-unsearchable` at `error`; an autonumber field an extension adds whose format names a field the object lacks is `autonumber-references-unknown-field` at `error`, and a format naming an owner `required: true` field an extension re-declares without it is `autonumber-references-optional-field` at `warning`; a flow template `{{ record.F.sub }}` through an owner text field an extension re-declares as a lookup is `flow-template-lookup-traversal` at `warning`. The widening half: a form section, a notify email-field recipient, a flow template hole, a gallery or calendar binding, a `searchableFields` entry (an object's own set, a list view's narrowing, a react page's `<ListView searchableFields>`) or an autonumber format naming a field an `objectExtensions` entry adds is no longer refused as unknown, and a list view over an object that declares no field is no longer refused once an extension adds to it. Metadata-protocol: narrowed Skills-docs: none — measured at `2818af5b`: `git grep` of the twelve moved rule ids over `skills/` and `content/docs/` hits `public-form-file-field` (`content/docs/ui/forms.mdx:258`, `content/docs/ui/public-data-collection.mdx:55`, `skills/objectstack-api/SKILL.md:106`), `searchable-field-unknown` / `searchable-field-unsearchable` (`content/docs/data-modeling/schema-design.mdx:153,172`, `content/docs/ui/views.mdx:182-184`, `skills/objectstack-data/SKILL.md:128,154`, `skills/objectstack-ui/rules/list-views.md:207-208`) and release pages only otherwise; none states a declared-only reading ("a file field", "a renamed / mistyped column"), so every sentence reads true. The patch round (`887e5623`) adds no rule id and changes no message. ## 维护者速读 - 改了什么:lint 里剩下的七个字段索引(表单布局、通知邮件字段收件人、公开表单文件字段、流程模板、列表页可视化绑定、可搜索字段、自动编号格式)都改读“注册表实际提供的对象”,即声明字段加上同一制品里 `objectExtensions` 加的字段;React 页的 `<ListView searchableFields>` 也读同一份;列表视图规则对“一个字段都没声明的对象”恢复不判。 - 为什么改:HotCRM 这类多包制品里,模块包给 app 包的对象加字段,运行时会提供这些字段,lint 却把引用它们报成“未知字段”;扩展重新声明的字段又按 owner 的旧定义判断,真正的问题被漏掉。 - 风险与回滚:这是收窄。扩展重新声明或新加的字段按它的定义判断,会拒以前放行的几种写法:通知收件字段被改成 text、公开表单上出现图片/文件字段、画廊页的图片字段被改成 text、可搜字段被改成 formula、扩展自带的自动编号格式引用不存在的字段(以上为错误),以及自动编号引用的必填字段被改成非必填、流程模板穿过被改成 lookup 的字段(以上为警告)。两个包在同一优先级写同一字段时不判定义;一个字段都没声明的对象照旧不判。回滚 = revert 本 PR。 - 先看:`packages/lint/src/validate-searchable-fields.ts` 的 `indexServedSearchTargets`(名字从带并列的 served 对象取、并列字段的定义置空占位、不声明字段对象的 null);`packages/lint/src/validate-page-visualization-bindings.ts` 的 skip 2 与 `tiedOnObject`;`packages/lint/src/object-graph.test.ts` 末尾的 `[#30608]` 一组。 - 两分钟验证:运行 `pnpm --filter @objectstack/lint exec vitest run src/object-graph.test.ts -t 30608`,预期 42 例全绿;其中 moved 表 14 行各自断言 main 视角(无扩展)的旧结论和现在的新结论。 - 需要你做的:无,席位落地。 ## What changes One artifact, two legs (the union artifact and the per-package body, identical on every row): an app package declares `xs_deal` (`name`, plus the owner field a row names) and `xs_account`; a module package extends `xs_deal`. | Site | Moved or kept | Pin | | --- | --- | --- | | `validate-form-layout.ts` field-name set | moved to `objectsAsServed`; tie name kept (existence); an unextended object keeps its map-only read | added passes, refused without, `nowhere` byte-for-byte | | `validate-notify-email-field-recipients.ts` `indexFieldTypes` | moved; a tied field is `null` (skipped); declares-nothing keeps its empty map | existence row; two moved rows; tie; declares-nothing | | `validate-public-form-file-fields.ts` `indexFieldTypes` | moved, ties dropped; declares-nothing keeps its empty map | three moved rows; tie; declares-nothing | | `validate-flow-template-paths.ts` field map | moved; a tied head is known with no type; root resolution and the provenance anchors stay on the declared object | existence row; two moved rows; tie | | `validate-page-visualization-bindings.ts` derivation | moved, ties dropped, an object carrying a tie not reported on a miss; skip 2 asked of the declared object; own list views stay declared | two moved rows; tie; declares-nothing | | `validate-searchable-fields.ts` | new `indexServedSearchTargets`: names read off the served objects with ties kept, then each tied field's meta blanked to a stub (so an object whose every served field is tied keeps a target and still refuses an unknown entry), declares-nothing kept `null`; `validateSearchableFields` reads it; `indexObjectSearchTargets` stays the declared primitive (docblock says why) | two existence rows; moved row; tie; declares-nothing; the all-tied corner on the object and the list view, both registration orders, with its unextended and single-writer controls | | `validate-react-page-props.ts` `<ListView searchableFields>` | moved to `indexServedSearchTargets` (file surface amended by the seat) | existence row; the PR #30533 "still declared" pin flipped in place | | `lint-autonumber-formats.ts` | walks and resolves the served fields, ties dropped (a tied reference is neither unknown nor optional); an object declaring no field is skipped | existence row; four moved rows; tie; declares-nothing | | `validate-list-view-field-refs.ts` (item 2) | skip 2 asked of the declared graph: an object that declares no field stays `null` when extended | item-2 pin with and without the extension, plus the declared control; declares-nothing row | ### Every finding that moves, against `main` Measured with `runAuthoringRules('validate', { normalized, parsed })` on `origin/main` `a8390207` and on `2818af5b`, union and per-package legs (identical on every case); 58 cases, every unlisted case unchanged (the no-extension controls and every `nowhere` control included). The last two rows were measured in the patch round on `a8390207` and `887e5623` (the rule sources of each, through `validateFlowTemplatePaths` / `validateFormLayout`, both legs identical). | Stack (field added or re-declared by the extension) | `main` | this head | | --- | --- | --- | | form section naming an added field (also on a tie) | `form-field-unknown` warning | nothing | | notify `emailField` naming an added `email` field (also on a tie) | `notify-email-field-unknown` error | nothing | | notify `emailField` naming an added `text` field | `notify-email-field-unknown` error | `notify-email-field-not-email` error | | notify `emailField` naming an owner `email` field re-declared `text` | nothing | **`notify-email-field-not-email` error** | | public form section naming an added `image` field | (`form-field-unknown` warning) | **`public-form-file-field` error** | | public form section naming an owner `text` field re-declared `file` | nothing | **`public-form-file-field` error** | | public form section naming an owner `file` field re-declared `text` | `public-form-file-field` error | nothing | | flow template `{{ record.region }}`, added field (also `{{ record.account.name }}` on a tie) | `flow-template-unknown-field` warning | nothing | | flow template `{{ record.account.name }}`, owner `text` re-declared lookup | nothing | **`flow-template-lookup-traversal` warning** | | flow template `{{ record.account.name }}`, owner lookup re-declared `text` | `flow-template-lookup-traversal` warning | nothing | | gallery page, added `image` field (also on a tie) | `page/visualization-without-binding` error | nothing | | gallery page, owner `image` field re-declared `text` | nothing | **`page/visualization-without-binding` error** | | `searchableFields` (object's own, list view's) naming an added field (also on a tie) | `searchable-field-unknown` error | nothing | | object `searchableFields` naming an owner `text` field re-declared `formula` | nothing | **`searchable-field-unsearchable` error** | | autonumber `{region}{000}`, added `required` field (also on a tie) | `autonumber-references-unknown-field` error | nothing | | autonumber `{region}{000}`, added optional field | `autonumber-references-unknown-field` error | `autonumber-references-optional-field` warning | | autonumber `{plan}{000}`, owner `required` field re-declared optional | nothing | **`autonumber-references-optional-field` warning** | | an autonumber field the extension adds, format naming nothing | nothing | **`autonumber-references-unknown-field` error** | | an owner autonumber re-declared `text` | `autonumber-references-unknown-field` error | nothing | | list view over `xs_empty` (declares no field), extended (item 2) | `list-view-field-unknown` error | nothing | | the same view over a declares-nothing object with a list `searchableFields` | `list-view-field-unknown` error | nothing | | a form over a declares-nothing object, extended: the added field's entry | `form-field-unknown` warning | nothing (its other entries still warn, as on `main`) | | a flow variable `partner` bound to an `xs_account` record (`{{ partner.nowhere }}`), where the extension adds a `partner` field to the trigger object | `flow-template-unknown-field` warning | nothing (the trigger scope now carries the served name, so `partner` is an ambiguous root, as a variable named after a declared trigger field is; a variable `acct` is still judged) | | a form section naming `legacy`, an owner field declared in the legacy array `fields` shape, on an extended object | `form-field-unknown` warning | nothing (an extended object's names come off the served list, which reads both shapes; unextended, still `form-field-unknown` warning) | ## Verification ### Patch round (head `887e5623`) The contract review (6670491743) asked for one code fix and two declarations. `887e5623`: `indexServedSearchTargets` reads names off the served objects with ties kept and blanks the tied metas afterwards, so an object whose every served field is tied (the owner declares only `title`, two packages re-declare it) still refuses `searchableFields: ['nowhere']` as `main` does; the changeset's Now / Not changed name the flow-variable root and the legacy array-shape moves (the last two rows of the table above). Same base `a8390207`, `NODE_OPTIONS=--max-old-space-size=4096`, heavy runs under `os-verify-lock`. - `pnpm --filter @objectstack/lint test && pnpm --filter @objectstack/lint typecheck`: Test Files 195 passed (195), Tests 8216 passed (8216); `check:test-typecheck: OK`; VERDICT command-exit 0. The `[#30608]` describe: 42 passed; `object-graph.test.ts` 200 passed. - Full dogfood battery: the closure build (VERDICT command-exit 0), then `pnpm --filter @objectstack/dogfood test` under the lock: VERDICT command-exit 0; Test Files 420 passed, 3 skipped (423); Tests 3504 passed, 36 skipped (3540). - `npx eslint --no-inline-config` over the 2 `.ts` files the round changes: exit 0. - `dispatch-gates --commands` at `887e5623`: the same 57 commands as at `2818af5b`, each run as printed, all 57 exit 0 (the seven `dist/` from the first round still present). `dispatch-gates --ran` over `<command> :: exit <code>` lines: "57 derived, 57 run, 0 NOT-MEASURED, 0 UNRUN", a derived zero, exit 0. `check-changeset-no-major --base origin/main --event EVENT` with this body as the `pull_request` payload: exit 0. - Ablation of the fix through `scripts/ablation-replace.mjs`, the target built from the tie-dropped objects with the tied names re-added (the `2818af5b` code, inlined): **1 failed** / 199 passed (the all-tied pin), restored, blob equal to HEAD, `git diff HEAD` empty. - `origin/main` is at `b77c160a`; not merged. ### First round (head `2818af5b`) At head `2818af5b` (base `origin/main` `a8390207`), `NODE_OPTIONS=--max-old-space-size=4096`, heavy runs under `os-verify-lock`. - `pnpm --filter @objectstack/lint test && pnpm --filter @objectstack/lint typecheck`: Test Files 195 passed (195), Tests 8210 passed, 5 skipped (8215); `check:test-typecheck: OK`; VERDICT command-exit 0. The `[#30608]` describe: 41 passed; `object-graph.test.ts` 199 passed. - Full dogfood battery (the `(narrowing)` arm owes it): the closure `turbo run build --filter='@objectstack/dogfood^...'` (VERDICT command-exit 0), then `pnpm --filter @objectstack/dogfood test` under the lock: VERDICT command-exit 0; Test Files 420 passed, 3 skipped (423); Tests 3504 passed, 36 skipped (3540). - `npx eslint --no-inline-config` over the 10 changed `.ts` files: exit 0. - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` at `2818af5b`: 57 derived commands, each run as printed with its exit code recorded. First pass: 56 exit 0 and `pnpm check:dual-build-cjs-loads` exit 3 (PREREQUISITE NOT MET: seven packages with no `dist/`: studio, client-react, embedder-openai, knowledge-memory, knowledge-ragflow, service-cluster-redis, service-knowledge); after building those seven under the lock it exits 0 (116 entry points across 71 packages load). `check-plugin-teardown-shape.mjs --self-test` ran after fetching its fixture commit `621a4876`. `dispatch-gates --ran`: "57 derived, 57 run, 0 NOT-MEASURED, 0 UNRUN", exit 0. `check-adr-0087-registration --base origin/main`, `check-changeset-no-major --base origin/main` and `check-empty-changeset --base origin/main`: exit 0. - `origin/main` moved to `b8929f48` after the base (one spec docblock commit, no `packages/lint` file); not merged. <details><summary>Ablations (each through scripts/ablation-replace.mjs: anchor hit once, object-graph.test.ts run, restored, blob equal to HEAD, git diff HEAD empty)</summary> The lint tests import `src` (relative imports, no alias to `dist`), so no build sits between the mutation and the run. Back to the declared index, per site: - form-layout `served = declared` → **1 failed** / 198 passed (the existence row). - notify `objectsAsServed` → declared objects, no ties → **4 failed** (existence, two moved rows, tie). - public form `served = declared` → **4 failed** (three moved rows, tie). - flow template field map over the declared object → **4 failed** (existence, two moved rows, tie). - page visualization derivation over the declared object → **2 failed** (two moved rows). - searchable `indexServedSearchTargets` → `indexObjectSearchTargets` → **4 failed** (two existence rows, moved row, tie). - react page searchable index handed the declared objects → **2 failed** (the existence row and the flipped PR #30533 pin). - autonumber `served = declared` → **6 failed** (existence, four moved rows, tie). - list-view declares-nothing restore removed → **2 failed** (item 2 and the declares-nothing row). Ties and declares-nothing guards, one leg each: page visualization tie guard off, notify / flow template / autonumber tie read as a definition, searchable tie stub dropped → **1 failed** each (that site's tie row); notify, public form, page visualization (skip 2 on the served object), searchable (declared `null` dropped) and autonumber declares-nothing guards off → **1 failed** each (that site's declares-nothing row). Two first attempts were refused by the tool (the replacement text already occurred in the file) and re-run with distinct replacements; both then went red. </details> ## Acceptance notes - **The searchable index ruling.** The ruling zone kept a react page's `searchableFields` refusal on an extension field unless the code shows the runtime search reads extension fields there. It does: the ingress gate's `resolveQueryFields` (`packages/metadata-protocol/src/protocol-query-validation.ts:341`) reads `registry.getObject(object)`, which resolves through `foldExtendersOntoDefinition` (`packages/objectql/src/registry.ts:6476`), and the engine's search expansion reads the same schema. The seat re-read it on `main` and amended the file surface to `validate-react-page-props.ts` (6669203452); the PR #30533 pin is flipped in place. - **`indexObjectSearchTargets` stays declared.** It is exported, the sort rule composes it (it hands it pre-served objects and reads its `null` off the declared ones), and out-of-repo callers read it; the served reading is a new function beside it. - **Declares-nothing, two readings kept.** The notify, public form, page visualization, searchable, autonumber and list-view rules skipped an object that declares no field on `main` and still do, extended or not, as the write, readonly and sort rules do after #30471. The form-layout and flow template rules judged such an object against the empty set on `main` (every entry reported); they still do, with the extension's names now known. Moving them to a skip would be a further widening outside this card. - **The public form rule now sees extension fields of `image` / `file` type** on an object that declares fields, which is where its new refusals come from; on `main` such a field was the form-layout rule's `form-field-unknown` warning. - **Other `indexServedObjectGraph` callers (sister card, carrier: seat).** Measured in the patch round on built `dist/` (`pnpm --filter @objectstack/lint build` of each commit's `packages/lint/src`, every other workspace package at this head), `runAuthoringRules('validate', { normalized })` after `normalizeStackInput`, union and per-package legs identical: `xs_empty` declares `fields: {}`, an extension adds `region`, a record page's `record:highlights` binds `['region', 'nowhere']` and a dataset's dimensions name `nowhere` and `region`. - `validate-dataset-references` (an `indexServedObjectGraph` caller): extended → `dataset-field-unknown` error, `dimension field "nowhere" is not a field on object "xs_empty".`; unextended → nothing (the declared `null` skip). The same on `887e5623`, on `origin/main` `b77c160a` and on `2046d21b` (`482e089a^`, before PR #30533): the extended object is judged on the extension's fields alone, so the gap predates #30533. - `validate-page-field-bindings` (reads `objectsAsServed` directly): extended → `page-field-unknown` warning, `field "nowhere" is not a field on object "xs_empty" — the component silently skips it, so it never renders.`; unextended → the same warning for `region` and for `nowhere`. The same on all three commits: this rule has no declares-nothing skip at all, so it judges such an object against the empty set extended or not (the form-layout reading), and the extension only takes `region` out of the report. - **Visualization ties are per object, not per binding** (should-fix 1, not adopted; the seat's ACCEPT records why). - **`rule-explanations.ts`** is not changed: none of the moved ids' explanations states a declared-only reading. - **The runtime publish gates** (a `view` write for form layout and public form, an `object` write for autonumber) hand the rules a snapshot; this PR measured the CLI legs only. --- _Generated by [Claude Code](https://claude.ai/code/session_01L6bhgVSM5tJd2UYk3Aa1MM)_ 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01L6bhgVSM5tJd2UYk3Aa1MM <!-- signed: domain:spec#2 · os-sales · session_01L6bhgVugtCCwBs98FeR27P --> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 92cb3d9 commit e9731fc

11 files changed

Lines changed: 565 additions & 58 deletions
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
---
2+
'@objectstack/lint': minor
3+
---
4+
5+
fix(lint)!: the remaining field indexes (form layout, notify email-field recipient, public form file field, flow templates, page visualization bindings, searchable fields, autonumber formats) judge an object as the registry serves it, and an object that declares no field keeps the list-view rule's skip once an extension adds to it (#30608)
6+
7+
Clause-②: yes (narrowing) — `os validate` / `os build` / `os lint` refuse shapes they accepted before, each a field an `objectExtensions` entry of the same artifact adds or re-declares, now judged on its served definition: a notify node's `recipients.emailField` naming an owner `email` field an extension re-declares as `text` is `notify-email-field-not-email` at `error`; a form open to anonymous intake whose section names an `image` / `file` field an extension adds, or an owner field an extension re-declares as one, is `public-form-file-field` at `error`; a `list` page whose `allowedVisualizations` leads with `gallery` over an owner `image` field an extension re-declares as `text` is `page/visualization-without-binding` at `error`; a `searchableFields` entry naming an owner text field an extension re-declares as `formula` is `searchable-field-unsearchable` at `error`; an autonumber field an extension adds whose format names a field the object lacks is `autonumber-references-unknown-field` at `error`, and an autonumber format naming an owner field declared `required: true` that an extension re-declares without it is `autonumber-references-optional-field` at `warning`; a flow template `{{ record.F.sub }}` through an owner text field an extension re-declares as a lookup is `flow-template-lookup-traversal` at `warning`. The widening half: a form section, a notify email-field recipient, a flow template hole, a gallery or calendar binding, a `searchableFields` entry (an object's own set, a list view's narrowing, a react page's `<ListView searchableFields>`) or an autonumber format naming a field an `objectExtensions` entry adds is no longer refused as unknown, and a list view over an object that declares no field is no longer refused once an extension adds to it.
8+
Metadata-protocol: narrowed
9+
Skills-docs: none — measured: no sentence in `skills/` or `content/docs/` states a declared-only reading of the moved rule ids
10+
11+
<!-- adr-0087: not-required (no-migration-prescription) No key of any schema is added, removed or renamed: objects, object extensions, forms, flows, pages, views and autonumber formats keep their shapes and meanings. What moves is that a field an objectExtensions entry adds or re-declares is judged on the definition the registry serves. Each new refusal names a field whose served definition the runtime already refuses or misrenders (a text address, a file on an anonymous form, a gallery with no image field, a formula search column, an autonumber whose scope field may be blank or absent). There is no rewrite that keeps what the author meant; the author fixes the extension's definition or the reference, and the refusal names which. -->
12+
13+
**BREAKING** accept-set narrowing in `os validate` / `os build` / `os lint` (`@objectstack/lint`), for multi-package artifacts whose bodies add fields to another package's object through `objectExtensions`.
14+
15+
- **Before.** Seven field indexes read an object's declared `fields` only: the form-layout rule (`form-field-unknown`), the notify email-field recipient rule, the public form file-field rule, the flow template rule, the page visualization binding rule, the searchable-fields index (`indexObjectSearchTargets`, behind both an object's or list view's `searchableFields` and a react page's `<ListView searchableFields>`) and the autonumber format lint. A field an `objectExtensions` entry adds was unknown to them although the runtime serves it, and a field an extension re-declares was judged on the owner's definition. The list-view rule, since it reads the served graph, judged an object that declares no field as having only the extension's fields.
16+
- **Now.** Each reads the shared fold in `object-graph.ts` (`objectsAsServed`): the declared fields plus each field an `objectExtensions` entry adds, an extension's re-declared field replacing the owner's definition whole, as the registry's `mergeObjectDefinitions` does. A name no body declares or adds keeps the same refusal, rule id, severity and message. These findings move (union artifact and per-package leg alike):
17+
- an extension-added field named by a form section, a notify email-field recipient (typed `email`), a flow template hole, a `searchableFields` entry or an autonumber format (declared `required: true`): its unknown-field verdict (`form-field-unknown` `warning`, `notify-email-field-unknown` `error`, `flow-template-unknown-field` `warning`, `searchable-field-unknown` `error`, `autonumber-references-unknown-field` `error`) → nothing; a `gallery` / `calendar` binding derives from it, so `page/visualization-without-binding` → nothing;
18+
- the same field typed otherwise: a notify recipient typed `text` → `notify-email-field-not-email` `error` (was `notify-email-field-unknown` `error`); an autonumber format naming it not `required` → `autonumber-references-optional-field` `warning` (was `autonumber-references-unknown-field` `error`); on a public form, an `image` / `file` / `avatar` / `video` / `audio` field → `public-form-file-field` `error` (was unjudged);
19+
- an owner field an extension re-declares: judged on the extension's type, `required`, or autonumber format (the rows the Clause-② line names), and the inverse re-declarations pass (an owner `file` field re-declared `text` on a public form, an owner lookup re-declared `text` in a template, an owner autonumber re-declared `text`);
20+
- an autonumber field an extension adds is checked as an owner's is;
21+
- a list view over an object that declares no field: `list-view-field-unknown` `error` → nothing, extended or not.
22+
- a flow variable bound to a record whose name is a field an `objectExtensions` entry adds to the trigger object: the record-triggered run flattens that field to top level too, so the variable is an ambiguous root and its holes are no longer judged (`flow-template-unknown-field` `warning` → nothing), as for a variable named after a declared trigger field;
23+
- a form section on an extended object whose owner declares `fields` in the legacy array shape: the owner's array fields are now known names, so `form-field-unknown` `warning` on them → nothing.
24+
- **Not judged.** A field two packages write at the same deciding priority is a served name, but no rule judges its type, `required` or autonumber format: which definition wins is registration order (a gallery page over an object carrying one is not reported). An object that declares no field of its own stays unjudged by the notify, public form, page visualization, searchable and autonumber rules and by the list-view rule, extended or not (an introspected schema). The form-layout and flow template rules judged such an object against the empty set before and still do, an extension's own names now known.
25+
- **Not changed.** Which extension entries the fold keeps, the runtime, the object-level reads (`external`, the injected anchors, an object's own list views and `searchableFields`), and the finding paths. A stack that names no extension field reads as before. A flow variable whose name no body declares or adds on the trigger object is judged as before. A form section on an object nothing extends reads only map-shaped `fields`, as before: its legacy array fields are still reported `form-field-unknown`.
26+
27+
**What an author does.** Fix the extension's field definition (type an email recipient `email`, keep a file field off a public form, give a gallery an image field, keep a search column stored, mark an autonumber scope field `required: true`) or the reference that names it.

‎packages/lint/src/lint-autonumber-formats.ts‎

Lines changed: 31 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,26 @@
2727
* a `format` shorthand the runtime then rendered and threw on. A field that
2828
* declares neither key is checked against the contract default, which is what
2929
* it mints with.
30+
*
31+
* [#30608] Each object is read as SERVED (`objectsAsServed`, the package's one
32+
* fold): the engine mints against the registry's merged object, so a format
33+
* may interpolate a field an `objectExtensions` entry of the same artifact
34+
* adds, an autonumber field an extension adds is minted and so checked, and an
35+
* owner's field an extension re-declares is read on the extension's
36+
* definition, which replaces the owner's whole (the registry's
37+
* `mergeObjectDefinitions`): an autonumber re-declared as `text` mints
38+
* nothing, and a referenced field re-declared without `required` is optional.
39+
* Whether a field is an autonumber, its format and whether it is required are
40+
* its definition, so a field two packages tie on is never walked as an
41+
* autonumber, and a format naming one is reported neither unknown (the name is
42+
* served) nor optional (which definition wins is registration order). An
43+
* object that declares no field (an introspected schema) is skipped, extended
44+
* or not: its other columns are not visible here, so a format naming one would
45+
* read as unknown.
3046
*/
3147

3248
import { parseAutonumberFormat, referencedFields, resolveAutonumberFormat } from '@objectstack/spec/data';
33-
import { recordsOf } from './object-graph.js';
49+
import { objectsAsServed, objectsWithoutTiedFields, recordsOf } from './object-graph.js';
3450

3551
export interface AutonumberLintFinding {
3652
where: string;
@@ -53,14 +69,22 @@ export const AUTONUMBER_LITERAL_TOKEN = 'autonumber-unrecognized-token';
5369
*/
5470
export function lintAutonumberFormats(stack: AnyRec): AutonumberLintFinding[] {
5571
const findings: AutonumberLintFinding[] = [];
56-
for (const obj of recordsOf(stack.objects)) {
72+
const declared = recordsOf(stack.objects);
73+
const servedFold = objectsAsServed(stack);
74+
const served = objectsWithoutTiedFields(servedFold);
75+
declared.forEach((obj, i) => {
5776
const objectName = typeof obj.name === 'string' ? obj.name : '(unnamed object)';
58-
const fields = recordsOf(obj.fields);
59-
// name → required?, for schema-aware reference checks.
60-
const fieldMeta = new Map<string, { required: boolean }>();
77+
// [#30608] Skipped when it declares no field, extended or not (module note).
78+
if (!recordsOf(obj.fields).some((f) => typeof f.name === 'string')) return;
79+
const fields = recordsOf(served[i].fields);
80+
const tiedKeys = typeof obj.name === 'string' ? servedFold.tied.get(obj.name) : undefined;
81+
// name → required?, for schema-aware reference checks. `null` is a field
82+
// two packages tie on: served, of no stated definition.
83+
const fieldMeta = new Map<string, { required: boolean } | null>();
6184
for (const f of fields) {
6285
if (typeof f.name === 'string') fieldMeta.set(f.name, { required: f.required === true });
6386
}
87+
for (const key of tiedKeys ?? []) fieldMeta.set(key, null);
6488

6589
for (const f of fields) {
6690
if (f.type !== 'autonumber') continue;
@@ -106,6 +130,7 @@ export function lintAutonumberFormats(stack: AnyRec): AutonumberLintFinding[] {
106130
continue;
107131
}
108132
const meta = fieldMeta.get(ref);
133+
if (meta === null) continue;
109134
if (!meta) {
110135
findings.push({
111136
where,
@@ -125,6 +150,6 @@ export function lintAutonumberFormats(stack: AnyRec): AutonumberLintFinding[] {
125150
}
126151
}
127152
}
128-
}
153+
});
129154
return findings;
130155
}

0 commit comments

Comments
 (0)