Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/hungry-pandas-shake.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
---
'@objectstack/cli': patch
---

`os validate` no longer drops a summary section that happens to be empty — every section prints its zero state

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`.

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`).

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.
67 changes: 54 additions & 13 deletions packages/cli/src/utils/format.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -810,19 +810,38 @@ export function printMetadataStats(stats: MetadataStats) {
label: string;
items: Array<[string, number]>;
/**
* The item(s) to force-print when EVERY item in the section is `0`.
*
* #10504 — a section whose every item is `0` used to vanish from the
* summary entirely, and that reads as "this summary does not report on
* this section" rather than "this project has none of it" — exactly the
* same output for a newcomer's freshly scaffolded project (intentionally
* zero apps) and for a summary that simply never covers UI. Triage ruled
* this narrowly for `UI:` — the newcomer-facing "is there a navigable
* app" signal — not as a blanket rule for every section, so this is an
* opt-in per section naming the one item to force-print at zero, not a
* change to the drop behavior of `Data:`/`Logic:`/`Security:` (those
* still omit the whole row when every item in them is zero — see #10952
* for whether that should change too).
* zero apps) and for a summary that simply never covers UI. That card
* measured the drop only through `UI:` and triage ruled narrowly on that
* row, so the mechanism landed opt-in and `Data:`/`Logic:`/`Security:`
* kept dropping.
*
* #10952 measured the same drop on the other three rows, against the real
* CLI (`bin/run-dev.js validate`, `NO_COLOR=1`): on a stack with one
* object, two fields and nothing else the entire summary was
*
* Data: 1 Objects 2 Fields
* UI: 0 Apps
*
* with no `Logic:` and no `Security:` line present at all; on a stack that
* also declares no objects it was the single line `UI: 0 Apps`. Both
* exited `0`. Triage generalised #10504's principle — a summary section is
* NEVER silently dropped; every section prints its zero state — so this is
* no longer opt-in. The field is REQUIRED and typed non-empty, and that
* typing is the enforcement: a section added to this array later cannot
* compile without naming what it prints at zero, so the dropped-row defect
* cannot be reintroduced one section at a time.
*
* Most sections name the single item carrying that section's signal
* (`Apps` for `UI:` — the shipped shape). `Security:` names both of its
* items; the rationale sits at its entry below.
*/
zeroFallback?: string;
zeroFallback: [string, ...string[]];
}> = [
{
label: 'Data',
Expand All@@ -832,6 +851,9 @@ export function printMetadataStats(stats: MetadataStats) {
['Extensions', stats.objectExtensions],
['Datasources', stats.datasources],
],
// `Objects` is the section's signal: a stack with no objects has no data
// model at all, which `validate` already warns about separately.
zeroFallback: ['Objects'],
},
{
label: 'UI',
Expand All@@ -843,7 +865,8 @@ export function printMetadataStats(stats: MetadataStats) {
['Reports', stats.reports],
['Actions', stats.actions],
],
zeroFallback: 'Apps',
// The shipped shape (#10504): `UI: 0 Apps`. Unchanged.
zeroFallback: ['Apps'],
},
{
label: 'Logic',
Expand All@@ -853,23 +876,41 @@ export function printMetadataStats(stats: MetadataStats) {
['Agents', stats.agents],
['APIs', stats.apis],
],
// `Flows` is this section's signal the way `Apps` is `UI:`'s — the
// primary automation primitive, and the one the boot banner's own
// automation summary counts.
zeroFallback: ['Flows'],
},
{
label: 'Security',
items: [
['Positions', stats.positions],
['Permissions', stats.permissions],
],
// BOTH peers, deliberately. `Security:` has no single canonical signal
// the way `UI:` has `Apps`: `Positions` and `Permissions` are
// independently authorable, so naming one would print a zero state that
// silently omits the other — the very "reads as never asked" defect this
// mechanism exists to remove. Printing both keeps the zero row's item set
// identical to its non-zero rendering, built from the same
// `<count> <Item>` fragments and the same two-space join as `UI: 0 Apps`,
// so it is the shipped shape rather than a second formatting concept.
zeroFallback: ['Positions', 'Permissions'],
},
];

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

const line = shown.map(([k, v]) => `${chalk.white(v)} ${chalk.dim(k)}`).join(' ');
Expand Down
93 changes: 84 additions & 9 deletions packages/cli/test/print-metadata-stats-zero-row.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -71,6 +71,13 @@ const ZERO_APPS_STATS: MetadataStats = {
/** The card's "one app" run: identical, plus one app. */
const ONE_APP_STATS: MetadataStats = { ...ZERO_APPS_STATS, apps: 1 };

/**
* #10952's harsher fixture: a stack that declares nothing at all, so EVERY
* section is empty — including `Data:`, which the `blank` scaffold's one object
* keeps populated. Before the fix this rendered the single line `UI: 0 Apps`.
*/
const ALL_ZERO_STATS: MetadataStats = { ...ZERO_APPS_STATS, objects: 0, fields: 0 };

describe('[#10504] printMetadataStats renders the UI: row at zero apps', () => {
it('prints "UI: 0 Apps" — not an absent row — when apps=0', () => {
const out = render(ZERO_APPS_STATS);
Expand All@@ -91,15 +98,83 @@ describe('[#10504] printMetadataStats renders the UI: row at zero apps', () => {
expect(out).toContain('Data: 1 Objects 2 Fields');
});

it('does not widen the fix to Logic:/Security: — those sections still drop the whole row at zero (tracked separately in #10952)', () => {
// Every Logic/Security item is 0 in ZERO_APPS_STATS. This asserts today's
// (still-general, unresolved) drop behavior for the sections #10504's
// triage ruling did NOT name, so an unrelated future change that widens
// zeroFallback to them fails HERE first, loudly, rather than silently
// drifting past this card's narrow scope. If #10952 lands the wider fix,
// this assertion is the deliberate one to update — name it in that PR.
// #10504's fourth test asserted the NARROW scope it shipped with: that
// `Logic:`/`Security:` still dropped their whole row at zero. That card named
// this assertion as "the deliberate one to update" if #10952 landed the wider
// fix. #10952 landed it — triage generalised the principle (a summary section
// is never silently dropped; every section prints its zero state) — so the
// assertion is replaced, deliberately and by name, with the per-section pins
// in the next describe block.
});

/**
* #10952 — the same drop, measured on the rows #10504 did not rule on.
*
* Reproduced at this branch's base against the real CLI (`bin/run-dev.js
* validate`, `NO_COLOR=1`) on two fixture stacks. One object, two fields and
* nothing else printed exactly:
*
* Data: 1 Objects 2 Fields
* UI: 0 Apps
*
* No `Logic:` line, no `Security:` line — absent, not `0`. A stack declaring no
* objects either printed the single line `UI: 0 Apps`, losing `Data:` too. Both
* exited `0`, which is why the hole stayed invisible: nothing failed, the rows
* just were not there, and "none of it" is indistinguishable from "not
* reported on".
*
* Triage (issue comment 5380549313) generalised #10504's ruling: a summary
* section is NEVER silently dropped; every section prints its zero state. The
* constraint it set is consistency with the shipped `UI: 0 Apps` shape, not a
* specific string.
*
* One pin PER SECTION, deliberately: each asserts only its own row, so deleting
* one section's zero rendering fails that section's pin and no other. A single
* aggregate assertion would go red for all four and could not tell you which
* row regressed.
*/
describe('[#10952] printMetadataStats prints every section\'s zero state — no row is silently dropped', () => {
it('Data: prints "Data: 0 Objects" when every Data item is 0', () => {
const out = render(ALL_ZERO_STATS);
// Before the fix `ALL_ZERO_STATS` rendered no `Data:` line whatsoever.
expect(out).toContain('Data: 0 Objects');
});

it('UI: still prints "UI: 0 Apps" — the shipped #10504 shape, unchanged at all-zero', () => {
const out = render(ALL_ZERO_STATS);
expect(out).toContain('UI: 0 Apps');
});

it('Logic: prints "Logic: 0 Flows" when every Logic item is 0', () => {
const out = render(ZERO_APPS_STATS);
// `Flows` carries this section's signal the way `Apps` carries `UI:`'s.
expect(out).toContain('Logic: 0 Flows');
});

it('Security: prints BOTH peers — "Security: 0 Positions 0 Permissions"', () => {
const out = render(ZERO_APPS_STATS);
expect(out).not.toContain('Logic:');
expect(out).not.toContain('Security:');
// Two independently authorable peers and no canonical single signal, so
// naming one would print a zero state that silently omits the other.
// Printing both keeps the zero row's item set identical to its non-zero
// rendering — same `<count> <Item>` fragments, same two-space join as the
// shipped `UI: 0 Apps`.
expect(out).toContain('Security: 0 Positions 0 Permissions');
});

it('a non-zero item still suppresses its section\'s zero rendering — the fallback is the empty case only', () => {
// One flow: `Logic:` must report the real count and NOT fall back.
const out = render({ ...ZERO_APPS_STATS, flows: 1 });
expect(out).toContain('Logic: 1 Flows');
expect(out).not.toContain('Logic: 0 Flows');
// The peer section is untouched by that — asserted as ROW PRESENCE, not as
// its exact fragments, so this test stays sensitive to `Logic:` alone and
// the `Security:` rendering is pinned in exactly one place above.
expect(out).toContain('Security:');
});

it('a partially-populated Security: reports only its non-zero peer, not the zero fallback', () => {
const out = render({ ...ZERO_APPS_STATS, permissions: 3 });
expect(out).toContain('Security: 3 Permissions');
expect(out).not.toContain('0 Positions');
});
});
Loading