Skip to content

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

Merged
claude[bot] merged 1 commit into
mainfrom
claude/issue-17790-info-package-owned-collections
Sep 12, 2026
Merged

claude[bot] merged 1 commit into
mainfrom
claude/issue-17790-info-package-owned-collections

Conversation

@claude

@claude claude Bot commented Sep 12, 2026

Copy link
Copy Markdown
Contributor

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.ts exports the same oclif command class it exported before, the --json payload 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 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 on the line above them, which learned to resolve packages[] in #17527, counted the same definitions.

Re-derived through the real binary on origin/main ef474594, on the card's own repro, before this change:

os info --json   exit 0   stats.objects = 1 · stats.apps = 1 · stats.views = 1
                          objects[] length = 0
os info          exit 0   Data: 1 Objects  2 Fields
                          UI: 1 Apps  1 Views
                          (no `Objects:` section, no `Apps:` section)

One payload asserting stats.objects: 1 beside objects: [], and nothing in it distinguishing this project has no objects from this reader could not see them. --json is 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:

read before after
the --json objects array (config.objects || []).map(…) objects.map(…)
the Objects: section config.objects && config.objects.length > 0 objects.length > 0
the Agents: section config.agents && config.agents.length > 0 agents.length > 0
the Apps: section config.apps && config.apps.length > 0 apps.length > 0

objects is resolved once, above the --json branch, 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:

  • Strictly additive. The seam answers the caller's original expression FIRST and consults packages[] only when the top level does not carry the key at all. objects / apps / agents are all in MAP_SUPPORTED_FIELDS, so after normalizeStackInput a 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.
  • No new failure mode. collectMetadataStats on the line above already resolves the same package list through the same seam, so a malformed packages has already answered its ADR-0112 422 before these reads run. Measured: a package body declaring a MAP-shaped collection is refused INVALID_ARTIFACT_PACKAGE_ENTRY at the entry gate, on this branch exactly as on main.

This is resolveStackCollection's FIRST production call site

Stated 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.ts already covers — three rows, all at the objects key on a ONE-package stack: the top level winning by presence without unioning packages[]; concatenation when the top level does not carry the key (plus [] for a key nothing declares); and the ADR-0112 refusal of a malformed packages, 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 agents and apps keys, 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.ts runs the real CLI over three projects written to a tmpdir. It spawns because these four reads are expressions inside an oclif command body, and stack-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.

  • the card's minimal repro (one package, nothing at the top level but the envelope): stats.objects equals objects[].length, and the listed entry is the object the summary counted;
  • the text face prints the Objects: and Apps: sections it silently omitted;
  • the control — the SAME definition literals, authored at the top level instead of inside 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;
  • a two-package project reaches the Agents: read and is the positive control for the concatenation — a reader that stopped at packages[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 own VERDICT command-exit line.

  • Derived gatesnode scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack: 62 families, all exit 0. Reconciled with --ran carrying 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 after turbo run build --filter=@objectstack/cli.
  • pnpm lint — the full union, eslint . --no-inline-config, ⛔ no narrowing: VERDICT command-exit 0.
  • pnpm --filter @objectstack/cli typecheckVERDICT command-exit 0; check:test-typecheck: OK — @objectstack/cli's test layer compiles.
  • The whole packages/cli suite, 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 classified integration by vitest-tiers.ts, and is deliberately NOT named .e2e so it runs in the queue's tier rather than the nightly one.
  • Build closure: turbo run build --filter='@objectstack/cli^...' then --filter=@objectstack/cli, both VERDICT command-exit 0.

Ablation — all four reads, not just one

Each leg reverts ONE read to its config.KEY form, proves the mutation reached the file on disk before the verdict is read (anchored grep -cF on both the injected and the removed text, plus a blob-hash difference against HEAD), runs the pin, then restores with git checkout HEAD -- [path] and proves the restore by blob hash and an empty git diff HEAD. A restore also ran from an EXIT INT TERM trap on absolute paths.

leg reverted pin verdict rows
the --json array exit 1 5 failed, 2 passed
the Objects: section exit 1 3 failed, 4 passed
the Agents: section exit 1 1 failed, 6 passed
the Apps: section exit 1 2 failed, 5 passed

Direction 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 --porcelain empty, final blob equal to the HEAD blob).

No dist/ preflight leg applies here and the reason is checked rather than assumed: the pin drives packages/cli/bin/run-dev.js, the SOURCE entry that runs the CLI from src/ through tsx, and packages/cli/dist did not exist at all while the legs ran — so no mutation of this package could have been mediated by a stale build.

Acceptance notes

  • Noted, not filed: the 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.
  • Noted, not filed: the --json objects entries 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

…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
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/cli, touching 1 documentable anchor(s).

3 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/deployment/cli.mdx (via os info (command, read off packages/cli/src/commands/info.ts))
  • content/docs/getting-started/examples.mdx (via os info (command, read off packages/cli/src/commands/info.ts))
  • content/docs/protocol/kernel/config-resolution.mdx (via os info (command, read off packages/cli/src/commands/info.ts))
What this run could not see
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 24 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json ee6fbd7a63f9515e233fa5c0f6d0f5203fb460b5packageMentionDocs.

Which tree this was computed on

This run read content/docs from bce1dcebd4b0a8b930c4e4f86010929520ee0212 — the merge of head cf8325965ccc9fabd8a27c1ae229510b6984c5a9 into base ee6fbd7a63f9515e233fa5c0f6d0f5203fb460b5, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs ee6fbd7a63f9515e233fa5c0f6d0f5203fb460b5 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Sep 12, 2026
@claude

claude Bot commented Sep 12, 2026

Copy link
Copy Markdown
Contributor Author

Contract review — PR #17902 (card #17790)

Head reviewed: cf8325965ccc9fabd8a27c1ae229510b6984c5a9. Three paths, +345/−7. Readings taken 2026-09-12T22:07Z against origin/* refs.

⚠️ Independence: the implementer is a mode:subagent dev of this same PM session; ⛔ not an arm's-length second opinion.

① Derived judgments — every acceptance-set and public-surface change, named and judged

1. ⛔ 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 info.ts carries the reason in its own comment — 「Each entry keeps the shape and the key set it has always had — no package attribution is added — so the grouping question stays exactly as open as it was, for the card that answers it.」 ⇒ the published machine face gains no key; what changes is only whether the array is populated for a project whose definitions live in packages[].

2. ⭐ The two faces of one run cannot disagree BY CONSTRUCTION, not by two parallel patches. — correct, read off the head. const objects = resolveStackCollection(config, 'objects') is declared at :69, above the if (flags.json) branch, and the --json payload maps that same binding. ⇒ the contradiction this card is about — stats.objects: 1 beside objects: [] — is now structurally unreachable rather than fixed twice. agents (:117) and apps (:130) resolve at their own sections. No config.objects / config.agents / config.apps read survives in the file.

3. No published surface moves, and the level follows. — correct. No new exported symbol; no new key on the payload; objects[] and stats both already existed. The primitive answers the caller's original expression first and consults packages[] only when the top level does not carry the key ⇒ for any stack the platform emits today the output is unchanged, and what this recovers is a fact the payload already claims. ⇒ 拉回已声明契约 ⇒ Clause-②: no, patch.

4. The fences held. packages/spec and content/docs/releases/ are 0 paths; collectMetadataStats (#17527's face) and the os lint readers (#17528's face) are untouched — the three-path diff is info.ts, one new test, one changeset.

5. ⭐ The ablation is FOUR legs, one per read — not the one the order asked for. Each leg reverts one read to its config.<key> form, proves the mutation reached disk before any verdict is read (anchored grep -cF on both the injected and the removed text — injected ≥ 1, removed = 0 — plus a blob hash differing from HEAD), runs the pin, and restores with a blob-equality check and an empty git diff HEAD, under an EXIT INT TERM trap on absolute paths. All four went red, each reddening a different row set--json array 5 failed / 2 passed · Objects: 3/4 · Agents: 1/6 · Apps: 2/5. ⭐ Four legs with four distinct failure signatures is stronger evidence than one leg: it shows the pin discriminates which read broke, not merely that something did.

6. ⭐ And the dev checked rather than assumed why no dist-preflight leg applies: the pin drives packages/cli/bin/run-dev.js, the source entry that runs the CLI from src/ through tsx, and packages/cli/dist did not exist at all while the legs ran ⇒ no mutation could have been mediated by a stale build. That is the failure mode a dist-preflight exists to catch, answered by measurement instead of ritual.

② semver level — patch on @objectstack/cli

Consistent with ①.3 and with what the changeset carries. Check Changeset's level axis stands down because the declaration is no. ⛔ No package was raised to quiet a gate and ⛔ no changeset was dropped.

③ Boundary flags, open_questions, and the docs-drift advisory

a. The one open question — flat union vs grouped-by-package — is answered A (leave as delivered), and that is ⛔ not a decision this PR makes. Triage put the presentation choice on the manual floor; the fix does not touch it, adds no attribution key, and every pin assertion is about agreement rather than shape ⇒ a later grouping card changes the expected output without contradicting anything claimed here. ⛔ Nothing is escalated; the question was already the maintainer's and stays exactly where triage left it.

b. ⚠️ The order owed a sentence on resolveStackCollection being its FIRST production call site under packages/cli/src/. The report re-derived that zero before editing, and the PR body carries the fact. ⇒ owed sentence delivered; the primitive is now exercised by a spawn-based pin that drives the real CLI binary over three fixtures (the card's minimal repro, the card's top-level-authored control, and a two-package project), asserting stats.objects === objects[].length and that the control's payload and detail block are identical to the option-B project's. ⛔ It is no longer taken on trust.

c. ⭐ The docs-drift advisory named three pages, and this seat answered it with a reading rather than waving it off. The advisory is the precision-first kind — each row says which anchor put it there, all three via os info read off info.ts:

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 ⚠️ a full output example, the only row that could be falsified 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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

1 participant