feat(spec): 未在手写文档中出现的 schema 变体让 CI 失败(#4165 反向漂移闸门) - #4177
Merged
Conversation
…itten doc The liveness gate asks whether a property does anything; check-doc-authoring asks whether docs use the right authoring form. Neither asked the inverse-drift question: does a variant the schema DECLARES appear in the prose at all? content/docs/references/ is generated from these schemas and cannot drift, but the hand-written pages are typed by humans and do. The founding case shipped for months. apps.mdx said the navigation tree "supports eight item types" and enumerated eight; the schema had nine — `separator` was added to match the objectui renderer (#1878/#1891/#1894) and no hand-written page ever learned about it. Nothing failed, because nothing was looking in this direction. #4165 raised the cost: a mistyped discriminator now answers with the list of valid values, and the doc's enumeration is exactly what an author checks it against. Identity is `<discriminator>:<variants sorted>`, deliberately not a source path — a union is reachable by several paths and the walk order picks one, so a path would churn. Keying on the variant set means adding or removing a variant CHANGES THE KEY, failing the ratchet and sending the author back through the ledger to the doc. variant-docs.json classifies all 20 unions found by reflection: 5 governed, 15 exempt as generated-reference-only (documented solely under references/, which is generated) or not-authorable (wire protocol, engine RPC, or a loader-derived discriminator such as `viewKind`, which no author ever types). Verified the gate actually bites, not just that it passes: removing `separator` from apps.mdx fails with exit 1 naming the variant; deleting a ledger entry fails as an undeclared union; mutating a key fails as an orphan plus an undeclared union — which is precisely the pair an author sees when they add a variant. Scope limit stated in the script header: a variant counts as documented if the page contains `<disc>: '<variant>'` or `` `<variant>` ``. The second form is loose, and for short generic variants it can match an unrelated sentence — a pass means the page says the word, not that it documents it correctly. This catches the omission that shipped for months; it does not replace reading the page. Refs #4001, #4165 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0147tNF4Snk7Ry1KGt4a5PY4
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
Contributor
📓 Docs Drift CheckThis PR changes 1 package(s): 106 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
os-zhuang
marked this pull request as ready for review
July 30, 2026 14:52
os-zhuang
pushed a commit
that referenced
this pull request
Jul 30, 2026
Two conflicts, both in code #4165 ("reject unknown keys across the app shell and navigation tree") rewrote underneath this branch: - `packages/spec/src/ui/app.zod.ts` — main turned the nav union into a `z.discriminatedUnion('type', …)` with `.strict()` members and kept the `z.ZodType<any>` annotation; this branch replaced that annotation with the real `NavigationItem`. Both are kept. Reconciling them needed one addition: main widens the member array (`as unknown as readonly [ZodObject<ZodRawShape>, …]`) because the members are lazySchema Proxies and a superRefine-wrapped variant, and `discriminatedUnion` then reports the union's output as `Record<string, unknown>` — which fits `z.ZodType<any>` and nothing sharper. The union therefore takes a matching cast back to `z.ZodType<NavigationItem>`, documented at the declaration: every branch of the type is still `z.infer<typeof XNavItemSchema>`, so only the MEMBERSHIP of the list is unchecked, and app.test.ts parses all nine variants. - `AGENTS.md` — main added the `check:generated` aggregate section; this branch added `check:exported-any` to the "pure checks with no generator" list. Both are kept. Also registers `check:exported-any` in `check-generated.ts`'s ledger, which main added and which reconciles itself against package.json on every run. Registers `check:variant-docs` in the same ledger while here: #4177 added the script and #4183 added the reconciliation, neither PR could see the other, so `check:generated` was already failing on `main` itself. One line, in a file this branch was editing anyway. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XY3swmCsdYx6AMGiRmFt1H
os-zhuang
added a commit
that referenced
this pull request
Jul 30, 2026
…d config with what the executors read (#4045) (#4210) The notify/http/connector step of the declared-vs-read worklist: - notify/http gain executor-derived Zod contracts (NotifyConfigSchema, HttpConfigSchema) written from the executors, not the form literals, plus a bidirectional form <-> Zod key-set ledger test. Both reconcile clean, with no deliberately-shallow entries. - connector_action's configSchema is retired: the executor reads only the declared FlowNodeSchema.connectorConfig sibling, but the published schema declared the trio as config keys - and the schema-driven Studio form roots every field at config.<key>, replacing the hand-written connectorConfig form, so online authoring produced nodes that refused to dispatch. Schemaless drops the designer back onto the correct form. - flow-node-connector-config-lift (ADR-0087 D2, protocol 17) heals stored flows carrying the mis-taught config.{connectorId,actionId, input} shape, with a completeness guard so a step-time refusal never becomes a load failure. - connectorConfig.input is optional, matching the executor (input ?? {}) and the designer's keyValue editor, which omits an empty map. - check:variant-docs (new in #4177) is classified in the check:generated ledger (NO_GENERATOR) - unclassified, it failed every gate run. Claude-Session: https://claude.ai/code/session_01UDhMtxPLLoFaGtdpNA7xTU Co-authored-by: Claude <noreply@anthropic.com>
os-zhuang
pushed a commit
that referenced
this pull request
Jul 31, 2026
Check Changeset failed: the gate wants a changeset on every PR, and its message says how — "an empty changeset is fine for changes that release nothing". I had asserted in the PR description that a docs-only change needs no changeset. That was wrong about this repo's convention: 72 empty changesets already exist, and they are how documentation work records itself without bumping a package. Added one in that form (empty frontmatter, description only). The alternative the message offers — a `skip-changeset` label — would suppress the record instead of writing it, which is the wrong trade for a change that has something worth saying in the release notes. Refs #4001, #4177 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0147tNF4Snk7Ry1KGt4a5PY4
os-zhuang
added a commit
that referenced
this pull request
Jul 31, 2026
…checked (#4244) Follow-up to the #4177 variant/doc gate, using the signal it produced. That gate passes a variant if the page contains either `<disc>: '<variant>'` or a bare `` `variant` ``, and its header says plainly that the second form is loose: a pass means the page says the word, not that it documents the thing. Six variants were passing on the loose form alone — the gate's own "weak pass" list, and a precise worklist. apps.mdx enumerated nine navigation item types but only documented six. `report`, `action` and `component` shared one sentence — "the spec also defines …" — while the other six each had a section with an example. They now have their own, and the intro no longer implies a second tier of half-supported types. The `action` section earns its place: `actionName` lives inside a nested `actionDef` block, not at the top level, which is exactly the shape a reader guesses wrong. Both it and `component` note which sub-object stays OPEN by design (`actionDef.params`, `component.params`) — the sibling contract owns those, and after #4165 everything around them is strict. knowledge.mdx had a table naming `object` / `file` / `http` and no example of any, so the per-kind keys (`contentFields` vs `prefix` vs `urls`) appeared nowhere. Added one example each. Every example was parsed against the real schema before committing — NavigationItemSchema and KnowledgeSourceKindSchema, six for six. Writing docs from memory is how the region example in #4218 ended up using an `id` key that does not exist. Anchored-form coverage for these two unions: 9/12 → 12/12. Carries an empty changeset. The first push asserted a docs-only PR needs none; Check Changeset disagreed and named the fix — an empty changeset is the repo's way for work that releases nothing, and 72 of them already exist. Refs #4001, #4177
os-zhuang
added a commit
that referenced
this pull request
Jul 31, 2026
…4255) (#4257) The aggregate reconciles its GATED/NO_GENERATOR ledger against package.json on every run, in both directions — but only where the aggregate runs, which was locally. CI runs the gates as individual steps, so a PR adding a check:/gen: script without classifying it kept every CI gate green while the wrapper AGENTS.md prescribes exited red on main at the reconcile stage, running zero gates. Twice in three days. #4177 added check:variant-docs and main stayed red for local wrappers until #4194 collided with the same wall (#4203). Then #4232 added check:strictness-ledger — found unclassified while wiring this fix, so the wrapper was red on main again at the moment of writing. Classified here as NO_GENERATOR: it audits the hand-written strictness ledger (docs/audits/) against the code it describes; there is no generator to name. The gate is a --reconcile-only mode (reconcile, report, exit — no gates; reads package.json only, no build, sub-second) wired into lint.yml's "TypeScript Type Check" job. Deliberately not ci.yml's check-generated job: its `generated` paths filter does not watch packages/spec/package.json — the one file every offending PR must touch — and both offenders' changed files missed every entry in that filter, so both skipped the job entirely. The typecheck job is unfiltered and required, so the meta-gate cannot go dormant — the reasoning that already placed check:docs, check:skill-refs and check:react-blocks there. Verified: --reconcile-only exits 0 on this tree (16 check: + 10 gen:, all classified) after catching #4232's script pre-classification; an injected unclassified script and a deleted ledger-named script both fail with the existing diagnostics; the default mode still reconciles and then runs the gates. AGENTS.md's wrapper section now states the CI backstop. Closes #4255 Refs #4203, #4232 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QCrKazC5o5a77yXd7ykaZg
This was referenced Jul 31, 2026
os-zhuang
added a commit
that referenced
this pull request
Jul 31, 2026
…ative `connectors:` path gets its page (#4289) (#4293) ADR-0097 made connectors tenant-authored metadata, but no hand-written page taught how to write an entry. New content/docs/automation/connectors.mdx covers the three entry shapes, the per-provider `providerConfig` contracts (rest / openapi / mcp — factory-validated at boot, invisible to `os validate`), credentialRef-based auth incl. why `oauth2` is deliberately absent from the declarative shape (enterprise tier, ADR-0015), the configuration-vs-operational failure split, and the showcase as the runnable reference. The two connector-auth exemptions in packages/spec/variant-docs.json flip to governed docs bindings per their own "when one is written, bind it" note, so the #4177 gate now enforces all five auth variants against the guide. Every example parses against the real schemas: the four full examples and the auth fragments via DeclarativeConnectorEntrySchema (including the documented rejections: inline `authentication` on a provider-bound instance, auth / providerConfig without provider, authored actions), the dispatch snippet via defineFlow, and the four marked blocks type-check under check:skill-examples (202 examples). check:variant-docs green at 8 governed / 12 exempt; check-doc-authoring clean. Closes #4289. Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
补一道现有闸门族里缺失的方向。liveness 闸门问「这个属性做不做事」,
check-doc-authoring问「文档用的授权形式对不对」——没有一个问:schema 声明的变体,手写文档里到底提没提过。content/docs/references/由build-docs.ts从 schema 生成,天然不会漂移;手写页面是人打出来的,会漂。缘起:一条漂了几个月的漏检
apps.mdx写导航树「supports eight item types」并列举了八个。schema 有九个——separator是当年为对齐 objectui 渲染器的item.type === 'separator'分支加进来的(#1878/#1891/#1894),而没有任何手写页面知道它存在。没有任何闸门失败,因为没有闸门朝这个方向看。#4165 把这个缺口的代价抬高了:
NavigationItemSchema现在按type判别,写错判别键会回一句「Invalid discriminator value. Expected ...」——而文档里那个枚举,正是作者拿来核对这份清单的东西。同时SeparatorNavItemSchema是只含type/id/order的.strict(),文档承诺「所有导航项共享」的基础属性,写在 separator 上现在是硬错误。(该文档已在 #4165 修好,所以本 PR 是纯增闸门、零文档改动。)
身份与棘轮
条目身份是
<判别键>:<变体排序后拼接>,刻意不用源码路径——一个 union 可由多条路径抵达,遍历顺序决定你看到哪条,路径会抖动。实测:导航 union 的发现路径是api/metadata#AppDefinitionResponseSchema.data.navigation[],而不是ui/app。以变体集合为身份的好处正是棘轮效应:增删任何一个变体都会改变 key,账本随即对不上,CI 把作者推回
variant-docs.json,从而推回文档。分类
反射扫出 20 个判别 union,与源码
grep -c 'z.discriminatedUnion('的计数完全吻合(两种方法互为交叉验证)。generated-reference-only——只在references/下有文档,而那是生成的(widget implementation、job schedule、connector auth ×2、tenant strategy、sync mapping transform、settings-manifest handler)not-authorable——线协议 / 引擎 RPC / 加载器派生判别键(websocket、data-engine ×2、device-token status、CRDT ×2、migration changeset,以及viewKind:作者写的是list/listViews/formViews容器,viewKind是加载器展开后盖上去的,没有作者会手打它)验证它真的会咬人,而不只是会通过
绿灯本身不算证据,三条失败路径逐条实测:
apps.mdx移除separator提及已知局限(已写进脚本头注,不藏着)
变体算「已记录」的判定是:文档中出现
<判别键>: '<变体>',或出现`<变体>`。第二种形态是宽松的,对object/api/value这类通用短词可能匹配到无关句子——通过只代表页面提到了这个词,不代表页面写对了。收紧它带来的误报成本高于精度收益。这道闸门抓的是「漂了几个月的彻底遗漏」,不替代人读文档。接入与影响
pnpm --filter @objectstack/spec check:variant-docs(脚本自包含,直接tsx跑也对)content/docs/**加进触发路径——改文档同样可能打破它scripts/不在 spec 的 tsconfiginclude内(与现有 liveness.mts一致),不影响类型检查Refs #4001, #4165
🤖 Generated with Claude Code
https://claude.ai/code/session_0147tNF4Snk7Ry1KGt4a5PY4
Generated by Claude Code