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 |
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