feat(cli): read package docs from each package directory of an ADR-0130 layout - #18962
os-support-ai wants to merge 9 commits into
Conversation
…30 layout Per-package docs attach to the owning package's body (packages[i].manifest.docs, ADR-0130 D4 option B) and are linted against that package's own namespace; a stack-level doc keeps stack.manifest.namespace. The #18170/#18428 warning stays for a directory neither convention reads, and now names why. Claude-Session: https://claude.ai/code/session_01DvvamiacK328idtBYJBxV3 Co-authored-by: Claude <noreply@anthropic.com>
The artifact on disk is the only place "packages[i].manifest.docs, not the top level" is observable, and the multi-package fixture's namespaces differ from the artifact manifest's on purpose: under a single global prefix rule the build exits 1 instead. Claude-Session: https://claude.ai/code/session_01DvvamiacK328idtBYJBxV3 Co-authored-by: Claude <noreply@anthropic.com>
`src/*/docs/` inside a block comment carries `*/`, which ends the comment: tsc read the prose after it as code. Spelled `src/<dir>/docs/` instead. Claude-Session: https://claude.ai/code/session_01DvvamiacK328idtBYJBxV3 Co-authored-by: Claude <noreply@anthropic.com>
…ster Artifact assembly, not a gate: it places docs collectAndLintDocs already collected and linted, judges nothing, and os validate emits no artifact to place them in. NOT_A_GATE under its own reason rather than BUILD_ONLY_GATES, whose rows assert a gate that cannot run read-only. Claude-Session: https://claude.ai/code/session_01DvvamiacK328idtBYJBxV3 Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DvvamiacK328idtBYJBxV3 Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 33 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 8 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 24 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 272b4535690575c52b9be69c894c8c44e4bc2e3e && git checkout 272b4535690575c52b9be69c894c8c44e4bc2e3e
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 89c6ec52b56a24d94c5bdea57fb8a7a8db4273da 1ba9dec2ed92f9156047ded0ab71db5fdbe137ff && git checkout -B drift-repro 89c6ec52b56a24d94c5bdea57fb8a7a8db4273da && git merge --no-ff 1ba9dec2ed92f9156047ded0ab71db5fdbe137ff
node scripts/docs-audit/affected-docs.mjs --json 89c6ec52b56a24d94c5bdea57fb8a7a8db4273da
|
条款②复核记录 —— 隔离达档子代理,裁断 FAIL,本席逐字采信
本席实测未达 档位核验(本席自取,⛔ 不采信它的自述): 对它自己的转录数 per-request ⇒ 达档。以下记录逐字采信,⛔ 未改写、未润色。 Contract reviewServed-tier: ① Derived judgmentsDerived from the diff at head (merge base Accept-set change (what the CLI now reads and refuses).
Public-surface change (what the CLI now publishes).
Body vs diff. The body's placement, partition, no-spec-changeset and compose-preserve claims are all borne out by the diff and the spec. The body's and changeset's statement of cost ("a doc owned by a package is now judged by the package's prefix", as the whole of it) is NOT the whole of what the diff costs: see ③ C2. ② Semver level
③ Boundary flagsA. Ruling clause 1 literal B. Single-package fence, derived from the diff, not the body. With no C. Behaviour changes for inputs that exist today, i.e. stacks that already declare
D. Ruling clause 3. The directory convention is implemented; the E. Ruling clause 4. The #18428 warning is kept: same rule id and severity everywhere; the no- F. Ruling clause 5, acceptance against the reference app. NOT ESTABLISHED: nothing in the PR shows hotcrm's split built on this branch. This is exactly where C2 would surface, since per-package docs that cross-link within one namespace are the fixture's likely shape. G. Scope and checks. Diff confined to Basis of the verdict. Clauses 1-4 of the ruling are implemented faithfully and the single-package fence holds from the diff. The diff nevertheless narrows the accept set for an existing, accepted input class (C2) in a way the ruling did not authorise, the changeset does not declare (no Implemented-by: VERDICT: FAIL 本席的处置⛔ 不放行 PR #18962。 复核指出的 C2 是一条未经授权、也未申报的收窄: ⭐ 而复核自己指出了同一个 diff 里的内部不一致:同一批目录文档的 metadata embeds 按产物全域解析,链接却按单包解析。⇒ 把同前缀链接改回产物全域解析,与 embeds 对齐 —— 复核原话「would clear it」,而且那是恢复既有行为,⛔ 不需要新裁决。另一支(申报这条收窄并请裁)需要维护者,本席 ⛔ 不代答。
本评论来自 Generated by Claude Code |
Contract review finding C2. lintDocs resolves a same-prefix link against the set it is handed; partitioning the doc set per package made that set one package, so under ADR-0130 D1 - where packages share one namespace - an ordinary link from one package's doc to another's became docs/broken-link and an artifact that built green stopped building. The ownership split answers who judges a NAME; it must not answer whether a sibling's doc EXISTS. Links now resolve against every name the artifact carries, the same scope lintMetadataEmbeds already used for the same docs. Claude-Session: https://claude.ai/code/session_01DvvamiacK328idtBYJBxV3 Co-authored-by: Claude <noreply@anthropic.com>
The rule preserves the refusal the single flattened set already produced; it is authoring hygiene. It is NOT "one silently overwrites the other at registration" - ADR-0048 s3.3/s3.4 retired that: packaged items are stored under a composite packageId:name key and two distinct packages coexist on one bare name by construction. Behaviour unchanged; only the justification and the message a reader acts on. Claude-Session: https://claude.ai/code/session_01DvvamiacK328idtBYJBxV3 Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DvvamiacK328idtBYJBxV3 Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DvvamiacK328idtBYJBxV3 Co-authored-by: Claude <noreply@anthropic.com>
Round 2 — C2 fixed, and the four questions the review left openAddendum to the PR body rather than an edit of it. The stored body already carries a footer this channel appended on create, and AGENTS.md is explicit that a body carrying an appended footer is never re-sent; this comment is therefore the record for everything below. Contract review record: comment 5727154128. Verdict FAIL on one finding, C2. Clauses 1–4 of the ruling and the single-package fence were judged to hold, and the 1. The fix — same-prefix links resolve artifact-wideThe review, verbatim on the mechanism:
That also closes the inconsistency the review named inside the diff: metadata embeds already resolved artifact-wide for the same directory docs while links did not. Both are artifact-wide now, so the two halves of one lint are partitioned the same way rather than two ways. ⭐ It RESTORES prior behaviour. Omit the third argument and 2. The pin that would have caught it, and proof it can fail
Ablation, on the committed fix, restoring with The three that stay green under the ablation are the true-positive twins, which assert that a genuinely dangling target IS refused — still true with links resolved per package. That asymmetry is the point of pairing them. 3. Does the changeset need a
|
| what | result |
|---|---|
pnpm --filter @objectstack/cli test — WHOLE, no --project narrowing |
268 files / 3511 tests passed, exit 0 (was 3505; +6 C2 cases) |
OS_TEST_TIERS=nightly pnpm --filter @objectstack/cli test — whole nightly tier |
69 files / 698 tests passed, exit 0 |
closure build, package build, typecheck |
exit 0 |
63 derived gate families, reconciled with --ran carrying each exit code |
63 derived, 63 run, 0 NOT-MEASURED, 0 UNRUN |
pnpm lint — whole repo, unnarrowed |
exit 0, zero findings |
artifact byte-identity, flat fixture, merge base 89c6ec52b5 vs HEAD |
byte-identical, 9294ad9c45b9093c9d80690152ed55036712ed39657b381238e39996e440b605 on both legs; stdout identical |
| C2 ablation | holds — 3 of 6 pins red without the fix, the 3 true-positive twins green |
check:dual-build-cjs-loads and check:i18n-coverage again returned exit 3 · PREREQUISITE NOT MET first, because this worktree is fresh and had no repo-wide dist. Recorded as NOT MEASURED, then pnpm build and both re-run: both exit 0. The reconciliation is the post-build reading.
Fences honoured: no test skipped, disabled or quarantined and no assertion weakened; content/docs/releases/ and every packages/*/CHANGELOG.md untouched; packages/cli/test/build-json-failure-* untouched (card #18894); no command was refused by the local permission classifier.
Generated by Claude Code
Fixes #18431
Clause-②: yes
os buildnow reads package docs from each package directory of an ADR-0130 layout —src/PKG/docs/*.md, wherePKGis the package's own directory undersrc/— and attaches them to the owning package's body, linted against that package's ownnamespace. A module can ship its own docs.This is the widening half of #18170. #18428 delivered that card's stated minimum (make the loss audible) and deliberately left two contract questions open; the maintainer answered both in batch #147 item 4, and this PR implements that ruling literally rather than choosing a shape of its own.
The ruling, clause by clause
1 — where per-package docs attach. To the owning package's body, ⛔ not the artifact top level. ADR-0130 D4 puts a package body at
packages[i].manifest(the wrapper position D4 reserves so a future{ ref, integrity }segment stays additive), so "the owning package's body" ispackages[i].manifest.docs. Nothing is copied to the top level: the runtime already merges a package-owned collection back up for readers (resolveArtifactCollections,packages/runtime/src/artifact-collections.ts), so a flattened duplicate would buy nothing and destroy the ownership ADR-0130 D1 is about.2 — whose namespace the doc lint uses. The owning package's. A doc outside any package keeps
stack.manifest.namespace. A multi-package artifact therefore has one prefix rule per package and ⛔ no single global prefix — and ⛔ no fallback between the two: a package doc that fails its own package's prefix is refused, never re-tried against the artifact's (Prime Directive #12).Each doc is judged exactly once. A doc a
packages[]body carries — composition folds a package's inlinedefineStack({ docs })there — or one read out of that package's directory goes to the package pass; everything else stays on the stack pass. Because the split removes the one set that used to see every doc at once, a newdocs/duplicate-nameerror covers the gap: doc uniqueness is logical (the registry key carries no package coordinate), and ADR-0130 D1 exists so that N packages can share one namespace, so the prefix rule does not keep them apart.3 — which source convention, measured. See the next section.
4 — the #18428 warning stays. Unchanged, word for word, for a stack that declares no
packages[], where "read fromsrc/docs/only" is still the whole truth. For a directory that matches no package it now lists the declared packages and the three spellings a directory name is matched against; for one matching more than one it names the candidates and refuses to guess.The clause-3 measurement — which convention, and what the other costs
The ruling asked the dev to measure which of the two conventions the collector can read without a second traversal, and to implement that one first.
Measured, and the answer is not symmetric — the two are not two ways of doing the same work:
src/PKG/docs/(directory)uncollectedDocsDirectorieshas read everysrc/PKG/docs/and listed its Markdown files by name since #18428, purely to warn about themdocson the package'sdefineStackThe second row is the finding.
assemblePackageBodyinpackages/spec/src/stack.zod.tsfolds every key ofassembledPackageBodyShape()onto each package body, anddocsis one of them, socomposeStacks([...], { manifest: 'preserve' })already puts a package's inline docs atpackages[i].manifest.docstoday, onmain, with no code in this repository to change. Measured directly and kept as a test (package-body-docs-are-composedincollect-docs.package-docs.test.ts), so the claim is not just a sentence in a PR body:So the directory convention is the one implemented here: it is the one with an actual capability gap, and the traversal it needs was already being paid for a warning.
What the other one costs from here: nothing for attachment, and the lint half is already in this PR. Clause 2 is shared — the per-package lint reads a package's body docs and its directory docs as one set — so an inline package doc is judged by its own package's namespace as of this PR too. What is genuinely left is documentation of the spelling plus a pin that it stays true; no collector work remains.
How a directory is attributed to a package
A
src/PKG/docs/directory is matched against a package'sid, the last dot-separated segment of thatid, and itsname. The middle spelling is the load-bearing one —examples/app-multi-packagedeclaresid: 'com.example.multi.core'withname: 'Multi-Package Core', so asrc/core/directory resolves only through the id's tail.⛔
namespaceis deliberately not a matching spelling. ADR-0130 D1 exists so that N packages of one artifact can SHARE one namespace, so matching on it would be ambiguous exactly where multi-package layouts are most common; that is pinned as its own case.Zero matches or more than one: not attributed, and reported. ⛔ The collector does not guess which body owns a doc.
The package id itself is not re-derived here —
collect-docs.tsimportsartifactPackagesfromartifact-packages.ts, whose own header forbids a second copy of that rule.@objectstack/lintis already on the boot path (packages/lint/src/lazy-deps.test.tsstates that contract and keeps its heavy deps lazy), so theos devcollection path pays nothing new for it.Scope beyond the collector
os devmirrorsos build. The block inserve.tsexists becauseos buildused to produce docsos devcould not show; leaving the per-package half out would re-open that asymmetry one level down.AppPluginreads a package-owned collection back up through the same resolver, so dev serves them as an artifact boot does.os build's package-docs step line prints before the collection it announces, so a build that collected nothing reads identically to one that did #18432 made that line report what it collected; a build that read four package docs must not announce0 collected. The parenthetical naming the package directories is added only when there are any, so a single-package build's line does not move.attachPackageDocsis classified invalidate-build-gate-parity.test.ts's closed roster —NOT_A_GATE, under its own reason, because it judges nothing: it places docscollectAndLintDocsalready collected and already linted, andos validateemits no artifact to place them in. ⛔ Deliberately not aBUILD_ONLY_GATESrow, whose entries assert a gate that cannot run read-only."Nothing existing moves" — a measurement, not a claim
The ruling says a single-package stack's flat
src/docs/keeps attaching exactly where it does today. That is measured rather than asserted: the same flat fixture — a stack with inlinedocs, a flatsrc/docs/carrying frontmatter, a locale variant, and asrc/sales/docs/nopackages[]declares — compiled with this branch's compiler and with the merge base's, and the emitted artifact compared byte for byte.Both legs prove the mutation reached disk before building (a marker grep plus
git hash-objectagainst the ref's blob on every file), and the restore is proved bygit diff HEADbeing empty and by re-hashing againstHEAD:, never by an exit code. The script carriestrap restore EXIT INT TERM. It is a one-off measurement; ⛔ no permanent test file is left behind.The mechanism behind the result:
attachPackageDocsreturns its ARGUMENT when it adds nothing, so a stack with no per-package docs serializes from the very same references as before, andcollectDocsFromSrccalled with one argument takes the same branch it always did.Verification
Run at
HEADunless noted; exit codes captured before any pipe.pnpm --filter '@objectstack/cli^...' build && pnpm --filter @objectstack/cli build && pnpm --filter @objectstack/cli typecheckpnpm --filter @objectstack/cli test— the WHOLE suite, no--projectnarrowingOS_TEST_TIERS=nightly pnpm --filter @objectstack/cli test— the whole nightly tiernode 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, WHOLE repo, unnarrowedTwo derived families first came back
exit 3 · PREREQUISITE NOT MET(check:dual-build-cjs-loads,check:i18n-coverage) because this worktree had no repo-widedist. That is NOT MEASURED, not a red — sopnpm buildwas run and both were re-run: both exit 0. The reconciliation above is the post-build reading.test/build-package-docs-attachment.e2e.test.tscarries the.e2e.filename tier, so it runs on the nightly, not on this PR — the same tier as every sibling that pins artifact shape through a spawnedos build(build-multi-package-artifact,compile-artifact-packages,build-docs-step-count). It is green locally underOS_TEST_TIERS=nightly; ⛔ do not read this PR's green as having run it. The per-PR coverage of the collector and the lint partition is the 60-case unit file next to the collector, which runs in theunitproject.Tests
packages/cli/src/utils/collect-docs.package-docs.test.ts(new, unit tier) — collection and attribution with PEDIGREE on every positive case (the directory read, the package index, the package id, the namespace, and a marker string written into exactly one file), all three resolution spellings, the namespace-is-not-a-spelling case, both unresolved-directory reports, the per-package prefix rule and its refusal control, the stack-level doc keepingstack.manifest.namespace, an inline body doc judged once rather than twice, the cross-owner duplicate, andattachPackageDocs' identity-when-empty contract.packages/cli/test/build-package-docs-attachment.e2e.test.ts(new, nightly tier) — the emitted artifact, read off disk: the doc underpackages[i].manifest.docswith its marker, the top level carrying only the stack's own doc and not the marker, the step line, and a single-package build with nopackageskey invented.packages/cli/src/utils/collect-docs.test.ts— unchanged, and the cli: the ADR-0046 package-docs collector reads only<config dir>/src/docs— under an ADR-0130 package layout a moved docs directory produces a green build whose artifact has silently lostdocs[]#18170 warning case asserts the message byte for byte.⭐ Every assertion that the per-package pass produced something also asserts its pedigree. A count of 1 is satisfiable by an echo of the flat
src/docs/doc; the marker strings are what tell the two apart.Acceptance notes
Noted while working here, not filed and not fixed in this PR:
examples/app-multi-packagenests its packages atsrc/packages/PACKAGE/, one level deeper than thesrc/PACKAGE/convention fix(cli): say what the ADR-0046 package-docs collector did not read (#18170) #18428's warning scans and this PR collects. Consequence today: that fixture would needsrc/core/docs/rather thansrc/packages/core/docs/, and the fix(cli): say what the ADR-0046 package-docs collector did not read (#18170) #18428 warning never fires for its layout. ⛔ Not touched here — the ruling's fence is that nothing existing moves, and the fixture ships no docs at all.find examples packages -maxdepth 4 -type d -path '*/src/*/docs'returns nothing, and both example apps that ship docs keep a flatsrc/docs.Scope
No governed surface is touched: the diff is
packages/cli/src,packages/cli/testand one changeset.Changeset:
@objectstack/climinor, per the ruling. ⛔ No@objectstack/specchangeset —packages[].docsis not a schema addition, measured rather than assumed:AssembledPackageBodySchemaderives its key set fromCOMPOSE_KEY_DISPOSITIONSminusASSEMBLED_PACKAGE_BODY_ENVELOPE_KEYS,docs: 'concat'has been in that table and outside that envelope all along, and a direct read of the schema's shape confirmsdocsis already declared on it. Nothing inpackages/specchanged, and nothing needed to.🤖 Generated with Claude Code
https://claude.ai/code/session_01DvvamiacK328idtBYJBxV3
Generated by Claude Code