From 043791f0a3e98ab0867b4d6c233c5522f934a4b4 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 01:37:31 +0000 Subject: [PATCH 1/5] fix(cli): a multi-package artifact's flat src/docs rides the package that owns its manifest placeCollectedDocs makes every docs placement os build performs: the per-package sets onto their bodies as before, and the stack's own flat src/docs onto the body of the packages[] entry whose id is the artifact's manifest.id when exactly one entry carries it. A stack with no packages[] keeps them on the top level, byte for byte. Claude-Session: https://claude.ai/code/session_01BmsuLyUeuG5CNpZFMH1jzS Co-authored-by: Claude --- packages/cli/src/commands/compile.ts | 26 ++-- packages/cli/src/utils/collect-docs.ts | 114 ++++++++++++++++-- .../test/validate-build-gate-parity.test.ts | 10 +- 3 files changed, 128 insertions(+), 22 deletions(-) diff --git a/packages/cli/src/commands/compile.ts b/packages/cli/src/commands/compile.ts index e6fd3c35339..004be27c3f2 100644 --- a/packages/cli/src/commands/compile.ts +++ b/packages/cli/src/commands/compile.ts @@ -24,7 +24,7 @@ import { buildAccessMatrix, diffAccessMatrix } from '@objectstack/lint'; import { runAuthoringRules, splitBySeverity, authoringRulesFor } from '@objectstack/lint'; import { resolveJsxGateManifest, printJsxGateNotices } from '../utils/sdui-manifest.js'; import { preflightDeclaredCapabilities, renderCapabilityMessage } from '../utils/capability-preflight.js'; -import { attachPackageDocs, collectAndLintDocs, type DocIssue } from '../utils/collect-docs.js'; +import { collectAndLintDocs, placeCollectedDocs, type DocIssue } from '../utils/collect-docs.js'; import { buildRuntimeBundle, cleanupOldRuntimeBundles } from '../utils/build-runtime.js'; import { printHeader, @@ -981,9 +981,6 @@ export default class Compile extends Command { } const finalBundle: Record = { ...(result.data as Record) }; - if (docsResult.docs.length > 0) { - finalBundle.docs = docsResult.docs; - } // [#18431] Docs read out of `src//docs/` attach to the body of the // package that owns them — `packages[i].manifest`, ADR-0130 D4 option // B — and ⛔ never to the top level, which is the maintainer's ruling @@ -993,11 +990,22 @@ export default class Compile extends Command { // `registerMetadataCollections` over `METADATA_ARRAY_KEYS`, which // carries `docs` — so a doc on a body is served under that package // and a flattened copy would buy nothing while destroying the - // attribution. `attachPackageDocs` hands back the - // ARGUMENT when it adds nothing, so a stack with no per-package docs - // serializes from the very same references as before. - if (docsResult.packageDocs.length > 0) { - finalBundle.packages = attachPackageDocs(finalBundle.packages, docsResult.packageDocs); + // attribution. + // [#22190] The stack's own flat `src/docs/` follows the same rule one + // step up: on a multi-package artifact it rides the body of the + // package that owns the artifact's manifest, because that artifact's + // top level is no package's body and the metadata door warns about + // every item it finds there. With no `packages[]` the top level IS + // the one package, so it stays there. `placeCollectedDocs` makes both + // choices; it hands back the very `docs` array and `packages` value + // when it moves nothing, so a single-package stack serializes from + // the same references as before. + const placed = placeCollectedDocs(finalBundle, docsResult); + if (placed.docs.length > 0) { + finalBundle.docs = placed.docs; + } + if (placed.packages !== finalBundle.packages) { + finalBundle.packages = placed.packages; } // 4b. Bundle handler functions into `/objectstack-runtime.{hash}.mjs` diff --git a/packages/cli/src/utils/collect-docs.ts b/packages/cli/src/utils/collect-docs.ts index 5df1d4cf1d0..5db191c3f45 100644 --- a/packages/cli/src/utils/collect-docs.ts +++ b/packages/cli/src/utils/collect-docs.ts @@ -702,10 +702,13 @@ function compileDocsDirectory(docsDir: string, relBase: string): { docs: DocItem * ADR-0130 D4; #18965 made that resolution depth-free). * * The two results stay SEPARATE and that separation is the ruling: the flat - * directory's docs are the stack's own and keep attaching where they always - * did (`docs` below, which `compile.ts` writes to the artifact's top level), - * while a package's docs go to `packageDocs` and from there into that package's - * own body — ⛔ never to the top level. + * directory's docs are the stack's own (`docs` below), while a package's docs + * go to `packageDocs` and from there into that package's own body — ⛔ never to + * the top level. WHERE the stack's own docs land in the artifact is not this + * reader's call: {@link placeCollectedDocs} makes it, once — the top level for + * a stack with no `packages[]`, whose top level IS its one package, and the + * body of the package that owns the artifact's manifest for one that has them + * (#22190). * * ⚠️ Called with ONE argument, this function behaves exactly as it always has: * `packageDocs` is empty, every `src//docs/` is reported by the #18170 @@ -1255,9 +1258,11 @@ function underPackage(issues: readonly DocIssue[], index: number): DocIssue[] { * the package that owns it. * * Returns the stack-level doc array (inline items first — they were already - * schema-validated), which is what the artifact's TOP LEVEL carries, plus the - * per-package sets {@link attachPackageDocs} writes into `packages[]`, plus - * every issue found. + * schema-validated), the subset of it read out of the flat `src/docs/` + * (`flatDocs`, the same objects), the per-package sets {@link attachPackageDocs} + * writes into `packages[]`, and every issue found. Where each set lands in the + * artifact is {@link placeCollectedDocs}'s decision; this function only + * collects and judges. * * ## The lint partition (#18431, the ruling's clause 2) * @@ -1296,7 +1301,7 @@ function underPackage(issues: readonly DocIssue[], index: number): DocIssue[] { export function collectAndLintDocs( configPath: string, stack: Record, -): { docs: DocItem[]; issues: DocIssue[]; packageDocs: PackageDocSet[] } { +): { docs: DocItem[]; flatDocs: DocItem[]; issues: DocIssue[]; packageDocs: PackageDocSet[] } { const inline = Array.isArray(stack.docs) ? (stack.docs as DocItem[]) : []; const collected = collectDocsFromSrc(configPath, stack.packages); const namespace = (stack.manifest as { namespace?: string } | undefined)?.namespace; @@ -1309,9 +1314,9 @@ export function collectAndLintDocs( return { ref, body, fromDisk, all: [...body, ...fromDisk] }; }).filter((entry) => entry.all.length > 0); - // The top level keeps every doc it carried — the artifact shape does not - // move. What the ownership split changes is only which namespace each doc is - // JUDGED against, so the stack-level lint drops the ones a package claims. + // `docs` keeps every stack-level doc — this function places nothing. What + // the ownership split changes is only which namespace each doc is JUDGED + // against, so the stack-level lint drops the ones a package claims. const claimed = claimedDocs(owned.flatMap((entry) => entry.body)); const stackScoped = docs.filter((doc) => !claimed.has(doc)); @@ -1345,7 +1350,7 @@ export function collectAndLintDocs( ...owned.map((entry) => ({ label: `package "${entry.ref.id}"`, docs: entry.all })), ])); - return { docs, issues, packageDocs: collected.packageDocs }; + return { docs, flatDocs: collected.docs, issues, packageDocs: collected.packageDocs }; } /** @@ -1396,3 +1401,88 @@ export function attachPackageDocs(packages: unknown, sets: readonly PackageDocSe }); return changed ? out : packages; } + +/** + * The package a multi-package artifact's FLAT `src/docs/` belongs to (#22190): + * the one `packages[]` entry whose id IS the artifact's own `manifest.id`. + * + * ## Why that package + * + * The #18431 ruling keeps a single-package stack's flat docs on the top level + * because there the top level IS the one package. A multi-package artifact has + * no such package at its top level: it carries its metadata once, in + * `packages[]` (ADR-0130 D4, the 2026-09-22 addendum). An item found at its top + * level is registered by the metadata door under the artifact's own + * `manifest.id`, because no body claims it, and the door warns that it did so — + * a warning whose remedy ("rebuild the artifact so each collection it ships is + * carried by the package that owns it") nobody could follow, since no + * directory in an ADR-0130 layout names the app package. The package that id + * names is therefore the owner the boot ALREADY assigns. Carrying the docs on + * its body keeps the owner and the count every door answers, and leaves the + * residual sweep nothing of this producer's to find. + * + * `composeStacks(…, { manifest: 'preserve' })` picks the artifact's `manifest` + * from one of its inputs and that input contributes its own package entry, so + * an artifact this repo's composer writes always has the match — on a body + * whose manifest fields, `namespace` included, are the artifact manifest's. + * + * ⛔ No guess. No `manifest.id`, no entry carrying it, or MORE than one entry + * carrying it — `os build` does not refuse a duplicate id, the boot does — + * answers `undefined`, and the flat docs keep the top-level position they have + * always had. The entry's id is read through {@link docsPackageRefs}, which + * reads `artifactPackages`; ⛔ it is not re-derived here. + */ +export function flatDocsOwner(stack: Record): DocsPackageRef | undefined { + const refs = docsPackageRefs(stack.packages); + if (refs.length === 0) return undefined; + const manifestId = (stack.manifest as { id?: unknown } | null | undefined)?.id; + if (typeof manifestId !== 'string' || manifestId === '') return undefined; + const owners = refs.filter((ref) => ref.id === manifestId); + return owners.length === 1 ? owners[0] : undefined; +} + +/** + * Place what {@link collectAndLintDocs} collected into the artifact `os build` + * writes — the ONE place that decides it, for both sets: + * + * - each package's own `src//docs/` set goes onto that package's body + * ({@link attachPackageDocs}; the #18431 ruling, clause 1); + * - the stack's own flat `src/docs/` set goes onto the body of + * {@link flatDocsOwner} when the artifact has one, after that package's own + * directory docs, and stays on the top level otherwise (#22190). + * + * Inline top-level `docs` never move: on a composed multi-package artifact + * they are either absent or copies a body already carries, and a top-level doc + * no body claims is exactly what the metadata door is right to warn about. + * + * Returns the artifact's `docs` and `packages` values. A stack with no + * `packages[]` gets back the very `docs` array it handed in and its own + * `packages` value untouched, so a single-package artifact is serialized from + * the same references as before. The doc lint is not re-run and not re-routed: + * it judged every flat doc against `stack.manifest.namespace` (the ruling's + * clause 2), which is the owner's namespace on any composed artifact. + */ +export function placeCollectedDocs( + stack: Record, + collected: { + readonly docs: DocItem[]; + readonly flatDocs: readonly DocItem[]; + readonly packageDocs: readonly PackageDocSet[]; + }, +): { docs: DocItem[]; packages: unknown } { + let packages = collected.packageDocs.length > 0 + ? attachPackageDocs(stack.packages, collected.packageDocs) + : stack.packages; + const owner = collected.flatDocs.length > 0 ? flatDocsOwner(stack) : undefined; + if (owner === undefined) return { docs: collected.docs, packages }; + + packages = attachPackageDocs(packages, [{ + index: owner.index, + id: owner.id, + ...(owner.namespace !== undefined ? { namespace: owner.namespace } : {}), + dir: `src/${COLLECTED_DOCS_DIR}`, + docs: [...collected.flatDocs], + }]); + const placed = new Set(collected.flatDocs); + return { docs: collected.docs.filter((doc) => !placed.has(doc)), packages }; +} diff --git a/packages/cli/test/validate-build-gate-parity.test.ts b/packages/cli/test/validate-build-gate-parity.test.ts index 5e44e323d15..df63c671349 100644 --- a/packages/cli/test/validate-build-gate-parity.test.ts +++ b/packages/cli/test/validate-build-gate-parity.test.ts @@ -276,8 +276,16 @@ const NOT_A_GATE: Readonly> = { // to place. ⛔ Nor is there a parity gap behind it: `os validate` emits no // artifact, so there is no `packages[]` for it to place them in, and every // issue the placement could ever raise was raised by the gate above it. + // + // [#22190] The row names `placeCollectedDocs`, the one call that now makes + // every docs placement `os build` performs: the per-package sets through + // `attachPackageDocs`, and the stack's own flat `src/docs/` onto the body of + // the package that owns the artifact's manifest when there is one. Same + // classification, for the same reasons — it raises nothing, and it hands + // back the `docs` array and `packages` value it was given when it has + // nothing to move. 'Artifact assembly — puts already-collected, already-linted content into the bundle `os build` writes; judges nothing': - ['attachPackageDocs'], + ['placeCollectedDocs'], 'Presentation — renders, formats or serialises a verdict something else reached; judges nothing': [ 'printHeader', From 3f560ff74e47ec093c0de3c62d26da85cce034b8 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 01:43:45 +0000 Subject: [PATCH 2/5] test(cli): pin the flat src/docs owner placement and the metadata door's silence Claude-Session: https://claude.ai/code/session_01BmsuLyUeuG5CNpZFMH1jzS Co-authored-by: Claude --- .../collect-docs.flat-docs-owner.test.ts | 356 ++++++++++++++++++ .../build-package-docs-attachment.e2e.test.ts | 53 ++- 2 files changed, 404 insertions(+), 5 deletions(-) create mode 100644 packages/cli/src/utils/collect-docs.flat-docs-owner.test.ts diff --git a/packages/cli/src/utils/collect-docs.flat-docs-owner.test.ts b/packages/cli/src/utils/collect-docs.flat-docs-owner.test.ts new file mode 100644 index 00000000000..f197a5b5bcf --- /dev/null +++ b/packages/cli/src/utils/collect-docs.flat-docs-owner.test.ts @@ -0,0 +1,356 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #22190 — a multi-package artifact's FLAT `src/docs/` rides the body of the + * package that owns the artifact's manifest, and the metadata door stops + * warning about it. + * + * ## The defect, as the card measured it + * + * `os build` placed the stack's own flat `src/docs/` on the artifact's TOP + * LEVEL whatever the artifact's shape. A multi-package artifact carries its + * metadata once, in `packages[]` (ADR-0130 D4, the 2026-09-22 addendum), so + * those docs were the only thing at its top level, and the metadata door's + * residual sweep registered them under the artifact's `manifest.id` and warned + * on EVERY boot that no package body claimed them. The warning's remedy + * ("rebuild the artifact so each collection it ships is carried by the package + * that owns it") could not be followed: in an ADR-0130 layout the app package + * lives in a directory such as `src/sales/`, which names no package, so no docs + * directory the build reads attaches to its body. + * + * ## What is pinned here, and at which tier + * + * The fixture is the card's shape, composed by the real composer: an app + * package whose directory names no package, and a service package whose + * `src/service/docs/` resolves by its id's last segment. Everything runs in the + * unit tier, so it runs on the pull request that would regress it — the + * command-level twin in `test/build-package-docs-attachment.e2e.test.ts` + * carries the `.e2e.` name and runs nightly. + * + * - PLACEMENT: the flat docs land on the app package's body, the service's + * directory docs stay on the service body, and the artifact keeps no + * top-level `docs`; + * - THE DOOR: the real `MetadataPlugin`, booted on the placed artifact in + * `artifact-only` mode, logs no warning at all and serves the same docs + * under the same owners. Its lit control is the SAME artifact with the flat + * docs put back on the top level — the pre-fix placement — which draws + * exactly one warning from the same door, so the zero is not the silence + * of an instrument that cannot hear. The warnings are COUNTED, never read: + * the sentence is the metadata package's to word; + * - NO GUESS: a manifest id that names no package entry, or two of them, or + * no manifest id at all, keeps the flat docs on the top level; + * - NOTHING EXISTING MOVES: a stack with no `packages[]` gets back the very + * `docs` array it handed in. + * + * ⚠️ Pedigree, not counts: every doc on disk carries a marker written into + * exactly one file, and the placement assertions read the marker back. + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { composeStacks, defineStack } from '@objectstack/spec'; +import { MetadataPlugin } from '@objectstack/metadata'; + +import { + collectAndLintDocs, + docsPackageRefs, + flatDocsOwner, + placeCollectedDocs, + type DocItem, +} from './collect-docs.js'; + +const APP_ID = 'app.example.acme'; +const SERVICE_ID = 'app.example.acme.service'; + +/** The app package. Its source directory is `src/sales/`, which names no package. */ +const appStack = { + manifest: { id: APP_ID, name: 'acme', namespace: 'acme', version: '1.0.0', type: 'app' }, + objects: [ + { name: 'acme_account', label: 'Account', sharingModel: 'private', fields: { name: { type: 'text', label: 'Name' } } }, + ], +}; + +/** + * The second package. A DIFFERENT version from the app on purpose: the door + * stamps `_packageVersion` from the body it found the item in, so a shared + * version would let a doc stamped from the wrong body pass unnoticed. + */ +const serviceStack = { + manifest: { id: SERVICE_ID, name: 'service', namespace: 'acme', version: '2.4.0', type: 'module' }, + objects: [ + { name: 'acme_case', label: 'Case', sharingModel: 'private', fields: { name: { type: 'text', label: 'Subject' } } }, + ], +}; + +/** + * The artifact `composeStacks(…, { manifest: 'preserve' })` writes for the + * card's project — the app listed LAST, so the artifact's own `manifest` is the + * app's (`'last'`), the way an ADR-0130 project composes its consumer-facing + * App over its modules. + */ +const composedArtifact = (): Record => + JSON.parse(JSON.stringify(composeStacks( + [defineStack(serviceStack as never, { strict: false }), defineStack(appStack as never, { strict: false })], + { manifest: 'preserve' }, + ))); + +const MARKER_FLAT_GUIDE = 'MARKER-22190-flat-guide'; +const MARKER_FLAT_FAQ = 'MARKER-22190-flat-faq'; +const MARKER_SERVICE = 'MARKER-22190-service-runbook'; + +let tmp: string; +let configPath: string; + +const writeDoc = (dir: string, name: string, marker: string): void => { + const target = path.join(tmp, 'src', dir); + fs.mkdirSync(target, { recursive: true }); + fs.writeFileSync(path.join(target, `${name}.md`), `# ${name}\n\n${marker}\n`); +}; + +beforeEach(() => { + tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'os-flat-docs-owner-')); + configPath = path.join(tmp, 'objectstack.config.ts'); + fs.writeFileSync(configPath, '// stub'); + // The card's layout: the app's own source directory exists and carries no + // docs; the stack's docs are flat; the service ships its own. + fs.mkdirSync(path.join(tmp, 'src', 'sales'), { recursive: true }); + writeDoc('docs', 'acme_guide', MARKER_FLAT_GUIDE); + writeDoc('docs', 'acme_faq', MARKER_FLAT_FAQ); + writeDoc(path.join('service', 'docs'), 'acme_service_runbook', MARKER_SERVICE); +}); + +afterEach(() => { + fs.rmSync(tmp, { recursive: true, force: true }); +}); + +/** + * Collect, lint and place exactly as `compile.ts` step 4 does, and hand back + * the artifact it would write. + */ +function build(stack: Record) { + const collected = collectAndLintDocs(configPath, stack); + const placed = placeCollectedDocs(stack, collected); + const artifact: Record = { ...stack }; + if (placed.docs.length > 0) artifact.docs = placed.docs; + if (placed.packages !== artifact.packages) artifact.packages = placed.packages; + return { collected, placed, artifact }; +} + +const bodyOf = (artifact: Record, id: string): Record => + (artifact.packages as Array<{ manifest: Record }>).find((entry) => entry.manifest.id === id)!.manifest; + +const docNames = (docs: unknown): string[] => (Array.isArray(docs) ? (docs as DocItem[]).map((doc) => doc.name) : []); + +/** + * Boot the REAL metadata door on `artifact` — `MetadataPlugin.start` in + * `artifact-only` mode over a file, the path `os start` takes — and report how + * many warnings it logged and which docs it registered under which package. + */ +async function door(artifact: Record): Promise<{ + warnings: number; + docs: Array<{ name: string; packageId?: string; packageVersion?: string; content?: string }>; +}> { + const file = path.join(tmp, `artifact-${Math.random().toString(36).slice(2)}.json`); + fs.writeFileSync(file, JSON.stringify(artifact)); + const warn = vi.fn(); + const ctx = { + logger: { info: vi.fn(), debug: vi.fn(), error: vi.fn(), warn }, + registerService: vi.fn(), + getService: vi.fn(() => undefined), + trigger: vi.fn(), + hook: vi.fn(), + } as any; + const plugin = new MetadataPlugin({ + watch: false, + artifactWatch: false, + artifactSource: { mode: 'local-file', path: file }, + config: { bootstrap: 'artifact-only' }, + } as any) as any; + try { + await plugin.start(ctx); + const docs = (await plugin.manager.list('doc')) as Array>; + return { + warnings: warn.mock.calls.length, + docs: docs + .map((doc) => ({ + name: doc.name, + packageId: doc._packageId, + packageVersion: doc._packageVersion ?? undefined, + content: doc.content, + })) + .sort((a, b) => a.name.localeCompare(b.name)), + }; + } finally { + await plugin.destroy?.(); + } +} + +describe('#22190 the fixture is the card shape (premise guard)', () => { + it('a composed two-package artifact: nothing at the top level, the app owns the manifest, its directory names no package', () => { + const artifact = composedArtifact(); + expect(artifact.manifest.id).toBe(APP_ID); + expect(artifact.packages.map((entry: any) => entry.manifest.id)).toEqual([SERVICE_ID, APP_ID]); + // ADR-0130 D4's 2026-09-22 addendum: a multi-package artifact carries its + // collections once, in the bodies — so the top level is no package's body. + expect(artifact.objects).toBeUndefined(); + expect(artifact.docs).toBeUndefined(); + // The reason the remedy could not be followed: `src/sales/` is not a + // spelling any package answers to, so the app has no docs directory. + const spellings = docsPackageRefs(artifact.packages).flatMap((ref) => ref.directoryNames); + expect(spellings).not.toContain('sales'); + expect(spellings).toContain('service'); + }); +}); + +describe('#22190 flatDocsOwner — the entry whose id IS the artifact manifest id, or nobody', () => { + it('answers the app package of the composed artifact', () => { + const owner = flatDocsOwner(composedArtifact()); + expect(owner?.index).toBe(1); + expect(owner?.id).toBe(APP_ID); + expect(owner?.namespace).toBe('acme'); + }); + + it('answers nobody for a stack with no packages[] — its top level IS its one package', () => { + expect(flatDocsOwner({ manifest: { ...appStack.manifest } })).toBeUndefined(); + }); + + it('answers nobody when the manifest id names no package entry', () => { + const artifact = composedArtifact(); + artifact.manifest = { ...artifact.manifest, id: 'app.example.elsewhere' }; + expect(flatDocsOwner(artifact)).toBeUndefined(); + }); + + it('answers nobody when TWO entries carry the manifest id — ⛔ never a guess between them', () => { + const artifact = composedArtifact(); + artifact.packages[0].manifest = { ...artifact.packages[0].manifest, id: APP_ID }; + expect(docsPackageRefs(artifact.packages).filter((ref) => ref.id === APP_ID)).toHaveLength(2); + expect(flatDocsOwner(artifact)).toBeUndefined(); + }); + + it('answers nobody when the artifact declares no manifest id', () => { + const artifact = composedArtifact(); + delete artifact.manifest; + expect(flatDocsOwner(artifact)).toBeUndefined(); + }); +}); + +describe('#22190 placement — the two-package build', () => { + it('puts the flat docs on the APP package body and keeps no top-level docs', () => { + const { collected, artifact } = build(composedArtifact()); + + // Asserted first: an error would make the build exit before placing. + expect(collected.issues).toEqual([]); + expect(artifact).not.toHaveProperty('docs'); + + const app = bodyOf(artifact, APP_ID); + expect(docNames(app.docs).sort()).toEqual(['acme_faq', 'acme_guide']); + const byName = new Map((app.docs as DocItem[]).map((doc) => [doc.name, doc.content])); + expect(byName.get('acme_guide')).toContain(MARKER_FLAT_GUIDE); + expect(byName.get('acme_faq')).toContain(MARKER_FLAT_FAQ); + }); + + it('control: `src/service/docs` still lands on the SERVICE body, and only there', () => { + const { artifact } = build(composedArtifact()); + + const service = bodyOf(artifact, SERVICE_ID); + expect(docNames(service.docs)).toEqual(['acme_service_runbook']); + expect((service.docs as DocItem[])[0].content).toContain(MARKER_SERVICE); + expect(JSON.stringify(bodyOf(artifact, APP_ID).docs)).not.toContain(MARKER_SERVICE); + }); + + it('appends after the owner\'s OWN directory docs — a second set for one package is not dropped', () => { + // The app's id tail is `acme`, so `src/acme/docs/` is a directory it + // answers to; that set and the flat set both address the same body. + writeDoc(path.join('acme', 'docs'), 'acme_overview', 'MARKER-22190-app-directory'); + const { collected, artifact } = build(composedArtifact()); + + expect(collected.issues).toEqual([]); + expect(docNames(bodyOf(artifact, APP_ID).docs)).toEqual(['acme_overview', 'acme_faq', 'acme_guide']); + expect(artifact).not.toHaveProperty('docs'); + }); + + it('keeps an inline top-level doc where it was — only the flat src/docs/ set moves', () => { + const inline: DocItem = { name: 'acme_inline', content: '# Inline' }; + const stack = { ...composedArtifact(), docs: [inline] }; + const { artifact } = build(stack); + + expect(artifact.docs).toEqual([inline]); + expect(docNames(bodyOf(artifact, APP_ID).docs).sort()).toEqual(['acme_faq', 'acme_guide']); + }); +}); + +describe('#22190 the metadata door — no residual warning, same docs, same owners', () => { + it('boots the placed artifact with ZERO warnings, serving every doc under the package that owns it', async () => { + const { artifact } = build(composedArtifact()); + const read = await door(artifact); + + expect(read.warnings).toBe(0); + expect(read.docs.map(({ name, packageId, packageVersion }) => ({ name, packageId, packageVersion }))).toEqual([ + { name: 'acme_faq', packageId: APP_ID, packageVersion: '1.0.0' }, + { name: 'acme_guide', packageId: APP_ID, packageVersion: '1.0.0' }, + { name: 'acme_service_runbook', packageId: SERVICE_ID, packageVersion: '2.4.0' }, + ]); + expect(read.docs.find((doc) => doc.name === 'acme_guide')?.content).toContain(MARKER_FLAT_GUIDE); + }); + + it('lit control: the SAME docs on the top level draw exactly one warning from the same door, under the same owners', async () => { + const { artifact } = build(composedArtifact()); + // Undo the placement by hand: the pre-fix artifact. + const app = bodyOf(artifact, APP_ID); + const flat = app.docs as DocItem[]; + expect(docNames(flat).sort()).toEqual(['acme_faq', 'acme_guide']); + delete app.docs; + const prefix = { ...artifact, docs: flat }; + + const read = await door(prefix); + expect(read.warnings).toBe(1); + // ...and it is the docs' OWNER and COUNT that the fix keeps: the residual + // sweep stamped the same package id, so no door's answer moves. + expect(read.docs.map(({ name, packageId }) => ({ name, packageId }))).toEqual([ + { name: 'acme_faq', packageId: APP_ID }, + { name: 'acme_guide', packageId: APP_ID }, + { name: 'acme_service_runbook', packageId: SERVICE_ID }, + ]); + }); +}); + +describe('#22190 no owner, no move — every case it cannot decide keeps today\'s placement', () => { + it('a manifest id naming no package entry: the flat docs stay on the top level, the bodies untouched', () => { + const stack = composedArtifact(); + stack.manifest = { ...stack.manifest, id: 'app.example.elsewhere' }; + const { collected, placed, artifact } = build(stack); + + expect(docNames(artifact.docs).sort()).toEqual(['acme_faq', 'acme_guide']); + expect(placed.docs).toBe(collected.docs); + expect(bodyOf(artifact, APP_ID)).not.toHaveProperty('docs'); + expect(docNames(bodyOf(artifact, SERVICE_ID).docs)).toEqual(['acme_service_runbook']); + }); + + it('two entries carrying the manifest id: the flat docs stay on the top level', () => { + const stack = composedArtifact(); + stack.packages[0].manifest = { ...stack.packages[0].manifest, id: APP_ID, name: 'acme-twin' }; + const { artifact } = build(stack); + + expect(docNames(artifact.docs).sort()).toEqual(['acme_faq', 'acme_guide']); + for (const entry of artifact.packages as Array<{ manifest: Record }>) { + expect(JSON.stringify(entry.manifest.docs ?? [])).not.toContain('MARKER-22190-flat'); + } + }); + + it('a stack with no packages[]: the very same docs array comes back, and no packages key appears', () => { + const single = { manifest: { ...appStack.manifest }, objects: appStack.objects }; + const { collected, placed, artifact } = build(single); + + expect(placed.docs).toBe(collected.docs); + expect(placed.packages).toBeUndefined(); + expect(artifact).not.toHaveProperty('packages'); + expect(docNames(artifact.docs).sort()).toEqual(['acme_faq', 'acme_guide']); + // The single-package shape still reports a per-package directory it does + // not read, exactly as before. + expect(collected.issues.map((issue) => [issue.rule, issue.path])).toEqual([ + ['docs/uncollected-directory', 'src/service/docs'], + ]); + }); +}); diff --git a/packages/cli/test/build-package-docs-attachment.e2e.test.ts b/packages/cli/test/build-package-docs-attachment.e2e.test.ts index 503fb652134..d5036f5339e 100644 --- a/packages/cli/test/build-package-docs-attachment.e2e.test.ts +++ b/packages/cli/test/build-package-docs-attachment.e2e.test.ts @@ -24,6 +24,16 @@ * exactly one file on disk, and the assertions read the marker out of the * artifact — a count of 1 is satisfiable by an echo of the flat `src/docs/` * doc, which is the failure shape this package measured twice this month. + * + * ## #22190 — the stack's own flat `src/docs/` on a multi-package artifact + * + * It rides the body of the package that owns the artifact's manifest, ⛔ not + * the top level: a multi-package artifact's top level is no package's body, + * and the metadata door warned on every boot about the docs it found there. + * The unit twin, with the real metadata door booted on the placed artifact, + * is `src/utils/collect-docs.flat-docs-owner.test.ts`; this file holds the + * command half — what `os build` writes — including the case it cannot + * decide (a manifest id no package carries), which keeps the top level. */ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; @@ -96,6 +106,15 @@ export default defineStack({ }, { strict: false }); `; +/** + * The same project, except that the artifact's own manifest names NO package + * entry — so nothing owns the flat `src/docs/`, and ⛔ the build does not guess. + */ +const CONFIG_NO_OWNER = CONFIG_MULTI.replace( + 'manifest: coreManifest,', + "manifest: { ...coreManifest, id: 'com.example.pkgdocs.release' },", +); + /** A single-package project with a flat `src/docs/` — the shape that must not move. */ const CONFIG_FLAT = ` import { defineStack } from '@objectstack/spec'; @@ -119,7 +138,7 @@ interface Artifact { const readArtifact = (dir: string): Artifact => JSON.parse(readFileSync(join(dir, 'dist', 'objectstack.json'), 'utf8')) as Artifact; -const dirs = { multi: '', flat: '' }; +const dirs = { multi: '', noOwner: '', flat: '' }; let root = ''; beforeAll(() => { @@ -140,6 +159,12 @@ beforeAll(() => { // The stack's own flat doc, which keeps `stack.manifest.namespace`. writeFileSync(join(dirs.multi, 'src', 'docs', 'pkgdocs_index.md'), `# Index\n\n${MARKER_FLAT}\n`); + dirs.noOwner = join(root, 'no-owner'); + mkdirSync(join(dirs.noOwner, 'src', 'docs'), { recursive: true }); + writeFileSync(join(dirs.noOwner, 'objectstack.config.ts'), CONFIG_NO_OWNER); + linkSpec(dirs.noOwner); + writeFileSync(join(dirs.noOwner, 'src', 'docs', 'pkgdocs_index.md'), `# Index\n\n${MARKER_FLAT}\n`); + dirs.flat = join(root, 'flat'); mkdirSync(join(dirs.flat, 'src', 'docs'), { recursive: true }); writeFileSync(join(dirs.flat, 'objectstack.config.ts'), CONFIG_FLAT); @@ -166,12 +191,30 @@ describe('[#18431] per-package docs reach the owning package body', () => { // Pedigree: the bytes came out of that one file, not out of the flat doc. expect(orders!.manifest.docs?.[0].content).toContain(MARKER_PKG); - // The core package was never given docs, and the top level carries only the - // stack's own flat doc — the ruling's clause 1, read off the emitted JSON. + // [#22190] The stack's own flat doc rides the body of the package that owns + // the artifact's manifest — `core` — and the top level carries no `docs` at + // all. Before, it sat on the top level, where the metadata door registered + // it under the same `core` id and warned on every boot. const core = artifact.packages?.find((p) => p.manifest.id === 'com.example.pkgdocs.core'); - expect(core!.manifest.docs).toBeUndefined(); + expect(core!.manifest.docs?.map((d) => d.name)).toEqual(['pkgdocs_index']); + expect(core!.manifest.docs?.[0].content).toContain(MARKER_FLAT); + expect(JSON.stringify(core!.manifest.docs)).not.toContain(MARKER_PKG); + expect(artifact).not.toHaveProperty('docs'); + }, 120_000); + + it('[#22190] keeps the flat doc on the top level when the manifest id names no package — no guess', async () => { + // The fixture differs from the one above in the manifest id ALONE — proved, + // not assumed: a replace whose anchor moved would build the owned shape. + expect(CONFIG_NO_OWNER).not.toBe(CONFIG_MULTI); + const run = await runCli(['build'], dirs.noOwner); + expect(run.code, `stdout:\n${run.stdout}\nstderr:\n${run.stderr}`).toBe(0); + + const artifact = readArtifact(dirs.noOwner); expect(artifact.docs?.map((d) => d.name)).toEqual(['pkgdocs_index']); - expect(JSON.stringify(artifact.docs)).not.toContain(MARKER_PKG); + expect(artifact.docs?.[0].content).toContain(MARKER_FLAT); + for (const entry of artifact.packages ?? []) { + expect(JSON.stringify(entry.manifest.docs ?? [])).not.toContain(MARKER_FLAT); + } }, 120_000); it('reports the whole collection on the step line, package directories named', async () => { From a3db4961c278b7f0e14bdd10a642e24d2d9cec29 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 01:47:41 +0000 Subject: [PATCH 3/5] test(cli): build the door's lit control from the collection, not from the placement Claude-Session: https://claude.ai/code/session_01BmsuLyUeuG5CNpZFMH1jzS Co-authored-by: Claude --- .../collect-docs.flat-docs-owner.test.ts | 24 ++++++++++++------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/packages/cli/src/utils/collect-docs.flat-docs-owner.test.ts b/packages/cli/src/utils/collect-docs.flat-docs-owner.test.ts index f197a5b5bcf..ec552c50330 100644 --- a/packages/cli/src/utils/collect-docs.flat-docs-owner.test.ts +++ b/packages/cli/src/utils/collect-docs.flat-docs-owner.test.ts @@ -257,7 +257,7 @@ describe('#22190 placement — the two-package build', () => { const service = bodyOf(artifact, SERVICE_ID); expect(docNames(service.docs)).toEqual(['acme_service_runbook']); expect((service.docs as DocItem[])[0].content).toContain(MARKER_SERVICE); - expect(JSON.stringify(bodyOf(artifact, APP_ID).docs)).not.toContain(MARKER_SERVICE); + expect(JSON.stringify(bodyOf(artifact, APP_ID).docs ?? [])).not.toContain(MARKER_SERVICE); }); it('appends after the owner\'s OWN directory docs — a second set for one package is not dropped', () => { @@ -296,13 +296,21 @@ describe('#22190 the metadata door — no residual warning, same docs, same owne }); it('lit control: the SAME docs on the top level draw exactly one warning from the same door, under the same owners', async () => { - const { artifact } = build(composedArtifact()); - // Undo the placement by hand: the pre-fix artifact. - const app = bodyOf(artifact, APP_ID); - const flat = app.docs as DocItem[]; - expect(docNames(flat).sort()).toEqual(['acme_faq', 'acme_guide']); - delete app.docs; - const prefix = { ...artifact, docs: flat }; + const { collected, artifact } = build(composedArtifact()); + // The pre-fix artifact, built from the collection rather than from the + // placement, so this control reads the same whether the fix is in or not: + // the flat set on the top level, and on no body. + const flat = new Set(collected.flatDocs); + expect(docNames(collected.flatDocs).sort()).toEqual(['acme_faq', 'acme_guide']); + const prefix = { + ...artifact, + docs: [...collected.flatDocs], + packages: (artifact.packages as Array<{ manifest: Record }>).map((entry) => { + const { docs, ...rest } = entry.manifest; + const kept = ((docs ?? []) as DocItem[]).filter((doc) => !flat.has(doc)); + return { ...entry, manifest: kept.length > 0 ? { ...rest, docs: kept } : rest }; + }), + }; const read = await door(prefix); expect(read.warnings).toBe(1); From 925e0742b47cf5151f96c16f56699063261b5753 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 01:57:30 +0000 Subject: [PATCH 4/5] chore(changeset): cli patch for the flat src/docs owner placement Claude-Session: https://claude.ai/code/session_01BmsuLyUeuG5CNpZFMH1jzS Co-authored-by: Claude --- .changeset/22190-flat-docs-owner-package.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 .changeset/22190-flat-docs-owner-package.md diff --git a/.changeset/22190-flat-docs-owner-package.md b/.changeset/22190-flat-docs-owner-package.md new file mode 100644 index 00000000000..369ba9c38de --- /dev/null +++ b/.changeset/22190-flat-docs-owner-package.md @@ -0,0 +1,15 @@ +--- +'@objectstack/cli': patch +--- + +`os build`: a multi-package artifact's flat `src/docs/` is now carried by the package that owns the artifact's manifest, so its boot no longer warns about them + +Clause-②: no + +A multi-package artifact (one that carries `packages[]`, ADR-0130 D4) keeps its metadata in its package bodies only. `os build` still wrote the stack's own flat `src/docs/*.md` to the artifact's top level. At every boot, the metadata service registered them under the artifact's `manifest.id` and logged `carries N top-level metadata item(s) that none of its N package bodies declare`. The warning told the author to rebuild the artifact, which did not help: in an ADR-0130 layout the app package's source directory (for example `src/sales/`) is not named after the package, so no docs directory the build reads belongs to it. + +- **What changes.** When exactly one `packages[]` entry has the artifact's `manifest.id` as its id, `os build` writes the flat docs onto that entry's body, after any docs from that package's own `src//docs/`. The artifact's top level no longer carries them. `composeStacks(…, { manifest: 'preserve' })` always produces this case, because the artifact's `manifest` is one of its inputs' manifests. +- **What stays the same.** The docs are served under the same package id, the doc count is the same, and books resolve the same tree. The doc lint still checks their names against `manifest.namespace`, and the `Collecting package docs` step line prints the same count. +- **What a running instance now reports differently.** These docs are now part of the owning package's installed record (`GET /api/v1/packages`) and are protected like that package's other docs (`lock: 'full'`, resettable), the same as the flat docs of a single-package artifact. Before, they were registered outside any package record and read back as freely editable. +- **When nothing moves.** If no entry has the manifest id, if several do, or if the artifact declares no `manifest.id`, the flat docs stay on the top level as before. A single-package artifact (no `packages[]`) is byte-identical to before. +- **To pick it up.** Rebuild with `os build`. An artifact that was already built keeps its shape, and the warning, until it is rebuilt. From 027bc7fa48d794dbafdcff18214415f3ad4bb5cf Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 01:59:43 +0000 Subject: [PATCH 5/5] docs(cli): name where a duplicate package id is refused in flatDocsOwner's docblock Claude-Session: https://claude.ai/code/session_01BmsuLyUeuG5CNpZFMH1jzS Co-authored-by: Claude --- packages/cli/src/utils/collect-docs.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/utils/collect-docs.ts b/packages/cli/src/utils/collect-docs.ts index 5db191c3f45..b750c4d2e32 100644 --- a/packages/cli/src/utils/collect-docs.ts +++ b/packages/cli/src/utils/collect-docs.ts @@ -1427,7 +1427,8 @@ export function attachPackageDocs(packages: unknown, sets: readonly PackageDocSe * whose manifest fields, `namespace` included, are the artifact manifest's. * * ⛔ No guess. No `manifest.id`, no entry carrying it, or MORE than one entry - * carrying it — `os build` does not refuse a duplicate id, the boot does — + * carrying it — this collector has no duplicate-id refusal of its own; the + * boot's is `resolveArtifactPackageOrder`'s `DUPLICATE_ARTIFACT_PACKAGE` — * answers `undefined`, and the flat docs keep the top-level position they have * always had. The entry's id is read through {@link docsPackageRefs}, which * reads `artifactPackages`; ⛔ it is not re-derived here.