diff --git a/.changeset/validate-zero-apps-row.md b/.changeset/validate-zero-apps-row.md new file mode 100644 index 0000000000..2dd4667266 --- /dev/null +++ b/.changeset/validate-zero-apps-row.md @@ -0,0 +1,28 @@ +--- +"@objectstack/cli": patch +--- + +`os validate`'s summary now prints `UI: 0 Apps` instead of dropping the whole +`UI:` row when a stack declares zero apps (#10504). + +Measured on the `blank` scaffold (`create-objectstack my-app -t blank`, +published 17.1.0, reproduced unchanged at this branch's head): a project with +no navigable UI and a project whose summary simply does not report on UI at +all printed identically — the `UI:` row was *absent*, not printed as `0`, so +a newcomer whose Console comes up empty had no way to tell which of the two +they were looking at. Both cases exited `0`. + +`printMetadataStats` (`packages/cli/src/utils/format.ts`, shared by +`os validate`, `os info` and `os compile`) gains an opt-in `zeroFallback` per +summary section — the one item to force-print at `0` instead of dropping the +whole row when every item in that section is zero. It is set only on `UI` +(`Apps`), matching the triage ruling on #10504: the `blank`/`crud`/`full` +templates all ship zero apps deliberately, so a *warning* would fire on every +clean scaffold's first run. This is a legibility fix only — nothing about +what `validate` accepts, rejects, or exits with has changed, and `Data:`, +`Logic:`, `Security:` keep their existing drop-at-zero behavior (tracked +separately in #10952). + +The `--json` path already reported `"apps": 0` explicitly at zero — no change +needed there; a separate, unrelated `--json` warnings gap is tracked in +#10953. diff --git a/packages/cli/src/utils/format.ts b/packages/cli/src/utils/format.ts index a69f69c369..54dea2bdd7 100644 --- a/packages/cli/src/utils/format.ts +++ b/packages/cli/src/utils/format.ts @@ -806,7 +806,24 @@ function printSeedSummary(sources: SeedSourceSummary[]) { } export function printMetadataStats(stats: MetadataStats) { - const sections: Array<{ label: string; items: Array<[string, number]> }> = [ + const sections: Array<{ + label: string; + items: Array<[string, number]>; + /** + * #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). + */ + zeroFallback?: string; + }> = [ { label: 'Data', items: [ @@ -826,6 +843,7 @@ export function printMetadataStats(stats: MetadataStats) { ['Reports', stats.reports], ['Actions', stats.actions], ], + zeroFallback: 'Apps', }, { label: 'Logic', @@ -846,10 +864,15 @@ export function printMetadataStats(stats: MetadataStats) { ]; for (const section of sections) { - const nonZero = section.items.filter(([, v]) => v > 0); - if (nonZero.length === 0) continue; - - const line = nonZero.map(([k, v]) => `${chalk.white(v)} ${chalk.dim(k)}`).join(' '); + 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]; + } + + const line = shown.map(([k, v]) => `${chalk.white(v)} ${chalk.dim(k)}`).join(' '); console.log(` ${chalk.bold(section.label + ':')} ${line}`); } diff --git a/packages/cli/test/print-metadata-stats-zero-row.test.ts b/packages/cli/test/print-metadata-stats-zero-row.test.ts new file mode 100644 index 0000000000..f0455117c9 --- /dev/null +++ b/packages/cli/test/print-metadata-stats-zero-row.test.ts @@ -0,0 +1,105 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #10504 — `os validate` dropped the whole `UI:` row at zero apps, so a + * project with no navigable UI read identically to one whose summary simply + * does not report on UI at all. + * + * Measured on the `blank` scaffold (published 17.1.0) and reproduced at this + * branch's head against the real CLI (`bin/run-dev.js validate`): the only + * difference between a zero-apps run and a one-app run was one `*.app.ts` + * plus two config lines, and the `UI:` line was ABSENT (not printed as `0`) + * for the zero-apps run. Both exited `0`. + * + * Triage ruled the shape in issue comment 5366623624: print `UI: 0 Apps` + * rather than warn — the `blank`/`crud`/`full` templates all ship zero apps + * deliberately (`src/objects/` only, no `*.app.ts` has ever existed under + * `packages/create-objectstack/src/templates/**`), so a warning would fire on + * every clean scaffold's first run. + * + * This pins `printMetadataStats` directly (the function `os validate`, + * `os info` and `os compile` all share) rather than spawning the full CLI — + * faster, and it isolates the assertion to the rendering logic the fix + * actually touches, following the `formatZodErrors` unit-test pattern in + * `format-zod-union.test.ts`. + */ + +import { describe, expect, it } from 'vitest'; +import { printMetadataStats, type MetadataStats } from '../src/utils/format.js'; + +/** Drop SGR sequences so an assertion reads the words, not chalk's opinion. */ +const stripAnsi = (s: string) => s.replace(/\u001B\[[0-9;]*m/g, ''); + +/** Run `printMetadataStats` and return everything it printed, as one string. */ +function render(stats: MetadataStats): string { + const captured: string[] = []; + const original = console.log; + console.log = (...args: unknown[]) => { + captured.push(args.map(String).join(' ')); + }; + try { + printMetadataStats(stats); + } finally { + console.log = original; + } + return stripAnsi(captured.join('\n')); +} + +/** The card's own shape: one object, two fields, no apps — the `blank` scaffold. */ +const ZERO_APPS_STATS: MetadataStats = { + objects: 1, + objectExtensions: 0, + fields: 2, + views: 0, + pages: 0, + apps: 0, + dashboards: 0, + reports: 0, + actions: 0, + flows: 0, + workflows: 0, + agents: 0, + apis: 0, + positions: 0, + permissions: 0, + datasources: 0, + translations: 0, + plugins: 0, + devPlugins: 0, +}; + +/** The card's "one app" run: identical, plus one app. */ +const ONE_APP_STATS: MetadataStats = { ...ZERO_APPS_STATS, apps: 1 }; + +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); + // The regression: before the fix this line was NOT in the output at all, + // and `out` would contain no `UI:` line whatsoever. + expect(out).toContain('UI: 0 Apps'); + }); + + it('the 0→1 transition triage said should become legible: prints "UI: 1 Apps" for one app', () => { + const out = render(ONE_APP_STATS); + expect(out).toContain('UI: 1 Apps'); + // And NOT the zero-apps line — the two ends must read differently. + expect(out).not.toContain('UI: 0 Apps'); + }); + + it('control: Data: keeps rendering unaffected (its section carries no zeroFallback)', () => { + const out = render(ZERO_APPS_STATS); + 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. + const out = render(ZERO_APPS_STATS); + expect(out).not.toContain('Logic:'); + expect(out).not.toContain('Security:'); + }); +});