diff --git a/.changeset/doctor-tenancy-posture-report.md b/.changeset/doctor-tenancy-posture-report.md new file mode 100644 index 0000000000..39fabd4ee0 --- /dev/null +++ b/.changeset/doctor-tenancy-posture-report.md @@ -0,0 +1,59 @@ +--- +"@objectstack/cli": patch +--- + +fix(cli): `os doctor` 指名道姓报告非法 `OS_TENANCY_POSTURE` 并以非零码退出,不再报成一句「Could not load config」(#5382) + +`resolveTenancyPosture()`(`@objectstack/types`)对无法识别的值抛错。doctor 有两处读 +posture —— ADR-0120 D5e 的 unique-scope 闸门,以及 `findUnscopedGlobalUniques()` —— +**两处都在 config 分析那个很宽的 `try` 里**,而它的 catch 只会打印 + +``` + ⚠ Could not load config for analysis (config checks skipped) +``` + +并记一个 warning。于是一个 `os serve` 会**拒绝启动**的环境,`os doctor` 报成: + +``` +⚠️ Environment is functional but has some warnings. EXIT=0 +``` + +全程不出现 `OS_TENANCY_POSTURE` 这个词。一行里两个缺陷:**归因错了**(配置本身没问题, +被指着的是配置),**严重级也错了**(exit 0 意味着任何把 `os doctor` 放进 CI/健康检查的 +地方,都不会因为这个「环境根本起不来」的配置错误变红)。这正是 #4801 / cloud#1020 那类 +「诊断面与运行时不一致」,而且落在最糟的位置 —— `os doctor` 就是运维在 `serve` 起不来 +之后会去跑的那条命令。 + +**现在的行为。** posture 在 `run()` 顶部、**任何 `try` 之外**解析一次。非法值产出一条 +普通的 `error` 体检项: + +``` + ✗ Tenancy posture OS_TENANCY_POSTURE="isolatd" is not a recognized tenancy posture — `os serve` refuses to boot this environment + → Set one of the accepted values: + • OS_TENANCY_POSTURE=single — one organization, no organization wall — the default + • OS_TENANCY_POSTURE=group — organization wall enforced by the open engine, one shared database + • OS_TENANCY_POSTURE=isolated — organization wall + the enterprise @objectstack/organizations runtime … + • or unset OS_TENANCY_POSTURE entirely — the posture then derives from + OS_MULTI_ORG_ENABLED (true ⇒ isolated, anything else ⇒ single) + Read from this process's environment only: unlike `os serve`, `os doctor` does not + load `.env*` files, so a value set in one is not visible here. + cause: Invalid OS_TENANCY_POSTURE="isolatd". … +``` + +修法清单由 `@objectstack/spec/security` 的 `TENANCY_POSTURES` 生成,不是第二份字面量, +新增一个 posture 不会让这段建议悄悄过期;`cause` 直接引用解析器自己的那句话,doctor 不 +维护会跟它跑偏的第二份措辞。 + +**与 #5359 / PR #5381 给 `serve` 加的闸门同形,但裁决不同,且是刻意的**:serve 是**拒绝** +(FATAL + 在任何启动动作之前 `process.exit(1)`),doctor 是**报告** —— 报告照常跑完,由 +doctor 自己的错误汇总给出非零退出码。doctor 的语义是「把所有问题一次说清」,不是「停下」。 + +两处读 posture 的地方现在复用同一个已解析值,不再各自重新解析。 + +**顺带修好的一个更大的洞:** 那两处读取此前都在 `if (configExists())` 之内 —— 一个没有 +`objectstack.config.ts` 的环境**从来没读过** posture,连那句归因错误的 warning 都不会有。 +现在与是否存在配置文件无关。 + +**一个如实说明的残留:** `os doctor` 不加载 `.env*`(`serve`/`dev`/`start` 用 dotenv-flow +加载),所以写在提交进仓库的 `.env` 里的非法 posture 仍然到得了服务器、到不了这份报告。 +文案里明说了这一点,不冒充自己检查过 —— 该跨命令不一致另行记录为 #5387。 diff --git a/packages/cli/src/commands/doctor-tenancy-posture-report.test.ts b/packages/cli/src/commands/doctor-tenancy-posture-report.test.ts new file mode 100644 index 0000000000..5c9b55ed23 --- /dev/null +++ b/packages/cli/src/commands/doctor-tenancy-posture-report.test.ts @@ -0,0 +1,285 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `os doctor`'s tenancy-posture report (#5382). + * + * `resolveTenancyPosture()` in `@objectstack/types` refuses an unrecognized + * `OS_TENANCY_POSTURE` by throwing. Doctor read the posture in two places — the + * ADR-0120 D5e unique-scope gate and `findUnscopedGlobalUniques()` — and BOTH + * sat inside the wide `try` that guards config analysis, whose `catch` prints + * + * ⚠ Could not load config for analysis (config checks skipped) + * + * and records a WARNING. So an environment `os serve` flatly refuses to boot + * was reported by `os doctor` as: + * + * ⚠️ Environment is functional but has some warnings. EXIT=0 + * + * with the string `OS_TENANCY_POSTURE` appearing nowhere in the run. Two + * separate defects in one line: the attribution was wrong (the config was + * fine), and the severity was wrong (exit 0 keeps every CI health check green + * on an environment that cannot start). + * + * ── Sibling, not a copy ────────────────────────────────────────────────── + * + * #5359 / PR #5381 fixed the same shape in `serve`, and the two verdicts differ + * on purpose: serve REFUSES (FATAL + `process.exit(1)` before any boot work), + * doctor REPORTS (an `error` health check flowing through doctor's own error + * summary, after the rest of the report has printed). Doctor's semantics are + * "tell me everything that is wrong", not "stop". + * + * ── What `packages/cli` pinned before this file ────────────────────────── + * + * Nothing, for doctor. `git grep -n OS_TENANCY_POSTURE packages/cli/src` matched + * serve's prose, `verify`'s back-compat tests, and PR #5381's serve gate test — + * no assertion of any kind on `doctor`. + */ + +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 { TENANCY_POSTURES } from '@objectstack/spec/security'; + +import Doctor, { resolveTenancyPostureOrFinding } from './doctor.js'; + +/** `packages/cli` — the oclif root the command is loaded against below. */ +const CLI_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..'); + +/** + * `chalk` may or may not emit SGR codes depending on TTY detection. + * + * 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 nobody's + * `git grep` can find is a test file that stops being maintained (#4890/#5157). + */ +const SGR = /\x1b\[[0-9;]*m/g; +const plain = (s: string) => s.replace(SGR, ''); + +const TOUCHED = ['OS_TENANCY_POSTURE', 'OS_MULTI_ORG_ENABLED'] as const; +let saved: Record = {}; + +beforeEach(() => { + saved = Object.fromEntries(TOUCHED.map((k) => [k, process.env[k]])); + for (const k of TOUCHED) delete process.env[k]; +}); + +afterEach(() => { + for (const k of TOUCHED) { + if (saved[k] === undefined) delete process.env[k]; + else process.env[k] = saved[k]; + } +}); + +describe('resolveTenancyPostureOrFinding — accepted values', () => { + it('passes every posture the spec vocabulary declares', () => { + for (const posture of TENANCY_POSTURES) { + process.env.OS_TENANCY_POSTURE = posture; + expect(resolveTenancyPostureOrFinding()).toEqual({ ok: true, posture }); + } + }); + + it("keeps the legacy 'multi' spelling normalizing to isolated", () => { + process.env.OS_TENANCY_POSTURE = 'multi'; + expect(resolveTenancyPostureOrFinding()).toEqual({ ok: true, posture: 'isolated' }); + }); + + it('unset falls back to the OS_MULTI_ORG_ENABLED derivation, not to a finding', () => { + expect(resolveTenancyPostureOrFinding()).toEqual({ ok: true, posture: 'single' }); + + process.env.OS_MULTI_ORG_ENABLED = 'true'; + expect(resolveTenancyPostureOrFinding()).toEqual({ ok: true, posture: 'isolated' }); + }); + + it('treats a blank value as unset — reporting it would flag `OS_TENANCY_POSTURE=` in a .env', () => { + process.env.OS_TENANCY_POSTURE = ' '; + expect(resolveTenancyPostureOrFinding()).toEqual({ ok: true, posture: 'single' }); + }); +}); + +describe('resolveTenancyPostureOrFinding — the finding', () => { + it('REPORTS AS A VALUE, never as a throw — the property the config catch destroyed', () => { + process.env.OS_TENANCY_POSTURE = 'bogus'; + + // The point of the whole change. `resolveTenancyPosture()` throws here, and + // doctor's posture reads lived inside the config-analysis `try`, so the + // throw was caught by a `catch` that knows nothing about env vars and + // downgraded "cannot start" to "config checks skipped". A verdict cannot be + // caught by an unrelated catch. + expect(() => resolveTenancyPostureOrFinding()).not.toThrow(); + + const reading = resolveTenancyPostureOrFinding(); + expect(reading.ok).toBe(false); + }); + + it('is an ERROR health check — the severity that makes doctor exit non-zero', () => { + process.env.OS_TENANCY_POSTURE = 'bogus'; + const reading = resolveTenancyPostureOrFinding(); + if (reading.ok) throw new Error('expected a finding'); + + // `status: 'error'` is load-bearing, not cosmetic: doctor's display loop + // sets `hasErrors` from exactly this field, and `hasErrors` is what turns + // the summary into `process.exit(1)`. A 'warning' here would reproduce the + // defect — a correct sentence with exit code 0. + expect(reading.result.status).toBe('error'); + }); + + it('names the fact: the variable and the value the operator actually typed', () => { + process.env.OS_TENANCY_POSTURE = 'islolated'; // a real transposition typo + const reading = resolveTenancyPostureOrFinding(); + if (reading.ok) throw new Error('expected a finding'); + + const text = plain(`${reading.result.message}\n${reading.result.fix ?? ''}`); + expect(text).toContain('OS_TENANCY_POSTURE="islolated"'); + + // The misattribution is the defect. Neither word may reappear in the text + // that replaces it: this is not a config problem and no config check was + // skipped because of it. + expect(text).not.toContain('Could not load config'); + expect(text).not.toContain('config checks skipped'); + }); + + it('prescribes a way out for EVERY posture the vocabulary declares (drift guard)', () => { + process.env.OS_TENANCY_POSTURE = 'bogus'; + const reading = resolveTenancyPostureOrFinding(); + if (reading.ok) throw new Error('expected a finding'); + + const fix = plain(reading.result.fix ?? ''); + // Generated from TENANCY_POSTURES rather than restated, so a posture added + // to the spec cannot leave this advice quietly incomplete. + for (const posture of TENANCY_POSTURES) { + expect(fix).toContain(`OS_TENANCY_POSTURE=${posture}`); + } + // …plus the escape the enumeration cannot express. + expect(fix).toContain('unset OS_TENANCY_POSTURE'); + expect(fix).toContain('OS_MULTI_ORG_ENABLED'); + }); + + it("carries the resolver's own sentence as `cause` rather than paraphrasing it", () => { + process.env.OS_TENANCY_POSTURE = 'bogus'; + const reading = resolveTenancyPostureOrFinding(); + if (reading.ok) throw new Error('expected a finding'); + + // `@objectstack/types` owns the vocabulary and its wording; doctor must not + // maintain a second copy that can disagree with it. + expect(plain(reading.result.fix ?? '')).toContain('cause: Invalid OS_TENANCY_POSTURE="bogus"'); + }); + + it('says what it did NOT read — doctor loads no .env, so a green report is not a serve guarantee', () => { + process.env.OS_TENANCY_POSTURE = 'bogus'; + const reading = resolveTenancyPostureOrFinding(); + if (reading.ok) throw new Error('expected a finding'); + + // Deliberately the OPPOSITE of serve's gate text, which says it checked + // "every .env file dotenv-flow loaded". serve calls `dotenvFlow.config()`; + // doctor does not load `.env*` at all, so claiming the same coverage here + // would be false. Overclaiming by one sentence is how a diagnostic stops + // being trustworthy — the same reason PR #5381 refused to write "no port + // has been bound". + expect(plain(reading.result.fix ?? '')).toContain('does not\n load `.env*` files'); + }); +}); + +describe('os doctor reports an unrecognized posture and exits non-zero', () => { + /** + * The end-to-end assertion, run against the real `doctor` command in-process. + * + * This is the one that would have caught #5382, and it is written as a + * DIFFERENTIAL over one variable: the same cwd, the same checks, the same + * everything, with only `OS_TENANCY_POSTURE` changing between the two cases. + * The valid-posture case on its own would pass against the broken code too + * (it asserts an absence) — it is the control half, not the evidence. + * + * The temp cwd is built so the pre-existing checks cannot manufacture the + * result: `node_modules/` exists, so the `Dependencies` check is `ok` rather + * than the `error` that would exit 1 on its own and make the interesting + * assertion pass for a reason having nothing to do with the posture. + */ + let tmp: string; + let cwdSpy: ReturnType; + + beforeEach(() => { + tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'os-doctor-5382-')); + // `Dependencies … Installed` — see above. Without this the baseline run + // already has an error and the differential proves nothing. + fs.mkdirSync(path.join(tmp, 'node_modules')); + // Spying beats `process.chdir()`: doctor reads `process.cwd()` directly and + // the spy works under every vitest pool, including worker threads where + // `chdir` is not available at all. + cwdSpy = vi.spyOn(process, 'cwd').mockReturnValue(tmp); + }); + + afterEach(() => { + cwdSpy.mockRestore(); + fs.rmSync(tmp, { recursive: true, force: true }); + }); + + /** Run the real command, capturing stdout and any `process.exit`. */ + async function runDoctor(): 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([], { 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('names OS_TENANCY_POSTURE, refuses to call the environment functional, and exits 1', async () => { + // ── Control: the same environment with a posture that parses ────────── + process.env.OS_TENANCY_POSTURE = 'isolated'; + const healthy = await runDoctor(); + + // Doctor completes normally. This is the sentence #5382 quoted, and here it + // is CORRECT: this environment really can start. + expect(healthy.exitCode).toBeUndefined(); + expect(healthy.out).toContain('Environment is functional'); + expect(healthy.out).not.toContain('Tenancy posture'); + + // ── The case: one character changed ────────────────────────────────── + process.env.OS_TENANCY_POSTURE = 'isolatd'; + const broken = await runDoctor(); + + // Before this change every one of these four was the other way round: no + // mention of the variable, "Environment is functional", exit 0. + expect(broken.out).toContain('OS_TENANCY_POSTURE="isolatd"'); + expect(broken.out).toContain('is not a recognized tenancy posture'); + expect(broken.out).not.toContain('Environment is functional'); + expect(broken.exitCode).toBe(1); + + // The prescription reaches the operator without `--verbose`: doctor prints + // an error's `fix` unconditionally, and a diagnostic that names a problem + // it will not tell you how to solve is half a diagnostic. + expect(broken.out).toContain('Set one of the accepted values'); + for (const posture of TENANCY_POSTURES) { + expect(broken.out).toContain(`OS_TENANCY_POSTURE=${posture}`); + } + + // And it is not blamed on the config. In this cwd there is no + // `objectstack.config.ts` at all, so the config-analysis block never ran — + // which is itself worth pinning: BEFORE the change, doctor's only posture + // readers lived inside `if (configExists())`, so this environment produced + // no posture diagnosis whatsoever, not even the misattributed one. + expect(broken.out).not.toContain('Could not load config'); + // 60s, not the 5s default: this case imports and runs the REAL doctor + // command in-process — twice — and each run shells out to `pnpm -v`, + // `tsc -v` and `git --version`. On a loaded merge-queue shard that blew the + // 5s default for PR #5381's equivalent case (queue run 30971902650), which + // is what took that PR out of the queue. Same posture as the existing + // `}, 60_000)` cases in this package (`utils/sqlite-occupancy.test.ts`, + // `utils/schema-migrate.deferred-ddl.integration.test.ts`). + }, 60_000); +}); diff --git a/packages/cli/src/commands/doctor.ts b/packages/cli/src/commands/doctor.ts index c60ee03eb1..509cdf26b0 100644 --- a/packages/cli/src/commands/doctor.ts +++ b/packages/cli/src/commands/doctor.ts @@ -19,6 +19,10 @@ import { GLOBAL_UNIQUE_ISOLATED_PRESCRIPTION, type GlobalUniqueFinding, } from '@objectstack/types'; +// The posture vocabulary, read from the package that DEFINES it (#5382) — the +// fix list below enumerates the accepted values, and a second literal list +// would be free to drift the day a posture is added. +import { TENANCY_POSTURES, type TenancyPosture } from '@objectstack/spec/security'; interface HealthCheckResult { name: string; @@ -27,6 +31,101 @@ interface HealthCheckResult { fix?: string; } +// ─── Tenancy Posture ──────────────────────────────────────────────── + +/** + * One-line descriptions of the accepted postures, keyed by the vocabulary + * `@objectstack/spec/security` owns. A posture declared there but not described + * here is still listed by the fix list (bare, without prose) rather than + * silently dropped — the advice can go terse, never stale. + */ +const TENANCY_POSTURE_FIX_HINTS: Readonly> = { + single: 'one organization, no organization wall — the default', + group: 'organization wall enforced by the open engine, one shared database', + isolated: + 'organization wall + the enterprise @objectstack/organizations runtime ' + + "(the legacy spelling 'multi' is accepted and normalizes to this)", +}; + +/** + * What doctor's tenancy-posture read decided (#5382). + * + * A verdict object rather than a throw. `resolveTenancyPosture()` refuses an + * unrecognized value by throwing, and doctor's every posture read used to sit + * inside the broad config-analysis `try` — so the refusal arrived as + * `⚠ Could not load config for analysis`, a warning, about the wrong subject, + * with exit code 0. A verdict cannot be caught by an unrelated `catch`. + */ +export type TenancyPostureReading = + | { ok: true; posture: TenancyPosture } + | { ok: false; result: HealthCheckResult }; + +/** + * Resolve the environment's requested tenancy posture, or produce the + * health-check finding that reports an unrecognized value (#5382). + * + * `resolveTenancyPosture()` (`@objectstack/types`) is the authority on the + * vocabulary and already refuses an unrecognized value — this wrapper does NOT + * re-decide that. It changes HOW the refusal travels and what it says. + * + * Why it exists: doctor read the posture in two places + * (`findUnscopedGlobalUniques()` and the ADR-0120 D5e gate), both of them under + * the wide `try` that guards config analysis, whose `catch` prints + * `Could not load config for analysis (config checks skipped)` and counts a + * WARNING. An environment that `os serve` flatly refuses to boot was therefore + * reported by `os doctor` as "functional", exit 0, without the string + * `OS_TENANCY_POSTURE` appearing anywhere in the run — sending the operator to + * look at their config, which was fine. That is the "diagnostic surface + * disagrees with the runtime" class of #4801 / cloud#1020, landed on the very + * command an operator reaches for after `serve` fails. + * + * The counterpart in `serve.ts` (`resolveTenancyPostureOrRefusal`, #5359) has + * the same shape but a different verdict, and deliberately so: serve REFUSES + * (FATAL + `process.exit(1)` before any boot work), doctor REPORTS (an `error` + * health check that flows through doctor's own error summary). The wording + * differs for the same reason — see the `.env` note below, which is true of + * doctor and false of serve. + */ +export function resolveTenancyPostureOrFinding(): TenancyPostureReading { + try { + return { ok: true, posture: resolveTenancyPosture() }; + } catch (err) { + const raw = (globalThis as { process?: { env?: Record } }) + .process?.env?.OS_TENANCY_POSTURE; + const cause = err instanceof Error ? err.message : String(err); + const fixes = TENANCY_POSTURES.map((posture) => { + const hint = TENANCY_POSTURE_FIX_HINTS[posture]; + return ` • OS_TENANCY_POSTURE=${posture}${hint ? ` — ${hint}` : ''}`; + }).join('\n'); + return { + ok: false, + result: { + name: 'Tenancy posture', + status: 'error', + message: + `OS_TENANCY_POSTURE=${JSON.stringify(String(raw ?? ''))} is not a recognized tenancy posture` + + ' — `os serve` refuses to boot this environment', + fix: + 'Set one of the accepted values:\n' + + `${fixes}\n` + + ' • or unset OS_TENANCY_POSTURE entirely — the posture then derives from\n' + + ' OS_MULTI_ORG_ENABLED (true ⇒ isolated, anything else ⇒ single)\n' + // Said out loud because it is a real limit of THIS report, and the + // opposite of serve's gate, which runs after `dotenv-flow` has loaded. + // `os doctor` loads no `.env*`, so a posture that lives in a committed + // `.env` reaches the server and never reaches this check — a green + // doctor is not proof that serve will accept the posture. + + ' Read from this process\'s environment only: unlike `os serve`, `os doctor` does not\n' + + ' load `.env*` files, so a value set in one is not visible here.\n' + // The resolver owns the vocabulary and its wording; quoting rather + // than paraphrasing keeps doctor from maintaining a second copy that + // can disagree with it. + + ` cause: ${cause}`, + }, + }; + } +} + // ─── Config-Aware Checks ──────────────────────────────────────────── function detectCircularDependencies(objects: any[]): string[] { @@ -290,8 +389,15 @@ async function readInstalledPackageEntries(cwd: string): Promise { * `'global'` is the correct, unambiguous meaning (`single` = one customer; * `group` = the installation IS the customer company). */ -async function findUnscopedGlobalUniques(cwd: string, config: any): Promise { - const posture = resolveTenancyPosture(); +async function findUnscopedGlobalUniques( + cwd: string, + config: any, + // #5382 — the posture the caller already resolved, not a fresh parse. This + // function runs inside doctor's broad config-analysis `try`, so a + // `resolveTenancyPosture()` here is a throw the wrong `catch` reports as + // "Could not load config for analysis". + posture: TenancyPosture, +): Promise { if (!postureGatesGlobalUniques(posture)) return []; const out: UniqueScopeAdvisory[] = []; @@ -469,9 +575,31 @@ export default class Doctor extends Command { const { flags } = await this.parse(Doctor); printHeader('Environment Health Check'); - + const results: HealthCheckResult[] = []; - + + // ── Tenancy posture (#5382) ────────────────────────────────────── + // Resolve ONCE, here, OUTSIDE every `try` in this method. + // + // Placement is the whole fix. Doctor's two posture readers — the ADR-0120 + // D5e unique-scope gate and `findUnscopedGlobalUniques()` — both sat under + // the wide config-analysis `try` further down, whose `catch` prints + // `Could not load config for analysis (config checks skipped)` and records + // a WARNING. So an unrecognized `OS_TENANCY_POSTURE` produced a report that + // blamed the config (which was fine), never printed the variable's name, + // and exited 0 under `⚠️ Environment is functional` — while `os serve` + // refused to boot the identical environment. + // + // Reading it here also widens the fix past the issue's own repro: those + // readers only ran `if (configExists())`, so an environment with no + // `objectstack.config.ts` never read the posture at all and said nothing + // whatsoever about it. + // + // Note this REPORTS rather than refuses — no `process.exit(1)` here. The + // finding is an ordinary `error` health check, so the rest of the report + // still runs and doctor's own summary owns the non-zero exit. + const postureReading = resolveTenancyPostureOrFinding(); + // Check Node.js version try { const nodeVersion = process.version; @@ -587,7 +715,15 @@ export default class Doctor extends Command { fix: 'Install Git for version control', }); } - + + // #5382 — the posture verdict resolved at the top of `run()`, reported here + // among the other environment facts. Only an unrecognized value produces a + // row: a valid posture is not a finding, and doctor's output for every + // environment that can actually start is unchanged. + if (!postureReading.ok) { + results.push(postureReading.result); + } + // Display environment results let hasErrors = false; let hasWarnings = false; @@ -685,9 +821,15 @@ export default class Doctor extends Command { // Runs whenever a config loaded, whether or not it declares objects: // the ledger half reports installed packages this project never // declared. - if (postureGatesGlobalUniques(resolveTenancyPosture())) { + // + // #5382 — reads the verdict resolved at the top of `run()`. Re-invoking + // the resolver here is what put its throw inside this swallowing `try` + // in the first place. An unrecognized posture skips the advisory (there + // is no posture to gate on) and is already reported above as an error, + // so nothing is silently lost. + if (postureReading.ok && postureGatesGlobalUniques(postureReading.posture)) { printStep("Checking unique scopes against the 'isolated' tenancy posture..."); - const scopeFindings = await findUnscopedGlobalUniques(cwd, config); + const scopeFindings = await findUnscopedGlobalUniques(cwd, config, postureReading.posture); if (scopeFindings.length > 0) { hasWarnings = true; for (const { source, finding } of scopeFindings) {