Skip to content

[app-shell] metadata-admin 的 FormFieldSpec 没有声明 dependsOn,但两个 widget 都读它 —— 类型化的 form spec 写不出这两个 widget 的必备配置 #5040

Description

@yinlianghui

发现于 #4871 实施时的 tsc -p tsconfig.test.json(基线 167ec42e7)。未认领,交 PM triage。

机制

packages/app-shell/src/views/metadata-admin/SchemaForm.tsx 导出的 FormFieldSpec 是 form 布局的授权面(section 的 fields[] 元素类型)。它声明了 type / options / reference / multiple / widget / language 等等,但没有 dependsOn。

而 widgets.tsx 里两个 widget 把 dependsOn 当必备配置读:

  • FieldSelectorWidget(widget: 'field-selector'):fieldSpec?.dependsOn || fieldSpec?.reference || 'objectName' —— 决定去哪个对象拉字段目录;
  • DynamicConfigWidget(widget: 'dynamic-config'):Array.isArray(fieldSpec?.dependsOn) ? fieldSpec!.dependsOn[0] : fieldSpec?.dependsOn —— 决定用哪个兄弟字段的值去查 context.dynamicSchemas。

它们读得到,是因为 WidgetProps.fieldSpec 是另一个内联结构类型(widgets.tsx :101 起),那个声明了 dependsOn?: string | string[]。两个类型描述同一个对象、由 MetadataField 把同一个值传下去,却对这个键不一致。

后果:用 FormFieldSpec 类型化地写 form spec 时,{ field: 'fieldName', widget: 'field-selector', dependsOn: 'objectName' } 是编译错误(TS2353: 'dependsOn' does not exist in type 'FormFieldSpec'),而这恰恰是让 field-selector 工作的唯一方式。当前仓内 spec 大多以 as any / 松类型经过,所以没被挡住 —— 这也是它至今没暴露的原因。

定级说明

按「观察类」提交(finding 标签,无 pm:queue):今天没有用户点得到的失效面 —— 运行期读的是同一个对象,dependsOn 传得到;坏的是授权时的类型面。但方向就是本仓反复付过学费的那一类:producer 与 consumer 对同一个契约各留一份不一致的描述。严重度请 PM triage 判,不由我在立卡时定。

建议

WidgetProps.fieldSpec 那份内联结构与 FormFieldSpec 明显是同一个契约的两次描述(前者还额外有 colSpan / immutable / readonly / required 等重叠键)。合并成一处,WidgetProps.fieldSpec 直接引 FormFieldSpec,是契约优先的做法;dependsOn 补进 FormFieldSpec 是其中一步。合并会牵动两个文件的导入方向(widgets.tsx 目前不从 SchemaForm.tsx 引类型,反向引会成环),所以形态需要先定,不宜顺手做。

边界与去重

Activity

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

    @os-support-ai
    Collaborator

    Triage (first-touch, triage seat, session session_01HXzdkKx5WjwwCTT3c7U5kB): promoted finding → pm:queue, type Bug — the declared authoring type (FormFieldSpec) rejects (TS2353) the only configuration that makes field-selector / dynamic-config work, i.e. declared ≠ enforced on the type surface, currently masked only by as any spec authoring.

    Rationale / scope: two inline descriptions of the same contract (SchemaForm.tsx FormFieldSpec vs widgets.tsx WidgetProps.fieldSpec) must converge to one; adding dependsOn?: string | string[] to FormFieldSpec is one step of that. The card flags the import-cycle risk (widgets.tsx ← SchemaForm.tsx would be circular) — extracting the shared type to a leaf module is acceptable latitude for the dev; stop and report only if unification would change authored runtime behavior.

    Size/model suggestion: S–M, mode:subagent.


    Generated by Claude Code

  2. added
    domain:uiobjectui ui stream: fix lands on the published library or apps — objectui execution seat
    on Aug 21, 2026
  3. claude commented on Aug 21, 2026

    @claude
    Contributor

    os-dev-report

    (Literal-text marker: the HTML-comment form of this marker was eaten by the GitHub sanitizer on this very issue last round — see issuecomment-5366135792 — so a scan keying on the comment form would not see this.)

    {
      "issue": 5040,
      "status": "done",
      "branch": "claude/issue-5040-formfieldspec-dependson",
      "pr": "https://github.com/objectstack-ai/objectui/pull/5537",
      "premise_still_valid": true,
      "summary": "FormFieldSpec now declares `dependsOn?: string | string[]`, and the two inline descriptions of that contract are ONE declaration: WidgetProps.fieldSpec IS FormFieldSpec, extracted (with VisibilityPredicate, which it depends on) into a leaf module views/metadata-admin/form-spec.ts that both halves import, because SchemaForm.tsx imports ./widgets.js and a back-edge would close a cycle. SchemaForm.tsx re-exports both names so no importer moves; the moved text is byte-identical apart from the one new key (proved by reconstructing the extracted block minus the insertion and comparing to the pre-edit bytes). The stale comment at SchemaForm.widgetLabelling.test.tsx is updated and its `as FormFieldSpec` cast dropped, making that file an incidental second pin. THE MEASUREMENT THE CARD TURNED ON: pointing WidgetProps.fieldSpec at FormFieldSpec narrows colSpan (number -> 1|2|3|4) and fields (any[] -> Array<string|FormFieldSpec>), and widgets.tsx STILL COMPILES — zero call-site changes needed, so no widening of FormFieldSpec was made. Enumerated before running: widgets.tsx reads exactly seven members off fieldSpec (options, dependsOn, language, reference, multiple, label, field) and neither colSpan nor fields is among them; then confirmed by a green `tsc --noEmit && tsc -p tsconfig.test.json`. No runtime behaviour changed — Clause-② roll-back trigger not reached. ⛔ PROCESS FAILURE I OWN, ALREADY CORRECTED: I opened the PR at 66ca55b0e, then committed the fix for a TS2493 in my own test file and DID NOT PUSH IT, while the PR description claimed every gate ran against '4660062e2, the final commit on this branch'. That commit was local-only. CI's Type Check went correctly red at 66ca55b0e on exactly the defect the unpushed commit fixed. The commit is now pushed, local HEAD == remote head == 4660062e2 verified by git ls-remote with a clean tree, the whole gate union was re-run at that confirmed head, and the PR description carries a correction block naming the mistake. A verified tree that is not the pushed tree is not evidence.",
      "narrowing_compiled": true,
      "tests": "ALL RE-RUN AT THE CONFIRMED PUSHED HEAD 4660062e2 (git rev-parse HEAD == git ls-remote origin <branch>, working tree clean). Verdicts quoted are the lines the gates printed, exit codes captured before any pipe. || BUILD: `pnpm --workspace-concurrency=2 --filter '@object-ui/app-shell^...' build` under the shared lock — 'os-verify-lock: VERDICT command-exit 0 · held the lock 132s (2m12s) · waited 0s'. || TYPE-CHECK: `pnpm --filter @object-ui/app-shell type-check` exit 0, log echoes '> @object-ui/app-shell@17.6.0 type-check' and '> tsc --noEmit && tsc -p tsconfig.test.json' — not a zero-match no-op. This is the load-bearing gate for this card. || VITEST targeted (6 files: the new pin, both labelling suites, DynamicConfigWidget, FieldSelectorWidget.loadFailure, WidgetContext.catalogUnion): 'Test Files 6 passed (6) · Tests 142 passed (142)'. The new pin file was separately run alone with --reporter=verbose and all four of its runtime legs printed as ✓, so its green is not a zero-match. || VITEST full metadata-admin family (190 files — the complete test family for this PR's entire file surface; every edit is inside that directory): 'Test Files 190 passed (190) · Tests 1955 passed | 1 skipped (1956)', lock 'VERDICT command-exit 0 · held the lock 581s (9m41s) · waited 40s'. This took two attempts: the first returned exit 99 (queue-timeout, never acquired after 9m00s) and was reported as NOT RUN rather than claimed; the retry acquired it. || GATES: check:control-bytes '✅ OK (scanned 4585 tracked text file(s); skipped 85 binary)'; check:esm-specifiers 'Specifier leg: no un-ledgered package emits an extensionless relative specifier' (the family this diff most directly implicates — it added two ./form-spec.js specifiers); check:self-import '✅ No package names itself inside its own src/'; check:phantom-deps '✅ Every in-scope import is declared by the package that publishes it'; check:spec-symbols '✅ spec symbol derivation: 1285 files scanned against 4912 spec export names'. check:published-dist and the full check:node-esm-load load leg are NOT owed by this diff — measured from their workflows' `on:` blocks, both trigger only nightly, on workflow_dispatch, or on a push touching the gate's own script. || LINT, scope stated so it is checkable: `pnpm lint` is `turbo run lint`, i.e. each package's own `eslint .`; the job for the only package this PR edits was run in full — exit 0, '✖ 2508 problems (0 errors, 2508 warnings)' (all pre-existing no-explicit-any warnings). Population read from eslint's own config, not guessed: 898 files from `--format json`, 0 errors, and all five changed files verified present in that population by filePath. No type-aware linting is configured (no `project` / `projectService` in eslint.config.js), so an app-shell edit cannot move the verdict on a file in a package this PR does not touch. || REVERSE VERIFICATION — prediction written to disk BEFORE the run (timestamped 2026-08-21T09:00:41Z). Ablation: delete only `dependsOn` from FormFieldSpec, keep everything else. NO REBUILD NEEDED OR PERFORMED, and this is stated rather than skipped: tsc reads app-shell src/ directly, and the only thing tsconfig.test.json resolves through built .d.ts is OTHER packages, which the ablation does not touch — so no stale dist can be credited for either colour. MUTATION CONFIRMED ON DISK before anything was read, anchored at the exact text removed: declaration lines matching '^  dependsOn?: string | string[];$' went 1 -> 0 and `git diff --numstat` showed 0/25 on that file — not a bare non-empty diff. The script carried `trap '<git checkout>' EXIT INT TERM`; restoration was verified by an empty `git status --porcelain` for that path afterwards. PREDICTED vs OBSERVED: (1) source project goes red — widgets.tsx now reads dependsOn OFF FormFieldSpec, TS2339, >=3 sites -> OBSERVED 4x TS2339 at widgets.tsx:670 and :2411 (x3). This is the card in one line: those same reads COMPILED before this PR, because they were checked against widgets.tsx's own copy. (2) test project goes red with the card's exact error -> OBSERVED TS2353 on PIN A/B and the three typed runtime specs, plus SchemaForm.widgetLabelling.test.tsx x3. (3) both @ts-expect-error negative controls stay SATISFIED, no TS2578 -> OBSERVED, no TS2578 anywhere. (4) vitest stays GREEN because the widgets read the key at runtime regardless -> OBSERVED 10/10 passed with the type removed, which is why a runtime-only verification of this card would have proved nothing. TWO HONEST DELTAS: (a) UNPREDICTED, same direction — DynamicConfigWidget.test.tsx also went red (5x TS2353); it passes fieldSpec={{…, dependsOn}} as an object literal, so the convergence pulled a PRE-EXISTING test under the authoring type's checking. More diagnostics, not fewer. (b) PIN E failed as TS2339 on the indexed access, not the TS2344 I predicted — same direction, different code. || NEGATIVE CONTROLS (the card's explicit requirement that the pin is not merely 'the type got looser'), both @ts-expect-error so a LOOSENING turns the file red via TS2578: PIN C — an undeclared key is still rejected, so the fix is not an index signature or `any`; PIN D — `dependsOn: [{ field, param }]` is still rejected, pinning the DependsOnInput trap as a refusal in the tree rather than as prose in a comment. Plus PIN E (exact shape string|string[]|undefined), PIN F (WidgetProps['fieldSpec'] IS FormFieldSpec, so a re-inlined copy fails even if it agrees on every key), PIN G (the leaf and SchemaForm's re-export are the same type).",
      "open_questions": [],
      "out_of_scope_findings": [
        "filed as #5542 (unassigned, `finding`, no `pm:queue`): a THIRD inline copy of this contract at apps/console/src/components/FormPage.tsx:278 — the card and triage both frame this as TWO descriptions converging; measured, it is three. apps/console is held by in-flight #5504 and was outside this card's declared surface, so it is untouched here and PR #5537 says in its scope section that this card lands the app-shell half only. Searched open issues before filing (semantic + keyword, FormFieldSpec / FormPage.tsx / console-duplicate phrasings) — no card covered it. The body deliberately leaves open whether the right answer is 'merge' or 'declare them distinct and rename one', since apps/console may legitimately author a narrower shape.",
        "NOT FILED, PM TOOLING, needs your call on where it belongs: `scripts/pm/dispatch-gates.mjs` resolves its repo root from its OWN location (fileURLToPath(import.meta.url)), not from cwd. Run from the objectui worktree as the dispatch instructed, it derived the changeset from the OBJECTSTACK tree and reported 'this branch changes nothing against origin/main (merge base 76deca249)' — 76deca249 is an objectstack commit. It exited 2 and refused rather than printing a wrong gate list, which is the good failure mode, but it means the mandated 'don't trust the dispatch gate list, re-derive it' step is UNAVAILABLE for any objectui card. I derived the families by hand from objectui's own .github/workflows/*.yml instead, and said so in the PR.",
        "NOT FILED, observation only, no action taken per basic rule 2: editing the PR body through the API appended a SECOND signature footer in the BARE form (`https://claude.ai/code` with no session id) after my session-URL one. It was not appended on create, only on the PATCH. My session-URL footer survived both paths exactly as documented. I did not remove the added line — it is not mine to correct, and the bare form self-strips on the next edit anyway."
      ]
    }

    Generated by Claude Code


    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

    Labels

    domain:uiobjectui ui stream: fix lands on the published library or apps — objectui execution seatpm:dispatched

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions