Repository navigation
Commit 0e671d2
feat(cli): derive each package's docs directory from the registered packages (#19492)
Fixes #18965
Clause-②: yes
`os build` now derives **each package's docs directory from the packages
the artifact registers**, not from a fixed depth under `src/` — the
maintainer's ruling, decision batch #204 item 5, letter **B** (relayed
at comment 5754492000). A (declare the convention, rename the reference
fixture) and C (scan two levels) were rejected there and are not
implemented here.
## The first reading the ruling named: yes, `packages[]` is assembled
before the collector runs
The ruling made this the first thing to check, and said to report rather
than restructure if it came out the other way. It comes out the right
way, so nothing was restructured. Measured in
`packages/cli/src/commands/compile.ts`:
| step | line | what it does |
| --- | --- | --- |
| `loadConfig` | 242 | loads `objectstack.config.ts`, already composed —
the fixture's own `composeStacks([...], { manifest: 'preserve' })` is
what produces `packages[]` |
| `ObjectStackDefinitionSchema.safeParse` | 347 | `result.data.packages`
is the parsed, assembled array from here on |
| `collectAndLintDocs(absolutePath, result.data)` | **778** | the
collector, handed that same array |
⇒ the resolution point the ruling assumes is available at the collector,
431 lines after `packages[]` exists. `os validate` (`validate.ts:560`),
`os lint` (`lint.ts:954`) and `os dev` (`serve.ts:2655`) reach the same
seam, so all four doors move together.
## What was wrong
`sweepPackageDocsDirectories` asked one question per **direct child** of
`src/`: does `src/CHILD/docs/` hold Markdown? #18962 added *ownership*
to that loop (match the directory name against the artifact's packages)
but not *resolution* — the one fixed level survived. So a project whose
packages sit one level deeper, which is the shape this repo's own
ADR-0130 D4 reference fixture `examples/app-multi-package` has, was
invisible.
**The card's repro, run through the real `os build` binary, before and
after.** The "before" leg is an ablation of this branch back to the
one-level walk, with `packages/cli` rebuilt and
`ablation-dist-preflight` proving the mutation reached the `dist/` the
bin actually loads:
```text
BEFORE (ablated to the one-level walk; dist preflight: marker absent from all 532 built files)
$ objectstack build # examples/app-multi-package + src/packages/orders/docs/crm_ord_guide.md
-> Collecting package docs (ADR-0046)... 0 collected
exit 0
packages[].docs -> [["com.example.multi.orders",[]],["com.example.multi.core",[]]]
... and no docs/uncollected-directory warning either: the sweep never looked there.
AFTER (this branch; dist preflight: marker present in packages/cli/dist/utils/collect-docs.js)
$ objectstack build
-> Collecting package docs (ADR-0046)... 1 collected (1 from 1 package directory)
exit 0
packages[].docs -> [["com.example.multi.orders",["crm_ord_guide"]],["com.example.multi.core",[]]]
top-level docs -> [] # still the package body, never the top level (#18431 clause 1)
marker in content -> true
```
Both legs restored the tree: `ablation-replace` reported `blob == HEAD`
with `git diff HEAD` empty, and a whole-tree `git status --porcelain`
read 0 lines afterwards.
## How the directory is found, and why no second depth is pinned
A registered package carries **no source path**. `ArtifactPackageSchema`
is a `strictObject` whose only key is `manifest`, and that body is
`AssembledPackageBodySchema` — `ManifestSchema` plus the collection
keys. Neither declares the directory the package was authored in. So the
only thing that can locate a package on disk is its **name**, and the
two spellings a docs directory is matched against are unchanged: the
package's `id`, and the last dot-separated segment of that `id`. ⛔ Never
`name` (a display string, free to be re-worded); ⛔ never `namespace`
(ADR-0130 D1 exists so N packages may share one).
The walk therefore carries no number at all. Two properties are the
whole design:
1. **The recursion exists only to find a registered package**, and stops
at the first directory that names one. A package's own subtree is that
package's source, not more packages, so a `docs/` deeper inside a
resolved package is not a second docs directory.
2. **A directory whose name names no package is never resolved**, at any
depth — so the walk can only ever add directories a package claims.
**The preserved fence, held by construction rather than by a branch
guarding it.** With no `packages[]` there is nothing to search for, so
there is no descent at all and a single-package stack is walked exactly
one level, as it always was. The ablation measures this rather than
asserting it: of the eight new cases, the ablation turned **seven red
and left the single-package one green** — that case never depended on
the recursion. All 73 pre-existing cases in the two files stayed green
under the same ablation, including the pre-#18965 flat-layout pin (the ⭐
lit control) and the byte-exact single-package warning-text pin.
## The two decisions the dispatch asked me to make and justify
**1 — the `src/docs/` only sentence at `collect-docs.ts`
`uncollectedDocsMessage`: left byte-identical, deliberately.** Read with
its own docblock in front of me, as asked. That string is emitted from
exactly one branch — `refs.length === 0`, a stack that declares no
`packages[]` — and for that stack letter B resolves no package directory
at all, so `src/docs/` really is the only place its docs are read from.
The sentence is not false for any stack that can reach it. A stack
**with** packages gets the other pair of messages, which name the
packages the directory was matched against and claim no fixed path. The
decision is recorded in the docblock so the next reader does not
re-litigate it. This is also what pin 3 requires: the single-package
warning is byte-for-byte what it was, and a pre-existing test asserts
the exact sentence.
**2 — the two-level layout is built in the test's own fixture, ⛔ not
added to `examples/app-multi-package`.** The reference fixture has no
`docs/` directory and never had one (7 files at `32b5831c4e`; `git log
--diff-filter=AD` over its docs paths returns nothing; lit control:
`examples/` holds 256 files, 11 under a `/docs/` path, so the probe can
see docs directories there). Two reasons, both recorded in the test
block's docblock:
- Several measurements quote that fixture's contents exactly —
`artifact-packages.ts` sizes the per-package de-duplication residue on
it, `build-json-advisory-parity.e2e.test.ts` reads its artifact — so
giving it docs changes what all of them read, to buy what the unit cases
already prove with per-file marker strings.
- ⛔ And I did **not** pin that fixture's on-disk shape from the test
either. Such a pin fails the day someone flattens the fixture to
`src/PKG/`, which after this card is harmless in both directions — it
would pin a property this fix deliberately stops being load-bearing, so
it could only ever produce false red.
The fixture's measured layout is recorded in the test docblock as the
reading the synthetic layout reproduces. The repro above is the
compensating evidence: it runs the real `os build` against the real
fixture.
## One new refusal, and why it is a refusal
Depth-free resolution makes a new ambiguity reachable: one package
answering to **two** doc-bearing directories (`src/core/docs` and
`src/packages/core/docs` in one tree). Both are reported and neither is
collected — the same answer this collector already gives when one
directory names two packages. ⛔ It is not merged and ⛔ not silently
halved: `attachPackageDocs` keys its sets by package index through a
`Map`, so collecting both would drop one without a word — this card's
own defect, re-created one layer up.
## Pins
| # | pin | where |
| --- | --- | --- |
| 1 | two-level `src/packages/PKG/docs/` collected and attributed to the
right package, with pedigree | `collects the ADR-0130 D4 two-level
layout…` |
| 2 | ⭐ flat `src/PKG/docs/` collected exactly as today, beside a
two-level one, each with its own marker | `⭐ lit control: the flat
layout is collected exactly as before…` + the pre-existing #18431 flat
pins |
| 3 | ⛔ no `packages[]` ⇒ walked one level, nothing deeper reported;
warning text byte-exact | `⛔ single-package regression…` + the
pre-existing exact-sentence pin |
| 4 | a directory matching no package, and one matching more than one,
keep their distinct answers at the new depth | `a directory naming NO
package…`, `an AMBIGUOUS directory name…` |
| + | one package, two docs directories: refused, never merged, never
dropped | `⛔ ONE package answering to TWO docs directories…` |
| + | the walk stops at a package root | `⛔ stops at the package root…`
|
## Verification
| what | result |
| --- | --- |
| `pnpm --filter @objectstack/cli exec vitest run --project unit` |
**222 files / 3141 tests, 0 failed** |
| `pnpm --filter @objectstack/cli typecheck` | exit 0 (`tsc --noEmit` +
`check:test-typecheck`; debt ledger unmoved: 3 files / 28 errors / 6
pinned signatures) |
| `pnpm --filter '@objectstack/cli^...' build` | exit 0 |
| reverse verification (ablation, unit) | 7 of 8 new cases red, the
single-package one green by construction, 73 pre-existing green; restore
proved `blob == HEAD`, `git diff HEAD` empty |
| reverse verification (ablation, e2e through the bin) | dist preflight
both ways; `0 collected` before, `1 collected` after; whole-tree
porcelain 0 lines after |
| `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack`, every derived family run | **61 derived, 61
run, 0 NOT-MEASURED, 0 UNRUN** — reconciled with `--ran`, each line
carrying its own exit code |
| `pnpm lint` (`eslint . --no-inline-config`, the whole repo — ⛔ not a
narrowing) | exit 0 in 85s, at `4afa1e4669` |
Four derived families first answered `exit 3` —
`check:dual-build-cjs-loads`, `check:i18n`, `check:i18n-coverage`,
`check:i18n-walk-parity` — every one of them **PREREQUISITE NOT MET**,
i.e. NOT MEASURED, never a finding. All four were re-run to `exit 0`
once the workspace was built, and the reconciliation above carries those
codes.
`packages/cli` `integration` tier is declared to CI: this diff touches
no integration-layer file, no spawn entry point (`bin/`,
`test/helpers/serve-process.ts`) and no driver/kernel startup path.
## Changeset
`@objectstack/cli` **minor**, measured rather than assumed.
`packages/cli` is a published package and `src/utils/collect-docs.ts`
ships inside it, so `skip-changeset` is refused; and `Clause-②: yes`
takes at least `minor`. ⛔ No `@objectstack/spec` changeset: nothing in
`packages/spec` changed and nothing needed to —
`packages[].manifest.docs` was already declared, which is what #18962
measured.
**Why `Clause-②: yes`, stated here rather than inherited.** Clause ② is
directional: widening the accept set triggers it, pulling code back to
the declared contract does not. `os build` now accepts a source layout
it previously read nothing from, so what an author may write and have
collected grows. ⛔ Nothing narrows — every tree that built green still
builds green, with the same `docs[]` and the same warnings. The same
declaration, on the same collector, is the precedent: PR #18962 (card
#18431) landed `Clause-②: yes` with a `@objectstack/cli` minor.
## Acceptance notes
- **The module header's "Absence" bullet was already stale before this
card**, and is corrected here because this change rewrites that exact
sentence. It read "a `src/PKG/docs/` directory one level down is NEVER
collected" — false since `df0c856e01` (#18962) made such a directory
collectable when it names a package. ⛔ Not filed: it is the docblock of
the function this PR changes, and leaving it while restating the
contract beside it is not an option.
- **Markdown under a `docs/` nested deeper INSIDE a resolved package**
(`src/packages/orders/components/docs/x.md`) is still not collected and
still not warned about. Unchanged in both directions — the one-level
sweep never reached it either — and it is the convention working as
designed rather than a defect: the package's docs directory is the one
at its root. Pinned as `⛔ stops at the package root…` so the boundary is
a decision on the record. Noted, not filed; ⛔ carrier: none — no queued
PR or person touches this path, and no layout in this repo has that
shape.
- No governed surface is touched: the diff is `packages/cli/src` and one
changeset. ⛔ No `docs/adr/**`, `.claude/**`, `skills/**`, `AGENTS.md`,
`CLAUDE.md` or `docs/NORTH-STAR.md`.
- ⛔ No labels were written by this branch. The dispatch permitted
exactly one, `skip-changeset`, and only if my own measurement refused a
changeset; it did not.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01QCdUBjM47SxioST9z5Zwdf)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent ef256e6 commit 0e671d2
3 files changed
Lines changed: 327 additions & 36 deletions
File tree
- .changeset
- packages/cli/src/utils
Lines changed: 24 additions & 0 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
Lines changed: 148 additions & 0 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
258 | 258 | | |
259 | 259 | | |
260 | 260 | | |
| 261 | + | |
| 262 | + | |
| 263 | + | |
| 264 | + | |
| 265 | + | |
| 266 | + | |
| 267 | + | |
| 268 | + | |
| 269 | + | |
| 270 | + | |
| 271 | + | |
| 272 | + | |
| 273 | + | |
| 274 | + | |
| 275 | + | |
| 276 | + | |
| 277 | + | |
| 278 | + | |
| 279 | + | |
| 280 | + | |
| 281 | + | |
| 282 | + | |
| 283 | + | |
| 284 | + | |
| 285 | + | |
| 286 | + | |
| 287 | + | |
| 288 | + | |
| 289 | + | |
| 290 | + | |
| 291 | + | |
| 292 | + | |
| 293 | + | |
| 294 | + | |
| 295 | + | |
| 296 | + | |
| 297 | + | |
| 298 | + | |
| 299 | + | |
| 300 | + | |
| 301 | + | |
| 302 | + | |
| 303 | + | |
| 304 | + | |
| 305 | + | |
| 306 | + | |
| 307 | + | |
| 308 | + | |
| 309 | + | |
| 310 | + | |
| 311 | + | |
| 312 | + | |
| 313 | + | |
| 314 | + | |
| 315 | + | |
| 316 | + | |
| 317 | + | |
| 318 | + | |
| 319 | + | |
| 320 | + | |
| 321 | + | |
| 322 | + | |
| 323 | + | |
| 324 | + | |
| 325 | + | |
| 326 | + | |
| 327 | + | |
| 328 | + | |
| 329 | + | |
| 330 | + | |
| 331 | + | |
| 332 | + | |
| 333 | + | |
| 334 | + | |
| 335 | + | |
| 336 | + | |
| 337 | + | |
| 338 | + | |
| 339 | + | |
| 340 | + | |
| 341 | + | |
| 342 | + | |
| 343 | + | |
| 344 | + | |
| 345 | + | |
| 346 | + | |
| 347 | + | |
| 348 | + | |
| 349 | + | |
| 350 | + | |
| 351 | + | |
| 352 | + | |
| 353 | + | |
| 354 | + | |
| 355 | + | |
| 356 | + | |
| 357 | + | |
| 358 | + | |
| 359 | + | |
| 360 | + | |
| 361 | + | |
| 362 | + | |
| 363 | + | |
| 364 | + | |
| 365 | + | |
| 366 | + | |
| 367 | + | |
| 368 | + | |
| 369 | + | |
| 370 | + | |
| 371 | + | |
| 372 | + | |
| 373 | + | |
| 374 | + | |
| 375 | + | |
| 376 | + | |
| 377 | + | |
| 378 | + | |
| 379 | + | |
| 380 | + | |
| 381 | + | |
| 382 | + | |
| 383 | + | |
| 384 | + | |
| 385 | + | |
| 386 | + | |
| 387 | + | |
| 388 | + | |
| 389 | + | |
| 390 | + | |
| 391 | + | |
| 392 | + | |
| 393 | + | |
| 394 | + | |
| 395 | + | |
| 396 | + | |
| 397 | + | |
| 398 | + | |
| 399 | + | |
| 400 | + | |
| 401 | + | |
| 402 | + | |
| 403 | + | |
| 404 | + | |
| 405 | + | |
| 406 | + | |
| 407 | + | |
| 408 | + | |
261 | 409 | | |
262 | 410 | | |
263 | 411 | | |
| |||
0 commit comments