fix(lint): field-no-consumers reads the synthesized layout and the upsert identity - #18381
Conversation
…psert identity The rule's first run on a real app reported 12 fields, all of them on screen or load-bearing. Two consumers name the field nowhere in metadata: - a declared field group places it on the SYNTHESIZED layout, so no `fields: [...]` anywhere mentions it; - a seed's `externalId` / an import mapping's `upsertKey` names the column the loader MATCHES ON, which a carrier root never got to ask about because the root decided the bucket first. Both are read off the spec (`deriveFieldGroupLayout`, ADR-0085 §5) rather than listed by hand, as the rule's other two exemptions already are. Co-authored-by: Claude <noreply@anthropic.com>
… the rule judging The synthesized-layout credit is pinned to a DECLARED group only — an undeclared key, the trailing flat bucket, a hidden field and an object with no `fieldGroups` at all each keep their verdict. The upsert-identity credit is pinned against a matched pair: two identical `hidden` + `readonly` declarations, one matched by a seed `externalId` and one not. Only the matched one goes quiet, so `hidden` is nowhere exempt. Co-authored-by: Claude <noreply@anthropic.com>
… the upsert identity Co-authored-by: Claude <noreply@anthropic.com>
…eld-consumers-synthesized-layout
📓 Docs Drift CheckThis PR changes 1 package(s): 6 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
What this run could not see
Coarse fallback — 4 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 638d139ef1e7f206546737545b185fd2fc14fbe9 && git checkout 638d139ef1e7f206546737545b185fd2fc14fbe9
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 8b81ab60d7b02e5540e8a36917cc86478bfbf899 73bd3ff28ff7216cd93ac8062babeb8ed93698a9 && git checkout -B drift-repro 8b81ab60d7b02e5540e8a36917cc86478bfbf899 && git merge --no-ff 73bd3ff28ff7216cd93ac8062babeb8ed93698a9
node scripts/docs-audit/affected-docs.mjs --json 8b81ab60d7b02e5540e8a36917cc86478bfbf899
|
…eclaration takes at least minor AGENTS.md: "The declaration is `Clause-②: yes|no` plus at most one arm from the closed pair `(widening)`/`(narrowing)`: `yes` takes at least `minor`". This PR declares `Clause-②: yes` — the acceptance set widens, as a precision fix to an advisory rule — so `patch` was below the floor. Co-authored-by: Claude <noreply@anthropic.com>
|
Served-tier: Contract reviewImplemented-by: ① Derived judgments —— diff 引出的接受集与公开面变化,逐条点名判对错
⇒ 接受集确有放宽(第 1、2 条), ② semver 定级与 changeset 声明一致⇒ 一致。 ③ 边界旗与
|
| 旗 | 处置 |
|---|---|
| 98% 到达面(hotcrm 331 个声明字段中 326 个落在已声明 group 里 ⇒ 显示半边实质依赖作者的分组决定) | 裁 A,按已落形态发(裁定全文见卡 #17135 评论 5693562007)。⛔ 不作否决:分诊给的验收判据是真实应用误报率,读数为 12 → 0 且 6/6 注入的真死字段仍被报。 |
SeedSchema / MappingSchema 别名在 pre-parse 栈上得不到 credit |
同意不立卡:os lint 吃 post-parse 栈,别名已归一化 ⇒ 无活表面够得到,carrier 为 none |
SeedSchema.externalId 默认 'name' ⇒ 每个 seeded 对象 credit 自己的 name 列 |
同意不立卡:该列通常已按 ADR-0079 标题字段豁免,且确实被 loader 匹配 ⇒ credit 落在对的地方 |
check:cross-package-test-inputs 在建过/没建过的树上给出不同裁决 |
已立 #18348(四小时前,由另一张卡的 dev 发现、本席独立复现并做了换门禁版本的回归对照)。本 dev 的对照(同一提交、纯净 worktree @ bce527080)更硬,已归入该卡。⛔ 不开第二张 |
⇒ 三旗答复,一旗已转出并有卡;open_questions 现为空。
独立性
Implemented-by: 是产 diff 的分支(mode:subagent),Reviewed-by: 是渲染本裁决的席位 session ⇒ 两者不同,非 SELF-REVIEW。packages/spec ⇒ 车道席自审即为 review of record)。本 PR 不属独立性件:无契约真分叉、dev 未挂旗要求隔离、派发后无跨车道面。
判定
PASS
⛔ 本记录是在 PR 被队列弹出之后补写的 —— 见下一条 provenance 评论。
73bd3ff28(9 位,取自本席自己截断的打印),而 C6 读的是 73bd3ff28f。⇒ 改为完整 40 位。⛔ 又一次「写了自己的缩写,而不是它读的那个字面」。
Generated by Claude Code
Landing provenance —— 清标同笔的出处记录
按 ⛔ 本席在此之前犯的两个错,记在案上
Generated by Claude Code |
Fixes #17135
Clause-②: yes
The rule's first run on a real application reported 12 fields, and all 12 were on screen or load-bearing that day. Triage ruled the direction (
#17135comment5620725440): "⇒ Teach the rule about both consumer paths", with the guardrail "⛔ Do not fix it by exemptinghiddenfields wholesale — ahiddenfield with genuinely no consumer is still a finding; the point is that a seeder-only identity column has one."Both consumers are now read off the spec, the way the rule's existing two exemptions (
injectedColumnsFor,resolveDisplayField) already are.1 · The synthesized layout is a display consumer
deriveFieldGroupLayout(@objectstack/spec/data, ADR-0085 §5) is the single derivation every renderer applies — form, detail, drawer, designer. It places a field by itsgroupmembership in the object's declaredfieldGroups, so nofields: [...]array anywhere names it. A field the derivation puts in a keyed section is drawn, and is now credited as adisplaysite.This follows the previews-count-as-consumers ruling (2026-08-10, #7131 comment 5237224148), which #17135 pointed at through #7427: "A designer preview that renders a key to a human is a runtime consumer."
The trailing untitled bucket is deliberately NOT credited, and this is the load-bearing line in the change.
deriveFieldGroupLayoutcollects every visible field that named no declared group into a trailing keyless section, and returnsnull— "callers fall back to their existing flat/auto layout" — for an object declaring no groups at all. So every visible field renders somewhere. Crediting "renders on the synthesized layout" in full would leavefield-no-consumersable to reporthiddenfields and nothing else, which is switching the gate off by another name. What is credited is the author's placement; what is not is the fallback that would draw anything.hotcrm@965933b, 326 of 331 declared fields (98%) sit in a declared group. The 12 findings were 11 of them, so the acceptance set widens by 11 fields on this application — but on an app that groups everything, the display half of this rule now rests on the author's grouping decision. That is the price of the ruled direction and it is stated rather than buried.2 · An upsert identity inside a carrier root is a read
bucketForreturnedcarrierfor a carrier root before anything else could ask. But a seed'sexternalId(SeedSchema: "Field (or composite list of fields) matched for the uniqueness check") and an import mapping'supsertKeyname the column the loader matches on — it reads that column on every row to decide insert from update. A seeded value is still a carrier; the identity is the one read a carrier root holds.crm_forecast.seed_keyis exactly the shape triage described:hidden: true,readonly: true, "Written only by the seed loader; empty on every real snapshot", andsrc/data/forecast.seed.tsdeclaresexternalId: 'seed_key'. It is consumed by being an identity.⛔ Nothing exempts
hiddenas a category. The derivation never draws a hidden field, so path 1 gives it nothing; path 2 rescues it only when an upsert actually matches on it. The matched-pair control below is the proof.Acceptance evidence — the false-positive rate on a real app
⭐ Triage: "The acceptance evidence is the false-positive rate on a real app, ⛔ not that the unit tests pass — the unit tests passed when it shipped."
Instrument.
hotcrmhas no checkout here, so one was cloned (objectstack-ai/hotcrm, public, read-only) and its authored stack dumped to JSON, with functions replaced by their source text — which is exactly what the rule does with a function node (Function.prototype.toString.call(node)→scanText), so the dump is behaviourally equivalent for this rule. The instrument is calibrated, not assumed: athotcrm@965933bthe app's ownnpx objectstack lint --json(its pinned@objectstack/lint@17.4.0) answerserrors: 0 · warnings: 13 · suggestions: 12— the card's reading byte for byte — with 12field-no-consumersrows, and the harness over the same tree reproduces the same 12, same objects, same declaration paths.Tree.
objectstack-ai/hotcrm@965933b—feat(platform): migrate onto the @objectstack/* 17.4.0 line (hotcrm#1814), the commit the 12 were reported on. (Notv3.0.0: the app removedcrm_product.tax_rateand later gave four fields consumers and the contact form its mailing block, so neither the tag normainis the tree the card measured.)12 → 0, per field
crm_account.logogroup: 'branding'crm_article_feedback.commentgroup: 'basic'crm_campaign.descriptiongroup: 'basic'crm_campaign_member.added_dategroup: 'basic'crm_contact.mailing_streetgroup: 'mailing_address'crm_contact.mailing_citygroup: 'mailing_address'crm_contact.mailing_stategroup: 'mailing_address'crm_contact.mailing_postal_codegroup: 'mailing_address'crm_contact.mailing_countrygroup: 'mailing_address'crm_contract.descriptiongroup: 'basic', and the seed matches on it (externalId: 'description')crm_forecast.seed_keydataseedexternalId: 'seed_key';hidden, so the layout gives it nothingcrm_quote_line_item.line_numbergroup: 'basic'Nothing remains: 12 → 0. Every one of the 12 carries a
groupnaming a declaredfieldGroupsentry, which is what the app's own CHANGELOG says about them — "every one of them still renders to users through itsfieldGroupon the synthesized detail layout, which the rule does not resolve."The per-field column is not asserted, it is ablated. Each path was removed from the committed file by anchored replacement (absent or non-unique anchor exits non-zero), the mutation was proven on disk by literal counts before reading any verdict, and each leg restored with
git checkout HEAD -- PATHproven by blob identity against theHEADblob plus an emptygit diff HEAD:maincrm_forecast.seed_key(the identity accounts for 2)⭐ The other half: does a real defect still get reported?
⛔ Showing the 12 disappear proves nothing on its own — switching the gate off does that too. Six deliberately unconsumed fields were injected into the same real hotcrm stack, nothing reading, displaying, seeding, translating or granting any of them:
crm_contact.ctl_ungroupedgroup→ trailing flat bucketcrm_contact.ctl_undeclared_groupgroupnaming a group the object never declaredcrm_contact.ctl_hidden_groupedhidden, in a declared groupcrm_contact.ctl_hidden_readonlyhidden+readonly, in a declared groupctl_plain.ctl_orphanfieldGroupsctl_seeded.ctl_not_seed_keyhidden+readonly, no upsert matches on itctl_seeded.ctl_seed_keyexternalIdThe last two are a matched pair on the same object: identical declarations, one variable between them. Only the one an upsert matches on goes quiet.
hiddenis exempt nowhere.Tests, gates
pnpm --filter @objectstack/lint test— 103 files, 3831 passed, 5 skipped, including 11 new pins on both paths and on each boundary that keeps the rule judging (undeclared group, flat bucket, hidden field, an object with no groups, array-shaped field maps, compositeexternalId, mappingupsertKey, the matched pair).pnpm --filter @objectstack/lint typecheck— exit 0.pnpm --filter '@objectstack/lint^...' build— exit 0 (dependency closure).patchon@objectstack/lint. Measured, not assumed — the new message text is present in all four published bundles named byfiles[](dist/index.js,dist/index.cjs,dist/runtime.js,dist/runtime.cjs) alongside a pre-existing-text positive control.Acceptance notes
Observed while measuring, not filed — no PR or author is heading for either file:
SeedSchemaaliaseskey/externalkey/naturalkeyontoexternalId, andMappingSchemaaliaseskey/matchOn/externalId/externalIdFieldontoupsertKey. On a pre-parse stack an author using an alias gets no credit from path 2.keyin particular is inLITERAL_KEYSand is skipped outright, for good reasons unrelated to this rule. Post-parse — which is whatos lintfeeds — aliases are already normalized, so the live surface is covered.SeedSchema.externalIddefaults to'name'. On a post-parse stack every seeded object therefore credits itsnamecolumn, which is usually already exempt as the ADR-0079 title field and is in any case genuinely matched by the loader.🤖 Generated with Claude Code
https://claude.ai/code/session_017ef78bLdybu3AffehKkhfk
Generated by Claude Code