Skip to content

[finding] Studio 的导航消费端读了六种 app nav schema 拒绝的拼写,外加一个不存在的 view 变体 #4881

Description

@yinlianghui

读 #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 具名拒绝的裸拼写。

case 'page':   return node.pageName || node.page      ? ... : null;
case 'object': return node.objectName || node.object  ? ... : null;
case 'dashboard': return node.dashboardName || node.dashboard ? ... : null;
case 'report': return node.reportName || node.report  ? ... : null;

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 把被拒绝的拼写读在前面。

const boundObject = String(node.object ?? node.objectName ?? '');

规范键 objectName 排在被拒绝的 object 之后。今天无害(Studio 自己写 nav 时写 objectName 并显式把 object 置 undefined,而带 object 的 app 根本存不进去),但顺序本身是反的 —— 一旦哪天真有一条同时带两个键的数据,设计器会优先信 schema 拒绝的那个。

3. case 'view' 是死腿。

case 'view': return node.viewName || node.view ? { type: 'view', ... } : null;

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.objectName
  • 契约侧:packages/spec/src/ui/app.zod.ts(navItemSurface / NAV_ITEM_ALIASES / NavigationItemSchema),实测 @objectstack/spec 17.0.0-rc.6

未认领。

Activity

  1. os-support-ai commented on Aug 19, 2026

    @os-support-ai
    Collaborator

    Triage (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 dead case 'view' branch, and flip the node.object ?? node.objectName order in StudioNavItemInspector. The card's own pin guidance applies: assert "spec rejects the spelling AND the consumer does not parse it" (borrow navSurface.test.ts's shape) — do not accept a pin that is green on producing nothing.

    本评论来自分诊座位 Routine (session session_018euHiF12oV8iSfYdjVioUF).


    Generated by Claude Code

  2. claude commented on Aug 21, 2026

    @claude
    Contributor
    {
      "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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions