Skip to content

fix(cli): keep the Runtime: summary row at zero plugins, and stop counting a metric nothing prints - #11469

Merged
os-zhuang merged 1 commit into
mainfrom
claude/issue-11172-metadata-stats-rows
Aug 24, 2026
Merged

fix(cli): keep the Runtime: summary row at zero plugins, and stop counting a metric nothing prints#11469
os-zhuang merged 1 commit into
mainfrom
claude/issue-11172-metadata-stats-rows

Conversation

@claude

@claudeclaudeBot commented Aug 23, 2026

Copy link
Copy Markdown
Contributor

Fixes#11172

Implements the maintainer's ruling recorded in comment 5386673724 (2026-08-23, live PM chat, verbatim: 「10950 不考虑存量,其他接受你的建议」) — direction 1, both halves, nothing else. Direction 2 (giving translations a rendered home, in UI: or a new i18n: row) was explicitly not approved and is not implemented; a pin in this PR asserts the summary mentions no translation count at all.

1. Runtime: renders unconditionally

Measured against the real CLI (bin/run-dev.js validate, NO_COLOR=1) on a stack declaring nothing ({ objects: [], apps: [] }). The before block is not recalled from the card — it was re-measured on this branch with the fix reverted in place (ablation 1 below):

Before

 ✓ Validation passed (65ms)
Data: 0 Objects
UI: 0 Apps
Logic: 0 Flows
Security: 0 Positions 0 Permissions

After

 ✓ Validation passed (108ms)
Data: 0 Objects
UI: 0 Apps
Logic: 0 Flows
Security: 0 Positions 0 Permissions
Runtime: 0 plugins

Why the row was folded into sections rather than fixed where it stood

The dispatch carried this seat's own sharpening: Runtime: was rendered outside the sections loop, after it closed, as a standalone if (stats.plugins > 0 || stats.devPlugins > 0). #11173's zeroFallback was structurally unable to reach it, so a one-line entry was never the fix. The two real options were a second mechanism beside the loop, or the fold. Chosen: the fold, on three readings of the code:

  1. The filter semantics were already identical. The standalone block appended a fragment per item with > 0, item by item — the same predicate section.items.filter(([, v]) => v > 0) applies. There was no behavioural difference to preserve, only a formatting one.
  2. A second mechanism would be a second, un-enforced copy of the invariant.zeroFallback is a required field on the array's element type, and [finding] os validate's summary printer drops ANY zero-count section row, not just UI:Logic:/Security: vanish the same way #10952's rationale says why in as many words: a section added later "cannot compile without naming what it prints at zero, so the dropped-row defect cannot be reintroduced one section at a time". A hand-rolled zero case next to the loop sits outside that enforcement and invites the next standalone row to copy the un-enforced shape.
  3. Being outside the loop was not incidental — it is the whole reason this row survived two fixes of the same defect. Fixing the symptom while leaving the structure would leave the next zeroFallback-class ruling equally unable to reach it.

The fragment style is carried across unchanged — deliberately

Runtime: has never rendered like the sections: it prints 2 plugins, 1 devPlugins (comma-joined, fully dim, lowercase item names) where a section prints 1 Objects 2 Fields (count in white, item name dim, two-space join). The dispatch required that difference to survive or be a stated change. It survives, byte for byte. The ruling was about the row's presence at zero, not its typography, and restyling a user-visible row while fixing its zero state would be an unruled widening of the card.

It is carried by one optional render?: (shown) => string hook on the section type, defaulting to the shipped <count> <Item> shape that every other row uses. One row's formatting becomes data on that row; the "never dropped" guarantee stays single-sourced in the loop.

plugins is the row's zero signal (zeroFallback: ['plugins']): devPlugins is a dev-only overlay on it, so Runtime: 0 devPlugins would report the narrower fact and stay silent about the broader one. A row with one non-zero peer still reports only that peer (Runtime: 4 devPlugins), exactly as Security: behaves.

2. translations removed from MetadataStats

Declared at format.ts:350, collected at :389 (count(config.translations)), read by no render path at any value — a stack with 40 translation bundles reported them nowhere. Removed implementation-first, per the ruling.

The pin does not assert the absence of one field name. It holds the general property: declared ⇒ collected ⇒ rendered. TypeScript already forces every field MetadataStats declares to be populated by collectMetadataStats (it returns that interface), and the new pin forces every field it collects to reach the printed output — each metric given a distinct non-zero count so no metric's value can be satisfied by another's rendered number. A metric counted on every os validate and shown nowhere cannot satisfy the chain, whatever it is called, so the next unread metric fails it too. A second, narrower pin names translations specifically, because the general one cannot see a field re-collected under a rendered alias.

One externally visible consequence the card's evidence did not name

All three commands spread the wholestats struct into their --json payload (validate.ts:308, info.ts:47, compile.ts:480), so os validate --json, os info --json and os compile --json no longer carry a stats.translations key. Measured after the change — the payload's stats now has exactly 18 keys and hasOwnProperty('translations') is false.

This does not change the ruling (the field still has zero readers, which is what the ruling turned on), but it is a machine-readable payload change and is called out here rather than left to be discovered. The field was undocumentedcontent/docs/deployment/cli.mdx documents --json for these commands but declares no payload shape for stats — carries no schema, and a repo-wide search for stats.translations returns zero consumers (the only hit is a doc comment in the new test). It is also outside the one place the two faces are held equal: validate-json-warning-parity.e2e.test.ts declares stat rows out of scope by name.

Anti-vacuity — both halves shown failing without the behaviour they pin

Every pin was run against a tree with its own fix reverted. No dist/ is involved in either ablation: the test imports ../src/utils/format.js by relative path into src/, and bin/run-dev.js runs the CLI from source under tsx, so no rebuild step can make a mutation invisible here. Each mutation was confirmed on disk by grep -c of both the injected and the removed text (an editor's exit code proves nothing — a zero-hit sed exits 0), and the driver carried trap … EXIT INT TERM so a foreground-cap kill could not leave the tree mutated.

Ablation 1 — the Runtime: fold reverted (the Runtime entry removed from sections, the original standalone conditional restored verbatim, printing the real Runtime: label):

injected standalone conditional: 1 (expect 1)
removed Runtime section entry: 0 (expect 0)
Test Files 1 failed (1)
Tests 2 failed | 13 passed (15)
× Runtime: prints "Runtime: 0 plugins" on a stack with no plugins and no devPlugins
× Runtime: stays the last row — the fold must not reorder the summary

The real CLI on the same reverted tree printed the Before block above — no Runtime: row, exit 0. Note which pin stayed green: Runtime: 2 plugins, 1 devPlugins. That is the point of it — it pins pre-existing shipped output, so it must not be able to distinguish the two trees, and only the zero-state pins carry the fix.

Ablation 2 — translations re-declared and re-collected:

translations declared+collected: 2 (expect 2)
Test Files 1 failed (1)
Tests 2 failed | 13 passed (15)
× every metric collectMetadataStats counts is rendered somewhere in the summary
× translations specifically: the field the ruling deleted is neither collected nor rendered
AssertionError: translations is counted by collectMetadataStats but never rendered by
printMetadataStats: expected ' Data: 101 Objects 103 Fields 102 …' to match /\b117\b/

Both restores were verified by the same grep -c counts (1/0 and 0), and git status is clean.

Verification

All gate runs below are at commit ec4d31be, this branch's head — the same tree the PR carries.

  • pnpm --filter @objectstack/cli typecheckVERDICT command-exit 0 (the run echoed > @objectstack/cli@17.2.0 typecheck, so the filter matched a real script rather than passing vacuously on zero matches).
  • pnpm --filter @objectstack/cli exec vitest run test/print-metadata-stats-zero-row.test.tsTest Files 1 passed (1), Tests 15 passed (15) (9 pre-existing + 6 new).
  • Gate families derived with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack (no hand-built path list), all green: check:nul-bytes, check:changeset-gate-self-tests, check:objectui-changeset, check:published-files, check:slot-lookup, check:test-source-alias, check:type-source-resolution, check:type-check-coverage, check:engine-double-contract, check:where-matcher, check:cross-package-test-inputs, check:query-options-erasure, plus check-adr-0087-registration, check-changeset-no-major, check-empty-changeset, check-ci-filter-parity and check-plugin-teardown-shape.
  • pnpm check:type-check-debt (the ratchet half, run with the workspace closure built as lint.yml does, since it refuses outright on an unbuilt tree rather than reporting a number from a different world) — check-type-check-coverage --re-measure: OK — 33 ledger entr(ies) re-measured in 391.9s, 1897 raw tsc error(s) total, none above its recorded number, VERDICT command-exit 0. The four surplus notices it prints are pre-existing and in other packages; this diff moves no ledger entry.
  • Declared narrowing — pnpm lint (repo-wide eslint . --no-inline-config) was not run; ESLint ran over the two changed source files instead. The narrowing is measured, not assumed: (1) the receiving population comes from ESLint's own config, not a guess — isPathIgnored reports both TS files in the linted set and the changeset .md ignored by config, so no lint is owed on it; (2) the file count is read from --format json: 2 files, 0 errors, 0 warnings; (3) the invariance claim — this repo runs one eslint.config.mjs which never enables type-aware linting for any file (no parserOptions.project, no typed @typescript-eslint rules), a property that config declares and measures with a positive control in its own header, so this diff cannot move the verdict on any file it does not touch. CI runs the full farm regardless.

Scope

packages/cli/src/utils/format.ts and its test twin — the declared file surface, unchanged. No other file is touched apart from the changeset. Nothing in this PR gives translations a rendered home.


Generated by Claude Code


Generated by Claude Code

…nting an unrendered metric
`printMetadataStats` rendered `Runtime:` outside the `sections` loop, wrapped
in `if (stats.plugins > 0 || stats.devPlugins > 0)`, so a stack with no plugins
printed no `Runtime:` row at all — the same "reads as never asked, not as zero"
drop #10504 and #10952 removed from the four sections. Folded into `sections`
so it inherits the loop's no-silent-drop guarantee and the required
`zeroFallback` typing, with its shipped comma-joined dim fragment style carried
over verbatim through a new optional per-section `render` hook.
`MetadataStats.translations` was collected on every run by
`collectMetadataStats` and read by no render path at any value. Removed
implementation-first; a new pin holds the general property (every collected
metric reaches the output) rather than the absence of one field name.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019siH5jDmk5hrayvfyojUqR
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/cli, touching 3 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 devPlugins (literal))
  • content/docs/getting-started/quick-start.mdx(via devPlugins (literal))
  • content/docs/plugins/index.mdx(via devPlugins (literal))
What this run could not see
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 45 of 222 client-bound route-ledger rows — the other 177 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run: node scripts/docs-audit/affected-docs.mjs --bridge-coverage

Coarse fallback — 23 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 8542bd457765444626023db6ffeb6f8105f7f83epackageMentionDocs.

Which tree this was computed on

This run read content/docs from 961099bf899d0e0a44b4ee0762ebb12656aad39d — the merge of head ec4d31becc6625b4d56fad399cd213502025df2d into base 8542bd457765444626023db6ffeb6f8105f7f83e, 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 961099bf899d0e0a44b4ee0762ebb12656aad39d && git checkout 961099bf899d0e0a44b4ee0762ebb12656aad39d
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 8542bd457765444626023db6ffeb6f8105f7f83e ec4d31becc6625b4d56fad399cd213502025df2d && git checkout -B drift-repro 8542bd457765444626023db6ffeb6f8105f7f83e && git merge --no-ff ec4d31becc6625b4d56fad399cd213502025df2d
node scripts/docs-audit/affected-docs.mjs --json 8542bd457765444626023db6ffeb6f8105f7f83e

⚠️ 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 8542bd457765444626023db6ffeb6f8105f7f83e → pass the list as
args.docs, on the commit named under Which tree this was computed on.

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

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

2 participants

@os-zhuang@claude