Repository navigation
Commit 26bf56f
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
- .changeset
- packages/cli
- src/commands
- test
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
2513 | 2513 | | |
2514 | 2514 | | |
2515 | 2515 | | |
| 2516 | + | |
| 2517 | + | |
| 2518 | + | |
| 2519 | + | |
| 2520 | + | |
| 2521 | + | |
| 2522 | + | |
| 2523 | + | |
| 2524 | + | |
2516 | 2525 | | |
2517 | 2526 | | |
2518 | | - | |
| 2527 | + | |
2519 | 2528 | | |
2520 | | - | |
| 2529 | + | |
| 2530 | + | |
| 2531 | + | |
| 2532 | + | |
| 2533 | + | |
| 2534 | + | |
| 2535 | + | |
| 2536 | + | |
| 2537 | + | |
2521 | 2538 | | |
2522 | | - | |
| 2539 | + | |
2523 | 2540 | | |
2524 | 2541 | | |
2525 | | - | |
2526 | 2542 | | |
2527 | 2543 | | |
2528 | | - | |
2529 | | - | |
2530 | | - | |
2531 | | - | |
| 2544 | + | |
2532 | 2545 | | |
2533 | 2546 | | |
2534 | 2547 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
405 | 405 | | |
406 | 406 | | |
407 | 407 | | |
408 | | - | |
| 408 | + | |
409 | 409 | | |
410 | 410 | | |
411 | 411 | | |
| |||
0 commit comments