Skip to content

Commit 11e6fc5

Browse files
feat(cli): Boot diagnostics noise budget, printer half: the line that needs the author first with its fix, stack traces only at debug (#22236)
Part of #22160 (the printer half; owners' levels routed) Clause-②: no The maintainer's ruling, quoted on the card: 「建议给 dev 模式定一个"噪音预算":预期中的降级记 info,堆栈只在 `--log-level debug` 出现,真正需要动手的那条单独高亮并给一行修法。」 This PR is the printer's half, per the seat's scope cut (claim `6053785493`). The level each other lane's line is logged at stays with its owner. The table below names each owner. ## What changes (`packages/cli/src/utils/format.ts`, `printBootDiagnostics` only) - **The record that needs the author prints first, once, highlighted, with its one-line fix.** A closed table, `ACTIONABLE_BOOT_CLASSES`, holds one row today: `@objectstack/objectql`'s `[action-governance] declared script actions with NO handler` (a dead button). It is matched the way #22097's `restatedAbove` matches. The highlighted line is the record's statement plus its subjects (the `actions` list from the record's structured tail). The fix is derived from the producer's own message: the text after its `; `, as `leadSentence` derives a flow reason. Nothing is hand-copied. A reworded producer that drops the `;` still prints highlighted and whole. It is never dropped. - **The header counts the two apart:** `⚠ Boot diagnostics — 1 needs your attention · 2 informational:`. With nothing actionable the header reads `ℹ Boot diagnostics — 4 informational (2 more already listed above):`, with no alarm glyph. The exception is a capture that dropped records: the printer has not read those, so the header keeps the `⚠`. - **Stack traces are withheld at the default level.** The record's structured tail is located (pretty, text and JSON renderings) and every `stack` property whose value is a V8 trace is dropped. A `stack` key that names something stays. The message stays. A record without a trace prints byte-identical. The last line counts what was withheld and points at `--log-level debug`. - **The debug half needs no code.** At `--log-level debug` (and `info`), `serve` never opens the boot-quiet window (`isVerboseBootLevel`). The same record streams live as `ObjectLogger` rendered it, stack included, and no replay block prints. Measured both before and after (below). `serve.ts` is untouched. - #22097's print-once and `restatedAbove` rules are unchanged. ## Measured (`origin/main` `7d7943dd`, built `packages/cli/dist`, `os dev --fresh`) Blank starter (from `packages/create-objectstack/src/templates/blank`, namespace `support_desk`) plus the tutorial's four files, extracted verbatim from `build-with-claude-code.mdx` as it stood at `7d7943dd`, `--ui`. At that commit Resolve was a script action with an unregistered `target`. #22204 has since made it a declarative update, which needs no handler (see the re-measure after the merge, below): ```text before: ⚠ Boot diagnostics — 3 warnings logged during startup: … WARN [Analytics] No admitObjectRead configured … (one line) … WARN [action-governance] declared script actions with NO handler — a button wired to nothing (ADR-0078); add a `body`, or register a handler under the declared `target` {"count":1,"actions":["support_desk_ticket:resolve_ticket"]} … WARN Insert operation failed {"object":"sys_migration","error":{"message":"UNIQUE constraint failed: sys_migration.id [statement and bound values redacted]","stack":"SqliteError: … (10 knex/driver/engine frames)"}} after: ⚠ 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` … WARN [Analytics] No admitObjectRead configured … (one line) … WARN Insert operation failed {"object":"sys_migration","error":{"message":"UNIQUE constraint failed: sys_migration.id [statement and bound values redacted]"}} 1 stack trace withheld — run with --log-level debug to watch the boot stream live, stacks included ``` `examples/app-showcase` (`dev --seed-admin --fresh`): before, `⚠ Boot diagnostics — 4 warnings logged during startup (2 more already listed above):` with the `sys_migration` stack. After, `ℹ Boot diagnostics — 4 informational (2 more already listed above):` with the stack withheld. The four records are `[Analytics]`, `[SettingsService]`, `sys_migration` and `[sharing-rule]`, none actionable. The banner's two `Flows:` class lines are unchanged. `--log-level debug` on the blank project, before and after: 0 replay blocks; the `sys_migration` record streams live 1 time, carrying `"stack":"SqliteError…` 1 time. On `main` the blank project shows 3 records, not the card's 4. The loopback OAuth line moved to `info` in #22097 and is gone at the default level. **Re-measured after merging `origin/main` (`7b926f76`, which carries #22204), at head `f8a80640`.** The blank starter plus the tutorial's current four files (Resolve is now `operation: 'update'` + `patch`) prints `ℹ Boot diagnostics — 2 informational:`, which is `[Analytics]` and the `sys_migration` insert with its stack withheld, plus the hint line. Nothing there needs the author any more. The two remaining lines are #22154's and #22099's to lower. ## Each non-actionable line and its owner (H5) | line (default level) | level | emitter | owner | card tracking its level | |---|---|---|---|---| | `[Analytics] No admitObjectRead configured and no "security" service registered at init …` | warn | `packages/services/service-analytics/src/plugin.ts:1450` | `domain:services` | #22154 (open) | | `Insert operation failed {"object":"sys_migration", …}` | warn | logged at `packages/objectql/src/engine.ts:14006`. The duplicate insert is plugin-auth's default-org owner bind recording id `adr-0093-default-org-owner-bind` twice (debug stream, blank project) | `domain:services` (plugin-auth) | #22099 (open) | | `[SettingsService] Pre-bind READ of namespace 'auth' …` | warn | `packages/services/service-settings/src/settings-service.ts:774` | `domain:services` | none known to this PR | | `[sharing-rule] active business-unit rule expands to NO recipients …` | warn | `packages/plugins/plugin-sharing/src/sharing-rule-service.ts:1524` | plugin-sharing | none known to this PR | | `N flow(s) declare a 'schedule' / 'time_relative' trigger but are NOT bound — disabled by deployment policy …` | banner class line (yellow `⚠`), restating `service-automation`'s `warn` | `printAutomationSummary` (this file) restating `packages/services/service-automation/src/plugin.ts:1533` | claim routes it to `service-automation`; see the open question in the report | none known to this PR | The dev did not search the tracker for the three "none known" rows. The role file reserves search for the filing seat. Dedupe words are in the report. ## Pins `packages/cli/src/utils/format.boot-warning-classes.test.ts` (unit tier; 11 existing + 7 new legs): - **Real producers, all three log formats** (`it.each` over pretty / text / json): a real `ObjectLogger` writes through the real `BootLogCapture`. The real `runActionGovernanceInventory` runs over a ticket whose Resolve is a script action with an unregistered `target`. The dead button prints once, first, highlighted, ending `: support_desk_ticket:resolve_ticket`. The next line is `fix: ` plus the inventory's own message after its `; `, read off the producer at call time. The header carries `1 needs your attention · 2 informational`. - **Stack withheld at the default level:** a failed-insert record built from a real thrown `Error` is captured with its escaped stack (premise asserted). It prints with `object` and `error.message` and no `"stack"`. No escaped frame is anywhere on screen. One hint line names the count and `--log-level debug`. - **Stack at `--log-level debug`:** `isVerboseBootLevel('debug')` holds. A debug-level `ObjectLogger` writes the same record to the terminal with its stack. - **Formatter:** a `stack` key that is not a trace prints unchanged. A capture with dropped records keeps the `⚠`. - **#22097's legs stay green.** Three of them asserted the header string verbatim, which is the text this ruling replaces. Those three expectations now read the new header. Their withheld counts are unchanged (`(3 more already listed above)`, `(1 more …)`), and every class and print-once assertion is untouched. `packages/cli/test/serve-boot-diagnostics-noise-budget.e2e.test.ts` (new; integration tier by behaviour, nightly by name). The blank starter is scaffolded through the real on-ramp `bin/`. Added to it: the tutorial's ticket object, its current declarative Resolve, and one `Escalate` script action whose `target` nothing registers. Real `os serve` then boots. Boot diagnostics highlights exactly Escalate's record, with one `fix:` line. Its header says `1 needs your attention`. Resolve, the control in the same boot, is named on no line, and no stack trace reaches the block. No informational count is pinned, because the owners above are moving those. ### Ablations Each ran on the committed fix through `scripts/ablation-replace.mjs` (WRAP mode). The unit legs ran at `77f6a48c`. The boot-pin leg ran at `77f6a48c` and again at `f8a80640`, red both times; `format.ts` is the same blob at both. Each anchor hit 1 time, the write was verified on disk by blob change, and each restore was proven `blob == HEAD` with an empty `git diff HEAD`. The test subjects import `format.ts` / `boot-log-capture.ts` from source, so no `dist` is involved. Expected direction: red. Observed: red every time. | leg | mutation | result | |---|---|---| | A | class row's record text no longer matches | 3 failed / 18 (the three format legs) | | B | `stack` traces kept | 1 failed / 18 (the withholding leg) | | C | fix never split off (`at = -1`) | 3 failed / 18 | | D | any string `stack` withheld, trace or not | 1 failed / 18 (the narrowness leg) | | E | dropped records no longer keep the alarm | 1 failed / 18 | | G | `isVerboseBootLevel` without `debug` | 1 failed / 18 (the debug leg) | | A, boot pin | class row's record text no longer matches | 1 failed / 3 (the highlight assertion) | Final restore proof: `format.ts` blob `9dc1b9d9` and `boot-log-capture.ts` blob `d2545d2e` equal `HEAD`'s, and `git diff HEAD` is empty. ## Tests and gates All readings at head `f8a80640`, after merging `origin/main` `7b926f76`; the merge had no conflicts and the CLI closure was rebuilt, 59/59. - `pnpm --filter @objectstack/cli typecheck`: exit 0. `tsc --noEmit` passed, then `check:test-typecheck: OK … 3 file(s) / 28 error(s) / 6 pinned signature(s)`, unchanged. Both test files are in a tsc program (`--listFilesOnly`): the unit pin under `tsconfig.json`, the e2e under `tsconfig.test.json`. - `pnpm --filter @objectstack/cli exec vitest run --project unit --maxWorkers=2`: `Test Files 263 passed (263)`, `Tests 3881 passed (3881)`. - The new e2e was run locally because the diff adds an integration-tier file: `OS_TEST_TIERS=nightly pnpm --filter @objectstack/cli exec vitest run --project integration --maxWorkers=2 test/serve-boot-diagnostics-noise-budget.e2e.test.ts` gave `Tests 3 passed (3)`. The rest of the integration tier is declared to CI. - Gates: `node scripts/pm/dispatch-gates.mjs --commands` derives 65 for this change set (5 paths vs merge base `7b926f76`). Against the order's list, that adds `check:cli-test-child-env` and drops `check:dispatcher-error-vocabulary` and `check:route-envelope`, which no path here reaches. I ran the union of 68, full `pnpm lint` included. 67 exit 0. `node scripts/check-empty-changeset.mjs --base origin/main` exits 1: that is the release-note correction named under Changeset. `dispatch-gates --ran`: `65 derived, 65 run, 0 NOT-MEASURED, 0 UNRUN`. - Full `pnpm lint` (`eslint . --no-inline-config`): exit 0. - At `e1a46a46`, before the merge, `check:dual-build-cjs-loads` printed PREREQUISITE NOT MET because eight packages had no `dist`, so it measured nothing. After building them it was green (106 require entries across 66 packages load), and it is green at `f8a80640`. ## Changeset `.changeset/22160-boot-diagnostics-noise-budget.md`, `@objectstack/cli` `patch`. **A pending release note is corrected here, and that needs a person's confirmation.** One sentence in `.changeset/22073-boot-warning-one-line-per-class.md` (pending, not yet released) quoted the old header verbatim: `⚠ Boot diagnostics — 5 warnings logged during startup (8 more already listed above):`. This PR changes that header, so the sentence would publish false. It now quotes only the withheld count, `(8 more already listed above)`, which stays true; nothing else in that note moved. `node scripts/check-empty-changeset.mjs --base origin/main` is red on this PR by its own design. It refuses any edit to a changeset the PR did not add, and for a deliberate correction it asks the PR to name the note and get it confirmed. The alternative is to restore the note from base and let the superseded sentence publish. ## Acceptance notes - **The card is not closed by this PR.** Its pin 4 (the blank template boots with `0 warnings`; the showcase boots with no non-actionable line above `info`) depends on the owners in the table above. - **Boundary:** at `--log-level info` the quiet window does not open either, so a record's stack streams there too. Changing what `ObjectLogger` renders at `info` is `packages/core`'s, not this printer's. - **Candidate actionable classes, not added (the table is closed and grows by a row with a real-producer pin):** `[action-governance] registered handlers with NO declaration …` (its fix is hedged, not `;`-shaped), and the runtime-assets `Branding asset not served … To fix, …` (its fix is introduced by `To fix,`, not by `; `). Neither appeared on the measured boots. - `docs/qa/platform-checklist/areas/platform-core.json`'s showcase boot-health reading classifies the `Flows:` banner's `⚠` lines and needs no change. On a stock showcase the *Boot diagnostics* header is now `ℹ`. --- _Generated by [Claude Code](https://claude.ai/code/session_01RWZbGvPFcRKvUqASZtunCU)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 036bf1d commit 11e6fc5

5 files changed

Lines changed: 609 additions & 16 deletions

File tree

‎.changeset/22073-boot-warning-one-line-per-class.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,6 @@ The startup banner prints one line per warning class, each warning appears once,
88
Clause-②: no
99

1010
- **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.
11-
- **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.
11+
- **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.
1212
- **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.
1313
- ⛔ 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.
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
"@objectstack/cli": patch
3+
---
4+
5+
*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.
6+
7+
Clause-②: no
8+
9+
- **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:
10+
11+
```text
12+
⚠ Boot diagnostics — 1 needs your attention · 2 informational:
13+
⚠ [action-governance] declared script actions with NO handler — a button wired to nothing (ADR-0078): support_desk_ticket:resolve_ticket
14+
fix: add a `body`, or register a handler under the declared `target`
15+
```
16+
17+
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.
18+
- **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.
19+
- ⛔ Nothing you author changes, and no warning's log level changes. The package that logs a warning sets its level.

‎packages/cli/src/utils/format.boot-warning-classes.test.ts‎

Lines changed: 190 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
22

33
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
4-
import { LiteKernel, type Plugin, type PluginContext } from '@objectstack/core';
4+
import { LiteKernel, ObjectLogger, type Plugin, type PluginContext } from '@objectstack/core';
5+
import { runActionGovernanceInventory } from '@objectstack/objectql';
56
import { AutomationServicePlugin } from '@objectstack/service-automation';
67
import { SCHEDULED_WORK_DISABLED_REASON, SCHEDULED_WORK_ENV } from '@objectstack/types';
78
import {
@@ -10,7 +11,7 @@ import {
1011
type AutomationReadySummary,
1112
type ServerReadyOptions,
1213
} from './format.js';
13-
import { BootLogCapture } from './boot-log-capture.js';
14+
import { BootLogCapture, isVerboseBootLevel } from './boot-log-capture.js';
1415
import { collectAutomationSummary } from '../commands/serve.js';
1516

1617
/**
@@ -45,8 +46,28 @@ import { collectAutomationSummary } from '../commands/serve.js';
4546
* `collectAutomationSummary`, and holds both pins against ONE transcript. The
4647
* formatter legs after it cover the shapes a real boot does not reach cheaply.
4748
*
48-
* ⚠️ The two `@objectstack/*` package imports resolve to their built `dist`
49-
* (both are already in `KNOWN_UNALIASED_TEST_IMPORTS['@objectstack/cli']`;
49+
* ## #22160 — the dev-mode noise budget, the printer's half
50+
*
51+
* A blank project carrying the Build-with-Claude-Code tutorial's files as they
52+
* stood on `main` 7d7943dd booted (`os dev --ui --fresh`) with three WARN
53+
* records under *Boot diagnostics*, one of which needs the author: its
54+
* `resolve_ticket` action was `type: 'script'` with a `target` nothing
55+
* registers — a button wired to nothing. It looked exactly like the other
56+
* two, and one of those carried a ten-frame knex stack. (The tutorial has
57+
* since made Resolve a declarative update; a script action whose handler
58+
* never registers is still what this record names.)
59+
* The pins (the seat's scope cut on #22160):
60+
*
61+
* - a record carrying a stack prints WITHOUT it at the default level, and
62+
* WITH it at `--log-level debug`, where the boot streams live;
63+
* - the dead-button class prints once, highlighted, with its fix line, and
64+
* the header counts it apart from the informational records;
65+
* - #22073's class pins above stay green.
66+
* The level each OTHER line is logged at belongs to its producer and is not
67+
* pinned here; nor is any informational count a sibling lane can move.
68+
*
69+
* ⚠️ The `@objectstack/*` package imports resolve to their built `dist` (all
70+
* are already in `KNOWN_UNALIASED_TEST_IMPORTS['@objectstack/cli']`;
5071
* `turbo.json` builds dependencies before `@objectstack/cli#test`).
5172
*/
5273

@@ -303,8 +324,10 @@ describe('print-once: Boot diagnostics withholds what the banner restated (forma
303324
// dropped.
304325
expect(linesWith('Branding asset not served')).toHaveLength(1);
305326
expect(linesWith('/runtime/assets/icon.svg')).toHaveLength(1);
327+
// [#22160] Nothing here is in an actionable class, so the header counts
328+
// one informational record under an `ℹ`; the withheld count is unchanged.
306329
expect(linesWith('Boot diagnostics')).toEqual([
307-
' ⚠ Boot diagnostics — 1 warning logged during startup (3 more already listed above):',
330+
' ℹ Boot diagnostics — 1 informational (3 more already listed above):',
308331
]);
309332
});
310333

@@ -364,13 +387,173 @@ describe('print-once: Boot diagnostics withholds what the banner restated (forma
364387
expect(linesWith('is ARMED')).toHaveLength(1); // the banner's line
365388
expect(linesWith('Flow name collision')).toHaveLength(1);
366389
expect(linesWith('Boot diagnostics')).toEqual([
367-
' ⚠ Boot diagnostics — 1 warning logged during startup (1 more already listed above):',
390+
' ℹ Boot diagnostics — 1 informational (1 more already listed above):',
368391
]);
369392
});
370393

371394
it('with no banner (the failed-boot path) replays every record', () => {
372395
printBootDiagnostics({ lines: [auditRecord('a_flow'), unrelated] });
373396
expect(linesWith('a_flow')).toHaveLength(1);
374-
expect(linesWith('Boot diagnostics')).toEqual([' ⚠ Boot diagnostics — 2 warnings logged during startup:']);
397+
expect(linesWith('Boot diagnostics')).toEqual([' ℹ Boot diagnostics — 2 informational:']);
398+
});
399+
});
400+
401+
// ---------------------------------------------------------------------------
402+
// #22160 — the dev-mode noise budget
403+
// ---------------------------------------------------------------------------
404+
405+
/**
406+
* A ticket object as the inventory reads it, with a Resolve action that is
407+
* `type: 'script'` with a `target` and no `body`, and no handler registered —
408+
* the dead button the measured boot carried.
409+
*/
410+
const DEAD_BUTTON_TICKET = {
411+
name: 'support_desk_ticket',
412+
actions: [{ name: 'resolve_ticket', label: 'Resolve', type: 'script', target: 'resolveTicket' }],
413+
};
414+
const DEAD_BUTTON = 'support_desk_ticket:resolve_ticket';
415+
const DEAD_BUTTON_CLASS = 'declared script actions with NO handler';
416+
417+
/** Route stdout into `capture` for the length of `fn`, exactly as `serve`'s boot-quiet window does. */
418+
async function underQuietWindow(capture: BootLogCapture, fn: () => Promise<unknown> | unknown): Promise<void> {
419+
const outSpy = vi.spyOn(process.stdout, 'write').mockImplementation(((chunk: unknown, encoding?: unknown) => {
420+
capture.write(chunk as string | Uint8Array, typeof encoding === 'string' ? encoding : undefined);
421+
return true;
422+
}) as never);
423+
try {
424+
await fn();
425+
} finally {
426+
outSpy.mockRestore();
427+
}
428+
}
429+
430+
/**
431+
* A failed insert, logged the way the engine's write doors log one: `warn`
432+
* with `{ object, error: { message, stack } }` (`writeFailureLogMeta` in
433+
* `@objectstack/objectql`'s engine), from a REAL thrown error, so the stack
434+
* is V8's own frames.
435+
*/
436+
function logFailedInsert(logger: ObjectLogger): void {
437+
const err = new Error('UNIQUE constraint failed: sys_migration.id [statement and bound values redacted]');
438+
logger.warn('Insert operation failed', { object: 'sys_migration', error: { message: err.message, stack: err.stack } });
439+
}
440+
441+
/** A V8 frame as it reads inside a JSON-escaped `stack`. */
442+
const ESCAPED_FRAME = /\\n\s+at /;
443+
444+
describe('the noise budget over real producers (#22160)', () => {
445+
/**
446+
* One boot's worth of boot-phase records through the REAL `ObjectLogger`
447+
* and `BootLogCapture`: an expected degradation, the REAL
448+
* `@objectstack/objectql` action inventory over that ticket, and a
449+
* failed insert carrying a stack. Returns the captured records and the
450+
* inventory's own message, read off the producer as it logged it.
451+
*/
452+
async function boot(format: 'pretty' | 'text' | 'json'): Promise<{ captured: string[]; inventoryMessage: string }> {
453+
const capture = new BootLogCapture();
454+
const logger = new ObjectLogger({ level: 'warn', format });
455+
const messages: string[] = [];
456+
const inventoryLogger = {
457+
warn: (message: string, meta?: Record<string, unknown>) => {
458+
messages.push(message);
459+
logger.warn(message, meta);
460+
},
461+
};
462+
await underQuietWindow(capture, async () => {
463+
logger.warn('[Analytics] No admitObjectRead configured at init — the bridge resolves per query');
464+
await runActionGovernanceInventory({ registered: [], objects: [DEAD_BUTTON_TICKET], logger: inventoryLogger });
465+
logFailedInsert(logger);
466+
});
467+
const captured = capture.diagnostics();
468+
printBootDiagnostics({ lines: captured, dropped: capture.droppedCount });
469+
expect(messages, 'the real inventory logged nothing for the ticket').toHaveLength(1);
470+
return { captured, inventoryMessage: messages[0] };
471+
}
472+
473+
it.each(['pretty', 'text', 'json'] as const)(
474+
'%s: the dead button prints once, first, highlighted, with the producer\'s own fix, counted apart',
475+
async (format) => {
476+
const { captured, inventoryMessage } = await boot(format);
477+
478+
// The premise: the producer's record reached the capture, naming the action.
479+
expect(captured.filter((record) => record.includes(DEAD_BUTTON)), captured.join('\n')).toHaveLength(1);
480+
481+
const highlighted = linesWith(DEAD_BUTTON_CLASS);
482+
expect(highlighted, transcript.join('\n')).toHaveLength(1);
483+
expect(linesWith(DEAD_BUTTON)).toEqual(highlighted);
484+
// Highlighted: its own glyph line, the statement with its subject.
485+
expect(highlighted[0]).toMatch(/^ {4}⚠ \[action-governance\] declared script actions with NO handler/);
486+
expect(highlighted[0].endsWith(`: ${DEAD_BUTTON}`)).toBe(true);
487+
// The fix is the producer's own sentence after its `; `, on the next line.
488+
const at = transcript.indexOf(highlighted[0]);
489+
expect(transcript[at + 1]).toBe(` fix: ${inventoryMessage.slice(inventoryMessage.indexOf('; ') + 2)}`);
490+
// First: above every informational record.
491+
expect(at).toBeLessThan(transcript.findIndex((line) => line.includes('[Analytics]')));
492+
expect(at).toBeLessThan(transcript.findIndex((line) => line.includes('Insert operation failed')));
493+
// Counted apart.
494+
const header = linesWith('Boot diagnostics');
495+
expect(header).toHaveLength(1);
496+
expect(header[0]).toContain('1 needs your attention · 2 informational');
497+
expect(header[0]).toMatch(/^ {2}⚠ /);
498+
},
499+
);
500+
501+
it('withholds a record\'s stack at the default level, keeps its message, and says where the stack is', async () => {
502+
const { captured } = await boot('pretty');
503+
504+
// The premise: the captured record really carries the stack, JSON-escaped.
505+
const failed = captured.filter((record) => record.includes('Insert operation failed'));
506+
expect(failed).toHaveLength(1);
507+
expect(failed[0]).toContain('"stack":"Error: UNIQUE constraint failed');
508+
expect(failed[0]).toMatch(ESCAPED_FRAME);
509+
510+
const printed = linesWith('Insert operation failed');
511+
expect(printed).toHaveLength(1);
512+
expect(printed[0]).toContain('"object":"sys_migration"');
513+
expect(printed[0]).toContain('"error":{"message":"UNIQUE constraint failed: sys_migration.id');
514+
expect(printed[0]).not.toContain('"stack"');
515+
expect(transcript.join('\n')).not.toMatch(ESCAPED_FRAME);
516+
517+
const hint = linesWith('stack trace');
518+
expect(hint).toHaveLength(1);
519+
expect(hint[0]).toContain('1 stack trace');
520+
expect(hint[0]).toContain('--log-level debug');
521+
});
522+
523+
it('at --log-level debug the same record reaches the terminal live, stack included', () => {
524+
// `serve` never opens the quiet window at a verbose level, so nothing is
525+
// captured or replayed: the record goes out exactly as the logger renders it.
526+
expect(isVerboseBootLevel('debug')).toBe(true);
527+
const written: string[] = [];
528+
const outSpy = vi.spyOn(process.stdout, 'write').mockImplementation(((chunk: unknown) => {
529+
written.push(String(chunk));
530+
return true;
531+
}) as never);
532+
try {
533+
logFailedInsert(new ObjectLogger({ level: 'debug' }));
534+
} finally {
535+
outSpy.mockRestore();
536+
}
537+
const live = written.join('').split('\n').filter((line) => line.includes('Insert operation failed'));
538+
expect(live).toHaveLength(1);
539+
expect(live[0]).toContain('"stack":"Error: UNIQUE constraint failed');
540+
expect(live[0]).toMatch(ESCAPED_FRAME);
541+
});
542+
});
543+
544+
describe('the noise budget (formatter, #22160)', () => {
545+
it('withholds only stack TRACES — a `stack` key that names something prints as it was', () => {
546+
const named = record('Stack loaded {"stack":"support-desk","objects":2}');
547+
printBootDiagnostics({ lines: [named] });
548+
expect(linesWith('Stack loaded')).toEqual([` ${named}`]);
549+
expect(linesWith('stack trace')).toEqual([]);
550+
});
551+
552+
it('keeps the alarm when the capture dropped records — unread records are not called informational', () => {
553+
printBootDiagnostics({ lines: [record('Service not provided — using in-memory fallback')], dropped: 3 });
554+
const header = linesWith('Boot diagnostics');
555+
expect(header).toHaveLength(1);
556+
expect(header[0]).toMatch(/^ {2}⚠ /);
557+
expect(header[0]).not.toContain('needs your attention');
375558
});
376559
});

0 commit comments

Comments
 (0)