Skip to content

Commit 26bf56f

Browse files
fix(cli): os serve on a multi-package config serves its flat src/docs under the manifest's package (#22520)
Fixes #22405 Clause-②: no The fix moves where an already-collected flat doc is registered, so that a config boot serves what `os build` already serves (PR #22403). No accepted input, key or export changes. ## What changes The dev-mode docs mirror in `packages/cli/src/commands/serve.ts` (the `!useArtifactFallback` block under the `[#18431]` comment) now hands the flat `src/docs` set to `placeCollectedDocs`, the placement `os build` uses since PR #22403. It no longer merges that set into the config's top level by hand. On a multi-package config the flat pages ride the body of the one `packages[]` entry whose id is the stack's `manifest.id`. With no such owner they stay on the top level, as `os build` keeps them. There is no second placement rule in `serve.ts`, and `collect-docs.ts` is not edited. - `collectDocsFromSrc` returns `{ docs, issues, packageDocs }`, and its `docs` IS the flat set. The call adapts at the call site: `docs` is the inline top level followed by the flat set (as `collectAndLintDocs` returns it to `os build`), `flatDocs` is the flat set, and `packageDocs` passes through. - The name dedupe that was there (a collected doc replaces an inline doc of the same name) now runs only over what stays on the top level. A single-package config, or one with no owner, gets byte-for-byte the array it got before. - `packages/cli/test/normalized-call-sites.test.ts`: the `commands/serve.ts :: config.docs` row's `why` now says the read is the top level's own inline `docs`, and that `placeCollectedDocs` places the flat set. - Changeset: `@objectstack/cli` patch. ## The door, before and after Measured through `os serve objectstack.config.ts --dev` from the workspace source entry (`packages/cli/bin/run-dev.js`, tsx over `src/`, with the workspace dependency closure built by turbo at `d2e7d6c6f`), with no compiled artifact on disk. Each fixture is a neutral project in an OS temp directory. The reading is the authenticated `GET /api/v1/meta/doc` as the seeded admin, followed by `GET /api/v1/meta/doc/NAME` for each listed doc. Each cell names the package the doc is served under, and the page's on-disk marker was read back from the served content. `setup_overview` (`com.objectstack.setup`) is served in every boot and is left out of the tables. **The card's shape.** `composeStacks([service, app], { manifest: 'preserve' })`, where `service` is `com.example.acme.service` and `app` is `com.example.acme` (so the stack's `manifest.id` is the app's). The flat `src/docs/` holds `acme_guide.md` and `acme_faq.md`. `src/service/docs/acme_service_runbook.md` is the service's own docs directory, and the app's source directory `src/sales/` names no package. | reading | `acme_guide` | `acme_faq` | `acme_service_runbook` | boot warnings about docs | |---|---|---|---|---| | config boot, base `d2e7d6c6f` | absent | absent | `com.example.acme.service` | none | | `os build`, then an artifact boot of `dist/objectstack.json` (no config), base `d2e7d6c6f`: the target | `com.example.acme` | `com.example.acme` | `com.example.acme.service` | none | | config boot, this branch | `com.example.acme` | `com.example.acme` | `com.example.acme.service` | none | The `os build` artifact carries `packages[0]` (service) `docs: [acme_service_runbook]`, `packages[1]` (app) `docs: [acme_faq, acme_guide]`, and no top-level `docs`. **Controls and edge shapes** (same door; "flat" means the page carries its flat-file marker): | fixture | config boot, base | config boot, this branch | `os build` of the same project | |---|---|---|---| | single package, no `packages[]`, two flat pages | both flat, under `com.example.acme` | unchanged | top-level `docs`; its artifact boot serves both under `com.example.acme` | | single package with inline `docs` `acme_guide` and `acme_inline_only` beside the flat pages | `acme_guide` flat, `acme_faq` flat, `acme_inline_only` inline, all under `com.example.acme` | unchanged | refused: `docs/duplicate-name` | | two packages, `manifest.id` `com.example.acme.release` names no entry | no flat page served | unchanged | flat pages stay on the top level; its artifact boot serves both under `com.example.acme.release` and the metadata door warns `carries 2 top-level metadata item(s) that none of its 2 package bodies declare` | | card's shape, and the app input's `defineStack` declares inline `docs` `acme_guide` and `acme_inline_only` (on the app body after composition) | `acme_guide` with the INLINE content, `acme_inline_only`, `acme_faq` absent | `acme_guide` with the FLAT content, `acme_faq` flat, `acme_inline_only`, all under `com.example.acme` | refused: `docs/duplicate-name` | | card's shape, with inline `docs` spread onto the composed stack's top level (`acme_guide`, `acme_top_only`) | nothing of either set served | `acme_guide` and `acme_faq` flat under `com.example.acme`; the inline top-level docs still not served | refused: the default export is not a built stack | **Did the inline/flat name collision change?** On a single-package config, no: the flat page still replaces the inline doc of the same name. On a multi-package config, yes, but only in two shapes that `os build` refuses. An inline top-level doc now stays on the top level, where a config boot of such a stack serves nothing, and the colliding flat page is served under the owner. An inline doc on the owner's body now sits beside the flat page on that body, and the door serves the flat page's content for that name, as the single-package dev boot already did. ## Pins `packages/cli/test/serve-config-boot-flat-docs.integration.test.ts` boots the real `os serve objectstack.config.ts --dev` three times. It runs in the integration tier (it spawns the CLI), which runs per PR. - **(a) the card's shape:** both flat pages are served under `com.example.acme`, carrying their markers. The lit control is the service's directory doc under `com.example.acme.service`. - **(b) control:** a single-package config serves its flat pages under its one package. - **(c) control:** when `manifest.id` names no package, no flat page is served under either package, so no owner is guessed. As in `os build`, the set stays on the top level. The file says what this does not claim: that a config boot serves those top-level pages. It does not, and that is noted below. **Reverse verification (ablation).** `serve.ts` was put back to its base blob (`git restore --source=d2e7d6c6f`). The on-disk hash matched the base blob `1b4876c6d7`. Anchor counts on disk: `placeCollectedDocs(config, {` 0, `attachPackageDocs((config as any).packages` 1. The pin then ran on that tree: ``` × both flat pages are served under the app package, the one whose id is the stack's manifest.id AssertionError: {"acme_service_runbook":{"packageId":"com.example.acme.service","marker":"MARKER-22405-service-runbook"},"setup_overview":{"packageId":"com.objectstack.setup"}}: expected undefined to deeply equal { packageId: 'com.example.acme', …(1) } Tests 1 failed | 4 passed (5) ``` The trap restore ran `git checkout HEAD --`. The on-disk hash then equalled the HEAD blob `d5e3bd6de3`, `git diff HEAD` was empty, and `git status` was clean. The direction was the expected one: (a) went red, and both controls and both lit controls stayed green. On the fix the same file reads `Tests 5 passed (5)`. ## Hypotheses H1–H5, measured - **H1 (the site): held.** The mirror is the `if (!useArtifactFallback)` block. Outside `collect-docs.ts` itself, where `collectAndLintDocs` calls `collectDocsFromSrc`, its only non-test caller in `packages/cli/src` is `serve.ts`. - **H2 (the input shape): held.** The input adapts at the call. No `collect-docs.ts` edit was needed. - **H3 (the owner resolves on a config): held.** On the loaded config value of the card's fixture, `docsPackageRefs(config.packages)` returns indexes 0 and 1. `flatDocsOwner(config)` returns `{ index: 1, id: com.example.acme, namespace: acme }`. - **H4 (inline docs): held, with the collision readings in the table above.** Inline top-level docs never move. - **H5 (no owner): held for placement.** The flat set stays on the top level, as `os build` keeps it. At the door the config boot still serves none of it, while the artifact boot serves it under the manifest id with a warning. That gap is in the boot's registration, not in the mirror, and is reported to the seat. ## Verification, at `d72d635b1` - **The pin**, on the fix: `Tests 5 passed (5)`. The ablation is above. - **cli unit layer**, `pnpm --filter @objectstack/cli exec vitest run --project unit --maxWorkers=2`: `Test Files 3 failed | 271 passed (274)`, `Tests 1 failed | 4023 passed | 29 skipped (4053)`. None of the three is about this diff: - `test/published-subpath-console.pin.test.ts` and `test/published-subpath-hook-body.pin.test.ts` refused to run: `packages/cli is not built (./dist/index.js is absent)`. - `test/hook-timeout-override-refusal.test.ts` timed out (5000 ms) on its CONTROL legs, which spawn `vitest list` over the whole package, while 67 gate scripts ran beside it. After the workspace build all three re-ran green. The two pins passed, and the hook-timeout file, run alone, read `Tests 4 passed (4)`. - **cli integration layer.** It is owed locally because `serve.ts` is a spawn entry. `pnpm --filter @objectstack/cli exec vitest run --project integration --maxWorkers=2`: `Test Files 100 passed (100)`, `Tests 931 passed | 2 skipped (933)`. - **`pnpm --filter @objectstack/cli typecheck`: exit 0.** It runs `tsc --noEmit` and then the test-layer program. That program is `tsconfig.test.json`, whose `include` is `test/**/*`, so both edited test files are in it. Its ledger is unchanged: `3 file(s) / 28 error(s) / 6 pinned signature(s) held`. - **Gates.** `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` derives 67 commands for this diff, the same 67 the dispatch named, and all of them exit 0. Four of them (`check:dual-build-cjs-loads`, `check:i18n`, `check:i18n-coverage`, `check:i18n-walk-parity`) first exited 3, PREREQUISITE NOT MET, because their dist was not built. They exit 0 after `pnpm turbo run build --filter=!@objectstack/docs`. `dispatch-gates --ran` with the exit codes: `67 derived famil(ies) accounted for — 67 run, 0 NOT-MEASURED (a DERIVED zero — all 67 recorded an exit code and none of them is 3)`. - **Lint, as a proven narrowing.** Eslint ran on the four changed files with the `pnpm lint` flags (`--no-inline-config`, `--format json`). It returned 4 results: 3 files linted with 0 errors and 0 warnings. The changeset is outside eslint's population, in eslint's own words: `File ignored because no matching configuration was supplied`. The narrowing leaves out nothing a full run would judge differently. `eslint.config.mjs` enables no type-aware linting: there is no `parserOptions.project` or `projectService` anywhere in it. The only files it reads at load are `scripts/slot-lookup-baseline.json` and `scripts/query-options-erasure-baseline.json`, and this diff touches neither. So each file's verdict depends on that file alone, and this diff cannot move the verdict of any file it does not touch. The full `pnpm lint` is CI's. - **The built entry.** One after-reading went through `packages/cli/bin/run.js` (dist) on the card's fixture. It lists the same answer as the source entry. - **Dogfood.** `git grep -n "/meta/doc" -- packages/qa/dogfood` has 0 hits. The only `'doc'` hits there are a file field named `doc`. No dogfood test boots a config and reads docs, so the dogfood suite is left to CI. - **Main moved.** Since the base `d2e7d6c6f`, `main` moved 9 commits, to `4e9fe9ff6`. None of them touches the four files here, and `git merge-tree` against it is clean, so the branch was not merged. ## Acceptance notes - **Reported to the seat, not fixed here.** A config boot of a multi-package stack registers no top-level item, and says nothing about it. Two cases above reach this: the no-owner flat set, which stays on the top level as `os build` keeps it, and inline docs written onto a composed stack's top level. `os serve` on the config serves neither, while an artifact boot of the same `os build` output registers top-level items under `manifest.id` and warns. The gap is in the boot's registration path (`AppPlugin`, then `manifest.register()` per body), which is outside this card's surface. - The two name collisions a config boot lets through are described above. A config boot does not lint docs; `os build` refuses both shapes. --- _Generated by [Claude Code](https://claude.ai/code/session_01BmsuLyUeuG5CNpZFMH1jzS)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 40a6ee5 commit 26bf56f

4 files changed

Lines changed: 374 additions & 9 deletions

File tree

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@objectstack/cli': patch
3+
---
4+
5+
`os serve <config>`: a multi-package config now serves its flat `src/docs/` pages, under the package that owns the stack's manifest
6+
7+
Clause-②: no
8+
9+
A config boot (`os serve objectstack.config.ts`, with or without `--dev`, and no compiled artifact) of a stack that carries `packages[]`, such as one `composeStacks([…], { manifest: 'preserve' })` returns, did not serve the stack's own `src/docs/*.md` pages. The boot put them on the config's top level, and a config boot registers that config's package bodies, never its top level. `GET /api/v1/meta/doc` did not list them, and nothing warned. `os build` of the same project, booted as an artifact, served them.
10+
11+
- **What changes.** The config boot places the flat pages where `os build` places them: on the body of the one `packages[]` entry whose id is the stack's `manifest.id`, after that package's own `src/<pkg>/docs/` pages. `GET /api/v1/meta/doc` now lists them under that package, as an artifact boot of the same project does.
12+
- **What stays the same.** A single-package config (no `packages[]`) serves its flat pages under its one package, as before. Each package's own `src/<pkg>/docs/` pages are served under that package, as before. Artifact boots (`os dev`, `os start`, `os serve` with `dist/objectstack.json`) do not change.
13+
- **When nothing moves.** If no entry has the manifest id, if several do, or if the stack declares no `manifest.id`, the flat pages stay on the top level, as `os build` keeps them. A config boot of such a stack still does not list them.
14+
- **A name shared by an inline doc and a flat page.** `os build` refuses this (`docs/duplicate-name`); a config boot does not lint docs. On a single-package config the flat page still replaces the inline doc of the same name. On a multi-package config the flat page is now served under the owning package, and an inline doc on that package's body with the same name is served with the flat page's content.

‎packages/cli/src/commands/serve.ts‎

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2513,22 +2513,35 @@ export default class Serve extends Command {
25132513
// an artifact boot does. Leaving this half out would re-open the asymmetry
25142514
// the paragraph above exists to close — `os build` producing docs that
25152515
// `os dev` cannot show.
2516+
//
2517+
// [#22405] WHERE the flat set lands is `placeCollectedDocs`'s decision,
2518+
// the one `os build` makes: on a multi-package config it rides the body
2519+
// of the package whose id is the stack's `manifest.id`, because this boot
2520+
// registers package bodies and never the top level of such a config, so a
2521+
// flat doc left there is served by nothing and warned about by nothing.
2522+
// With no owner (no `packages[]`, or a `manifest.id` naming no single
2523+
// entry) it stays on the top level, exactly as before. Inline top-level
2524+
// docs never move; the name dedupe below runs only over what stays.
25162525
if (!useArtifactFallback) {
25172526
try {
2518-
const { collectDocsFromSrc, attachPackageDocs } = await import('../utils/collect-docs.js');
2527+
const { collectDocsFromSrc, placeCollectedDocs } = await import('../utils/collect-docs.js');
25192528
const collected = collectDocsFromSrc(absolutePath, (config as any)?.packages);
2520-
if (collected.docs.length > 0) {
2529+
const inline = Array.isArray((config as any).docs) ? (config as any).docs : [];
2530+
// `collectDocsFromSrc`'s `docs` IS the flat set; the top-level set is
2531+
// inline first, as `collectAndLintDocs` returns it to `os build`.
2532+
const placed = placeCollectedDocs(config, {
2533+
docs: [...inline, ...collected.docs],
2534+
flatDocs: collected.docs,
2535+
packageDocs: collected.packageDocs,
2536+
});
2537+
if (collected.docs.length > 0 && placed.docs.length > 0) {
25212538
const byName = new Map<string, any>();
2522-
for (const d of (Array.isArray((config as any).docs) ? (config as any).docs : [])) {
2539+
for (const d of placed.docs) {
25232540
if (d?.name) byName.set(d.name, d);
25242541
}
2525-
for (const d of collected.docs) byName.set(d.name, d);
25262542
config = { ...config, docs: Array.from(byName.values()) };
25272543
}
2528-
if (collected.packageDocs.length > 0) {
2529-
const packages = attachPackageDocs((config as any).packages, collected.packageDocs);
2530-
if (packages !== (config as any).packages) config = { ...config, packages };
2531-
}
2544+
if (placed.packages !== (config as any).packages) config = { ...config, packages: placed.packages };
25322545
} catch {
25332546
/* docs are additive — never block boot on collection */
25342547
}

‎packages/cli/test/normalized-call-sites.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -405,7 +405,7 @@ const READS: Readonly<Record<string, ReadRow>> = {
405405
},
406406
'commands/serve.ts :: config.docs': {
407407
reads: 'top-level',
408-
why: 'the dev mirror of `os build`: the root `src/docs` collection joins the TOP-LEVEL `docs`, as `collectAndLintDocs` returns it for the artifact\'s top level, and each package\'s own docs are attached to that package\'s body (`attachPackageDocs`, beside it).',
408+
why: 'the dev mirror of `os build`: the read is the top level\'s own inline `docs`, which never move. The root `src/docs` collection is placed by `placeCollectedDocs`, as `os build` places it: on the body of the package whose id is the stack\'s `manifest.id` on a multi-package stack, on the top level when no single package owns it, and each package\'s own docs on that package\'s body.',
409409
},
410410
// ── shared readers ─────────────────────────────────────────────────────
411411
'utils/authoring-filter-judge.ts :: stack': {

0 commit comments

Comments
 (0)