Repository navigation
feat(cli): derive each package's docs directory from the registered packages - #19492
Conversation
…packages `os build` derived a package's docs directory from a fixed one level under `src/`, so the ADR-0130 D4 reference layout (`src/packages/<pkg>/docs/`) was dropped silently — exit 0, nothing printed, not even the `docs/uncollected-directory` warning, because the sweep never looked there. The sweep now finds each package's directory by the names the artifact's `packages[]` answers to, at no fixed depth: the recursion exists only to find a registered package, stops at one, and does not happen at all when there are none — so a single-package stack is still walked exactly one level. Claude-Session: https://claude.ai/code/session_01QCdUBjM47SxioST9z5Zwdf Co-authored-by: Claude <noreply@anthropic.com>
…ectory Clause-②: yes — `os build` accepts a source layout it previously read nothing from, so what an author may write and have collected widens. Claude-Session: https://claude.ai/code/session_01QCdUBjM47SxioST9z5Zwdf Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 3 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
What this run could not see
Coarse fallback — 25 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 2d2ecc4b712ceb4955c693348847eed42414d52a && git checkout 2d2ecc4b712ceb4955c693348847eed42414d52a
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 4045781faff6ef4811b7438aee009be750d7cdde 4afa1e46697c2daf085ca54cc27cd024e5b253e4 && git checkout -B drift-repro 4045781faff6ef4811b7438aee009be750d7cdde && git merge --no-ff 4afa1e46697c2daf085ca54cc27cd024e5b253e4
node scripts/docs-audit/affected-docs.mjs --json 4045781faff6ef4811b7438aee009be750d7cdde
|
Two CI failure notices arrived — both name a dead head, and one is already green on the live oneRecorded so nobody chases them. ⛔ No re-run was spent and none is warranted.
Why
|
ACCEPT — and the provenance for stripping the dual carrierHead judged CI, all three layers, on the head being judged
The carrier comes off, and here is the reading that authorises it⭐
⇒ the ⛔ This seat's earlier correction said the review was "in-seat" here — that was order error #10, an obligation invented from a partial reading, and it is corrected on the card at the comment above. Independent verification — re-derived, ⛔ not read off the report
What the round decided, and why each is rightThe ruling's first reading is answered: The preserved fence holds by construction, not by a guard. With no The two decisions the order left open, both taken with reasons this seat accepts:
One branch added beyond the ruling, and it earns its place: one package answering to two doc-bearing directories is refused rather than merged, because Also corrected in passing: the module header's Gates
Stripping the carrier, then readying and arming. Generated by Claude Code |
Contract reviewServed-tier: Isolated at-tier reviewer spawned by the ① Derived judgments
② Semver level
Declaration legibility: the PR body carries ③ Boundary flags
Implemented-by: VERDICT: PASS Generated by Claude Code |
Provenance — both carriers stripped against the at-tier recordRecord: Authority: the director-seat ruling at The record's shape, verified by this seat rather than taken on the reviewer's wordAll eleven field checks read PASS against the stored body: the Tier, verified on two independent legs
What the review did that this seat's ACCEPT had not⛔ It is not a ratification of the ACCEPT, and it did not read like one. It re-derived the resolution rule from the code rather than the report ("any depth under ⭐ It also found one thing this seat's own docs sweep missed, and the reason is worth keeping:
Carriers
Generated by Claude Code |
…bjectstack-ai#21013) Fixes objectstack-ai#19525 Clause-②: no ## What changed Docs only: `content/docs/deployment/cli.mdx`, the ADR-0130 paragraph under `os compile` (one paragraph; no code, no `content/docs/releases/**`, no governed surface). The paragraph described per-package docs collection as `src/PKG/docs/`, one level under `src/`. Since PR objectstack-ai#19492 (`0e671d20`) the collector finds each package's directory from the packages the artifact registers, at any depth. No sentence on the page was false; it was narrower than the rule. The rewrite: - keeps verbatim the two sentences that still hold: the `id` / last-dot-segment matching, and the display `name` not being a directory key; - states the depth rule (`src/PKG/docs/` and `src/packages/PKG/docs/` are one case); - states where the search stops: it descends through directories that name no declared package (never into `node_modules`) and stops at the first one that names any of them. Everything under that root is the package's own source, so a deeper `src/PKG/components/docs/` is neither read nor reported; - states the no-`packages[]` case: nothing to search for, so docs are read from `src/docs/` alone and a one-level `src/DIR/docs/` is reported, not read; - restates what the warning now means, as three cases: a directory naming none of the packages (an intermediate one such as `src/packages/` included), a directory naming more than one, and the new refusal of one package that answers to more than one docs directory (neither is read; each warning names the others). ## Each behaviour against the source, not the card Read at `origin/main` `a5bce40888` (`packages/cli/src/utils/collect-docs.ts`), then measured. | Page sentence | Source | Measured by | |---|---|---| | any depth; two-level layout collected | `packageDirectoryCandidates` docblock: "derived from the packages the artifact REGISTERS rather than from a fixed depth under `src/`"; `walk` recurses only into a directory with no owners, and only when the artifact declares packages | test "collects the ADR-0130 D4 two-level layout…"; probe P2 (`src/a/b/orders/docs` collected) | | never into `node_modules` | `entry.name !== 'node_modules'` on the descent condition | probe P3 (`src/node_modules/orders/docs`: nothing collected, no issue) | | stops at the first directory naming any package; deeper `docs/` neither read nor reported | descent requires `owners.length === 0` | test "stops at the package root…" (`issues` is `[]`); probe P4 (an ambiguous directory stops the search too) | | no `packages[]` ⇒ one level, reported not read | no refs ⇒ no recursion; the `refs.length === 0` branch emits `uncollectedDocsMessage` | test "a stack with no packages[] gets the original sentence"; test "single-package regression…"; probe P6 | | `id` and its last dot segment, not `name` | `docsPackageRefs` | tests "resolves a directory named by the id TAIL…", "the package `name` is not a resolution spelling" | | none ⇒ warning listing the declared packages, intermediate directories included | `owners.length === 0` message: "Declared packages: …" | test "a directory naming NO package keeps its own answer at the new depth"; probe P1 (`src/packages/docs` reported while `src/packages/orders/docs` is collected) | | more than one ⇒ warning naming those packages | message for a directory with two or more owners | test "an AMBIGUOUS directory name keeps its own answer at the new depth" | | one package, more than one docs directory ⇒ neither read, each names the others | `locationsOf` refusal in `sweepPackageDocsDirectories` | test "ONE package answering to TWO docs directories is refused…"; probe P5 (three directories, each warning lists the other two); control P7 (a package directory without Markdown is not a second location) | The probe was a one-off `tsx` script over `collectDocsFromSrc` against temp trees, run from the built dependency closure. It is not committed. ## Verification, at head `32d0a0178c` - `pnpm --filter @objectstack/cli exec vitest run --maxWorkers=2 src/utils/collect-docs.package-docs.test.ts`: `Tests 40 passed (40)` (code unchanged; this confirms the behaviours the page now states are the ones the landed tests pin). - `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` (no paths) derived 41 commands from the 1-path change set. All 41 were run, and `--ran` answered `41 derived famil(ies) accounted for — 41 run, 0 NOT-MEASURED`. They include `pnpm check:doc-authoring` (exit 0), `pnpm check:docs-transcript-drift` (exit 0: "4 declared transcript value(s) across 408 page(s)… equal what the registry derives today"), `pnpm check:nul-bytes` (exit 0) and `pnpm --filter @objectstack/spec run check:docs` (exit 0). - `check:skill-examples` first exited 3 (PREREQUISITE NOT MET: no `client-react` declarations). After building `@objectstack/client-react...` and `@objectstack/client...` under the verify lock it exited 0 ("259 prose examples type-check across 3 surface(s)"). - MDX compile of the page with `@mdx-js/mdx` 3.1.1: compiles at HEAD and at base. - NOT MEASURED locally: CI's `Build Docs` job (`ci.yml`), which this path schedules. It is left to CI. Changeset: none. `content/docs/**` ships in no package's `files[]` (`apps/docs` is private), so the PR takes the `skip-changeset` label. ## Acceptance notes - **Two rows of the card's table, corrected by measurement.** Row 3 says "the warning is what happens when the search finds nothing". In fact every directory the search passes through is a candidate in its own right: `src/packages/docs/*.md` is reported even while `src/packages/orders/docs/` is collected beneath it (probe P1). Row 4 merges two cases. A directory name matching more than one package (named in the warning by package) is a different case from the new refusal of one package answering to more than one directory (named in the warning by directory). The page states both separately. - **Silent by design, now stated on the page.** Markdown under a resolved package's subtree, under `node_modules`, or under an ambiguous directory's subtree is neither collected nor reported. With no `packages[]`, a `docs/` two levels down is not reported either. This is the ruled design (`packageDirectoryCandidates` property 1) and is pinned by tests. The module header's "Absence" bullet ("Markdown under a `DIR/docs/` that no registered package claims is … REPORTED") reads wider than that; the function docblocks are exact. Comment precision only, not filed. Carrier: none. - `doc-pages.mdx`, `skills/objectstack-ui/rules/pages.md` and ADR-0046 are untouched: the card records them as a separate, older gap. --- _Generated by [Claude Code](https://claude.ai/code/session_01JAhu8u8QfBvRjVZDox7CP9)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes #18965
Clause-②: yes
os buildnow derives each package's docs directory from the packages the artifact registers, not from a fixed depth undersrc/— 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 runsThe 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:loadConfigobjectstack.config.ts, already composed — the fixture's owncomposeStacks([...], { manifest: 'preserve' })is what producespackages[]ObjectStackDefinitionSchema.safeParseresult.data.packagesis the parsed, assembled array from here oncollectAndLintDocs(absolutePath, result.data)⇒ 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) andos dev(serve.ts:2655) reach the same seam, so all four doors move together.What was wrong
sweepPackageDocsDirectoriesasked one question per direct child ofsrc/: doessrc/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 fixtureexamples/app-multi-packagehas, was invisible.The card's repro, run through the real
os buildbinary, before and after. The "before" leg is an ablation of this branch back to the one-level walk, withpackages/clirebuilt andablation-dist-preflightproving the mutation reached thedist/the bin actually loads:Both legs restored the tree:
ablation-replacereportedblob == HEADwithgit diff HEADempty, and a whole-treegit status --porcelainread 0 lines afterwards.How the directory is found, and why no second depth is pinned
A registered package carries no source path.
ArtifactPackageSchemais astrictObjectwhose only key ismanifest, and that body isAssembledPackageBodySchema—ManifestSchemaplus 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'sid, and the last dot-separated segment of thatid. ⛔ Nevername(a display string, free to be re-worded); ⛔ nevernamespace(ADR-0130 D1 exists so N packages may share one).The walk therefore carries no number at all. Two properties are the whole design:
docs/deeper inside a resolved package is not a second docs directory.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 atcollect-docs.tsuncollectedDocsMessage: 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 nopackages[]— and for that stack letter B resolves no package directory at all, sosrc/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 nodocs/directory and never had one (7 files at32b5831c4e;git log --diff-filter=ADover 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:artifact-packages.tssizes the per-package de-duplication residue on it,build-json-advisory-parity.e2e.test.tsreads 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.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 buildagainst 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/docsandsrc/packages/core/docsin 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:attachPackageDocskeys its sets by package index through aMap, so collecting both would drop one without a word — this card's own defect, re-created one layer up.Pins
src/packages/PKG/docs/collected and attributed to the right package, with pedigreecollects the ADR-0130 D4 two-level layout…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 pinspackages[]⇒ walked one level, nothing deeper reported; warning text byte-exact⛔ single-package regression…+ the pre-existing exact-sentence pina directory naming NO package…,an AMBIGUOUS directory name…⛔ ONE package answering to TWO docs directories…⛔ stops at the package root…Verification
pnpm --filter @objectstack/cli exec vitest run --project unitpnpm --filter @objectstack/cli typechecktsc --noEmit+check:test-typecheck; debt ledger unmoved: 3 files / 28 errors / 6 pinned signatures)pnpm --filter '@objectstack/cli^...' buildblob == HEAD,git diff HEADempty0 collectedbefore,1 collectedafter; whole-tree porcelain 0 lines afternode scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack, every derived family run--ran, each line carrying its own exit codepnpm lint(eslint . --no-inline-config, the whole repo — ⛔ not a narrowing)4afa1e4669Four 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 toexit 0once the workspace was built, and the reconciliation above carries those codes.packages/cliintegrationtier 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/climinor, measured rather than assumed.packages/cliis a published package andsrc/utils/collect-docs.tsships inside it, soskip-changesetis refused; andClause-②: yestakes at leastminor. ⛔ No@objectstack/specchangeset: nothing inpackages/specchanged and nothing needed to —packages[].manifest.docswas 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 buildnow 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 samedocs[]and the same warnings. The same declaration, on the same collector, is the precedent: PR #18962 (card #18431) landedClause-②: yeswith a@objectstack/climinor.Acceptance notes
src/PKG/docs/directory one level down is NEVER collected" — false sincedf0c856e01(feat(cli): read package docs from each package directory of an ADR-0130 layout #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.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.packages/cli/srcand one changeset. ⛔ Nodocs/adr/**,.claude/**,skills/**,AGENTS.md,CLAUDE.mdordocs/NORTH-STAR.md.skip-changeset, and only if my own measurement refused a changeset; it did not.Generated by Claude Code