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
28 changes: 28 additions & 0 deletions .changeset/validate-zero-apps-row.md
Original file line numberDiff line numberDiff line change
@@ -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.
33 changes: 28 additions & 5 deletions packages/cli/src/utils/format.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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: [
Expand All@@ -826,6 +843,7 @@ export function printMetadataStats(stats: MetadataStats) {
['Reports', stats.reports],
['Actions', stats.actions],
],
zeroFallback: 'Apps',
},
{
label: 'Logic',
Expand All@@ -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}`);
}

Expand Down
105 changes: 105 additions & 0 deletions packages/cli/test/print-metadata-stats-zero-row.test.ts
Original file line numberDiff line numberDiff line change
@@ -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:');
});
});
Loading