Repository navigation
docs(skills): the html-tier page example spells object-metric's aggregate in the object form - #21667
Conversation
…gate in the object form
The `kind: 'html'` example in skills/objectstack-ui/rules/pages.md wrote
`aggregate="count"`, a string the html tier only warns on (`type-mismatch`)
and the metric tile cannot read: ObjectMetricWidget's computeOne reads
`aggregate.field` / `aggregate.function`. The line now reads
`aggregate={{"function":"count"}}`, the html tier's own expression spelling
the example's sibling attributes already use.
Claude-Session: https://claude.ai/code/session_01CB6W87z22K2yjUCDyVrJRk
Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: Read-only shape otherwise: the diff against the merge base eea82af, card #21627 with every comment, the objectui tree at the live pin, the committed manifest, and this head's check-runs. Reviewed by the dispatch seat in seat (served tier equals the constant's value, read from ① Derived judgments
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS |
维护者速读(终稿)— PR #21667(#21627)· skills seat 1 · 2026-10-04T02:06Z改了什么: 对外发布的 UI 技能包 为什么改: 这一行是技能教给 AI 作者的「标准示例」。指标卡组件读的是对象(objectui 当前 pin 风险与代价(含回滚): 纯技能文本,不碰 席位意见: 建议批准。一行一值,席位逐项核过:diff 只有这一行;对象形与消费端、manifest 声明( 你要做的(一个动作): 在 PR #21667 上给一次 APPROVED review;批准后由席位清标、ready、挂 auto-merge 入队。 |
… error, not a warning (objectstack-ai#21678) Fixes objectstack-ai#21671 Clause-②: no (narrowing) ## What changed The html-tier compiler (`@objectstack/sdui-parser` `compile`) now grades every `type-mismatch` as `error`, not only the ones whose input declares an `enum` arm. `aggregate="count"` on an `object-metric` (repo-root `sdui.manifest.json` declares `aggregate` as `type: "object"`) now fails the compile, so `os build` fails on it. Before, it compiled `ok` with one warning, the build stayed green, and the tile drew no number. The diagnostic code and message are unchanged. No new code and no new gate. ## The mechanism, measured The brief assumed that literals and expressions both reach `checkType`, and that a signal would have to be passed in to tell them apart. Measured on `93a54b87e7`, that is not how it works: - `validateTree` (`packages/sdui-parser/src/validate.ts`) sends a value that `isExpr` matches (the parser's deferred `$expr` marker) to `inert-expression`, which is a `warning`. It calls `checkType` only in the `else` branch. So **every value that reaches `checkType` is already a literal**: a quoted attribute (a string), a bare attribute (`true`), or a braced value that `interpretBrace` materialized in full. - `interpretBrace` is all-or-nothing. Probed: `["a", foo]`, `{"a": foo}` and `{a: 1, b: x.y}` each become ONE `$expr` marker; `[1,2]` and `{function: "count"}` materialize in full. So a container never reaches the type check with an expression inside it. So the expression case is already separate before `checkType` runs, and no new signal is needed. The change is the severity, plus a restated header that names the second certain fact (a literal's coarse type) next to the enum's closed list. A braced expression still gets the `inert-expression` warning, and a test pins that. **`checkMemberTypes` follows the same rule** (the brief left this to measurement). Its members come from a container that was materialized in full, so each member is a literal too, and a member that no declared arm accepts is just as certain a mismatch. Its header already said "Severity mirrors `checkType`'s rule", and that stays true. `member-type-mismatch` is now `error`. **Unchanged on purpose:** the single-arm `invalid-enum` diagnostic is byte-identical, severity included (the existing pin in `union-arm-type-mismatch.test.ts` passes untouched). ## Census (first step): every stored html page source, compiled against the committed `sdui.manifest.json` It was run with `compile` from `@objectstack/sdui-parser` (source), not with grep. The page modules were imported and each `kind: 'html'` export's `source` was compiled (`capability-map.page.ts` interpolates, so a regex would have read a different string). Every fenced block in `skills/**/*.md` and `content/docs/ui/*.mdx` that has a lowercase tag was compiled too. A positive control (`aggregate="count"`) was compiled in the same run. | source | literal handed to a non-string input | verdict | |---|---|---| | `examples/app-showcase/src/ui/pages/command-center-jsx.page.ts` (`CommandCenterJsxPage`) | none (0 diagnostics) | clean, nothing to fix | | `examples/app-showcase/src/ui/pages/capability-map.page.ts` (`CapabilityMapPage`) | none (0 diagnostics) | clean, nothing to fix | | `examples/app-showcase/src/ui/pages/start-here.page.ts` (`StartHerePage`) | none (0 diagnostics) | clean, nothing to fix | | `skills/objectstack-ui/rules/pages.md:154` block (its line 160 is the `object-metric` example) | none: line 160 reads `aggregate={{"function":"count"}}`, so PR objectstack-ai#21667's fix is confirmed on `origin/main` | clean, nothing to fix | | hotcrm | no `kind: 'html'` page in this repo (both `packages/metadata/src/__fixtures__/hotcrm-*.artifact.json` have 0) | not applicable | | other fenced blocks in `skills/**` and `content/docs/ui/**` | React-tier or non-page code (each fails at `no-root` or `forbidden-tag`, which shows they are not html-tier sources) | not applicable | | control: `aggregate="count"` | before: `ok=true`, `[warning] type-mismatch`; after: `ok=false`, `[error] type-mismatch` | the census can see the case | There are zero writers to fix, so no example or skill file changes in this PR. ## Pins (`packages/sdui-parser/src/__tests__/literal-type-mismatch-error.test.ts`) The inputs are copied verbatim from the tracked `sdui.manifest.json` and written inline, so the test reads nothing outside its package. - `aggregate="count"`: exactly one `{ severity: 'error', code: 'type-mismatch', message: 'object-metric prop "aggregate" expected an object' }` (the real message has the tag in angle brackets), and `ok === false`. - `aggregate={{"function":"count"}}`: zero diagnostics, `ok === true`. - A string literal on a `number` (`object-kanban` `limit`), a `boolean` (`invert`) and an `array` (`filter`) input: each gives one `error` `type-mismatch`, and `ok === false`. - An expression handed to an object input (`aggregate={count}`), and a container that holds an expression: each gives exactly one `warning` `inert-expression`, no `type-mismatch`, and `ok === true`. Three existing pins described the old rule, and they were updated: the non-enum union case and the single string-arm case in `union-arm-type-mismatch.test.ts`, and the member severity in `member-type-mismatch.test.ts`. Their codes and messages are unchanged. Only severity, `ok`, and the wording that called these "byte-identical" were edited. ## `os build` probe (the html-tier path through `packages/cli/src/utils/sdui-manifest.ts`) A scratch project with one `kind: 'html'` page, the committed `sdui.manifest.json` copied beside its config, and the CLI run from source (`bin/run-dev.js build`): | page source | before (severity reverted, rebuilt) | after (this PR) | |---|---|---| | `aggregate="count"` | exit 0, a warning that the `aggregate` prop expected an object, `Build complete` | **exit 1**, `Author-time rules failed (1 issue)`, a failure that the `aggregate` prop expected an object | | `aggregate={{"function":"count"}}` | not run | exit 0, `Build complete` | The before leg is a one-off ablation run from the committed fix. It used `scripts/ablation-replace.mjs` (anchor hit, 1 marker on disk), then `pnpm --filter @objectstack/sdui-parser build`, then `ablation-dist-preflight.mjs` (marker present in `dist/`, exit 0). After the probe it was restored with `git checkout HEAD --`: `git diff HEAD` was empty and the blob matched HEAD (`86cc5784`). The package was rebuilt, the `--absent` preflight passed for both readings, and the probe was re-run with exit 1. No permanent test file was left behind. ## Tests run (at `79df1db84f`) - `pnpm --filter @objectstack/sdui-parser test`: 14 files, 225 tests passed. `typecheck`: exit 0. - Downstream consumers, after rebuilding `sdui-parser` (`dist` checked: 0 copies of the old ternary left). `pnpm --filter @objectstack/lint exec vitest run`: 119 files, 5627 tests passed. `pnpm --filter @objectstack/metadata-protocol exec vitest run`: 209 files passed and 3 skipped, 3463 tests passed. CLI unit tier, limited to the 4 files that compile html pages (`src/utils/sdui-manifest.test.ts`, `test/validate-build-gate-parity.test.ts`, `test/platform-page-i18n-parity.test.ts`, `test/i18n-section-coverage.test.ts`): 129 tests passed. The rest of the CLI unit tier and its integration tier are left to CI. - `node scripts/pm/dispatch-gates.mjs --commands` gave 63 derived commands. 61 exited 0, including `check:sdui-lockstep`, `check:nul-bytes`, `check:cross-package-test-inputs`, `check-adr-0087-registration` and `check-changeset-no-major`. **NOT MEASURED: `check:dual-build-cjs-loads`**: `PREREQUISITE NOT MET`, because `embedder-openai` and `service-cluster-redis` have no `dist/` (packages this diff does not touch). **NOT MEASURED: `check:type-check-debt`**: it is a whole-tree tsc ratchet and hit the 240s local timeout. CI runs both. - eslint was run on only the 4 changed `.ts` files, with `--no-inline-config --format json`: 4 files, 0 errors, 0 warnings. The repo config has no `parserOptions.project` (type-aware linting is off), so this diff cannot change the lint result of any file it does not touch. CI runs the full `pnpm lint`. ## Changeset `.changeset/21671-html-literal-type-mismatch-error.md`: `@objectstack/sdui-parser` `minor`. **Clause-② conflict for the seat to resolve:** the claim states `Clause-②: no`, and this body carries that line verbatim. The changeset declares `Clause-②: no (narrowing)`, because a page that used to compile (and save, where the host has a component manifest) is now refused. Earlier PRs treated a new refusal at an authoring door as an accept-set narrowing (`21459`, `20827`). The changeset therefore carries the `**BREAKING**` header, a `minor` bump under the launch-window convention, and an ADR-0087 `not-required (no-migration-prescription)` disposition: no key, declaration or stored shape moves. `check-adr-0087-registration` and `check-changeset-no-major` both pass, whether the body line has the arm or not (both were simulated locally). ## objectui lockstep (declared, not acted on) objectui has its own copy of this validator, `packages/sdui-parser/src/validate.ts`, at the `.objectui-sha` pin `ab187972`. It still has the old ternary in both `checkMemberTypes` (`:418`) and `checkType` (`:465`). After this PR the two copies agree on codes, messages and the accepted grammar, and differ only in **severity**. `check:sdui-lockstep` compares grammar, codes and the containment predicate, not severity, so it passes. This repo's copy is the stricter one (save gate and `os build`). The dangerous direction, a page that saves clean and then renders inert, cannot come from this difference. The lockstep header in `validate.ts` now records this lead. The port belongs to objectui's lane, and this PR does not write to objectui. ## Acceptance notes - The new severity covers every literal mismatch, including a number literal handed to a string input (`label={42}`). That was the narrower reading in the triage title ("a string literal against a declared non-string input"). The claim and the brief specify the general rule ("a literal whose coarse type no declared arm accepts"), and the measurement shows that every value at this point is a literal, so the general rule is the one implemented. The census found no writers of either form in the repo. --- _Generated by [Claude Code](https://claude.ai/code/session_01VDtqoecgES7ScQYGbFVDRv)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes #21627
Clause-②: no
Summary
skills/objectstack-ui/rules/pages.md:160, theobject-metricline of thekind: 'html'example, readaggregate="count". The metric tile readsaggregateas an object: at the objectui pinab187972(.objectui-shaateea82af67),packages/plugin-dashboard/src/ObjectMetricWidget.tsxcomputeOne(:452–:497) sends{ field: aggregate.field, function: aggregate.function, groupBy, filter }tods.aggregateand reads the answer back underaggregate.function === 'count'throughr.count(its own comment: "the literal'count'for a fieldless count"), so a count needsfunction: 'count'and nofield. The string form hands both members over asundefined: the build passes with a warning and the tile draws no number.The line now reads
aggregate={{"function":"count"}}, the html tier's own expression spelling the example's sibling attributes already use (style={{"maxWidth":…}},gap={6}). One attribute value changed; no other byte of the file, no other file. The sibling surfaces already spell the object form (rules/dashboards.md:125, theanalytics-inline-vs-dataseteval) and are untouched.Positive control: the html tier's own compile, before and after
@objectstack/sdui-parsercompile(source, manifest)over the example'ssourcetemplate literal, with the committed repo-rootsdui.manifest.json(the manifestos buildresolves throughpackages/cli/src/utils/sdui-manifest.ts; it declaresobject-metric'saggregateinput astype: "object"):eea82af67):ok=true, 1 diagnostic:[warning] type-mismatch: object-metric prop "aggregate" expected an object(the tag is spelled bare here; the diagnostic prints it in angle brackets); the compiled node carriesaggregate: "count".d5e95044e):ok=true, 0 diagnostics; the compiled node carriesaggregate: {"function":"count"}.The script and its raw output are in the dev report comment on #21627.
Readings
eea82af67)d5e95044e)skills/objectstack-ui/rules/pages.md, whole file, linesskills/**/SKILL.md(10 files), linesscripts/check-skills-token-ratchet.mjs,pages.mdBytes 22701 → 22716 (+15). Net lines 0. No ceiling moved. Token count reported because the sibling gate (
check-skills-token-ratchet) defines one; there is no second token gate on this file.Gates run locally at
d5e95044eDerived with
node scripts/pm/dispatch-gates.mjs --commands(no paths; change set from the merge baseeea82af67): 23 commands. Every one run, exit captured before any pipe;--ranreconciliation attached in the report.node scripts/check-ci-filter-parity.mjs: exit 0, "OK: all 193 declared cross-package glob(s) … are covered".node scripts/check-closing-keyword-parity.mjs: exit 0;--self-test: exit 0, "40 assertions, 5 mutations … each driven to red".node scripts/check-comment-mask-corpus.mjs: exit 0, "8127 files, 0 disagree, 0 unparseable".node scripts/check-doc-route-spelling.mjs --advisory: exit 0, "population clean";--self-test: exit 0.node scripts/check-skills-token-ratchet.mjs: exit 0, "54 authored bundle file(s) within their ceilings";--self-test: exit 0, "65 cases pass".pnpm --filter @objectstack/lint run check:doc-formula-expressions(after building the@objectstack/lintclosure under the verify lock, VERDICT command-exit 0): exit 0.pnpm check:agent-test-spelling·check:corpus-claim-drift·check:cross-package-test-inputs·check:doc-authoring("53 published skill files clean") ·check:driver-memory-census·check:gitlink-declared·check:nul-bytes("scanned 10035 text file(s) … no raw ASCII control bytes") ·check:pm-governed-merges·check:refd-timer-probe·check:role-word·check:skill-compatibility("10 SKILL.md file(s) reconciled") ·check:skill-frame-sync·check:skill-identifier-liveness("457 citation(s) over 53 published file(s)") ·check:watch-hint-literal: all exit 0.Dispatch-named leads outside the derived list:
pnpm --filter @objectstack/spec run check:skill-docs: exit 0, "Skill docs in sync".pnpm --filter @objectstack/spec run check:skill-examples: first run exit 3,PREREQUISITE NOT MET(noclient-reactdist; nothing measured); after building the@objectstack/client-reactclosure under the verify lock (VERDICT command-exit 0, 210s held), exit 0: "260 prose examples type-check across 3 surface(s)". The edited html example block carries noos:checkmarker (the file's two markers sit at:64and:211), so this gate reads no byte of the diff; it is run because the dispatch named it.NOT MEASURED locally, CI's own: the
Test Coreshards, the four type-check lanes, the 11 whole-tree families, the 55 artifact-roster families and the 15 changeset-derived families (this PR carries no changeset:skills/**is shipped bynpx skills addfrom the repository, and no published package'sfiles[]lists it; measured, see the report).维护者速读(草稿)
改了什么
对外发布的 UI 技能包
skills/objectstack-ui里,rules/pages.md的kind: 'html'页面示例中object-metric那一行的aggregate属性,由字符串"count"改为对象{{"function":"count"}}。全文件只动这一个属性值:行数 453 → 453,整包 SKILL.md 行数 4397 → 4397,token 棘轮 5676 → 5679(上限 5692 未动)。为什么改
这一行是技能包教给 AI 作者的「标准示例」。原来的字符串写法
os build只给一条 warning(构建照样通过),而指标卡组件读的是对象(aggregate.field/aggregate.function),字符串下两个成员都是 undefined,页面上这块指标卡不显示数字 —— 技能本身在教一种「构建通过、运行时静默空白」的写法。同一技能包里dashboards.md与 eval 用的都是对象形,只有这一行走偏。实测:改前 html 层编译 1 条type-mismatch警告,改后 0 条。风险与代价(含回滚)
纯文本改动,不碰代码、schema 或任何其它文件;发布面是
skills/**(经npx skills add进客户项目),不走 npm,无 changeset。回滚即git revert本 PR 的单个 commit。已知残余:html 层对字符串形仍只给 warning 而非 error,卡与分诊裁决都把它划在本卡范围外,本 PR 不碰。席位意见
(留空,席位定稿)
你要做的
本 PR 触及
skills/**(Tier H),需要你的一次 APPROVED review;之后由席位落地。你不需要手改任何东西。Acceptance notes
89cad75d557;.objectui-shaateea82af67isab187972. ThecomputeOnereading above is taken atab187972(:452–:497) and holds there. Noted, not filed.type-mismatchseverity (a string handed to anobject-typed input is a warning, not an error) is out of scope by the card's own words and the triage ruling; noted, not filed.rules/dashboards.md:125spells the react tier's object form as a JSX object literal (single quotes); this html-tier line spells JSON, as its sibling attributes do. Both are the object form; neither changes.kind: 'html'example block is not anos:check-marked block (markers atpages.md:64and:211only), socheck:skill-examplescannot see this line; the positive control above is the measurement that stands in for it.skills/**is outside every published package'sfiles[](measured over every trackedpackage.json);skip-changesetrequested throughscripts/pm/label-write.mjs.Generated by Claude Code