fix(cli): os info's four detail reads resolve an option-B project's package-owned collections, so one --json payload stops contradicting itself - #17902
Conversation
…project declares, so one --json payload stops contradicting itself `os info` rendered its `Objects:` / `Agents:` / `Apps:` sections and its `--json` `objects` array off the TOP LEVEL alone. On an ADR-0130 D4 / option-B project (every definition inside `packages[]`, none flattened up) those four reads saw nothing while the summary beside them — which learned to resolve `packages[]` — counted the same definitions, so one payload asserted `stats.objects: 1` next to `objects: []`. The four reads now go through `resolveStackCollection` (`utils/stack-collections.ts`), the one place this package resolves a package-owned collection. The seam answers the caller's original expression first and consults `packages[]` only when the top level does not carry the key at all, so every stack the platform emits today reports exactly what it reported before. Not decided here: whether an option-B project's detail listing should be this flat union or grouped per package. Each entry keeps the shape and the key set it has always had. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TSf4DV7ziu4V5j73e46b7c
📓 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 — 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 bce1dcebd4b0a8b930c4e4f86010929520ee0212 && git checkout bce1dcebd4b0a8b930c4e4f86010929520ee0212
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin ee6fbd7a63f9515e233fa5c0f6d0f5203fb460b5 cf8325965ccc9fabd8a27c1ae229510b6984c5a9 && git checkout -B drift-repro ee6fbd7a63f9515e233fa5c0f6d0f5203fb460b5 && git merge --no-ff cf8325965ccc9fabd8a27c1ae229510b6984c5a9
node scripts/docs-audit/affected-docs.mjs --json ee6fbd7a63f9515e233fa5c0f6d0f5203fb460b5
|
Contract review — PR #17902 (card #17790)Head reviewed:
① Derived judgments — every acceptance-set and public-surface change, named and judged1. ⛔ The hard line held: flat-versus-grouped is NOT decided here. — correct, and the source says so itself. The order adopted triage's ⛔ verbatim — 「⛔ 不得在修矛盾的同一笔里把「平铺 vs 分组」默默定掉」. Measured on the head: the diff adds no package attribution to any entry, and 2. ⭐ The two faces of one run cannot disagree BY CONSTRUCTION, not by two parallel patches. — correct, read off the head. 3. No published surface moves, and the level follows. — correct. No new exported symbol; no new key on the payload; 4. The fences held. 5. ⭐ The ablation is FOUR legs, one per read — not the one the order asked for. Each leg reverts one read to its 6. ⭐ And the dev checked rather than assumed why no dist-preflight leg applies: the pin drives ② semver level —
|
| page | what it says about os info |
falsified? |
|---|---|---|
content/docs/deployment/cli.mdx:566 |
a one-line command-table entry | no |
content/docs/getting-started/examples.mdx:280 |
a one-line command listing | no |
content/docs/protocol/kernel/config-resolution.mdx:343 |
「inspected via os info / os doctor」 |
no |
content/docs/deployment/cli.mdx:672-703 |
no — and here is why |
That example's fixture is named on the page itself: npm create objectstack@latest my-app's blank starter plus objects from the Build-with-Claude-Code walkthrough — a conventionally scaffolded, top-level-authored project. Its documented output already prints the Objects: section (my_app_note, my_app_ticket). ⇒ the top level carries the key, resolveStackCollection returns that same array, and the page's bytes are unchanged. ⭐ This is the primitive's guarantee doing exactly what triage said it would: 「对今天平台发出的任何 stack 都不改变输出」.
Three out_of_scope_findings, all noted, not filed, all accepted: the fixed plural in the printer ((1 fields, user)) — a style nit in a printer the PR touched, carrier is its next editor; the absent package attribution — ⛔ not a gap this card may close, because it is the manual-floor question; and a measured non-defect — a MAP-shaped package collection is refused loudly at the entry gate with INVALID_ARTIFACT_PACKAGE_ENTRY rather than being silently dropped by the seam, on this branch exactly as on main, so the 「absence must be loud」 direction holds for the shape the seam cannot read.
Independence pair
Implemented-by: claude/issue-17790-info-package-owned-collections (mode:subagent)
Reviewed-by: os-sales — domain:cli execution seat, issue #6024, session_01TSf4DV7ziu4V5j73e46b7c
Independence: SELF-REVIEW — the implementer is a subagent of the reviewing seat's own session
Tier: default judgment — 「余席条款②复核 = 默认判断档自审加门禁」.
Verdict
PASS. ⛔ No carrier to clear — the claim declared Clause-②: no and the measurement agrees, so needs:contract-review was never hung and ⛔ must not be. ⇒ the landing pre-check reduces to ③: every check green on this head, ⛔ not the required subset. At 22:06Z it read 17 success / 3 skipped / 11 in progress / zero failures; the flip waits for the rest.
Generated by Claude Code
Fixes #17790
Clause-②: no
Re-derived from the delivered diff rather than carried over from the claim: it adds no schema key, no closed-set member, no published export and no registry entry.
info.tsexports the same oclif command class it exported before, the--jsonpayload keeps exactly the keys it had, and no authorable key moves. What changes is which stack three existing reads are handed. Output is unchanged for every stack the platform emits today — measured below, not asserted.The defect
os inforendered itsObjects:/Agents:/Apps:sections and its--jsonobjectsarray off the top level alone. On an ADR-0130 D4 / option-B project (every definition insidepackages[], none flattened up) those four reads saw nothing — while the summary on the line above them, which learned to resolvepackages[]in #17527, counted the same definitions.Re-derived through the real binary on
origin/mainef474594, on the card's own repro, before this change:One payload asserting
stats.objects: 1besideobjects: [], and nothing in it distinguishing this project has no objects from this reader could not see them.--jsonis the face a machine reads, so a consumer cannot recover from it.The fix
The four reads go through
resolveStackCollection(packages/cli/src/utils/stack-collections.ts), the one place this package resolves a package-owned collection:--jsonobjectsarray(config.objects || []).map(…)objects.map(…)Objects:sectionconfig.objects && config.objects.length > 0objects.length > 0Agents:sectionconfig.agents && config.agents.length > 0agents.length > 0Apps:sectionconfig.apps && config.apps.length > 0apps.length > 0objectsis resolved once, above the--jsonbranch, and both faces read that one list — so the two faces of a single run cannot disagree with each other by construction.Two properties this inherits rather than re-derives:
packages[]only when the top level does not carry the key at all.objects/apps/agentsare all inMAP_SUPPORTED_FIELDS, so afternormalizeStackInputa top-level one is an array or absent — which means the first leg is byte-identical to the old expression for every stack the platform emits today. Pinned by a control run, not by argument.collectMetadataStatson the line above already resolves the same package list through the same seam, so a malformedpackageshas already answered its ADR-0112422before these reads run. Measured: a package body declaring a MAP-shaped collection is refusedINVALID_ARTIFACT_PACKAGE_ENTRYat the entry gate, on this branch exactly as onmain.This is
resolveStackCollection's FIRST production call siteStated because the seat measured it and it is easy to get wrong: before this PR the identifier appeared under
packages/cli/src/in exactly two files — its own definition and its own test. Adopting it here is its first production use, so it is ⛔ not battle-tested and the PR owes the coverage.What
src/utils/stack-collections.test.tsalready covers — three rows, all at theobjectskey on a ONE-package stack: the top level winning by presence without unioningpackages[]; concatenation when the top level does not carry the key (plus[]for a key nothing declares); and the ADR-0112 refusal of a malformedpackages, reachable only on the leg the caller's own expression did not answer.What it does not cover, and what the new pin adds: the
agentsandappskeys, a stack with more than one package, and a caller resolving three keys off one stack — i.e. this usage.The pin, and why it spawns the binary
packages/cli/test/info-detail-package-fold.test.tsruns the real CLI over three projects written to a tmpdir. It spawns because these four reads are expressions inside an oclif command body, andstack-collections.ts' own header states the consequence as a rule — 「A pin can only attach to a callable」 — which is why they sat outside the option-B acceptance probe's ledger while every callable reader sat inside it. Running the command is the only way to measure the reads themselves rather than a second copy of them, and it is also what the card's evidence is: same command, same binary, two projects.stats.objectsequalsobjects[].length, and the listed entry is the object the summary counted;Objects:andApps:sections it silently omitted;packages[], produce an identical payload and an identical detail block. Same command, same binary: that equality is the proof that the absence was produced by the READER and not by the stack, and it is simultaneously the additive-rule guard, since the top-level project is the shape every stack the platform emits today has;Agents:read and is the positive control for the concatenation — a reader that stopped atpackages[0]satisfies every row above and fails this one.⛔ What this does NOT decide
Whether an option-B project's detail listing should be this flat union or grouped by package. That is a published-output-shape decision on the manual floor, and settling it inside a fix for a self-contradicting payload would settle it by accident. Every entry keeps the shape and the key set it has always had — no package attribution is added — and every assertion in the pin is about AGREEMENT rather than about shape, so a later card that groups the listing changes the expected output without contradicting anything claimed here.
Verification
Exit codes captured BEFORE any pipe; heavy runs serialised through
scripts/pm/os-verify-lock.sh, and the verdicts quoted are that wrapper's ownVERDICT command-exitline.node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack: 62 families, all exit 0. Reconciled with--rancarrying each recorded code: "62 derived famil(ies) accounted for — 62 run, 0 NOT-MEASURED (a DERIVED zero — all 62 recorded an exit code and none of them is 3)." Four of them (check:dual-build-cjs-loads,check:i18n,check:i18n-coverage,check:i18n-walk-parity) first answered exit 3 —PREREQUISITE NOT MET, which is neither a pass nor a finding; they were re-run to exit 0 afterturbo run build --filter=@objectstack/cli.pnpm lint— the full union,eslint . --no-inline-config, ⛔ no narrowing:VERDICT command-exit 0.pnpm --filter @objectstack/cli typecheck—VERDICT command-exit 0;check:test-typecheck: OK — @objectstack/cli's test layer compiles.packages/clisuite, both tiers.unit: 204 files / 2935 tests passed.integration: all 46 files / 404 tests passed (run in three slices to stay inside the foreground window). The new pin is classifiedintegrationbyvitest-tiers.ts, and is deliberately NOT named.e2eso it runs in the queue's tier rather than the nightly one.turbo run build --filter='@objectstack/cli^...'then--filter=@objectstack/cli, bothVERDICT command-exit 0.Ablation — all four reads, not just one
Each leg reverts ONE read to its
config.KEYform, proves the mutation reached the file on disk before the verdict is read (anchoredgrep -cFon both the injected and the removed text, plus a blob-hash difference against HEAD), runs the pin, then restores withgit checkout HEAD -- [path]and proves the restore by blob hash and an emptygit diff HEAD. A restore also ran from anEXIT INT TERMtrap on absolute paths.--jsonarrayObjects:sectionAgents:sectionApps:sectionDirection as predicted for all four: red, each leg reddening a different row set, which is what says each read has its own coverage rather than one row standing for all of them. The whole tree was clean at the end (
git status --porcelainempty, final blob equal to the HEAD blob).No
dist/preflight leg applies here and the reason is checked rather than assumed: the pin drivespackages/cli/bin/run-dev.js, the SOURCE entry that runs the CLI fromsrc/through tsx, andpackages/cli/distdid not exist at all while the legs ran — so no mutation of this package could have been mediated by a stale build.Acceptance notes
Objects:line renders a count with a fixed plural — a one-field object prints(1 fields, user). A style nit in the same section, not a defect; the next PR touching this printer is its carrier.--jsonobjectsentries carry no package attribution, so a consumer of an option-B project cannot tell which package an object came from. That is not a gap this card may close — it IS the flat-versus-grouped question triage put on the manual floor, and its carrier is the card that answers it.🤖 Generated with Claude Code
https://claude.ai/code/session_01TSf4DV7ziu4V5j73e46b7c
Generated by Claude Code