Repository navigation
Commit e9731fc
<!-- 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
File tree
- .changeset
- packages/lint/src
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
27 | 27 | | |
28 | 28 | | |
29 | 29 | | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
30 | 46 | | |
31 | 47 | | |
32 | 48 | | |
33 | | - | |
| 49 | + | |
34 | 50 | | |
35 | 51 | | |
36 | 52 | | |
| |||
53 | 69 | | |
54 | 70 | | |
55 | 71 | | |
56 | | - | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
57 | 76 | | |
58 | | - | |
59 | | - | |
60 | | - | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
61 | 84 | | |
62 | 85 | | |
63 | 86 | | |
| 87 | + | |
64 | 88 | | |
65 | 89 | | |
66 | 90 | | |
| |||
106 | 130 | | |
107 | 131 | | |
108 | 132 | | |
| 133 | + | |
109 | 134 | | |
110 | 135 | | |
111 | 136 | | |
| |||
125 | 150 | | |
126 | 151 | | |
127 | 152 | | |
128 | | - | |
| 153 | + | |
129 | 154 | | |
130 | 155 | | |
0 commit comments