Repository navigation
Commit ccccdcc
feat(spec): doc navigation item targeting a book and/or a doc (#19789)
Fixes #19482
Clause-②: yes
The spec half of the ruling on #19482 (letter A, with the book/doc
addendum): one `doc` navigation item variant that opens a `book` and/or
a `doc`, with at least one required. The objectui half (renderer +
editor) is objectui#10188 and is not touched here.
## What changed
- **`DocNavItemSchema`** (`packages/spec/src/ui/app.zod.ts`) sits with
the other nav variants and is strict in the same way
(`strictObject(navItemSurface('doc'), …)`). It is a member of the
`NavigationItemSchema` discriminated union and of both hand-tied types
(`NavigationItem`, `NavigationItemInput`). New exports:
`DocNavItemSchema`, `DocNavItem`, `DocNavItemParsed`.
- `book?`: a declared book name, or the package id for the package's
implicit book. `doc?`: a doc name (snake_case filename stem, so
`crm_lead_guide.md` is refused).
- `.refine(requiredOneOf(['book', 'doc']))`: an item with neither is
refused, and the message names both keys.
- The `.describe()` states the audience rule from the addendum. A `book`
entry shows the member's pruned subset of pages and is hidden from a
member with no readable page. A `doc` entry the member may not read is
hidden. `visible` / `requiredPermissions` can only narrow further.
- Near-miss answers: `docName` → `doc` and `bookName` → `book`. `book` /
`doc` written on another variant points at `type: 'doc'`, and the two
prose targets are added to `PROSE_ALIAS_TARGETS`.
- **The "names no existing book/doc in the package" refusal is new rule
`docs/nav-target`** in `packages/cli/src/utils/collect-docs.ts`
(`lintDocNavTargets`, called from `collectAndLintDocs`, so it runs in
`os build`, `os validate` and `os lint`). It refuses with a did-you-mean
and a remedy. It walks app `navigation`, `areas[].navigation` (children
included) and `manifest.navigationContributions` items.
- Docs: `content/docs/ui/apps.mdx` gains a Doc Navigation section, and
the `app navigation item` row in `packages/spec/variant-docs.json` is
re-keyed to include `doc` (`check:variant-docs`).
`skills/objectstack-ui/SKILL.md` gains a `doc` row, which
`check:skill-identifier-liveness` requires (see "Governed surface"
below).
- Regenerated with the repo's generators (`check:generated --fix`, never
by hand): `authorable-surface/ui.json`, `api-surface/ui.json`,
`export-origins/ui.json`, `declaration-map/ui.json`,
`json-schema.manifest/ui.json`, `content/docs/references/**`, and the
strictness-ledger counts.
- Changeset `.changeset/19482-doc-nav-item.md`: `@objectstack/spec`
minor, `@objectstack/cli` minor.
## Landing site of the reference check, and why it is not
`validateCrossReferences`
The dispatch assumed the check would go beside the sibling nav-target
checks (`objectName` / `dashboardName` / `pageName` / `reportName`) in
`validateCrossReferences`, `packages/spec/src/stack.zod.ts`. I measured
that site and it cannot see docs. Docs join the artifact from
`src/docs/*.md` in `collectAndLintDocs`, after the config module (and so
`defineStack`) has already run. For example, `examples/app-showcase`
authors `books` inline in `defineStack` but has no inline `docs`: its
nine docs live in `src/docs/`. A doc arm in `validateCrossReferences`
would therefore do one of two wrong things. Size-gated like its
siblings, it would never fire. Ungated, it would refuse a doc that the
build is about to collect. The CLI docs step is the one place that holds
the complete doc set (inline docs, docs read off disk, and every
package's docs). It already hosts the reverse-direction check
(`docs/metadata-embed-ref`). Both targets of one item are judged there,
so the rule has a single site.
## JSON Schema: refine, not a union — the PM assumption is falsified
The "at least one of" rule is written as `.refine(requiredOneOf(['book',
'doc']))`. `requiredOneOf` is one of the closed, projectable refinements
from #18670 item 2 (`src/shared/refinement-projection.ts`), so the
generator publishes it. Measured: `json-schema/ui/DocNavItem.json`
carries an `allOf` of `anyOf: [{required:[book]}, {required:[doc]}]`,
and the new test pins the projection. With this, the published schema is
no wider than zod. A union of three shapes would have given the same
JSON Schema. It would also have replaced the single remedy message with
an `invalid_union` aggregate, and it would have split the per-variant
strict surface into three.
The `allOf` entry appears twice. That is a pre-existing generator quirk:
`automation/AssignmentExpressionValue.json` does the same. The two
entries are identical, so the result is unchanged. See the Acceptance
notes.
## The ruling's three confidence gaps — first readings
1. **The `book` runtime overlay in objectui is not measurable from this
repo.** There is no objectui checkout in this container (`.objectui-sha`
= `87af769e`). Backend side, measured: the `book` registry row is
`allowOrgOverride: false`
(`packages/spec/src/kernel/metadata-plugin.zod.ts:1080`). That value was
rolled back from ADR-0046 §6.4's `true` (#6483). A Studio drag-edit of a
PACKAGED book answers `403 not_overridable`, and new runtime books still
work (`allowRuntimeCreate: true`).
2. **`/meta/doc` content limits, measured: none at the schema, and none
on the save path.** `DocSchema.content` is an unbounded `z.string()`
(`packages/spec/src/system/doc.zod.ts`). The MDX and image bans run only
in the build lint (`lintDocs` in `collect-docs.ts`), not on `PUT
/meta/doc`. A grep for a body limit (`bodyLimit`, `maxBody`,
`PAYLOAD_TOO_LARGE`) finds none on the metadata routes; the only hit is
`packages/rest/src/import-prepare.ts`.
3. **How the docs audience gate applies to a nav entry, measured: the
content is gated server-side, the entry is not.** `/meta/doc`,
`/meta/book` and `/meta/book/:name/tree` apply the ADR-0046 §6.7
audience gate (`packages/rest/src/rest-server.ts`, the book tree route
and the list gates). The server-side app-nav filter
(`filterAppForUserWithReason`) prunes only on `requiredPermissions`,
`requiresService` and object servability. It has no audience arm. So the
server still sends a `doc` entry to a member who may not read the doc,
and hiding it is up to the renderer (objectui#10188). Clicking it hits
the gated read (401/403). This PR does not add an audience arm to that
filter. That question is raised in the report for the seat.
## Governed surface: this PR is Tier H
`skills/objectstack-ui/SKILL.md` is a `skills/**` path, which makes the
whole PR Tier H. `check:skill-identifier-liveness` holds the Navigation
Item Types table exhaustive over `NavItemVariant`. Adding the variant
without its row reds that gate, and the other remedy (`--update` of the
gap ledger) is maintainer-only.
Readings:
| Measure | Before | After |
|:--|--:|--:|
| `skills/objectstack-ui/SKILL.md` lines | 309 | 310 |
| All `skills/*/SKILL.md` lines | 4401 | 4402 |
| `skills/objectstack-ui/SKILL.md` tokens (ceiling 3856) | ≤ 3856 | 3854
|
The new row's tokens are paid for by deleting text in the same file:
- The object row's `Target precedence: recordId → filters → viewName`
was wrong. The spec refuses `filters` with `recordId` or `viewName` and
states no precedence (`objectNavTargetExclusivity`).
- The page row's `(type: 'home' | 'list' | ...)` aside is gone; page
types are covered in `rules/pages.md`.
- The component row's `params` become props clause is gone.
## Tests (at `f9baf5cb3f`)
- `pnpm --filter @objectstack/spec test`: 517 files, 15077 passed, 1
todo. `pnpm --filter @objectstack/spec typecheck`: exit 0
(`check:test-typecheck: OK`).
- `pnpm --filter @objectstack/cli exec vitest run --project unit` over
`collect-docs.nav-target.test.ts`, `collect-docs.test.ts` and
`collect-docs.package-docs.test.ts`: 3 files, 88 passed. `pnpm --filter
@objectstack/cli typecheck`: exit 0. The integration tier is left to CI:
no spawn entry or integration-tier file is touched.
- New spec pins in `src/ui/app-doc-nav-item.test.ts`:
- the four accepted shapes, plus a doc entry nested in a group;
- the missing-target refusal (`custom` issue at `['navigation', 0]`,
message names both keys) on the union mount and on the standalone
export;
- a filename or path as `doc` is refused, and so is an empty `book`;
- the alias answers;
- the JSON Schema projection.
- New CLI pins in `collect-docs.nav-target.test.ts`: unknown doc and
unknown book are refused with a did-you-mean and the remedy. Also
covered: the implicit book (package id), package-body books, group /
area / contribution walks, and a real `src/docs/*.md` doc that resolves
through `collectAndLintDocs`.
- Existing enumerations updated: `app-strictness-batch19.test.ts` (nine
exported branches), `app.test.ts` ("accepts every variant"),
`alias-integrity.test.ts` (ten nav tables, two prose targets),
`app.nav-type-assertions.ts` (doc probes, one `@ts-expect-error`).
- Ablation, by the in-repo tool: `node scripts/ablation-replace.mjs
--file …/app.zod.ts --anchor "}).refine(requiredOneOf(['book', 'doc']),
{" --replacement "}).refine(() => true, {" -- vitest run
src/ui/app-doc-nav-item.test.ts`.
- Anchor count went 1 → 0 and the blob changed, so the mutation reached
disk. **3 failed | 10 passed**: the two missing-target refusals and the
projection pin. The rest stayed green.
- Restored: blob == HEAD `7ac11bf63025` and `git diff HEAD` is empty.
- No dist is involved: the spec suite resolves `./app.zod` from source.
- Gates: `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` derives 117 families at `f9baf5cb3f`. All
117 were run on that head and all exited 0. `--ran` reconciles "117 run,
0 NOT-MEASURED (a DERIVED zero — all 117 recorded an exit code and none
of them is 3)".
- First-pass reds fixed in this PR: `check:variant-docs` (ledger key +
apps.mdx), `check:skill-identifier-liveness` (SKILL.md row),
`check-skills-token-ratchet` (paid by deletion).
- Prerequisite refusals (exit 3) rebuilt and re-run to 0:
`check:skill-examples`, `check:i18n*`, `check:dual-build-cjs-loads`.
- `npx eslint --no-inline-config` over the five touched TypeScript
files: exit 0. This is a targeted run for my own check. The repo-wide
`pnpm lint` is left to CI and is not claimed here.
## Acceptance notes
- **No doc-in-book membership check.** When both `book` and `doc` are
given, the build does not check that the doc is a member of the book.
Membership is derived at read time from group rules (ADR-0046 §6.2.1),
so a build-time check would be contradicted by the next doc a package
adds.
- **Book targets are resolved within the package.** A `book` target
resolves to the stack's `books`, package-body `books`, or a package id.
A book owned by a different package is refused. That matches the
ruling's "in the package" and `docs/metadata-embed-ref`'s same-package
posture.
- **Doubled `allOf` in the generated JSON Schema.** The generator emits
the projected `requiredOneOf` twice on `DocNavItem.json`, as it already
does on `AssignmentExpressionValue.json`. The two entries are identical,
so what the schema accepts is unchanged. Observation only.
- **The console does not render `doc` items yet.** Until objectui#10188
ships in a pinned console, a `doc` item parses, is checked and
publishes, but the menu does not show it. `apps.mdx` and the changeset
both say so.
## 维护者速读(草稿)
**改了什么**:应用菜单多了一种「文档」导航项(`type:
'doc'`),可以指向一本书(`book`)、一篇文档(`doc`),或者两个都填(书里的那一页);两个都不填会在保存时被拒,报错里点名两个键。构建(`os
build` / `os validate` / `os lint`)时,如果填的书或文档在本包里不存在,会被拒并给出「你是不是想写
…」的提示。另外在对外技能包 `skills/objectstack-ui/SKILL.md` 的导航项表里加了一行。
**为什么改**:按 #19482 的裁决(A,含「可直接链接到 book」的追加)落 spec 这一半;技能包那一行是门禁
`check:skill-identifier-liveness` 要求的——新增的导航项种类必须在技能目录里有一行,否则 AI
作者读不到这个能力。
**风险与代价(含回滚)**:纯新增,不改变任何已有写法的判定;控制台要等 objectui#10188
才会画出这个菜单项,在此之前它能存、能校验、但菜单上看不到。技能包那一行的 token 用同文件删字抵账(顺手删掉了对象行里一句与 spec
不符的「目标优先级」说法)。回滚=整 PR revert,无数据迁移。
**席位意见**:
**你要做的**:这个 PR 碰了 `skills/**`,是 Tier
H,需要您亲自批准一次;另外请看一眼报告里的开放问题——服务端导航过滤要不要也按文档 audience
把看不了的「文档」项剪掉(目前只有渲染端剪、内容读取有门)。
---
_Generated by [Claude
Code](https://claude.ai/code/session_01VWsFyWDp8Rjb2Ma6a3Cyo8)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent cdc1ae0 commit ccccdcc
26 files changed
Lines changed: 695 additions & 56 deletions
File tree
- .changeset
- content/docs
- references
- api
- kernel
- ui
- ui
- docs/audits
- packages
- cli/src/utils
- spec
- api-surface
- authorable-surface
- declaration-map
- export-origins
- json-schema.manifest
- src
- ui
- skills/objectstack-ui
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
92 | 92 | | |
93 | 93 | | |
94 | 94 | | |
95 | | - | |
| 95 | + | |
96 | 96 | | |
97 | 97 | | |
98 | 98 | | |
| |||
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1934 | 1934 | | |
1935 | 1935 | | |
1936 | 1936 | | |
1937 | | - | |
| 1937 | + | |
1938 | 1938 | | |
1939 | 1939 | | |
1940 | 1940 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1 | 1 | | |
2 | 2 | | |
3 | | - | |
| 3 | + | |
4 | 4 | | |
5 | 5 | | |
6 | 6 | | |
| |||
32 | 32 | | |
33 | 33 | | |
34 | 34 | | |
35 | | - | |
36 | | - | |
| 35 | + | |
| 36 | + | |
37 | 37 | | |
38 | 38 | | |
39 | 39 | | |
| |||
361 | 361 | | |
362 | 362 | | |
363 | 363 | | |
364 | | - | |
| 364 | + | |
365 | 365 | | |
366 | 366 | | |
367 | 367 | | |
368 | 368 | | |
369 | 369 | | |
370 | 370 | | |
371 | 371 | | |
372 | | - | |
| 372 | + | |
373 | 373 | | |
374 | 374 | | |
375 | 375 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
44 | 44 | | |
45 | 45 | | |
46 | 46 | | |
47 | | - | |
| 47 | + | |
48 | 48 | | |
49 | 49 | | |
50 | 50 | | |
| |||
107 | 107 | | |
108 | 108 | | |
109 | 109 | | |
110 | | - | |
| 110 | + | |
111 | 111 | | |
112 | 112 | | |
113 | 113 | | |
| |||
0 commit comments