Skip to content

Commit 91a4036

Browse files
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

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
'@objectstack/cli': patch
3+
---
4+
5+
fix(cli)!: `docs/metadata-embed-ref` judges a package's doc against that package and the packages it declares as dependencies (#27090)
6+
7+
Clause-②: yes (narrowing) — a multi-package artifact's package docs may now embed their own package's `state_machine`, `flow` and `permission` items, and those of the packages they declare in `manifest.dependencies`, which `os build`, `os validate` and `os lint` refused before. Two shapes that built before are refused: a package's inline body doc with a dead embed (no pass judged it), and, in an artifact that keeps a flattened top level, a package doc embedding an item of a sibling it does not declare.
8+
Metadata-protocol: none
9+
10+
<!-- adr-0087: not-required (no-migration-prescription) No key of any schema is added, removed, renamed or re-shaped, and no export or type of the package's public surface changes: the build-time docs lint resolves a metadata embed in the scope ADR-0051 already gives it (the doc's own package, then its dependencies). A refused embed is fixed in the doc, or by declaring the dependency; there is nothing for a migration to rewrite. -->
11+
12+
**BREAKING** accept-set narrowing in the build-time docs lint (`os build`, `os validate`, `os lint`), beside a widening.
13+
14+
- **Before.** Every embed was judged against the artifact's top level. A composed multi-package artifact (`composeStacks(…, { manifest: 'preserve' })`) carries its definitions under `packages[]` and nothing at the top level, so a package doc embedding its own package's object was refused with "does not exist in this package", and a package's inline `defineStack({ docs })` doc was never judged at all. An artifact that keeps a flattened top level (a reconciled composition) resolved every package doc's embeds against the whole artifact.
15+
- **Now.** A package's docs (directory and inline alike) resolve against that package's body plus the packages of this artifact its `manifest.dependencies` names. The stack-level docs (`src/docs/`, unclaimed inline docs) resolve against the stack's top level plus the package the artifact's `manifest.id` names, which is the package they ship on, and its declared dependencies. A declaration is one-way. A refusal names the scope it judged: "does not exist in this package", or "in this package or its declared dependencies (IDS)".
16+
- **Unchanged.** A stack with no `packages[]` is judged against its own collections, as before. Links between docs still resolve across the whole artifact.
17+
18+
**The remedy.** A refused embed of a sibling package's item: declare that package in the embedding package's `manifest.dependencies`, or move the doc to the package that owns the item. A dead reference: fix the name.

‎packages/cli/src/utils/collect-docs.package-docs.test.ts‎

Lines changed: 136 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -685,21 +685,148 @@ describe('same-prefix links resolve ARTIFACT-WIDE, not per package (C2)', () =>
685685
expect(brokenLinks(issues)).toEqual([]);
686686
});
687687

688-
it('metadata embeds and links are partitioned the SAME way — artifact-wide', () => {
689-
// The inconsistency the review named: embeds already resolved artifact-wide
690-
// while links did not. One fixture, both halves, one verdict.
688+
it('a link resolves artifact-wide while an embed resolves in its OWNER\'s scope (#27090)', () => {
689+
// One fixture, both halves. `crm_onboard` sits on the artifact's top level,
690+
// which the door registers under the artifact's `manifest.id` — `core`'s
691+
// id — so `core` owns it and its doc may embed it. ⚠️ Before #27090 the
692+
// embed resolved against the whole stack, the same as the link.
691693
const FENCE = '```';
692694
const embed = [`${FENCE}metadata`, 'type: flow\nname: crm_onboard', FENCE].join('\n');
693695
writePackageDoc('core', 'crm_core_guide', `Link: [o](./crm_orders_guide.md)\n\n${embed}`);
694-
writePackageDoc('orders', 'crm_orders_guide', '# Orders');
696+
writePackageDoc('orders', 'crm_orders_guide', `Back: [c](./crm_core_guide.md)\n\n${embed}`);
695697

696-
const { issues } = collectAndLintDocs(configPath, sharedStack({
697-
// `crm_onboard` is owned by the artifact, not by `core` — an embed has
698-
// always resolved against the whole stack, and now a link does too.
699-
flows: [{ name: 'crm_onboard' }],
700-
}));
698+
const { issues } = collectAndLintDocs(configPath, sharedStack({ flows: [{ name: 'crm_onboard' }] }));
699+
// Both links resolve; only `orders`' embed is refused — `orders` neither owns
700+
// the top level nor declares `core`.
701+
expect(issues.filter((i) => i.severity === 'error').map((i) => [i.rule, i.path])).toEqual([
702+
['docs/metadata-embed-ref', 'packages[1].docs/crm_orders_guide'],
703+
]);
704+
});
705+
});
706+
707+
// ── #27090: an embed is judged against the package that ships the doc ──────
708+
describe('docs/metadata-embed-ref judges a package doc against its package and declared dependencies', () => {
709+
const FENCE = '```';
710+
const smEmbed = (object: string, name: string) =>
711+
[`${FENCE}metadata`, `type: state_machine\nobject: ${object}\nname: ${name}`, FENCE].join('\n');
712+
const lifecycle = (object: string) => ({
713+
name: object,
714+
label: object,
715+
fields: { status: { type: 'text' as const, label: 'Status' } },
716+
validations: [{
717+
type: 'state_machine' as const,
718+
name: `${object}_lifecycle`,
719+
message: 'Not an allowed status change',
720+
field: 'status',
721+
transitions: { new: ['closed'] },
722+
}],
723+
});
724+
const embedRefs = (issues: readonly DocIssue[]) => issues.filter((i) => i.rule === 'docs/metadata-embed-ref');
725+
726+
/** Two packages of one artifact, the way `composeStacks(…, { manifest: 'preserve' })` writes them. */
727+
const composed = (serviceDependsOnApp = false, appDependsOnService = false) => composeStacks([
728+
defineStack({
729+
manifest: {
730+
id: 'com.example.split.service', name: 'service', version: '1.0.0', type: 'module', namespace: 'crm',
731+
...(serviceDependsOnApp ? { dependencies: { 'com.example.split.app': '^1.0.0' } } : {}),
732+
},
733+
objects: [lifecycle('crm_case')],
734+
}),
735+
defineStack({
736+
manifest: {
737+
id: 'com.example.split.app', name: 'app', version: '1.0.0', type: 'app', namespace: 'crm',
738+
...(appDependsOnService ? { dependencies: { 'com.example.split.service': '^1.0.0' } } : {}),
739+
},
740+
objects: [lifecycle('crm_account')],
741+
}),
742+
], { manifest: 'preserve' }) as unknown as Record<string, unknown>;
743+
744+
it('a package doc embedding its OWN package\'s object builds — the card\'s acceptance 1', () => {
745+
writePackageDoc('service', 'crm_case_guide', smEmbed('crm_case', 'crm_case_lifecycle'));
746+
const artifact = composed();
747+
// The measured premise: the composed artifact carries no top-level copy, so
748+
// the old pass (the artifact's top level) had nothing to resolve against.
749+
expect(artifact.objects).toBeUndefined();
750+
751+
const { issues, packageDocs } = collectAndLintDocs(configPath, artifact);
752+
expect(packageDocs.map((set) => [set.dir, set.docs.map((d) => d.name)])).toEqual([
753+
['src/service/docs', ['crm_case_guide']],
754+
]);
701755
expect(issues.filter((i) => i.severity === 'error')).toEqual([]);
702756
});
757+
758+
it('...and a dead reference in that same doc is still refused, naming the scope it judged', () => {
759+
writePackageDoc('service', 'crm_case_guide', smEmbed('crm_cas', 'crm_case_lifecycle'));
760+
const refused = embedRefs(collectAndLintDocs(configPath, composed()).issues);
761+
expect(refused).toHaveLength(1);
762+
expect(refused[0]).toMatchObject({ severity: 'error', path: 'packages[0].docs/crm_case_guide' });
763+
expect(refused[0].message).toContain('which does not exist in this package — did you mean `crm_case`?');
764+
});
765+
766+
it('a doc embedding a SIBLING\'s object it does not declare is refused — the card\'s acceptance 2', () => {
767+
writePackageDoc('app', 'crm_app_guide', smEmbed('crm_case', 'crm_case_lifecycle'));
768+
const refused = embedRefs(collectAndLintDocs(configPath, composed()).issues);
769+
expect(refused).toHaveLength(1);
770+
expect(refused[0]).toMatchObject({ severity: 'error', path: 'packages[1].docs/crm_app_guide' });
771+
});
772+
773+
it('...and builds once that package DECLARES the sibling in manifest.dependencies', () => {
774+
writePackageDoc('app', 'crm_app_guide', smEmbed('crm_case', 'crm_case_lifecycle'));
775+
const { issues } = collectAndLintDocs(configPath, composed(false, true));
776+
expect(issues.filter((i) => i.severity === 'error')).toEqual([]);
777+
});
778+
779+
it('⛔ a declaration is one-way: the declared package does not reach back', () => {
780+
// `service` declares `app`; `app` declares nothing. `app`'s doc embedding
781+
// `service`'s object stays refused, and the message names `app`'s scope.
782+
writePackageDoc('app', 'crm_app_guide', smEmbed('crm_case', 'crm_case_lifecycle'));
783+
writePackageDoc('service', 'crm_case_guide', smEmbed('crm_account', 'crm_account_lifecycle'));
784+
const refused = embedRefs(collectAndLintDocs(configPath, composed(true, false)).issues);
785+
expect(refused.map((i) => i.path)).toEqual(['packages[1].docs/crm_app_guide']);
786+
});
787+
788+
it('a refusal in a package that declares dependencies names them as the scope it judged', () => {
789+
writePackageDoc('service', 'crm_case_guide', smEmbed('crm_lead', 'crm_lead_lifecycle'));
790+
const refused = embedRefs(collectAndLintDocs(configPath, composed(true, false)).issues);
791+
expect(refused).toHaveLength(1);
792+
expect(refused[0].message).toContain(
793+
'which does not exist in this package or its declared dependencies (com.example.split.app)',
794+
);
795+
});
796+
797+
it('a package\'s INLINE body doc is judged in the same scope — it was not judged at all before', () => {
798+
const artifact = composed();
799+
const packages = artifact.packages as Array<{ manifest: Record<string, unknown> }>;
800+
packages[0].manifest.docs = [{ name: 'crm_case_inline', content: smEmbed('crm_case', 'crm_case_lifecycle') }];
801+
packages[1].manifest.docs = [{ name: 'crm_app_inline', content: smEmbed('crm_case', 'crm_case_lifecycle') }];
802+
803+
const refused = embedRefs(collectAndLintDocs(configPath, artifact).issues);
804+
// `service`'s own embed builds; `app`'s embed of `service`'s object does not.
805+
expect(refused.map((i) => i.path)).toEqual(['packages[1].docs/crm_app_inline']);
806+
});
807+
808+
it('a flat src/docs/ doc ships on the package the artifact manifest names, and resolves in its scope', () => {
809+
// `composeStacks` picks the LAST input's manifest, so the flat docs belong to
810+
// `app` (#22190): its own object resolves, `service`'s does only once `app`
811+
// declares it.
812+
writeFlatDoc('crm_index', `${smEmbed('crm_account', 'crm_account_lifecycle')}\n\n${smEmbed('crm_case', 'crm_case_lifecycle')}`);
813+
const refused = embedRefs(collectAndLintDocs(configPath, composed()).issues);
814+
expect(refused.map((i) => [i.path, i.message.includes('"crm_case"')])).toEqual([['docs/crm_index', true]]);
815+
816+
expect(embedRefs(collectAndLintDocs(configPath, composed(false, true)).issues)).toEqual([]);
817+
});
818+
819+
it('a stack-level doc embedding a stack object is unchanged, in a stack with no packages[]', () => {
820+
writeFlatDoc('crm_index', smEmbed('crm_lead', 'crm_lead_lifecycle'));
821+
const single = { manifest: { namespace: 'crm' }, objects: [lifecycle('crm_lead')] };
822+
expect(collectAndLintDocs(configPath, single).issues).toEqual([]);
823+
824+
writeFlatDoc('crm_index', smEmbed('crm_leed', 'crm_lead_lifecycle'));
825+
const refused = embedRefs(collectAndLintDocs(configPath, single).issues);
826+
expect(refused).toHaveLength(1);
827+
expect(refused[0]).toMatchObject({ path: 'docs/crm_index' });
828+
expect(refused[0].message).toContain('which does not exist in this package — did you mean `crm_lead`?');
829+
});
703830
});
704831

705832
// ── "Nothing existing moves" — the single-package shape, item for item ─────

0 commit comments

Comments
 (0)