Repository navigation
Commit 91a4036
fix(cli)!: docs/metadata-embed-ref judges a package doc against its package and declared dependencies (#27204)
<!-- claude-projects-attribution: {"github_login":"os-project-manager"}
-->
_Requested by **Leehom** · [project
thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1XDd56RvDyEQZZkUKBW44k2Z)_
Fixes #27090
Clause-②: yes (narrowing)
Metadata-protocol: none
Skills-docs: follow-up owed — skills/objectstack-ui/rules/pages.md (its
"Inline metadata views" section says a referenced item "must exist in
this package"; it should add "or a package it declares in
`manifest.dependencies`")
## 维护者速读
- **改了什么**:`os build` / `os validate` / `os lint` 检查文档里的 ````
```metadata ```` 嵌入(状态机、流程、权限集)时,不再拿整个产物的顶层去找被引用的对象,而是拿"发这篇文档的那个包"加上"它在
`manifest.dependencies` 里声明的包"去找。顶层 `src/docs/` 的文档仍按栈自身的定义判,再加上产物
`manifest.id` 指向的那个包(这些文档就落在它身上)及其声明的依赖。
- **为什么改**:多包产物(`composeStacks(…, { manifest: 'preserve' })`)把定义都放在
`packages[]` 里,顶层是空的,所以包文档连自己包的对象都嵌不了(HotCRM 实测,被迫改用手写
mermaid)。分诊裁定:按发文档的包加它声明的依赖判,不是"产物里有就行"。
- **风险与回滚**:放宽为主;同时收紧两种原本能过的写法:包内联文档(`defineStack({ docs
})`)里引用不存在的对象(以前根本没人检查),以及顶层保留扁平副本的产物里,包文档嵌入一个它没声明依赖的兄弟包的对象。只改构建期检查,不改元数据协议,也不改运行时渲染。回滚即
revert。
- **先看**:`packages/cli/src/utils/collect-docs.ts` 的
`lintMetadataEmbeds`(唯一的 map 构建,现在吃 own + dependencies)和
`collectAndLintDocs` 里的 `embedScope`;`collect-docs.package-docs.test.ts`
新增的 `docs/metadata-embed-ref judges a package doc …` 一组。
- **两分钟验证**:`pnpm --filter @objectstack/cli exec vitest run
--maxWorkers=2 src/utils/collect-docs.package-docs.test.ts`,预期 49/49
通过;在 `main` 上同一文件 8 条失败。
- **需要你做的**:无,席位落地。
## What changes
`lintMetadataEmbeds` (`packages/cli/src/utils/collect-docs.ts`) is the
ADR-0051 embed check. It built its object, flow and permission maps from
the stack it was handed, and `collectAndLintDocs` handed it the
artifact's top level both times: for the stack-level docs, and for each
package's directory docs. A composed multi-package artifact carries its
definitions under `packages[]` and nothing at the top level (#14512), so
every package-doc embed was refused, including one naming the package's
own object.
| | Before (`origin/main` `6866e2e7`) | This head |
|:--|:--|:--|
| A package's directory docs (`src/PKG/docs/`) | judged against the
artifact's top level | judged against that package's body plus the
bodies of the packages of this artifact its `manifest.dependencies`
names |
| A package's inline body docs (`packages[i].manifest.docs`) | judged
only through a top-level copy, which a disjoint composed artifact does
not carry, so never judged | judged with the directory docs, in the same
scope |
| Stack-level docs (`src/docs/`, unclaimed inline docs) | judged against
the stack's top level | the stack's top level, plus, when the artifact's
`manifest.id` names one of its packages (`flatDocsOwner`), that
package's body and its declared dependencies. Those docs ship on that
body (#22190) and the door registers the top level under that id. |
| The refusal's scope words | "does not exist in this package" (false
for a package doc) | "does not exist in this package", or "in this
package or its declared dependencies (IDS)" when the owner declares any
|
| A stack with no `packages[]` | its own collections | unchanged:
`embedScope` answers `[stack]` and no dependencies |
- **One map builder.** `lintMetadataEmbeds(docs, own, dependencies =
[])`. `own` is one record or a list of them (a stack, a body, or both).
The maps are built once from `[...dependencies' bodies, ...own]`, so an
owner's object wins a name a dependency also carries. No second builder,
and every existing one-stack caller is unchanged.
- **The declaration is the field the artifact already carries.**
`declaredDependencyIds` from `@objectstack/core` reads
`manifest.dependencies` (the `{ [packageId]: range }` record) for the
artifact load order (ADR-0130 D5). An id the artifact does not carry is
an installed package's business and is not judged here. The edge is
one-way. No new manifest key.
- **The partition now matches `lintDocs`.** The stack pass judges
`stackScoped`, the package pass judges `entry.all`. A body doc's
top-level copy (a reconciled composition keeps one) is no longer judged
a second time against the whole artifact.
- **Comments made true.** The `collectAndLintDocs` and `lintDocs`
docblocks said embeds resolve artifact-wide, the same as links. Links
still do. The docblocks now say why embeds do not: an embed renders
another package's live metadata, and ADR-0051 resolves it through the
dependency set only.
## Tests
`packages/cli/src/utils/collect-docs.package-docs.test.ts`, a new block
over a real `composeStacks([service, app], { manifest: 'preserve' })`
artifact (each package owns one object with a `state_machine` rule):
| Case | `main` | head |
|:--|:--|:--|
| `service`'s directory doc embeds `service`'s own object: builds
(acceptance 1). The test also asserts the artifact's top level carries
no `objects`. | red | green |
| ...a dead reference in that doc is refused, and the message names
"this package" | red | green |
| `app`'s doc embeds `service`'s object, `app` declares nothing: refused
(acceptance 2) | green | green |
| ...builds once `app` declares `service` | red | green |
| The declaration is one-way: `service` declares `app`, and `app`'s
embed of `service`'s object stays refused | red | green |
| A refusal in a package that declares dependencies names them | red |
green |
| Inline body docs are judged in the same scope | red | green |
| A flat `src/docs/` doc resolves in the owner package's scope, and
reaches `service` once `app` declares it | red | green |
| A stack with no `packages[]`: a stack object embeds, a dead name is
refused with "this package" (acceptance 3) | green | green |
The existing case `metadata embeds and links are partitioned the SAME
way — artifact-wide` pinned the old artifact-wide resolution, which the
triage ruling retires. It is restated in place as `a link resolves
artifact-wide while an embed resolves in its OWNER's scope (#27090)`:
both links resolve, `core`'s embed of the top-level flow builds (core
owns the top level), and `orders`' embed is refused.
<details><summary>Red on main: 8 failed, 41 passed (collect-docs.ts
restored from origin/main, tests at head)</summary>
```
× a link resolves artifact-wide while an embed resolves in its OWNER's scope (#27090)
× a package doc embedding its OWN package's object builds — the card's acceptance 1
× ...and a dead reference in that same doc is still refused, naming the scope it judged
× ...and builds once that package DECLARES the sibling in manifest.dependencies
× ⛔ a declaration is one-way: the declared package does not reach back
× a refusal in a package that declares dependencies names them as the scope it judged
× a package's INLINE body doc is judged in the same scope — it was not judged at all before
× a flat src/docs/ doc ships on the package the artifact manifest names, and resolves in its scope
Tests 8 failed | 41 passed (49)
```
Restore proven: the file's blob equals HEAD's and `git diff HEAD` is
empty.
</details>
<details><summary>Ablations (scripts/ablation-replace.mjs, restored and
verified each time)</summary>
- The dependency arm replaced by `return [];`: anchor 1 → 0, blob
`f85d1bed` → `85f76a70`. 4 failed / 45 passed: the "declares",
"one-way", "names them" and "flat doc reaches service" cases. Restored,
blob == HEAD.
- The owner's top-level arm (`index === flatOwner?.index ? [stack, body]
: [body]` → `[body]`): 1 failed / 63 passed (`collect-docs.package-docs`
+ `collect-docs.flat-docs-owner`): the link/embed case. Restored, blob
== HEAD.
</details>
<details><summary>Live: HotCRM (four packages, `preserve`), the card's
own probe</summary>
HotCRM `main` `353dace`, with `src/service/docs/crm_probe_service.md`
and `src/docs/crm_probe_app.md`, each embedding `state_machine`
`crm_case` / `case_status_progression` (`crm_case` is the service
package's; `service` declares `app.objectstack.hotcrm`, and the app
package declares nothing).
- Published `@objectstack/cli` 18.2.0: exit 1, `Package docs validation
failed (2 issues)`, both docs refused with "does not exist in this
package".
- This head's cli (`node packages/cli/bin/run.js build`): exit 1, 1
issue, only `docs/crm_probe_app` (the app package does not declare the
service package, which is the ruled refusal).
- This head, with only the service probe: exit 0, `Build complete`, and
`dist/objectstack.json` carries `crm_probe_service` on the
`app.objectstack.hotcrm.service` body.
</details>
Other suites: cli unit project (`--project unit`) and cli `typecheck`
are in **Verification**.
## Acceptance notes
- **Direct dependencies only.** A package reaches the packages it
declares, not their dependencies. That matches ADR-0046 §3.3 ("the
package's docs plus its dependencies' docs") and ADR-0051's "within the
doc's own package, then its dependencies".
- **The flat-docs owner reaches the top level.** In a reconciled
composition (`objectConflict: 'merge'`, or an input that keeps its own
collections) the top level is the flattened union, so the owner's docs
still resolve artifact-wide there. That follows the order's ruling that
stack-level docs are judged against the stack's own definitions. A
non-owner package in the same artifact gets its own scope.
- **The refused shapes**, named in the changeset's BREAKING banner: (1)
an inline body doc with a dead embed, never judged before; (2) in an
artifact that keeps a flattened top level, a non-owner package doc
embedding an undeclared sibling's item. No example or HotCRM doc carries
either (HotCRM builds at this head with all 21 of its collected docs,
the probe included; `examples/app-multi-package` ships no docs).
- **The census pin.** `test/normalized-call-sites.test.ts` gets a
`top-level` row for collect-docs' `stack.books` read (in
`lintDocNavTargets`, untouched here). The read was on `main` already,
and the census never saw it: its `codeOnly` masker loses its place in a
template literal nested inside another one (the did-you-mean suffix of
each embed refusal), and on `main` it blanked collect-docs'
`stack.objects` / `stack.flows` / `stack.permissions` / `stack.books`
reads, all four. This head's literals shifted the parity and exposed
`stack.books`. The masker defect is reported to the seat, not fixed
here.
- **Not in this diff:** the skills page above (`Skills-docs: follow-up
owed`).
## Verification
Every reading below is at head `8149b3d2`, after the last commit.
- **cli unit project:** `pnpm --filter @objectstack/cli exec vitest run
--project unit --maxWorkers=2`. Lock VERDICT command-exit 0, Test Files
344/344, Tests 4987/4987. The integration project is left to CI (no
spawn entry or integration-tier file is touched).
- **cli typecheck:** `pnpm --filter @objectstack/cli run typecheck`
(`tsc --noEmit && pnpm check:test-typecheck`, which covers the test
files). VERDICT command-exit 0.
- **eslint** (`pnpm exec eslint --no-inline-config` on the three touched
TS files): exit 0.
- **Derived gates:** `node scripts/pm/dispatch-gates.mjs --commands
--repo objectstack-ai/objectstack` gives 61 commands. Each was run with
its exit code captured before any pipe, and all 61 exited 0. `--ran`:
"61 derived famil(ies) accounted for — 61 run, 0 NOT-MEASURED". This was
after a full `turbo run build` (VERDICT 0), so
`check:dual-build-cjs-loads`, `check:dts-closure` and
`check:i18n-coverage` measured, and the `check-plugin-teardown-shape
--self-test` positive control was fetched.
- **Dogfood battery** (owed by the `(narrowing)` arm): `pnpm --filter
@objectstack/dogfood test` under the lock. VERDICT command-exit 0, Test
Files 348 passed / 3 skipped (351), Tests 2804 passed / 20 skipped
(2824), no failures.
- **Live:** `os build` of `examples/app-multi-package` at this head:
exit 0, "0 collected" docs (it ships none, so nothing moves there).
HotCRM is in **Tests**.
- **CI-owned:** the repo-wide `pnpm lint`, Test Core shards, Type Check
lanes and the families `dispatch-gates` lists outside its derived total.
## Human Minimum
- [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic.
(`packages/spec` untouched)
- [x] 2. Every name I added uses the casing for its kind.
- [x] 3. No formatter ran; `pnpm lint` passes. (eslint on the touched
files: exit 0; the repo-wide run is CI's)
- [x] 4. One issue, one branch off an up-to-date `main`, one pull
request.
- [x] 5. A change that publishes carries a changeset, and both
declaration lines are filled in here and in the changeset.
- [x] 6. `packages/spec` touched: n/a.
- [x] 7. No hand edits to `content/docs/references/`,
`packages/*/CHANGELOG.md` or `content/docs/releases/`.
- [x] 8. Title, body and comments are in English. (The 维护者速读 block is
the repo's Chinese maintainer summary.)
---
_Generated by [Claude
Code](https://claude.ai/code/session_01FEkQ5kKDkUCEhVR7RZ3Npk)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent 39fd5fb commit 91a4036
4 files changed
Lines changed: 259 additions & 38 deletions
File tree
- .changeset
- packages/cli
- src/utils
- test
| 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 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
685 | 685 | | |
686 | 686 | | |
687 | 687 | | |
688 | | - | |
689 | | - | |
690 | | - | |
| 688 | + | |
| 689 | + | |
| 690 | + | |
| 691 | + | |
| 692 | + | |
691 | 693 | | |
692 | 694 | | |
693 | 695 | | |
694 | | - | |
| 696 | + | |
695 | 697 | | |
696 | | - | |
697 | | - | |
698 | | - | |
699 | | - | |
700 | | - | |
| 698 | + | |
| 699 | + | |
| 700 | + | |
| 701 | + | |
| 702 | + | |
| 703 | + | |
| 704 | + | |
| 705 | + | |
| 706 | + | |
| 707 | + | |
| 708 | + | |
| 709 | + | |
| 710 | + | |
| 711 | + | |
| 712 | + | |
| 713 | + | |
| 714 | + | |
| 715 | + | |
| 716 | + | |
| 717 | + | |
| 718 | + | |
| 719 | + | |
| 720 | + | |
| 721 | + | |
| 722 | + | |
| 723 | + | |
| 724 | + | |
| 725 | + | |
| 726 | + | |
| 727 | + | |
| 728 | + | |
| 729 | + | |
| 730 | + | |
| 731 | + | |
| 732 | + | |
| 733 | + | |
| 734 | + | |
| 735 | + | |
| 736 | + | |
| 737 | + | |
| 738 | + | |
| 739 | + | |
| 740 | + | |
| 741 | + | |
| 742 | + | |
| 743 | + | |
| 744 | + | |
| 745 | + | |
| 746 | + | |
| 747 | + | |
| 748 | + | |
| 749 | + | |
| 750 | + | |
| 751 | + | |
| 752 | + | |
| 753 | + | |
| 754 | + | |
701 | 755 | | |
702 | 756 | | |
| 757 | + | |
| 758 | + | |
| 759 | + | |
| 760 | + | |
| 761 | + | |
| 762 | + | |
| 763 | + | |
| 764 | + | |
| 765 | + | |
| 766 | + | |
| 767 | + | |
| 768 | + | |
| 769 | + | |
| 770 | + | |
| 771 | + | |
| 772 | + | |
| 773 | + | |
| 774 | + | |
| 775 | + | |
| 776 | + | |
| 777 | + | |
| 778 | + | |
| 779 | + | |
| 780 | + | |
| 781 | + | |
| 782 | + | |
| 783 | + | |
| 784 | + | |
| 785 | + | |
| 786 | + | |
| 787 | + | |
| 788 | + | |
| 789 | + | |
| 790 | + | |
| 791 | + | |
| 792 | + | |
| 793 | + | |
| 794 | + | |
| 795 | + | |
| 796 | + | |
| 797 | + | |
| 798 | + | |
| 799 | + | |
| 800 | + | |
| 801 | + | |
| 802 | + | |
| 803 | + | |
| 804 | + | |
| 805 | + | |
| 806 | + | |
| 807 | + | |
| 808 | + | |
| 809 | + | |
| 810 | + | |
| 811 | + | |
| 812 | + | |
| 813 | + | |
| 814 | + | |
| 815 | + | |
| 816 | + | |
| 817 | + | |
| 818 | + | |
| 819 | + | |
| 820 | + | |
| 821 | + | |
| 822 | + | |
| 823 | + | |
| 824 | + | |
| 825 | + | |
| 826 | + | |
| 827 | + | |
| 828 | + | |
| 829 | + | |
703 | 830 | | |
704 | 831 | | |
705 | 832 | | |
| |||
0 commit comments