Skip to content
Draft
33 changes: 33 additions & 0 deletions .changeset/18431-per-package-docs-collector.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
---
"@objectstack/cli": minor
---

Clause-②: yes

`os build` reads package docs from **each package directory** of an ADR-0130 layout — `src/<pkg>/docs/*.md` — and attaches them to the **owning package's body** (`packages[i].manifest.docs`), linted against **that package's own `namespace`** (#18431).

A module can now ship its own docs. Before this, ADR-0046 collection was anchored at exactly one path, `<config dir>/src/docs`, so an ADR-0130 project that moved its docs into their packages lost all of them — loudly since #18428, but lost. The maintainer's ruling (batch #147 item 4) decided the two contract questions that blocked the widening, and both are implemented literally:

- **Where they attach**: to `packages[i]`, ⛔ never the artifact top level. The runtime already merges a package-owned collection back up for readers (`resolveArtifactCollections`, ADR-0130 D4), so a flattened copy would buy nothing and destroy the ownership D1 is about.
- **Whose namespace the lint uses**: the owning package's. A doc outside any package keeps `stack.manifest.namespace`. A multi-package artifact therefore has **one prefix rule per package** and ⛔ no single global prefix — and ⛔ no fallback between the two: a package doc that fails its own package's prefix is refused, never re-tried against the artifact's.

**What it costs, stated as the whole of it.** Exactly ONE class of input that `os build` accepted before is refused now, and it is the direct consequence of the ruled prefix rule: in a multi-package artifact (only `composeStacks(…, { manifest: 'preserve' })` produces one) whose packages declare namespaces DIFFERENT from the artifact manifest's, a doc owned by such a package used to be judged by the artifact's prefix and is now judged by its own package's.

```
FROM packages[i] with namespace "sales" inside an artifact whose manifest.namespace is "crm"
shipping a doc named crm_orders_guide -> accepted before, REFUSED now
TO rename it to sales_orders_guide (and the file to sales_orders_guide.md)
```

The refusal is `docs/namespace-prefix`, an error, and it names that exact spelling. Nothing else that built green stops building: an artifact whose packages share one namespace — the ADR-0130 D1 shape, and the one `examples/app-multi-package` documents — sees no change at all, because the per-package rule and the artifact rule are then the same rule. In the other direction the same change is a widening, and the larger half: that package could not ship a doc under its OWN prefix at all before.

⚠️ Same-prefix LINKS and metadata-embed references are deliberately NOT partitioned with the naming rule — both resolve across the whole artifact. A doc's prefix says who judges its NAME; a link asks whether the target EXISTS, and ADR-0130 D1 exists so that N packages may share a namespace and cross-link inside it. Partitioning links too would have turned an ordinary cross-package link into `docs/broken-link` and stopped an artifact that built green from building; that was caught by this card's contract review and is pinned in the unit tier.

Also in this change:

- **The #18428 warning stays**, and now says *why* a directory was not read. Unchanged, word for word, for a stack that declares no `packages[]` — where "read from `src/docs/` only" is still the whole truth. For a directory that names **no** package it lists the declared packages and the three spellings a directory is matched against (`id`, the last dot-segment of `id`, `name`); for one that names **more than one** it names the candidates and refuses to guess. ⛔ `namespace` is not a matching spelling: ADR-0130 D1 exists so that N packages can share one, so matching on it would be ambiguous exactly where it matters.
- **A cross-owner duplicate doc name stays an error.** It PRESERVES a refusal rather than adding one: before the split every doc reached the lint in one flattened array, so two owners declaring one name already raised `docs/duplicate-name`. Splitting the set per package would have dropped that silently, and ADR-0130 D1 lets packages of one artifact share a namespace, so the prefix does not keep them apart. The rule is authoring hygiene — ⛔ not a claim that one registration overwrites the other, which ADR-0048 §3.3/§3.4 retired.
- **`os dev` mirrors `os build`.** The config-load path collects the same per-package directories onto the same bodies, so dev serves what a built artifact serves.
- **The step line counts the whole collection**, package sets included, and says how many came from package directories — a build that read four package docs no longer announces `0 collected`.

Single-package projects are untouched: with no `packages[]` there is nothing to attribute, the flat `src/docs/` keeps attaching exactly where it always did, and the emitted artifact is byte-identical.
32 changes: 30 additions & 2 deletions packages/cli/src/commands/compile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import { buildAccessMatrix, diffAccessMatrix } from '@objectstack/lint';
import { runAuthoringRules, splitBySeverity, authoringRulesFor } from '@objectstack/lint';
import { resolveSduiManifest } from '../utils/sdui-manifest.js';
import { preflightRequiredCapabilities, renderCapabilityMessage } from '../utils/capability-preflight.js';
import { collectAndLintDocs, type DocIssue } from '../utils/collect-docs.js';
import { attachPackageDocs, collectAndLintDocs, type DocIssue } from '../utils/collect-docs.js';
import { buildRuntimeBundle, cleanupOldRuntimeBundles } from '../utils/build-runtime.js';
import {
printHeader,
Expand Down Expand Up @@ -767,8 +767,24 @@ export default class Compile extends Command {
// a run that collected four documents. Reporting the count is what
// makes the two runs distinguishable; the ordering is what makes the
// count available to report.
//
// [#18431] The count is the whole collection, per-package sets included.
// Those docs do not join `docs` — they go to the body of the package
// that owns them (ADR-0130 D4 option B) — so counting only the
// top-level array would re-create exactly the defect above one layer
// down: a build that read four package docs announcing `0 collected`.
// The parenthetical is added only when there ARE package docs, so a
// single-package build's line is unchanged.
const docsResult = collectAndLintDocs(absolutePath, result.data as Record<string, unknown>);
if (!flags.json) printStep(`Collecting package docs (ADR-0046)... ${docsResult.docs.length} collected`);
const packageDocCount = docsResult.packageDocs.reduce((n, set) => n + set.docs.length, 0);
if (!flags.json) {
printStep(
`Collecting package docs (ADR-0046)... ${docsResult.docs.length + packageDocCount} collected`
+ (packageDocCount > 0
? ` (${packageDocCount} from ${docsResult.packageDocs.length} package director${docsResult.packageDocs.length === 1 ? 'y' : 'ies'})`
: ''),
);
}
const docErrors = docsResult.issues.filter((i) => i.severity === 'error');
// [#11727] Consumed by BOTH faces — the text block below and the `--json`
// payload. Only the text block read it before, so the advisories were
Expand Down Expand Up @@ -816,6 +832,18 @@ export default class Compile extends Command {
if (docsResult.docs.length > 0) {
finalBundle.docs = docsResult.docs;
}
// [#18431] Docs read out of `src/<pkg>/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
// (batch #147 item 4) and also what keeps ownership readable: the
// runtime merges a package-owned collection back up through
// `resolveArtifactCollections`, so a flattened copy would buy nothing
// and destroy 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);
}

// 4b. Bundle handler functions into `<artifactDir>/objectstack-runtime.{hash}.mjs`
// and stamp the relative path into the JSON so the runtime can
Expand Down
17 changes: 15 additions & 2 deletions packages/cli/src/commands/serve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2639,10 +2639,19 @@ export default class Serve extends Command {
// them and an artifact boot serves them. Mirror compile's collection so
// docs render under /docs/<name> in dev exactly as from a built artifact.
// Collection only (no lint-fail): docs are additive; never block boot.
//
// [#18431] The same mirroring, one level down: a `src/<pkg>/docs/`
// directory naming one of this config's `packages[]` entries is collected
// onto THAT package's body (ADR-0130 D4 option B), exactly where
// `os build` puts it. `AppPlugin` reads a package-owned collection back
// up through `resolveArtifactCollections`, so dev serves them as an
// artifact boot does. Leaving this half out would re-open the asymmetry
// the paragraph above exists to close — `os build` producing docs that
// `os dev` cannot show.
if (!useArtifactFallback) {
try {
const { collectDocsFromSrc } = await import('../utils/collect-docs.js');
const collected = collectDocsFromSrc(absolutePath);
const { collectDocsFromSrc, attachPackageDocs } = await import('../utils/collect-docs.js');
const collected = collectDocsFromSrc(absolutePath, (config as any)?.packages);
if (collected.docs.length > 0) {
const byName = new Map<string, any>();
for (const d of (Array.isArray((config as any).docs) ? (config as any).docs : [])) {
Expand All @@ -2651,6 +2660,10 @@ export default class Serve extends Command {
for (const d of collected.docs) byName.set(d.name, d);
config = { ...config, docs: Array.from(byName.values()) };
}
if (collected.packageDocs.length > 0) {
const packages = attachPackageDocs((config as any).packages, collected.packageDocs);
if (packages !== (config as any).packages) config = { ...config, packages };
}
} catch {
/* docs are additive — never block boot on collection */
}
Expand Down
Loading
Loading