diff --git a/.changeset/hungry-pandas-shake.md b/.changeset/hungry-pandas-shake.md new file mode 100644 index 0000000000..60b05d8c30 --- /dev/null +++ b/.changeset/hungry-pandas-shake.md @@ -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. diff --git a/packages/cli/src/utils/format.ts b/packages/cli/src/utils/format.ts index 54dea2bdd7..32ab392f66 100644 --- a/packages/cli/src/utils/format.ts +++ b/packages/cli/src/utils/format.ts @@ -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', @@ -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', @@ -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', @@ -853,6 +876,10 @@ 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', @@ -860,16 +887,30 @@ export function printMetadataStats(stats: MetadataStats) { ['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 + // ` ` 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(' '); diff --git a/packages/cli/test/print-metadata-stats-zero-row.test.ts b/packages/cli/test/print-metadata-stats-zero-row.test.ts index f0455117c9..0b05e47e30 100644 --- a/packages/cli/test/print-metadata-stats-zero-row.test.ts +++ b/packages/cli/test/print-metadata-stats-zero-row.test.ts @@ -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); @@ -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 ` ` 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'); }); });