Repository navigation
[finding] Studio 的导航消费端读了六种 app nav schema 拒绝的拼写,外加一个不存在的 view 变体 #4881
Copy link
Copy link
Closed
Labels
Description
Activity
os-support-ai commented
on Aug 19, 2026 CollaboratorMore actionsTriage (concentrated finding round): promoted
finding→pm:queue. Lenient-consumer cleanup restoring the declared invariant (AGENTS.md #0.1): delete the five||legs reading schema-rejected bare spellings, the deadcase 'view'branch, and flip thenode.object ?? node.objectNameorder inStudioNavItemInspector. The card's own pin guidance applies: assert "spec rejects the spelling AND the consumer does not parse it" (borrownavSurface.test.ts's shape) — do not accept a pin that is green on producing nothing.本评论来自分诊座位 Routine (session
session_018euHiF12oV8iSfYdjVioUF).
Generated by Claude Code
{ "issue": 4881, "status": "done", "branch": "claude/issue-4881-studio-nav-lenient-consumer", "pr": "https://github.com/objectstack-ai/objectui/pull/5526", "premise_still_valid": true, "summary": "Deleted the four bare-spelling `||` legs in `resolveSurface` (page/object/dashboard/report), deleted `case 'view'` with its fifth leg (`node.viewName || node.view`), dropped the now-unread `page`/`object`/`dashboard`/`report`/`view`/`viewName` declarations from `NavNode`, and flipped `StudioNavItemInspector` to `node.objectName ?? node.object`. Two pins added, ablation-verified. DEVIATION, declared: the flip leaves the `?? node.object` fallback readable, so a draft carrying `object` ALONE still displays as bound — by the card's own logic that is the last rejected-spelling read in `views/studio-design/**`. A test asserting it should show UNBOUND was written, failed against the flip-only scope ZONE 1 fixed, and was removed rather than widening the card; filed unassigned as objectui#5518 for triage. Also corrected in place (same defect class, same file, one sentence): the doc comment over `StudioNavItemInspector` claimed the picker writes `{ object }` — it has always written `{ type: 'object', objectName }` and cleared `object`. `StudioNavItemInspector` is now exported for its test (file precedent: `DataPillar` and two others); the package index lists exports one by one, so nothing new reaches the public API.", "spec_reverification_17_0_0_GA": { "installed_version_measured": "@objectstack/spec 17.0.0 (node_modules/@objectstack/spec/package.json; pnpm-lock resolves ^17.0.0 -> 17.0.0)", "card_measured": "17.0.0-rc.6", "premise_holds": true, "NavigationItemSchema.safeParse": { "{type:'page',page:'home'}": "REJECT unrecognized_keys:['page'] + invalid_type@['pageName']", "{type:'object',object:'crm_lead'}": "REJECT unrecognized_keys:['object'] + invalid_type@['objectName']", "{type:'dashboard',dashboard:'sales'}": "REJECT unrecognized_keys:['dashboard'] + invalid_type@['dashboardName']", "{type:'report',report:'pipeline'}": "REJECT unrecognized_keys:['report'] + invalid_type@['reportName']", "{type:'object',objectName:'crm_lead',view:'all'}": "REJECT unrecognized_keys:['view']", "{type:'view',viewName:'all'}": "REJECT invalid_union@['type'] — discriminator enumerates object|dashboard|page|url|report|action|component|separator|group, nine members, no 'view'", "canonical page/object/dashboard/report": "PARSES", "{type:'object',objectName:'crm_lead',viewName:'all'}": "PARSES — viewName is a key ON the object item, not a nav type" }, "AppSchema.safeParse": { "app with bare-object nav item": "REJECT unrecognized_keys:['object']@['navigation',0]", "app with type:'view' nav item": "REJECT invalid_union@['navigation',0,'type']" }, "structural_cross_check": "NavigationItemSchema.unwrap().def.options has length 9; discriminators ['object','dashboard','page','url','report','action','component','separator','group'] — measured, not asserted in the test (behavioural rejection is pinned instead, to avoid coupling a pin to zod internals)" }, "sweep": { "method": "repo-wide grep over packages/ and apps/ for `node|item|n|leaf|nav`.(page|object|dashboard|report|view), for `(pageName|objectName|dashboardName|reportName|viewName)\\s*(\\|\\||\\?\\?)`, and for `case '(page|object|dashboard|report|view)':`", "in_scope_population_fixed": [ "navSurface.ts resolveSurface: 5 `||` legs (the 4 bare keys + node.view inside case 'view') — PM's 'five legs' and 'case view' are the same five, not five plus one", "StudioDesignSurface.tsx StudioNavItemInspector: 1 read (node.object ?? node.objectName)" ], "counter_probe_examined_not_touched": [ "packages/layout/src/NavigationRenderer.tsx resolveHref — canonical-only already; no bare-key read", "views/metadata-admin/previews/AppNavCanvas.tsx navKind — cleaned in objectui#3275 with this card's own argument; its comment is the precedent cited in the new code", "views/metadata-admin/inspectors/nav-target.ts inferNavItemType (4 legs) + AppNavInspector.tsx:292 — DELIBERATE legacy-normalisation ramp ('edit is the migration': classify a legacy node so the next edit clears the off-spec keys), different intent from an accidental lenient read; also the directory #5416 just rewrote. Examined, NOT touched, NOT filed — flagging for PM to rule on rather than filing noise", "views/metadata-admin/anchors.ts / registry.ts anchorByField('object'), ResourceEditPage.tsx v?.objectName ?? v?.object ?? v?.object_name — other metadata contracts (validation/flow/chart anchors, `view` metadata), not the nav union", "packages/app-shell/src/views/ObjectView.overrideMasquerade.test.ts:51 sv.objectName || sv.object — list-view masquerade fixture, different contract" ], "no_stale_twin_of_navSurface_found": "the #4019 line-for-line copy left no second resolveSurface; the only `case 'view'` in the repo was this one (plugin-view/ObjectView.tsx:1286 is a form-title switch on a different domain)" }, "pin_shape": { "files": [ "packages/app-shell/src/views/studio-design/navSurface.test.ts (appended, 3 describe blocks)", "packages/app-shell/src/views/studio-design/StudioDesignSurface.navItemInspector.test.tsx (new)" ], "shape": "per-variant PAIR on the SAME `type`: (a) canonical fixture PARSES under NavigationItemSchema AND resolveSurface returns the surface; (b) bare-spelling fixture is REJECTED with an unrecognized_keys issue naming that exact key AND resolveSurface returns null. Plus: `type:'view'` fails the discriminator AND resolves null; `viewName` on an object item PARSES and that leaf still binds as `object`; inspector: the both-keys draft is refused by AppSchema while the picker shows the canonical name, with BOTH names present as options; picker output parses under NavigationItemSchema once serialized.", "why_not_green_on_producing_nothing": "a lone toBeNull() would have passed before the deletion too. Each null is paired with a non-null on the identical `type` where only the key spelling changed, so the null is attributable to the SPELLING rather than to the variant having no surface; and the ablation makes it falsifiable — 6 of the 25 tests go red on the pre-fix reads.", "removed_assertion": "'shows nothing bound when the draft carries the rejected spelling ALONE' — asserted deletion of the inspector fallback, which ZONE 1 did not order; removed, filed as objectui#5518" }, "tests": "All on 9cd3c5f2d (final commit), all heavy runs through /home/user/objectstack/scripts/pm/os-verify-lock.sh, vitest --maxWorkers=1 throughout. (1) pnpm exec vitest run packages/app-shell/src/views/studio-design/navSurface.test.ts packages/app-shell/src/views/studio-design/StudioDesignSurface.navItemInspector.test.tsx --maxWorkers=1 => 'Test Files 2 passed (2)' / 'Tests 25 passed (25)', LEG1 EXIT=0. (2) ABLATION, same lock acquisition: `git show origin/main:navSurface.ts > navSurface.ts` plus a targeted flip of the inspector read back to `node.object ?? node.objectName` (test-only export KEPT, so the ablation cannot pass through an import error); restore by `trap 'git checkout HEAD -- ...' EXIT INT TERM`, and `git diff HEAD` empty afterwards. NO REBUILD NEEDED and none done — both tests import the implementation by RELATIVE SOURCE path and vitest.config.mts aliases every @object-ui/* to src, so no dist is in the resolution path (@objectstack/spec is a prebuilt external dep). MUTATION CONFIRMED ON DISK by counting the anchored text, not by an editor exit code: navSurface 'node.pageName || node.page' 1 (want 1), \"case 'view'\" 1 (want 1), canonical-only 'node.pageName ? { type' 0 (want 0); StudioDesignSurface 'node.object ?? node.objectName' 1 (want 1), 'node.objectName ?? node.object' 0 (want 0), 'export function StudioNavItemInspector' 1 (want 1). PREDICTED BEFORE RUNNING: 6 failures (4 bare-spelling pins + the view pin + the inspector picker pin). OBSERVED: 'Test Files 2 failed (2)' / 'Tests 6 failed | 19 passed (25)', LEG2 EXIT=1, exactly those six by name. Direction as predicted (turn red), no reversal. (3) pnpm --workspace-concurrency=2 --filter '@object-ui/app-shell^...' build => BUILD EXIT=0; pnpm --filter @object-ui/app-shell type-check => TYPECHECK EXIT=0 (objectui spells the script with a hyphen; output echoed 'tsc --noEmit && tsc -p tsconfig.test.json', so it was not a zero-match no-op). Exit codes captured before any pipe (cmd > log 2>&1; EXIT=$?), never through tail.", "gates": { "vitest (2 targeted files, maxWorkers=1)": "PASS — Tests 25 passed (25)", "pnpm --filter @object-ui/app-shell type-check": "PASS — exit 0", "pnpm --workspace-concurrency=2 --filter '@object-ui/app-shell^...' build": "PASS — exit 0", "pnpm check:control-bytes": "PASS — 'check-control-bytes: OK (scanned 4566 tracked text file(s); skipped 85 binary)'", "pnpm check:phantom-deps": "PASS — 'Every in-scope import is declared by the package that publishes it.'", "pnpm check:self-import": "PASS — 'No package names itself inside its own src/.'", "pnpm check:esm-specifiers": "PASS — 'no un-ledgered package emits an extensionless relative specifier'", "node scripts/check-changeset-presence.mjs": "PASS — '4 source file(s) of 1 released package(s) changed, and this change declares 1 changeset(s): .changeset/studio-nav-canonical-keys.md'", "eslint (narrowed to the 4 changed files, --format json)": "PASS — 0 errors, 15 warnings, all pre-existing react-hooks/* or unused-var reports on lines this PR does not touch. NARROWING DECLARED: repo-wide `pnpm lint` (turbo run lint) is CI's; the narrowing is sound for this diff because eslint.config.js is NOT type-aware (no projectService, no parserOptions.project), so these files cannot move a verdict in a file they do not import.", "CI": "in_progress — reported at draft-PR time per the standing rule; PM reads the real job conclusions", "note_on_gate_derivation": "scripts/pm/dispatch-gates.mjs does not exist in objectui (confirmed); family derived by hand from .github/workflows/ci.yml + lint.yml + changeset-presence.yml. i18n gates not run: no t() call site added or changed, no locale pack touched." }, "files_changed": [ "packages/app-shell/src/views/studio-design/navSurface.ts", "packages/app-shell/src/views/studio-design/navSurface.test.ts", "packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx", "packages/app-shell/src/views/studio-design/StudioDesignSurface.navItemInspector.test.tsx (new)", ".changeset/studio-nav-canonical-keys.md (new)" ], "file_surface_respected": "yes — git diff origin/main...HEAD (three-dot) touches only packages/app-shell/src/views/studio-design/** plus the required changeset. No metadata-admin/**, no preview/**, no vite.config.ts, no packages/spec, no packages/types.", "deviations": [ "ZONE 1 item 3 implemented as ordered (flip, not delete); the surviving `?? node.object` fallback is filed as objectui#5518 rather than removed here.", "One bounded in-place fix beyond the enumerated three: the stale doc comment above StudioNavItemInspector claiming the picker writes `{ object }`. Same defect class, same file, mechanically pinned by the code below it, named in the PR body.", "`export` added to StudioNavItemInspector so its pin does not have to drive the whole pillar (file precedent, not re-exported from the package index).", "Eslint run narrowed to the changed files (declared above with the reason it is sound)." ], "resource_symptoms": [ "Shared verify lock: one acquisition waited 461s (7m41s) behind another container's full `pnpm exec vitest run packages/app-shell --maxWorkers=2`; that locked command itself held 15s. Later acquisitions: 0s and 66s. Longest hold by me: 209s (build + type-check). No 10-minute foreground cap hit, no OOM.", "pnpm install in the fresh worktree took 8.1s (store hardlinks), run outside the lock." ], "platform_observations": [ "Issue-body sanitizer (measured on objectui#5518, this session): the trailing `---` plus a BARE `_Generated by [Claude Code](https://claude.ai/code)_` footer was stripped on creation; the session-URL form survived on the PR body.", "PR creation APPENDED a second session-URL footer after mine (pull/5526) — cosmetic duplicate, left alone.", "api.github.com/search/* is blocked by the agent proxy for this session (HTTP 403, 'sessions are bound to their configured repositories'); repos/* endpoints work, and the MCP search tool was used for the duplicate search instead.", "Measured for the record: zod's strictObject counts an own key whose VALUE is undefined as unrecognized, so the inspector's clear-by-undefined patch (`object: undefined, path: undefined`) fails a direct in-memory parse while the SAVED (JSON-serialized) item parses. Nothing in the Studio parses that draft in memory (validateMetadataDraft has one caller, ResourceEditPage), so this is inert today — the write-side pin uses the serialized shape and says why." ], "open_questions": [], "out_of_scope_findings": [ "filed as #5518: after #4881's flip, StudioNavItemInspector still READS the schema-rejected `object` spelling as a fallback — the last one in views/studio-design/**; unassigned, labelled `finding`, no `pm:queue`", "not filed, flagged for PM: views/metadata-admin/inspectors/nav-target.ts `inferNavItemType` reads the same four bare spellings, but as a documented legacy-normalisation ramp; if PM reads that as the same defect class it needs a card, and it belongs to whoever holds metadata-admin after #5416" ] }
Generated by Claude Code
- added a commit that references this issue
on Sep 28, 2026
读 #4019 的 Interfaces rail 时顺手发现,不在该卡范围内,未修。观察类:今天没有用户会撞到(全部只在 spec 已经拒绝的元数据上才可能触发),按 #4949 挂
finding、不挂pm:queue,交分诊定级。事实
packages/app-shell/src/views/studio-design/navSurface.ts(#4019 前在StudioDesignSurface.tsx,内容逐行照搬)的resolveSurface,以及StudioDesignSurface.tsx的StudioNavItemInspector,读的键与@objectstack/spec的 app 导航契约对不上:1. 五条
||兜底腿读的是 schema 具名拒绝的裸拼写。NavigationItemSchema的九个成员各自是strictObject(navItemSurface(variant), ...)(packages/spec/src/ui/app.zod.ts:203),裸page/object/dashboard/report既不在任一变体的 shape 里,也不在NAV_ITEM_ALIASES里 —— 于是它们是未知键,AppSchema解析时直接unrecognized_keys报错。也就是说这些||右腿只可能在通不过 spec 解析的 app 上生效。这正是 objectstack#4984 / #5009 那一族:消费端替 schema 接受了它拒绝的方言,把错误约定化石化成第二契约(AGENTS.md Commandment #0.1)。2.
StudioNavItemInspector把被拒绝的拼写读在前面。规范键
objectName排在被拒绝的object之后。今天无害(Studio 自己写 nav 时写objectName并显式把object置undefined,而带object的 app 根本存不进去),但顺序本身是反的 —— 一旦哪天真有一条同时带两个键的数据,设计器会优先信 schema 拒绝的那个。3.
case 'view'是死腿。NavigationItemSchema是九选一的判别联合,成员为 object / dashboard / page / url / report / action / component / separator / group —— 没有view。viewName是ObjectNavItemSchema的一个可选键(「打开哪个列表视图」),不是一种导航条目类型。所以node.type === 'view'对任何 spec 合法的导航都不成立,这条分支永远不执行。为什么算观察类
三条都只在 spec 已经拒绝的元数据上才可能触发,今天没有产品路径能造出这种数据(Studio 的 nav 编辑器写规范键,
AppSchema在保存时把不规范的挡在门外)。所以是休眠代码 + 宽松消费端,不是线上缺陷 —— 但它同时也是下一个读这段代码的人会照抄的模板:#4019 加action分支时就得刻意不照抄这个||形状,并把理由写进注释,否则宽松就顺着扩散了。修的时候注意(别踩 #5046 的坑)
删掉这些腿属于「减法」:钉子如果只断言
resolveSurface(...) === null,腿删掉后照样绿 —— 绿在什么都没产出上,不是绿在逻辑对上。要么换成断言 spec 拒绝该拼写且消费端不解析(#4019 的navSurface.test.ts就是这个形状,可直接借),要么换成一个存活逻辑真会读的 fixture。现场
packages/app-shell/src/views/studio-design/navSurface.ts——resolveSurface的五条||腿 +case 'view'packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx——StudioNavItemInspector的node.object ?? node.objectNamepackages/spec/src/ui/app.zod.ts(navItemSurface/NAV_ITEM_ALIASES/NavigationItemSchema),实测@objectstack/spec17.0.0-rc.6未认领。