diff --git a/.changeset/doctor-config-load-cause.md b/.changeset/doctor-config-load-cause.md new file mode 100644 index 0000000000..6dcad448ab --- /dev/null +++ b/.changeset/doctor-config-load-cause.md @@ -0,0 +1,68 @@ +--- +"@objectstack/cli": patch +--- + +fix(cli): `os doctor` 说出配置载入失败的**原因**,不再只说一句「载入不了」(#5403) + +config 分析那个很宽的 `catch` **不带绑定**(`catch {`),error 对象在被捕获的那一刻当场丢弃。 +于是配置真坏掉时,报告里没有任何线索: + +``` +$ cat objectstack.config.ts +throw new Error('this config is genuinely broken'); + +$ os doctor + → Loading configuration for analysis... + ⚠ Could not load config for analysis (config checks skipped) + +⚠️ Environment is functional but has some warnings. +``` + +`this config is genuinely broken` 一个字都不出现,`--verbose` 也没有 —— 这句话是裸 +`printWarning` 直接打的,不是 `HealthCheckResult`,所以根本没有 `fix` 可供展开,没有任何 +旗标能让操作者看到更多。而 `os serve` 在同一个目录会把这个错误**完整**打出来。诊断命令在 +它最该出力的一刻(配置坏了),给出的信息**严格少于**直接跑 `os serve`。 + +这与前三单是同一句话的不同侧面:#5382 / #5387 / #5397 修的是这句话的**归因**(先把 +posture 的抛错挪出这个 catch,再让 env 派生检查读到 serve 的环境,最后让配置载入也在 +serve 的环境下进行)。三单之后这句话触发时配置**确实**坏了。本单修的是它归因正确之后 +**说了什么**。 + +**现在的行为。** 这条路径不再是裸 `printWarning`,而是一条常规 `HealthCheckResult`,与 +`Environment files` / `Tenancy posture` 走**同一个渲染器**、同一条 `--verbose` 展开规则: + +``` +$ os doctor + → Loading configuration for analysis... + ⚠ Config load Could not load config for analysis (config checks skipped) — this config is genuinely broken + +$ os doctor --verbose + ⚠ Config load Could not load config for analysis (config checks skipped) — this config is genuinely broken + → `os serve` loads this same file the same way — bundle-require, under the `.env*` + cascade named above (#5397) — and prints this error in full, so a config that + lands here is one the server cannot boot either. + The config-aware checks were SKIPPED, not passed: spec version, circular + dependencies, unused objects, orphan views, dashboard integrity. + cause: this config is genuinely broken +``` + +四条刻意的取舍: + +- **原话照引,不改写。** 与 #5390 引 `resolveTenancyPosture()` 原话同一体例:抛错方拥有措辞。 + 配置载入的失败可能来自四个不同的权威(用户自己的 `throw`、esbuild 的打包诊断、Node 的 + 模块解析、`loadConfig()` 自己的 "no default export"),doctor 没有立场把它们总结得比它们 + 自己更好。 +- **cause 进 `message`,而不只进 `fix`。** `Environment files` 把 cause 只放在 `fix` 里是对的 + —— 那一行本身已经把结论说完整了,cause 是脚注。这里 cause **就是**结论:没有它,这一行 + 只说了「出了点没被指名的问题」。所以平铺一行放收敛后的引文(多行折叠成一行、超长截断), + `--verbose` 给未截断的原文。折叠而不是取首行,是因为 esbuild 的首行恰好是它最没信息量的 + 一句(`Build failed with 1 error:`,文件与原因在下一行)。 +- **档位不变。** 仍是 warning,doctor 仍然跑完其余检查,仍然 exit 0。本单让这句话说得更多, + 不是说得更响。 +- **那句话本身原样保留**,作为该行的开头。它被两份 changeset 引用、被本文件四处注释引用, + 也是操作者会去 grep 的字符串;更要紧的是,兄弟测试用它的**缺席**来表示「配置载入成功」, + 改写它会让那些断言在「没有任何东西能匹配」的空理由下继续变绿。 + +顺带消掉的是一条旁路:渲染规则此前只存在于环境检查那个 `forEach` 的循环体里,任何在它之后 +产生的结论都只能自己再手打一遍格式 —— 而手打的那份没有 `fix` 通道,`--verbose` 对它无效。 +渲染规则现在是一个具名函数,两处共用。 diff --git a/packages/cli/src/commands/doctor-config-load-cause.test.ts b/packages/cli/src/commands/doctor-config-load-cause.test.ts new file mode 100644 index 0000000000..f5b50ebd69 --- /dev/null +++ b/packages/cli/src/commands/doctor-config-load-cause.test.ts @@ -0,0 +1,307 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `os doctor` says WHY the config could not be loaded (#5403). + * + * ── The defect ─────────────────────────────────────────────────────────── + * + * The config-analysis `catch` took no binding — `catch {` — so the error object + * was discarded at the point it was caught: + * + * $ cat objectstack.config.ts + * throw new Error('this config is genuinely broken'); + * + * $ os doctor + * → Loading configuration for analysis... + * ⚠ Could not load config for analysis (config checks skipped) + * + * ⚠️ Environment is functional but has some warnings. + * + * `this config is genuinely broken` appeared nowhere, and no flag could reveal + * it: the sentence came from a bare `printWarning`, not from a + * `HealthCheckResult`, so `--verbose` had no `fix` to expand. Meanwhile + * `os serve`, in that same directory, prints the error in full. The diagnostic + * command returned STRICTLY LESS than the command it exists to diagnose, at the + * one moment it is most needed. + * + * ── Why this is a separate issue from the three before it ──────────────── + * + * #5382 → #5387 → #5397 fixed this sentence's ATTRIBUTION: first by lifting the + * tenancy-posture throw out of this `catch`, then by letting doctor's + * env-derived checks read serve's `.env*` cascade, then by loading the config + * itself under that cascade. After all three, a run that reaches this `catch` + * has a genuinely broken config — `os serve` cannot load it either. #5403 is + * about what the sentence SAYS once it is finally saying it about the right + * thing. + * + * ── What is pinned here ────────────────────────────────────────────────── + * + * • the cause reaches the terminal, quoted from the thrower, not paraphrased; + * • the recognizable sentence SURVIVES verbatim as the head of the row — + * sibling tests assert its absence to mean "the config loaded", and two + * changesets quote it; + * • the verdict stays a `warning`, exit 0, rest of the report still runs + * (#5397's "not a silencer" constraint, from the other direction); + * • the row goes through the ONE `HealthCheckResult` renderer, so `--verbose` + * expands it exactly like `Environment files` / `Tenancy posture`; + * • #5397's `.env*` overlay is not regressed: a config that throws only + * without its `.env` value must still never reach this path. + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import Doctor, { configLoadFailureCheck } from './doctor.js'; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +/** `packages/cli` — the oclif root the real command is loaded against below. */ +const CLI_ROOT = path.resolve(HERE, '..', '..'); + +/** + * The escape is written as `\x1b`, never as the byte itself: one raw control + * character makes `grep` treat the whole file as binary, and a test file no + * `git grep` can find stops being maintained (#4890 / #5157). + */ +const SGR = /\x1b\[[0-9;]*m/g; +const plain = (s: string) => s.replace(SGR, ''); + +/** The sentence three issues spent their effort making true. It must survive. */ +const HEADLINE = 'Could not load config for analysis (config checks skipped)'; + +/** The issue's own repro anchor. */ +const BROKEN = 'this config is genuinely broken'; + +describe('configLoadFailureCheck — the finding the discarded error used to be', () => { + it('quotes the thrown message, in the row AND in the verbose detail', () => { + const check = configLoadFailureCheck(new Error(BROKEN)); + + // Before #5403 this string existed only inside a `catch` that named + // nothing. Both channels carry it now: the row so a plain `os doctor` is + // actionable, the `fix` so `--verbose` has the untruncated original. + expect(check.message).toContain(BROKEN); + expect(check.fix).toContain(BROKEN); + }); + + it('keeps the recognizable sentence intact at the head of the row', () => { + const check = configLoadFailureCheck(new Error(BROKEN)); + + // Load-bearing, not cosmetic: `doctor-config-env-overlay.test.ts` and + // `doctor-tenancy-posture-report.test.ts` assert this string's ABSENCE to + // mean "the config loaded fine". Rewording it would leave those assertions + // passing for the empty reason that nothing matches them any more. + expect(check.message.startsWith(`${HEADLINE} — `)).toBe(true); + }); + + it('stays a warning, and stays a named row like every other check', () => { + const check = configLoadFailureCheck(new Error(BROKEN)); + + // #5397's constraint, restated from the other side: the point of #5403 is + // that the warning says more, never that it says it louder. A broken config + // does not stop doctor from finishing, and does not fail the run. + expect(check.status).toBe('warning'); + // A `name` is what makes it renderable by the shared renderer at all — the + // bare `printWarning` it replaces had no such column. + expect(check.name).toBe('Config load'); + }); + + it('points at `os serve` and says the skipped checks were skipped, not passed', () => { + const fix = configLoadFailureCheck(new Error(BROKEN)).fix ?? ''; + + expect(fix).toContain('os serve'); + // The second half of the harm the issue describes: a reader who sees the + // config-aware sections simply missing from the report can otherwise read + // their silence as a clean bill of health. + expect(fix).toContain('SKIPPED, not passed'); + }); + + it('folds a multi-line cause onto the row WITHOUT losing the informative line', () => { + // esbuild's shape, and the reason the row is not simply `cause.split("\n")[0]`: + // the first line is the least informative thing the failure has to say. + const esbuild = new Error( + 'Build failed with 1 error:\nobjectstack.config.ts:3:0: ERROR: Expected ";" but found "}"', + ); + const check = configLoadFailureCheck(esbuild); + + expect(check.message).toContain('Build failed with 1 error:'); + expect(check.message).toContain('objectstack.config.ts:3:0'); + expect(check.message).toContain('Expected ";" but found "}"'); + // One row is one line. + expect(check.message).not.toContain('\n'); + // The verbose channel keeps the original's own line breaks. + expect(check.fix).toContain('objectstack.config.ts:3:0: ERROR: Expected ";" but found "}"'); + }); + + it('clamps an overlong cause on the row and keeps it whole in the detail', () => { + const long = `HEAD ${'x'.repeat(4000)} TAIL`; + const check = configLoadFailureCheck(new Error(long)); + + expect(check.message).toContain('HEAD '); + expect(check.message).toContain('…'); + expect(check.message.length).toBeLessThan(260); + // Truncation is a property of the ROW, never of the record: `--verbose` + // hands over every character the thrower wrote. + expect(check.fix).toContain('TAIL'); + expect(check.fix).toContain('x'.repeat(4000)); + }); + + it('never trails off into nothing when the Error carries no message', () => { + // `throw new Error()` and `throw new TypeError()` are rare but real, and a + // headline ending in a bare dash is the same "no information" defect this + // issue is about, reintroduced at the edge. + const check = configLoadFailureCheck(new TypeError()); + + expect(check.message.endsWith('— ')).toBe(false); + expect(check.message).toContain('TypeError'); + }); + + it('reports a thrown non-Error rather than swallowing it', () => { + // A config is arbitrary user code; `throw 'boom'` is legal. + expect(configLoadFailureCheck('boom').message).toContain('boom'); + expect(configLoadFailureCheck(42).message).toContain('42'); + }); +}); + +describe('os doctor, end to end, against a config that cannot be loaded', () => { + /** + * `node_modules/` exists in the temp cwd on purpose — without it doctor's + * `Dependencies` check is itself an `error` and exits 1 on its own, which + * would make an assertion pass for a reason having nothing to do with this + * change (the trap PR #5390 wrote down and #5398 / #5402 inherited). + */ + let tmp: string; + let cwdSpy: ReturnType; + + beforeEach(() => { + tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'os-doctor-5403-e2e-')); + fs.mkdirSync(path.join(tmp, 'node_modules')); + cwdSpy = vi.spyOn(process, 'cwd').mockReturnValue(tmp); + }); + + afterEach(() => { + cwdSpy.mockRestore(); + fs.rmSync(tmp, { recursive: true, force: true }); + }); + + const writeFile = (name: string, body: string) => fs.writeFileSync(path.join(tmp, name), body); + + async function runDoctor(argv: string[] = []): Promise<{ out: string; exitCode: number | undefined }> { + const logs: string[] = []; + const logSpy = vi.spyOn(console, 'log').mockImplementation((...a: unknown[]) => { + logs.push(a.join(' ')); + }); + let exitCode: number | undefined; + const exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code?: number) => { + exitCode = code; + throw new Error(`__PROCESS_EXIT__:${code}`); + }) as never); + + try { + await Doctor.run(argv, { root: CLI_ROOT }); + } catch (err) { + if (!(err instanceof Error) || !err.message.startsWith('__PROCESS_EXIT__')) throw err; + } finally { + logSpy.mockRestore(); + exitSpy.mockRestore(); + } + return { out: plain(logs.join('\n')), exitCode }; + } + + it('prints the cause on a plain run — the issue’s own repro', async () => { + writeFile('objectstack.config.ts', `throw new Error('${BROKEN}');\n`); + + const run = await runDoctor(); + + // THE assertion of this issue. Before #5403 this string appeared nowhere in + // any doctor output, under any flag, while `os serve` printed it in full. + expect(run.out).toContain(BROKEN); + // …without losing the sentence that says what was skipped. + expect(run.out).toContain(HEADLINE); + // Rendered through the shared renderer, so it carries a name column like + // every other health check rather than being a naked one-liner. + expect(run.out).toContain('Config load'); + // Gauge unchanged: warning, report finishes, exit 0. + expect(run.out).toContain('Environment is functional but has some warnings'); + expect(run.exitCode).toBeUndefined(); + }, 60_000); + + it('expands the full detail under --verbose, and only under --verbose', async () => { + writeFile('objectstack.config.ts', `throw new Error('${BROKEN}');\n`); + + const plainRun = await runDoctor(); + const verboseRun = await runDoctor(['--verbose']); + + // The `fix` channel follows the ONE rule every other warning follows: + // shown when asked for, or when the finding is an error. This row is a + // warning, so a default run stops at the (bounded) headline. + expect(plainRun.out).not.toContain('cause:'); + expect(verboseRun.out).toContain('cause:'); + expect(verboseRun.out).toContain(BROKEN); + // The verbose block is what tells the reader the skipped checks are not + // silent passes. + expect(verboseRun.out).toContain('SKIPPED, not passed'); + expect(verboseRun.exitCode).toBeUndefined(); + }, 60_000); + + it('surfaces a SYNTAX error’s file and reason, not just “build failed”', async () => { + // The bundle never runs the file here, so the failure comes from esbuild + // rather than from user code — a different authority, quoted the same way. + writeFile('objectstack.config.ts', 'export default { manifest: { name: "broken",\n'); + + const run = await runDoctor(); + + expect(run.out).toContain(HEADLINE); + expect(run.out).toContain('objectstack.config.ts'); + expect(run.exitCode).toBeUndefined(); + }, 60_000); + + it('does NOT fire for a config that only needed its .env — #5397 is not regressed', async () => { + // The load-bearing overlay: `loadConfig()` runs inside + // `withDotenvOverlayAsync`, and the async variant is what survives the + // dynamic `import()` the bundle performs. If this change broke that, the + // config below would throw and this row would appear. + writeFile('.env', 'OS_5403_DB_URL=postgres://from-dotenv/db\n'); + writeFile( + 'objectstack.config.ts', + [ + 'const url = process.env.OS_5403_DB_URL;', + "if (!url) throw new Error('OS_5403_DB_URL is required');", + 'export default {', + " manifest: { name: 'os5403', label: 'Config Load Cause', version: '1.0.0' },", + " objects: [{ name: 'account', label: 'Account', fields: [{ name: 'name', type: 'text', label: 'Name' }] }],", + '};', + '', + ].join('\n'), + ); + + const run = await runDoctor(['--verbose']); + + expect(run.out).not.toContain(HEADLINE); + expect(run.out).not.toContain('Config load '); + expect(run.out).not.toContain('OS_5403_DB_URL is required'); + // The checks ran rather than merely not failing. + expect(run.out).toContain('No circular references detected'); + // The overlay left no residue behind it. + expect(Object.prototype.hasOwnProperty.call(process.env, 'OS_5403_DB_URL')).toBe(false); + }, 60_000); + + it('says nothing at all when the config loads — the row is a finding, not a status line', async () => { + writeFile( + 'objectstack.config.ts', + [ + 'export default {', + " manifest: { name: 'os5403ok', label: 'Healthy', version: '1.0.0' },", + " objects: [{ name: 'account', label: 'Account', fields: [{ name: 'name', type: 'text', label: 'Name' }] }],", + '};', + '', + ].join('\n'), + ); + + const run = await runDoctor(['--verbose']); + + expect(run.out).not.toContain('Config load'); + expect(run.out).not.toContain(HEADLINE); + }, 60_000); +}); diff --git a/packages/cli/src/commands/doctor.ts b/packages/cli/src/commands/doctor.ts index f7b87ea687..bb82817df4 100644 --- a/packages/cli/src/commands/doctor.ts +++ b/packages/cli/src/commands/doctor.ts @@ -32,6 +32,35 @@ interface HealthCheckResult { fix?: string; } +/** + * The ONE way a `HealthCheckResult` reaches the terminal (#5403). + * + * Extracted from the environment block's `forEach` so that a finding produced + * later in the report — after that loop has already run — can still be printed + * the same way, instead of reaching for a bare `printWarning` and quietly + * inventing a second rendering with its own rules. The config-load failure was + * exactly that second rendering: a naked one-liner with no name column and, + * crucially, no `fix` channel, so `--verbose` had nothing to expand and the + * operator had no flag that could reveal more. + * + * `fix` shows unasked only for an `error`: an error's remedy is not optional + * reading, a warning's detail is. + */ +function renderHealthCheckResult(result: HealthCheckResult, verbose: boolean): void { + const padded = result.name.padEnd(20); + if (result.status === 'ok') { + printSuccess(`${padded} ${result.message}`); + } else if (result.status === 'warning') { + printWarning(`${padded} ${result.message}`); + } else { + printError(`${padded} ${result.message}`); + } + + if (result.fix && (verbose || result.status === 'error')) { + console.log(chalk.dim(` → ${result.fix}`)); + } +} + // ─── Environment sources (#5387, #5397) ───────────────────────────── // // `serve` / `dev` / `start` all load `.env*` through dotenv-flow before they @@ -961,6 +990,97 @@ function scanDeprecatedPatterns(dir: string): Array<{ file: string; line: number return results; } +// ─── Config load ──────────────────────────────────────────────────── + +/** + * Quote what was thrown, without paraphrasing it (#5403). + * + * Same posture as the tenancy-posture finding's `cause:` line: the thrower owns + * the wording. A config file's failure can come from four different authorities + * — the user's own `throw`, esbuild's bundle diagnostics, Node's module + * resolution, or `loadConfig()`'s own "no default export" — and doctor is not + * in a position to summarise any of them better than they summarise themselves. + */ +function describeThrown(err: unknown): string { + if (err instanceof Error) { + // An `Error` thrown with no message still identifies itself by name. + // Quoting `''` would print a headline that trails off into nothing. + return err.message.trim().length > 0 ? err.message : err.name; + } + return String(err); +} + +/** + * How much of the cause the single report row carries before `--verbose`. + * The full text always survives in `fix`; this bound only keeps one aligned + * line aligned. + */ +const CONFIG_LOAD_HEADLINE_MAX = 160; + +/** + * Fold a possibly multi-line cause onto the one line a report row is. + * + * Whitespace-collapsing, not rewriting: esbuild's failures open with + * `Build failed with 1 error:` and put the file, line and reason on the NEXT + * line, so a naive "first line" would quote the least informative sentence it + * has. Every word is upstream's, in upstream's order; only the line breaks and + * an overlong tail are ours. + */ +function configLoadHeadline(cause: string): string { + const collapsed = cause.replace(/\s+/g, ' ').trim(); + return collapsed.length <= CONFIG_LOAD_HEADLINE_MAX + ? collapsed + : `${collapsed.slice(0, CONFIG_LOAD_HEADLINE_MAX - 1)}…`; +} + +/** Keep a multi-line quote under the report's ` → ` gutter. */ +function indentUnderGutter(text: string): string { + return text.split('\n').join('\n '); +} + +/** + * What doctor reports when `objectstack.config.ts` cannot be loaded (#5403). + * + * The `catch` this replaces took no binding at all — `catch {` — so the error + * object was discarded where it was caught, and the run printed + * `Could not load config for analysis (config checks skipped)` and nothing + * else. No flag could reveal more: the sentence came from a bare + * `printWarning`, not from a `HealthCheckResult`, so `--verbose` had no `fix` + * to expand. `os serve`, in the same directory, prints the whole error. The + * diagnostic command was returning strictly less than the command it exists to + * diagnose, at the one moment it is most needed. + * + * Three deliberate choices: + * + * • **Still a warning.** #5382 → #5387 → #5397 spent three issues making this + * sentence's attribution true; #5403 is about what it SAYS, not how loudly. + * Doctor keeps running the rest of its checks and keeps exiting 0. + * • **The sentence is unchanged.** It survives verbatim as the head of + * `message` — two changesets quote it, four comments in this file cite it, + * and operators grep for it. #5403 adds everything after the dash. + * • **The cause is in `message`, not only in `fix`.** `Environment files` + * puts its cause in `fix` alone, and that is right there: the row already + * states its own finding in full and the cause is a footnote. Here the + * cause IS the finding — without it the row says only that something + * unnamed went wrong — so a default run carries a bounded quote and + * `--verbose` carries the untruncated one. + */ +export function configLoadFailureCheck(err: unknown): HealthCheckResult { + const cause = describeThrown(err); + return { + name: 'Config load', + status: 'warning', + message: `Could not load config for analysis (config checks skipped) — ${configLoadHeadline(cause)}`, + fix: + '`os serve` loads this same file the same way — bundle-require, under the `.env*`\n' + + ' cascade named above (#5397) — and prints this error in full, so a config that\n' + + ' lands here is one the server cannot boot either.\n' + + ' The config-aware checks were SKIPPED, not passed: spec version, circular\n' + + ' dependencies, unused objects, orphan views, dashboard integrity.\n' + + ` cause: ${indentUnderGutter(cause)}`, + }; +} + // ─── Command ──────────────────────────────────────────────────────── export default class Doctor extends Command { @@ -1142,19 +1262,11 @@ export default class Doctor extends Command { console.log(''); results.forEach((result) => { - const padded = result.name.padEnd(20); - if (result.status === 'ok') { - printSuccess(`${padded} ${result.message}`); - } else if (result.status === 'warning') { - printWarning(`${padded} ${result.message}`); - } else { - printError(`${padded} ${result.message}`); - } - - if (result.fix && (flags.verbose || result.status === 'error')) { - console.log(chalk.dim(` → ${result.fix}`)); - } - + // #5403 — the rendering rules live in `renderHealthCheckResult` so that + // the config-load finding further down prints identically instead of + // growing a second, flagless format of its own. + renderHealthCheckResult(result, flags.verbose); + if (result.status === 'error') hasErrors = true; if (result.status === 'warning') hasWarnings = true; }); @@ -1317,14 +1429,31 @@ export default class Doctor extends Command { printSuccess('Dashboard integrity All widgets resolve datasets, dimensions, and measures'); } } - } catch { + } catch (err) { // #5397 — still fires, and deliberately so: with the `.env*` cascade now // applied around the load, a config that STILL cannot be loaded is one // `os serve` cannot load either. What changed is that this sentence is // no longer reachable by a config whose only problem was that doctor // withheld the environment from it. Silencing the warning outright would // have traded a misattributed warning for no warning at all. - printWarning('Could not load config for analysis (config checks skipped)'); + // + // #5403 — and `err` is now BOUND. This `catch` took no binding, so the + // one artifact that could explain the failure was discarded at the + // moment it was caught: the operator got "could not load" with no + // subject, no flag that revealed more, and `os serve` one directory + // over printing the whole thing. Three issues fixed this sentence's + // attribution; this one gives it content. + const finding = configLoadFailureCheck(err); + // Rendered here, in place — the environment block's loop above has + // already run, and reordering the report to route this row through it + // would move the finding away from the step that produced it. Same + // renderer, same `--verbose` rule, same shape as `Environment files` + // and `Tenancy posture`; only the call site differs. + renderHealthCheckResult(finding, flags.verbose); + // Kept in `results` all the same, so the run's record is complete and + // the summary's fix list stays correct if this verdict is ever raised + // above a warning. Inert today: that list filters on `error`. + results.push(finding); hasWarnings = true; } }