Skip to content

Commit 0d4bd93

Browse files
Elon Muskclaude
andauthored
fix(cli): print every metadata-summary section's zero state, never drop the row (#10952) (#11173)
`printMetadataStats` skipped any section whose every item was 0. #10504 fixed that for `UI:` alone via an opt-in `zeroFallback`; `Data:`, `Logic:` and `Security:` kept dropping, so "declares none of this" read identically to "not reported on". Measured against the real CLI (`bin/run-dev.js validate`, NO_COLOR=1): a stack with one object and nothing else printed only `Data:` + `UI: 0 Apps`, with no `Logic:` and no `Security:` line; a stack with no objects printed the single line `UI: 0 Apps`. Both exited 0. `zeroFallback` is now REQUIRED and typed non-empty (`[string, ...string[]]`), which is the enforcement: a section added later cannot compile without naming what it prints at zero. `Security:` names both peers — it has no canonical single signal the way `UI:` has `Apps`. Pins are per section so each is individually sensitive. Claude-Session: https://claude.ai/code/session_019bmVFqoQPq63zhKrxdYG1r Co-authored-by: Claude <noreply@anthropic.com>
1 parent c9bafab commit 0d4bd93

3 files changed

Lines changed: 149 additions & 22 deletions

File tree

.changeset/hungry-pandas-shake.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'@objectstack/cli': patch
3+
---
4+
5+
`os validate` no longer drops a summary section that happens to be empty — every section prints its zero state
6+
7+
The metadata summary shared by `os validate`, `os info` and `os compile` skipped any section whose every item was `0`. #10504 fixed that for `UI:` only; `Data:`, `Logic:` and `Security:` still vanished. Measured against the real CLI: a stack with one object and nothing else printed `Data:` and `UI: 0 Apps` and no `Logic:` or `Security:` line at all, so "this project declares no automation" was indistinguishable from "this summary does not report on automation". A stack declaring no objects printed the single line `UI: 0 Apps`.
8+
9+
All four rows now always print, in the shipped `UI: 0 Apps` shape: `Data: 0 Objects`, `Logic: 0 Flows`, and `Security: 0 Positions 0 Permissions` (both peers — `Security:` has no single canonical signal the way `UI:` has `Apps`).
10+
11+
Patch, not minor: no API, flag, exit code or `--json` payload changes — this is the human-readable summary printing rows it previously omitted. Anything scraping the text summary for the absence of a section row will now see it present.

packages/cli/src/utils/format.ts

Lines changed: 54 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -810,19 +810,38 @@ export function printMetadataStats(stats: MetadataStats) {
810810
label: string;
811811
items: Array<[string, number]>;
812812
/**
813+
* The item(s) to force-print when EVERY item in the section is `0`.
814+
*
813815
* #10504 — a section whose every item is `0` used to vanish from the
814816
* summary entirely, and that reads as "this summary does not report on
815817
* this section" rather than "this project has none of it" — exactly the
816818
* same output for a newcomer's freshly scaffolded project (intentionally
817-
* zero apps) and for a summary that simply never covers UI. Triage ruled
818-
* this narrowly for `UI:` — the newcomer-facing "is there a navigable
819-
* app" signal — not as a blanket rule for every section, so this is an
820-
* opt-in per section naming the one item to force-print at zero, not a
821-
* change to the drop behavior of `Data:`/`Logic:`/`Security:` (those
822-
* still omit the whole row when every item in them is zero — see #10952
823-
* for whether that should change too).
819+
* zero apps) and for a summary that simply never covers UI. That card
820+
* measured the drop only through `UI:` and triage ruled narrowly on that
821+
* row, so the mechanism landed opt-in and `Data:`/`Logic:`/`Security:`
822+
* kept dropping.
823+
*
824+
* #10952 measured the same drop on the other three rows, against the real
825+
* CLI (`bin/run-dev.js validate`, `NO_COLOR=1`): on a stack with one
826+
* object, two fields and nothing else the entire summary was
827+
*
828+
* Data: 1 Objects 2 Fields
829+
* UI: 0 Apps
830+
*
831+
* with no `Logic:` and no `Security:` line present at all; on a stack that
832+
* also declares no objects it was the single line `UI: 0 Apps`. Both
833+
* exited `0`. Triage generalised #10504's principle — a summary section is
834+
* NEVER silently dropped; every section prints its zero state — so this is
835+
* no longer opt-in. The field is REQUIRED and typed non-empty, and that
836+
* typing is the enforcement: a section added to this array later cannot
837+
* compile without naming what it prints at zero, so the dropped-row defect
838+
* cannot be reintroduced one section at a time.
839+
*
840+
* Most sections name the single item carrying that section's signal
841+
* (`Apps` for `UI:` — the shipped shape). `Security:` names both of its
842+
* items; the rationale sits at its entry below.
824843
*/
825-
zeroFallback?: string;
844+
zeroFallback: [string, ...string[]];
826845
}> = [
827846
{
828847
label: 'Data',
@@ -832,6 +851,9 @@ export function printMetadataStats(stats: MetadataStats) {
832851
['Extensions', stats.objectExtensions],
833852
['Datasources', stats.datasources],
834853
],
854+
// `Objects` is the section's signal: a stack with no objects has no data
855+
// model at all, which `validate` already warns about separately.
856+
zeroFallback: ['Objects'],
835857
},
836858
{
837859
label: 'UI',
@@ -843,7 +865,8 @@ export function printMetadataStats(stats: MetadataStats) {
843865
['Reports', stats.reports],
844866
['Actions', stats.actions],
845867
],
846-
zeroFallback: 'Apps',
868+
// The shipped shape (#10504): `UI: 0 Apps`. Unchanged.
869+
zeroFallback: ['Apps'],
847870
},
848871
{
849872
label: 'Logic',
@@ -853,23 +876,41 @@ export function printMetadataStats(stats: MetadataStats) {
853876
['Agents', stats.agents],
854877
['APIs', stats.apis],
855878
],
879+
// `Flows` is this section's signal the way `Apps` is `UI:`'s — the
880+
// primary automation primitive, and the one the boot banner's own
881+
// automation summary counts.
882+
zeroFallback: ['Flows'],
856883
},
857884
{
858885
label: 'Security',
859886
items: [
860887
['Positions', stats.positions],
861888
['Permissions', stats.permissions],
862889
],
890+
// BOTH peers, deliberately. `Security:` has no single canonical signal
891+
// the way `UI:` has `Apps`: `Positions` and `Permissions` are
892+
// independently authorable, so naming one would print a zero state that
893+
// silently omits the other — the very "reads as never asked" defect this
894+
// mechanism exists to remove. Printing both keeps the zero row's item set
895+
// identical to its non-zero rendering, built from the same
896+
// `<count> <Item>` fragments and the same two-space join as `UI: 0 Apps`,
897+
// so it is the shipped shape rather than a second formatting concept.
898+
zeroFallback: ['Positions', 'Permissions'],
863899
},
864900
];
865901

866902
for (const section of sections) {
867903
let shown = section.items.filter(([, v]) => v > 0);
868904
if (shown.length === 0) {
869-
if (!section.zeroFallback) continue;
870-
const fallback = section.items.find(([k]) => k === section.zeroFallback);
871-
if (!fallback) continue; // defensive — zeroFallback must name a real item
872-
shown = [fallback];
905+
// Never drop the row (#10504, #10952) — the row is what says "this
906+
// project has none of this"; its absence says nothing at all.
907+
shown = section.zeroFallback
908+
.map((key) => section.items.find(([itemKey]) => itemKey === key))
909+
.filter((item): item is [string, number] => item !== undefined);
910+
// Defensive only — zeroFallback must name real items. A bare `Security:`
911+
// with no counts would read worse than the drop, so this one path still
912+
// omits the row.
913+
if (shown.length === 0) continue;
873914
}
874915

875916
const line = shown.map(([k, v]) => `${chalk.white(v)} ${chalk.dim(k)}`).join(' ');

packages/cli/test/print-metadata-stats-zero-row.test.ts

Lines changed: 84 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,13 @@ const ZERO_APPS_STATS: MetadataStats = {
7171
/** The card's "one app" run: identical, plus one app. */
7272
const ONE_APP_STATS: MetadataStats = { ...ZERO_APPS_STATS, apps: 1 };
7373

74+
/**
75+
* #10952's harsher fixture: a stack that declares nothing at all, so EVERY
76+
* section is empty — including `Data:`, which the `blank` scaffold's one object
77+
* keeps populated. Before the fix this rendered the single line `UI: 0 Apps`.
78+
*/
79+
const ALL_ZERO_STATS: MetadataStats = { ...ZERO_APPS_STATS, objects: 0, fields: 0 };
80+
7481
describe('[#10504] printMetadataStats renders the UI: row at zero apps', () => {
7582
it('prints "UI: 0 Apps" — not an absent row — when apps=0', () => {
7683
const out = render(ZERO_APPS_STATS);
@@ -91,15 +98,83 @@ describe('[#10504] printMetadataStats renders the UI: row at zero apps', () => {
9198
expect(out).toContain('Data: 1 Objects 2 Fields');
9299
});
93100

94-
it('does not widen the fix to Logic:/Security: — those sections still drop the whole row at zero (tracked separately in #10952)', () => {
95-
// Every Logic/Security item is 0 in ZERO_APPS_STATS. This asserts today's
96-
// (still-general, unresolved) drop behavior for the sections #10504's
97-
// triage ruling did NOT name, so an unrelated future change that widens
98-
// zeroFallback to them fails HERE first, loudly, rather than silently
99-
// drifting past this card's narrow scope. If #10952 lands the wider fix,
100-
// this assertion is the deliberate one to update — name it in that PR.
101+
// #10504's fourth test asserted the NARROW scope it shipped with: that
102+
// `Logic:`/`Security:` still dropped their whole row at zero. That card named
103+
// this assertion as "the deliberate one to update" if #10952 landed the wider
104+
// fix. #10952 landed it — triage generalised the principle (a summary section
105+
// is never silently dropped; every section prints its zero state) — so the
106+
// assertion is replaced, deliberately and by name, with the per-section pins
107+
// in the next describe block.
108+
});
109+
110+
/**
111+
* #10952 — the same drop, measured on the rows #10504 did not rule on.
112+
*
113+
* Reproduced at this branch's base against the real CLI (`bin/run-dev.js
114+
* validate`, `NO_COLOR=1`) on two fixture stacks. One object, two fields and
115+
* nothing else printed exactly:
116+
*
117+
* Data: 1 Objects 2 Fields
118+
* UI: 0 Apps
119+
*
120+
* No `Logic:` line, no `Security:` line — absent, not `0`. A stack declaring no
121+
* objects either printed the single line `UI: 0 Apps`, losing `Data:` too. Both
122+
* exited `0`, which is why the hole stayed invisible: nothing failed, the rows
123+
* just were not there, and "none of it" is indistinguishable from "not
124+
* reported on".
125+
*
126+
* Triage (issue comment 5380549313) generalised #10504's ruling: a summary
127+
* section is NEVER silently dropped; every section prints its zero state. The
128+
* constraint it set is consistency with the shipped `UI: 0 Apps` shape, not a
129+
* specific string.
130+
*
131+
* One pin PER SECTION, deliberately: each asserts only its own row, so deleting
132+
* one section's zero rendering fails that section's pin and no other. A single
133+
* aggregate assertion would go red for all four and could not tell you which
134+
* row regressed.
135+
*/
136+
describe('[#10952] printMetadataStats prints every section\'s zero state — no row is silently dropped', () => {
137+
it('Data: prints "Data: 0 Objects" when every Data item is 0', () => {
138+
const out = render(ALL_ZERO_STATS);
139+
// Before the fix `ALL_ZERO_STATS` rendered no `Data:` line whatsoever.
140+
expect(out).toContain('Data: 0 Objects');
141+
});
142+
143+
it('UI: still prints "UI: 0 Apps" — the shipped #10504 shape, unchanged at all-zero', () => {
144+
const out = render(ALL_ZERO_STATS);
145+
expect(out).toContain('UI: 0 Apps');
146+
});
147+
148+
it('Logic: prints "Logic: 0 Flows" when every Logic item is 0', () => {
149+
const out = render(ZERO_APPS_STATS);
150+
// `Flows` carries this section's signal the way `Apps` carries `UI:`'s.
151+
expect(out).toContain('Logic: 0 Flows');
152+
});
153+
154+
it('Security: prints BOTH peers — "Security: 0 Positions 0 Permissions"', () => {
101155
const out = render(ZERO_APPS_STATS);
102-
expect(out).not.toContain('Logic:');
103-
expect(out).not.toContain('Security:');
156+
// Two independently authorable peers and no canonical single signal, so
157+
// naming one would print a zero state that silently omits the other.
158+
// Printing both keeps the zero row's item set identical to its non-zero
159+
// rendering — same `<count> <Item>` fragments, same two-space join as the
160+
// shipped `UI: 0 Apps`.
161+
expect(out).toContain('Security: 0 Positions 0 Permissions');
162+
});
163+
164+
it('a non-zero item still suppresses its section\'s zero rendering — the fallback is the empty case only', () => {
165+
// One flow: `Logic:` must report the real count and NOT fall back.
166+
const out = render({ ...ZERO_APPS_STATS, flows: 1 });
167+
expect(out).toContain('Logic: 1 Flows');
168+
expect(out).not.toContain('Logic: 0 Flows');
169+
// The peer section is untouched by that — asserted as ROW PRESENCE, not as
170+
// its exact fragments, so this test stays sensitive to `Logic:` alone and
171+
// the `Security:` rendering is pinned in exactly one place above.
172+
expect(out).toContain('Security:');
173+
});
174+
175+
it('a partially-populated Security: reports only its non-zero peer, not the zero fallback', () => {
176+
const out = render({ ...ZERO_APPS_STATS, permissions: 3 });
177+
expect(out).toContain('Security: 3 Permissions');
178+
expect(out).not.toContain('0 Positions');
104179
});
105180
});

0 commit comments

Comments
 (0)