diff --git a/.changeset/22073-boot-warning-one-line-per-class.md b/.changeset/22073-boot-warning-one-line-per-class.md index b45306b5d99..d4451624563 100644 --- a/.changeset/22073-boot-warning-one-line-per-class.md +++ b/.changeset/22073-boot-warning-one-line-per-class.md @@ -8,6 +8,6 @@ The startup banner prints one line per warning class, each warning appears once, Clause-②: no - **One line per class.** Flows that declare a trigger but are not bound are grouped by trigger type and reason, with the flows listed: `⚠ 8 flows declare a 'schedule' trigger but are NOT bound — disabled by deployment policy — … (OS_AUTOMATION_SCHEDULED_WORK_ENABLED is unset or not truthy), so no time trigger arms …: flow_a, flow_b, …`. Before, the full reason (up to ~650 characters) printed once per flow. The banner now shows the reason's first sentence; `--log-level debug` still streams each flow's full reason. A real binding failure, or a missing trigger, keeps its own line, worded as before. -- **Printed once.** *Boot diagnostics* no longer repeats the automation plugin's per-flow `… is NOT bound` and shadowed-flow warnings, which the banner's `Flows:` list already shows. Its header counts them instead: `⚠ Boot diagnostics — 5 warnings logged during startup (8 more already listed above):`. Every other boot warning replays exactly as before. A boot that fails before the banner still replays all of them. +- **Printed once.** *Boot diagnostics* no longer repeats the automation plugin's per-flow `… is NOT bound` and shadowed-flow warnings, which the banner's `Flows:` list already shows. Its header counts them instead: `(8 more already listed above)`. Every other boot warning replays exactly as before. A boot that fails before the banner still replays all of them. - **OAuth on loopback.** `OAuth is served UNENCRYPTED: …` is logged at `info` when the issuer's host is loopback (`localhost`, `*.localhost`, `127.0.0.0/8`, `::1`), so it is not shown at the default `warn` level. It stays `warn` on a private or link-local issuer. The sentence and the transport rule are unchanged. - ⛔ Nothing you author changes. Which flows bind, the scheduled-work switch, the transport rule, the service-automation warning an embedded host reads, and every public key, export and parameter are unchanged. diff --git a/.changeset/22160-boot-diagnostics-noise-budget.md b/.changeset/22160-boot-diagnostics-noise-budget.md new file mode 100644 index 00000000000..4b53ab9f021 --- /dev/null +++ b/.changeset/22160-boot-diagnostics-noise-budget.md @@ -0,0 +1,19 @@ +--- +"@objectstack/cli": patch +--- + +*Boot diagnostics* puts the one record you have to act on first, with its fix, counts it apart from the informational ones, and no longer prints stack traces at the default log level. + +Clause-②: no + +- **The record that needs you comes first, with its fix.** A script action declared with no handler (a button wired to nothing) prints first in *Boot diagnostics*, highlighted, naming each action, with its fix on the next line. A blank project with one such action shows: + + ```text + ⚠ Boot diagnostics — 1 needs your attention · 2 informational: + ⚠ [action-governance] declared script actions with NO handler — a button wired to nothing (ADR-0078): support_desk_ticket:resolve_ticket + fix: add a `body`, or register a handler under the declared `target` + ``` + + Every other record prints once, dim, as before, and the header counts it as informational. When nothing needs your attention the header reads `ℹ Boot diagnostics — 4 informational:`. This record class is the only one highlighted so far. +- **No stack traces at the default level.** A record that carries an error's stack trace prints with its message and without the trace. The block's last line says how many traces were withheld. At `--log-level debug` the boot streams live, and the same record prints there with its stack. +- ⛔ Nothing you author changes, and no warning's log level changes. The package that logs a warning sets its level. diff --git a/packages/cli/src/utils/format.boot-warning-classes.test.ts b/packages/cli/src/utils/format.boot-warning-classes.test.ts index d283c558423..112f5b78c3e 100644 --- a/packages/cli/src/utils/format.boot-warning-classes.test.ts +++ b/packages/cli/src/utils/format.boot-warning-classes.test.ts @@ -1,7 +1,8 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { LiteKernel, type Plugin, type PluginContext } from '@objectstack/core'; +import { LiteKernel, ObjectLogger, type Plugin, type PluginContext } from '@objectstack/core'; +import { runActionGovernanceInventory } from '@objectstack/objectql'; import { AutomationServicePlugin } from '@objectstack/service-automation'; import { SCHEDULED_WORK_DISABLED_REASON, SCHEDULED_WORK_ENV } from '@objectstack/types'; import { @@ -10,7 +11,7 @@ import { type AutomationReadySummary, type ServerReadyOptions, } from './format.js'; -import { BootLogCapture } from './boot-log-capture.js'; +import { BootLogCapture, isVerboseBootLevel } from './boot-log-capture.js'; import { collectAutomationSummary } from '../commands/serve.js'; /** @@ -45,8 +46,28 @@ import { collectAutomationSummary } from '../commands/serve.js'; * `collectAutomationSummary`, and holds both pins against ONE transcript. The * formatter legs after it cover the shapes a real boot does not reach cheaply. * - * ⚠️ The two `@objectstack/*` package imports resolve to their built `dist` - * (both are already in `KNOWN_UNALIASED_TEST_IMPORTS['@objectstack/cli']`; + * ## #22160 — the dev-mode noise budget, the printer's half + * + * A blank project carrying the Build-with-Claude-Code tutorial's files as they + * stood on `main` 7d7943dd booted (`os dev --ui --fresh`) with three WARN + * records under *Boot diagnostics*, one of which needs the author: its + * `resolve_ticket` action was `type: 'script'` with a `target` nothing + * registers — a button wired to nothing. It looked exactly like the other + * two, and one of those carried a ten-frame knex stack. (The tutorial has + * since made Resolve a declarative update; a script action whose handler + * never registers is still what this record names.) + * The pins (the seat's scope cut on #22160): + * + * - a record carrying a stack prints WITHOUT it at the default level, and + * WITH it at `--log-level debug`, where the boot streams live; + * - the dead-button class prints once, highlighted, with its fix line, and + * the header counts it apart from the informational records; + * - #22073's class pins above stay green. + * The level each OTHER line is logged at belongs to its producer and is not + * pinned here; nor is any informational count a sibling lane can move. + * + * ⚠️ The `@objectstack/*` package imports resolve to their built `dist` (all + * are already in `KNOWN_UNALIASED_TEST_IMPORTS['@objectstack/cli']`; * `turbo.json` builds dependencies before `@objectstack/cli#test`). */ @@ -303,8 +324,10 @@ describe('print-once: Boot diagnostics withholds what the banner restated (forma // dropped. expect(linesWith('Branding asset not served')).toHaveLength(1); expect(linesWith('/runtime/assets/icon.svg')).toHaveLength(1); + // [#22160] Nothing here is in an actionable class, so the header counts + // one informational record under an `ℹ`; the withheld count is unchanged. expect(linesWith('Boot diagnostics')).toEqual([ - ' ⚠ Boot diagnostics — 1 warning logged during startup (3 more already listed above):', + ' ℹ Boot diagnostics — 1 informational (3 more already listed above):', ]); }); @@ -364,13 +387,173 @@ describe('print-once: Boot diagnostics withholds what the banner restated (forma expect(linesWith('is ARMED')).toHaveLength(1); // the banner's line expect(linesWith('Flow name collision')).toHaveLength(1); expect(linesWith('Boot diagnostics')).toEqual([ - ' ⚠ Boot diagnostics — 1 warning logged during startup (1 more already listed above):', + ' ℹ Boot diagnostics — 1 informational (1 more already listed above):', ]); }); it('with no banner (the failed-boot path) replays every record', () => { printBootDiagnostics({ lines: [auditRecord('a_flow'), unrelated] }); expect(linesWith('a_flow')).toHaveLength(1); - expect(linesWith('Boot diagnostics')).toEqual([' ⚠ Boot diagnostics — 2 warnings logged during startup:']); + expect(linesWith('Boot diagnostics')).toEqual([' ℹ Boot diagnostics — 2 informational:']); + }); +}); + +// --------------------------------------------------------------------------- +// #22160 — the dev-mode noise budget +// --------------------------------------------------------------------------- + +/** + * A ticket object as the inventory reads it, with a Resolve action that is + * `type: 'script'` with a `target` and no `body`, and no handler registered — + * the dead button the measured boot carried. + */ +const DEAD_BUTTON_TICKET = { + name: 'support_desk_ticket', + actions: [{ name: 'resolve_ticket', label: 'Resolve', type: 'script', target: 'resolveTicket' }], +}; +const DEAD_BUTTON = 'support_desk_ticket:resolve_ticket'; +const DEAD_BUTTON_CLASS = 'declared script actions with NO handler'; + +/** Route stdout into `capture` for the length of `fn`, exactly as `serve`'s boot-quiet window does. */ +async function underQuietWindow(capture: BootLogCapture, fn: () => Promise | unknown): Promise { + const outSpy = vi.spyOn(process.stdout, 'write').mockImplementation(((chunk: unknown, encoding?: unknown) => { + capture.write(chunk as string | Uint8Array, typeof encoding === 'string' ? encoding : undefined); + return true; + }) as never); + try { + await fn(); + } finally { + outSpy.mockRestore(); + } +} + +/** + * A failed insert, logged the way the engine's write doors log one: `warn` + * with `{ object, error: { message, stack } }` (`writeFailureLogMeta` in + * `@objectstack/objectql`'s engine), from a REAL thrown error, so the stack + * is V8's own frames. + */ +function logFailedInsert(logger: ObjectLogger): void { + const err = new Error('UNIQUE constraint failed: sys_migration.id [statement and bound values redacted]'); + logger.warn('Insert operation failed', { object: 'sys_migration', error: { message: err.message, stack: err.stack } }); +} + +/** A V8 frame as it reads inside a JSON-escaped `stack`. */ +const ESCAPED_FRAME = /\\n\s+at /; + +describe('the noise budget over real producers (#22160)', () => { + /** + * One boot's worth of boot-phase records through the REAL `ObjectLogger` + * and `BootLogCapture`: an expected degradation, the REAL + * `@objectstack/objectql` action inventory over that ticket, and a + * failed insert carrying a stack. Returns the captured records and the + * inventory's own message, read off the producer as it logged it. + */ + async function boot(format: 'pretty' | 'text' | 'json'): Promise<{ captured: string[]; inventoryMessage: string }> { + const capture = new BootLogCapture(); + const logger = new ObjectLogger({ level: 'warn', format }); + const messages: string[] = []; + const inventoryLogger = { + warn: (message: string, meta?: Record) => { + messages.push(message); + logger.warn(message, meta); + }, + }; + await underQuietWindow(capture, async () => { + logger.warn('[Analytics] No admitObjectRead configured at init — the bridge resolves per query'); + await runActionGovernanceInventory({ registered: [], objects: [DEAD_BUTTON_TICKET], logger: inventoryLogger }); + logFailedInsert(logger); + }); + const captured = capture.diagnostics(); + printBootDiagnostics({ lines: captured, dropped: capture.droppedCount }); + expect(messages, 'the real inventory logged nothing for the ticket').toHaveLength(1); + return { captured, inventoryMessage: messages[0] }; + } + + it.each(['pretty', 'text', 'json'] as const)( + '%s: the dead button prints once, first, highlighted, with the producer\'s own fix, counted apart', + async (format) => { + const { captured, inventoryMessage } = await boot(format); + + // The premise: the producer's record reached the capture, naming the action. + expect(captured.filter((record) => record.includes(DEAD_BUTTON)), captured.join('\n')).toHaveLength(1); + + const highlighted = linesWith(DEAD_BUTTON_CLASS); + expect(highlighted, transcript.join('\n')).toHaveLength(1); + expect(linesWith(DEAD_BUTTON)).toEqual(highlighted); + // Highlighted: its own glyph line, the statement with its subject. + expect(highlighted[0]).toMatch(/^ {4}⚠ \[action-governance\] declared script actions with NO handler/); + expect(highlighted[0].endsWith(`: ${DEAD_BUTTON}`)).toBe(true); + // The fix is the producer's own sentence after its `; `, on the next line. + const at = transcript.indexOf(highlighted[0]); + expect(transcript[at + 1]).toBe(` fix: ${inventoryMessage.slice(inventoryMessage.indexOf('; ') + 2)}`); + // First: above every informational record. + expect(at).toBeLessThan(transcript.findIndex((line) => line.includes('[Analytics]'))); + expect(at).toBeLessThan(transcript.findIndex((line) => line.includes('Insert operation failed'))); + // Counted apart. + const header = linesWith('Boot diagnostics'); + expect(header).toHaveLength(1); + expect(header[0]).toContain('1 needs your attention · 2 informational'); + expect(header[0]).toMatch(/^ {2}⚠ /); + }, + ); + + it('withholds a record\'s stack at the default level, keeps its message, and says where the stack is', async () => { + const { captured } = await boot('pretty'); + + // The premise: the captured record really carries the stack, JSON-escaped. + const failed = captured.filter((record) => record.includes('Insert operation failed')); + expect(failed).toHaveLength(1); + expect(failed[0]).toContain('"stack":"Error: UNIQUE constraint failed'); + expect(failed[0]).toMatch(ESCAPED_FRAME); + + const printed = linesWith('Insert operation failed'); + expect(printed).toHaveLength(1); + expect(printed[0]).toContain('"object":"sys_migration"'); + expect(printed[0]).toContain('"error":{"message":"UNIQUE constraint failed: sys_migration.id'); + expect(printed[0]).not.toContain('"stack"'); + expect(transcript.join('\n')).not.toMatch(ESCAPED_FRAME); + + const hint = linesWith('stack trace'); + expect(hint).toHaveLength(1); + expect(hint[0]).toContain('1 stack trace'); + expect(hint[0]).toContain('--log-level debug'); + }); + + it('at --log-level debug the same record reaches the terminal live, stack included', () => { + // `serve` never opens the quiet window at a verbose level, so nothing is + // captured or replayed: the record goes out exactly as the logger renders it. + expect(isVerboseBootLevel('debug')).toBe(true); + const written: string[] = []; + const outSpy = vi.spyOn(process.stdout, 'write').mockImplementation(((chunk: unknown) => { + written.push(String(chunk)); + return true; + }) as never); + try { + logFailedInsert(new ObjectLogger({ level: 'debug' })); + } finally { + outSpy.mockRestore(); + } + const live = written.join('').split('\n').filter((line) => line.includes('Insert operation failed')); + expect(live).toHaveLength(1); + expect(live[0]).toContain('"stack":"Error: UNIQUE constraint failed'); + expect(live[0]).toMatch(ESCAPED_FRAME); + }); +}); + +describe('the noise budget (formatter, #22160)', () => { + it('withholds only stack TRACES — a `stack` key that names something prints as it was', () => { + const named = record('Stack loaded {"stack":"support-desk","objects":2}'); + printBootDiagnostics({ lines: [named] }); + expect(linesWith('Stack loaded')).toEqual([` ${named}`]); + expect(linesWith('stack trace')).toEqual([]); + }); + + it('keeps the alarm when the capture dropped records — unread records are not called informational', () => { + printBootDiagnostics({ lines: [record('Service not provided — using in-memory fallback')], dropped: 3 }); + const header = linesWith('Boot diagnostics'); + expect(header).toHaveLength(1); + expect(header[0]).toMatch(/^ {2}⚠ /); + expect(header[0]).not.toContain('needs your attention'); }); }); diff --git a/packages/cli/src/utils/format.ts b/packages/cli/src/utils/format.ts index d47b791348d..9dc1b9d954c 100644 --- a/packages/cli/src/utils/format.ts +++ b/packages/cli/src/utils/format.ts @@ -1277,6 +1277,159 @@ export interface BootDiagnosticsReplayOptions { restatedAbove?: readonly string[]; } +/** + * One class of boot diagnostic that needs the AUTHOR's hand (#22160) — a + * record whose producer names a fix the author applies in their own project. + */ +interface ActionableBootClass { + /** + * The identifying text of the producer's record, matched the way + * {@link BootDiagnosticsReplayOptions.restatedAbove} matches: a substring of + * the record, ANSI stripped. + */ + record: string; + /** The key the producer lists the record's subjects under, in its structured tail. */ + subjectsKey: string; +} + +/** + * The CLOSED set of boot diagnostics *Boot diagnostics* highlights as needing + * the author's attention (#22160), each printed once with its one-line fix. + * Every other record is informational: printed once, dim, as before. + * + * Closed on purpose, the same way {@link printAutomationSummary}'s class + * table is: a free-text guess at which warning "sounds actionable" would + * promote whatever a producer happened to phrase as advice, and a new logger + * metadata key every producer adopts is a cross-package contract this printer + * does not own. A class joins by a row here, with a pin that boots its REAL + * producer (`format.boot-warning-classes.test.ts`). + * + * The fix is DERIVED, never hand-copied, as `leadSentence` derives a flow + * reason's short form: each producer listed here writes its record as + * `STATEMENT; FIX`, so the banner prints the statement with its subjects on + * one highlighted line and the producer's own fix under it. A producer that + * stops writing a `;` still prints highlighted, whole, with no separate fix + * line — never dropped, never demoted to informational by a rewording that + * keeps the identifying text. + */ +const ACTIONABLE_BOOT_CLASSES: readonly ActionableBootClass[] = [ + { + // `@objectstack/objectql` `runActionGovernanceInventory` (ADR-0110 D5): a + // declared `script` action that no handler serves — a dead button. + record: '[action-governance] declared script actions with NO handler', + subjectsKey: 'actions', + }, +]; + +/** + * The structured tail `ObjectLogger` appends to a record — `JSON.stringify` of + * its context — located and parsed, or `undefined` when the record carries + * none. + * + * Every rendering ends with it: pretty `… message {"ctx":1}`, text + * `… | message | {"ctx":1}`, and a JSON-format record IS one object, so its + * tail starts at 0 and spans the whole record. Located as the LEFTMOST `{` at + * the start of the line or after a space from which the rest of the line + * parses as ONE JSON object. A brace inside the message cannot win that race + * while a tail follows it (`{…} more {…}` is not one object); a message that + * itself ends in a JSON object and carries no tail is read as that object, + * which is the record's structured content either way. + */ +function structuredTail(text: string): { start: number; value: Record } | undefined { + for (let i = text.indexOf('{'); i !== -1; i = text.indexOf('{', i + 1)) { + if (i > 0 && text[i - 1] !== ' ') continue; + try { + const value: unknown = JSON.parse(text.slice(i)); + if (value !== null && typeof value === 'object' && !Array.isArray(value)) { + return { start: i, value: value as Record }; + } + } catch { + // Not the tail — a brace inside the message. Keep looking. + } + } + return undefined; +} + +/** A V8 stack trace: a first line, then `at` frames on lines of their own. */ +const STACK_TRACE = /\n\s*at\s/; + +/** + * `value` with every `stack` property that holds a stack trace removed, at any + * depth, and how many were removed (#22160). + * + * `ObjectLogger` serializes a logged error as `error: { message, stack }`, and + * the engine's write doors rebuild the same pair for `warn` + * (`writeFailureLogMeta`), so a failed insert reaches the banner carrying ten + * knex frames. Only a `stack` whose value IS a trace goes: a `stack` key that + * names something (a stack id, a config name) stays, and `message` always + * stays. + */ +function withoutStackTraces(value: unknown): { value: unknown; removed: number } { + if (Array.isArray(value)) { + let removed = 0; + const items = value.map((item) => { + const r = withoutStackTraces(item); + removed += r.removed; + return r.value; + }); + return { value: items, removed }; + } + if (value !== null && typeof value === 'object') { + let removed = 0; + // Null prototype: a parsed `__proto__` key stays an own property. + const kept: Record = Object.create(null); + for (const [key, item] of Object.entries(value)) { + if (key === 'stack' && typeof item === 'string' && STACK_TRACE.test(item)) { + removed += 1; + continue; + } + const r = withoutStackTraces(item); + removed += r.removed; + kept[key] = r.value; + } + return { value: kept, removed }; + } + return { value, removed: 0 }; +} + +/** + * One captured record as the default banner prints it: its structured tail + * re-serialized without its stack traces (#22160). A record that carries none + * comes back byte-identical. + */ +function recordWithoutStacks(line: string): { line: string; removed: number } { + const tail = structuredTail(line); + if (!tail) return { line, removed: 0 }; + const stripped = withoutStackTraces(tail.value); + if (stripped.removed === 0) return { line, removed: 0 }; + return { line: line.slice(0, tail.start) + JSON.stringify(stripped.value), removed: stripped.removed }; +} + +/** + * The highlighted form of a record in {@link ACTIONABLE_BOOT_CLASSES}, or + * `undefined` when the record is in no class (#22160). + */ +function actionableRecord(line: string): { statement: string; fix?: string } | undefined { + const text = stripAnsi(line); + const cls = ACTIONABLE_BOOT_CLASSES.find((c) => text.includes(c.record)); + if (!cls) return undefined; + const tail = structuredTail(text); + const message = + tail && tail.start === 0 && typeof tail.value.msg === 'string' + ? tail.value.msg // a JSON-format record: the message is a field + : text.slice(text.indexOf(cls.record), tail ? tail.start : text.length).replace(/\s*\|?\s*$/, ''); + const raw = tail?.value[cls.subjectsKey]; + const subjects = Array.isArray(raw) ? raw.filter((s): s is string => typeof s === 'string') : []; + const at = message.indexOf('; '); + const head = at === -1 ? message : message.slice(0, at); + // Parsed out of JSON, these are unescaped again — and the subjects are names + // an author wrote — so they pass through the banner's control-byte scrub. + return { + statement: bannerSafe(subjects.length > 0 ? `${head}: ${subjects.join(', ')}` : head), + ...(at === -1 ? {} : { fix: bannerSafe(message.slice(at + 2).trim()) }), + }; +} + /** * Replay what the boot-quiet stdout window held back (#4012). * @@ -1294,6 +1447,24 @@ export interface BootDiagnosticsReplayOptions { * section already restated (`options.restatedAbove`), and its header counts * them separately, so no warning appears in both places. * + * [#22160] The dev-mode noise budget, the printer's half of it: + * - a record in {@link ACTIONABLE_BOOT_CLASSES} prints FIRST, once, + * highlighted, with its one-line fix under it — the line the author must + * act on no longer looks like the ones they may ignore; + * - the header counts the two apart: `1 needs your attention · 3 + * informational`. With nothing actionable it says only how many are + * informational, under an `ℹ`, not an alarm — unless the capture buffer + * dropped records, which this printer has not read and so cannot call + * informational; + * - a record's stack trace is withheld and counted. It is not lost: at + * `--log-level debug` (and `info`) `serve` never opens the quiet window + * (`isVerboseBootLevel`), so the same record streams live as the logger + * rendered it, stack included — which is where the hint line sends the + * reader. + * The LEVEL each producer logs at is the producer's own; this printer + * relabels nothing it captured, and an informational record still prints with + * the level its producer gave it. + * * Replayed to **stderr** (#7915) — these are the kernel's own diagnostics, held * back and re-emitted, so they land where every other `serve` diagnostic does. */ @@ -1309,18 +1480,45 @@ export function printBootDiagnostics(diagnostics: BootDiagnostics, options: Boot const listedAbove = lines.length - shown.length; if (shown.length === 0 && dropped === 0) return; + const actionable: Array<{ statement: string; fix?: string }> = []; + const informational: string[] = []; + let stacksWithheld = 0; + for (const line of shown) { + const highlighted = actionableRecord(line); + if (highlighted) { + actionable.push(highlighted); + continue; + } + const printed = recordWithoutStacks(line); + stacksWithheld += printed.removed; + informational.push(printed.line); + } + + const needs = actionable.length; + const counts = [ + ...(needs > 0 ? [`${needs} need${needs === 1 ? 's' : ''} your attention`] : []), + ...(informational.length > 0 || needs === 0 ? [`${informational.length} informational`] : []), + ].join(' · '); + const header = + `Boot diagnostics — ${counts}` + `${listedAbove > 0 ? ` (${listedAbove} more already listed above)` : ''}:`; console.error(''); - console.error( - chalk.yellow( - ` ⚠ Boot diagnostics — ${shown.length} warning${shown.length === 1 ? '' : 's'} logged during startup` + - `${listedAbove > 0 ? ` (${listedAbove} more already listed above)` : ''}:`, - ), - ); - for (const line of shown) console.error(chalk.dim(` ${line}`)); + console.error(needs > 0 || dropped > 0 ? chalk.yellow(` ⚠ ${header}`) : chalk.blue(` ℹ ${header}`)); + for (const record of actionable) { + console.error(chalk.bold.yellow(` ⚠ ${record.statement}`)); + if (record.fix) console.error(chalk.cyan(` fix: ${record.fix}`)); + } + for (const line of informational) console.error(chalk.dim(` ${line}`)); if (dropped > 0) { console.error(chalk.dim(` …and ${dropped} more (capture buffer full)`)); } - console.error(chalk.dim(' run with --log-level debug to watch the boot stream live')); + console.error( + chalk.dim( + stacksWithheld > 0 + ? ` ${stacksWithheld} stack trace${stacksWithheld === 1 ? '' : 's'} withheld — ` + + 'run with --log-level debug to watch the boot stream live, stacks included' + : ' run with --log-level debug to watch the boot stream live', + ), + ); } /** diff --git a/packages/cli/test/serve-boot-diagnostics-noise-budget.e2e.test.ts b/packages/cli/test/serve-boot-diagnostics-noise-budget.e2e.test.ts new file mode 100644 index 00000000000..623cd29386f --- /dev/null +++ b/packages/cli/test/serve-boot-diagnostics-noise-budget.e2e.test.ts @@ -0,0 +1,193 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #22160 — the dev-mode noise budget, over a REAL boot of the blank starter. + * + * A project scaffolded by the on-ramp (`npm create objectstack`) and given the + * Build-with-Claude-Code tutorial's ticket object and two actions boots with + * exactly ONE record that needs the author. The tutorial's Resolve action is + * a declarative update and needs no handler. The second action, Escalate, is + * `type: 'script'` with a `target` that nothing registers, so it is a button + * wired to nothing. (Until the tutorial moved Resolve to a declarative update, + * Resolve itself had that shape. Measured on `main` 7d7943dd with + * `os dev --ui --fresh`: three WARN records under *Boot diagnostics*, the dead + * button indistinguishable from the other two.) + * + * What this file pins is the printer's half only, over the whole command: + * Escalate's record is the ONE line highlighted as needing attention, with its + * fix line under it. Resolve is named on no line, and no stack trace reaches + * the banner. It deliberately pins NO informational count — the level every + * other boot record is logged at belongs to that record's producer, and those + * owners are changing them. + * The per-shape legs (each log format, the stack withholding, the debug-level + * stream) are `src/utils/format.boot-warning-classes.test.ts`. + * + * Nightly (`.e2e`): one on-ramp scaffold and one `os serve` cold start. The + * project lives under this package's `node_modules`, so the starter's imports + * resolve to workspace copies without an install, as + * `starter-field-consumers.e2e.test.ts` runs the same on-ramp. + */ + +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { execFile } from 'node:child_process'; +import { appendFileSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { childEnv, randomPort, runServe, type ServeRun } from './helpers/serve-process.js'; + +const HERE = resolve(fileURLToPath(import.meta.url), '..'); +// One `resolve(HERE, …)` call per line: `check:cross-package-test-inputs` +// reconstructs this read by SOURCE SCAN. +const ON_RAMP_BIN = resolve(HERE, '../../..', 'packages/create-objectstack/bin/create-objectstack.js'); + +const PROJECT = 'support-desk'; +/** The handler-less script action: the one record that needs the author. */ +const DEAD_BUTTON = 'support_desk_ticket:escalate_ticket'; +/** The tutorial's declarative Resolve: no handler needed, so never highlighted. */ +const DECLARATIVE = 'resolve_ticket'; + +/** The tutorial's `src/objects/ticket.object.ts`. */ +const TICKET_OBJECT = `import { ObjectSchema, Field } from '@objectstack/spec/data'; + +export const Ticket = ObjectSchema.create({ + name: 'support_desk_ticket', + label: 'Ticket', + pluralLabel: 'Tickets', + icon: 'life-buoy', + description: 'A customer support request.', + fields: { + subject: Field.text({ label: 'Subject', required: true, searchable: true, maxLength: 200 }), + description: Field.textarea({ label: 'Description' }), + priority: Field.select({ + label: 'Priority', + required: true, + options: [ + { label: 'Low', value: 'low', default: true }, + { label: 'Normal', value: 'normal' }, + { label: 'High', value: 'high' }, + { label: 'Urgent', value: 'urgent' }, + ], + }), + status: Field.select({ + label: 'Status', + required: true, + options: [ + { label: 'Open', value: 'open', color: '#3B82F6', default: true }, + { label: 'Pending', value: 'pending', color: '#F59E0B' }, + { label: 'Resolved', value: 'resolved', color: '#10B981' }, + { label: 'Closed', value: 'closed', color: '#6B7280' }, + ], + }), + }, + sharingModel: 'private', + enable: { apiEnabled: true, searchable: true }, +}); +`; + +/** + * `src/actions/ticket.actions.ts`: the tutorial's Resolve, a declarative + * single-record update that needs no handler, beside Escalate, a script action + * whose `target` nothing registers, with no `body`. + */ +const TICKET_ACTIONS = `import { defineAction } from '@objectstack/spec/ui'; + +export const ResolveTicketAction = defineAction({ + name: 'resolve_ticket', + label: 'Resolve', + objectName: 'support_desk_ticket', + icon: 'check-circle', + operation: 'update', + patch: { status: 'resolved' }, + locations: ['record_header', 'list_item'], + visible: 'has(record.status) && record.status != "resolved" && record.status != "closed"', + successMessage: 'Ticket resolved.', + refreshAfter: true, +}); + +export const EscalateTicketAction = defineAction({ + name: 'escalate_ticket', + label: 'Escalate', + objectName: 'support_desk_ticket', + icon: 'arrow-up', + type: 'script', + target: 'escalateTicket', + locations: ['record_header'], +}); +`; + +/** One scaffold, one cold start. */ +const RUN_TIMEOUT_MS = 300_000; + +let root: string; +let scaffold: { code: number; output: string }; +let boot: ServeRun | undefined; + +function scaffoldProject(cwd: string): Promise<{ code: number; output: string }> { + return new Promise((resolveRun) => { + execFile( + process.execPath, + [ON_RAMP_BIN, PROJECT, '--skip-install', '--skip-skills'], + { cwd, maxBuffer: 16 * 1024 * 1024, env: childEnv({ NO_COLOR: '1' }) }, + (err, stdout, stderr) => { + const code = err ? (typeof (err as { code?: unknown }).code === 'number' ? (err as unknown as { code: number }).code : 1) : 0; + resolveRun({ code, output: String(stdout) + String(stderr) }); + }, + ); + }); +} + +beforeAll(async () => { + root = mkdtempSync(join(HERE, '..', 'node_modules', '.boot-noise-budget-e2e-')); + scaffold = await scaffoldProject(root); + if (scaffold.code !== 0) return; + const dir = join(root, PROJECT); + writeFileSync(join(dir, 'src', 'objects', 'ticket.object.ts'), TICKET_OBJECT); + appendFileSync(join(dir, 'src', 'objects', 'index.ts'), "export { Ticket } from './ticket.object.js';\n"); + writeFileSync(join(dir, 'src', 'actions', 'ticket.actions.ts'), TICKET_ACTIONS); + appendFileSync( + join(dir, 'src', 'actions', 'index.ts'), + "export { ResolveTicketAction, EscalateTicketAction } from './ticket.actions.js';\n", + ); + boot = await runServe(dir, ['--port', randomPort()], { waitFor: /Press Ctrl\+C to stop/, timeoutMs: RUN_TIMEOUT_MS - 60_000 }); +}, RUN_TIMEOUT_MS); + +afterAll(() => { + if (root) rmSync(root, { recursive: true, force: true }); +}); + +/** The *Boot diagnostics* block, header to the line before `Press Ctrl+C`. */ +function bootDiagnosticsBlock(run: ServeRun): string[] { + const lines = (run.stdout + run.stderr).split('\n'); + const from = lines.findIndex((line) => line.includes('Boot diagnostics')); + if (from === -1) return []; + const to = lines.findIndex((line, i) => i > from && line.includes('Press Ctrl+C to stop')); + return lines.slice(from, to === -1 ? undefined : to); +} + +describe('[#22160] a project with one dead button boots with exactly one line that needs the author', () => { + it('scaffolds and boots', () => { + expect(scaffold.code, scaffold.output).toBe(0); + expect(boot, 'os serve never reached its banner').toBeDefined(); + }); + + it('highlights the dead button once, with its fix line, and counts it as the one that needs attention', () => { + const block = bootDiagnosticsBlock(boot!); + const all = boot!.stdout + boot!.stderr; + expect(block.length, all).toBeGreaterThan(0); + + const header = block[0]; + expect(header).toContain('1 needs your attention'); + + const named = block.filter((line) => line.includes(DEAD_BUTTON)); + expect(named, block.join('\n')).toHaveLength(1); + expect(named[0]).toMatch(/^ {4}⚠ \[action-governance\] declared script actions with NO handler/); + expect(block[block.indexOf(named[0]) + 1]).toMatch(/^ {6}fix: \S/); + expect(block.filter((line) => /^ {6}fix: /.test(line))).toHaveLength(1); + // The control in the same boot: the declarative action is named nowhere. + expect(block.filter((line) => line.includes(DECLARATIVE)), block.join('\n')).toEqual([]); + }); + + it('carries no stack trace into the banner', () => { + expect(bootDiagnosticsBlock(boot!).join('\n')).not.toMatch(/\\n\s+at /); + }); +});