Uh oh!
There was an error while loading. Please reload this page.
fix(cli): keep the Runtime: summary row at zero plugins, and stop counting a metric nothing prints - #11469
Conversation
…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
📓 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 — 23 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 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
|
Uh oh!
There was an error while loading. Please reload this page.
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
translationsa rendered home, inUI:or a newi18n: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 unconditionallyMeasured 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
After
Why the row was folded into
sectionsrather than fixed where it stoodThe dispatch carried this seat's own sharpening:
Runtime:was rendered outside thesectionsloop, after it closed, as a standaloneif (stats.plugins > 0 || stats.devPlugins > 0). #11173'szeroFallbackwas 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:> 0, item by item — the same predicatesection.items.filter(([, v]) => v > 0)applies. There was no behavioural difference to preserve, only a formatting one.zeroFallbackis a required field on the array's element type, and [finding]os validate's summary printer drops ANY zero-count section row, not justUI:—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.zeroFallback-class ruling equally unable to reach it.The fragment style is carried across unchanged — deliberately
Runtime:has never rendered like the sections: it prints2 plugins, 1 devPlugins(comma-joined, fully dim, lowercase item names) where a section prints1 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) => stringhook 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.pluginsis the row's zero signal (zeroFallback: ['plugins']):devPluginsis a dev-only overlay on it, soRuntime: 0 devPluginswould 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 asSecurity:behaves.2.
translationsremoved fromMetadataStatsDeclared 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
MetadataStatsdeclares to be populated bycollectMetadataStats(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 everyos validateand shown nowhere cannot satisfy the chain, whatever it is called, so the next unread metric fails it too. A second, narrower pin namestranslationsspecifically, 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 whole
statsstruct into their--jsonpayload (validate.ts:308,info.ts:47,compile.ts:480), soos validate --json,os info --jsonandos compile --jsonno longer carry astats.translationskey. Measured after the change — the payload'sstatsnow has exactly 18 keys andhasOwnProperty('translations')isfalse.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 undocumented —
content/docs/deployment/cli.mdxdocuments--jsonfor these commands but declares no payload shape forstats— carries no schema, and a repo-wide search forstats.translationsreturns 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.tsdeclares 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.jsby relative path intosrc/, andbin/run-dev.jsruns the CLI from source under tsx, so no rebuild step can make a mutation invisible here. Each mutation was confirmed on disk bygrep -cof both the injected and the removed text (an editor's exit code proves nothing — a zero-hitsedexits0), and the driver carriedtrap … EXIT INT TERMso a foreground-cap kill could not leave the tree mutated.Ablation 1 — the
Runtime:fold reverted (the Runtime entry removed fromsections, the original standalone conditional restored verbatim, printing the realRuntime:label):The real CLI on the same reverted tree printed the Before block above — no
Runtime:row, exit0. 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 —
translationsre-declared and re-collected:Both restores were verified by the same
grep -ccounts (1/0and0), andgit statusis clean.Verification
All gate runs below are at commit
ec4d31be, this branch's head — the same tree the PR carries.pnpm --filter @objectstack/cli typecheck—VERDICT 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.ts—Test Files 1 passed (1),Tests 15 passed (15)(9 pre-existing + 6 new).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, pluscheck-adr-0087-registration,check-changeset-no-major,check-empty-changeset,check-ci-filter-parityandcheck-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.pnpm lint(repo-wideeslint . --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 —isPathIgnoredreports both TS files in the linted set and the changeset.mdignored 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 oneeslint.config.mjswhich never enables type-aware linting for any file (noparserOptions.project, no typed@typescript-eslintrules), 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.tsand its test twin — the declared file surface, unchanged. No other file is touched apart from the changeset. Nothing in this PR givestranslationsa rendered home.Generated by Claude Code
Generated by Claude Code